게시판오목으로 이해하는 자기대국과 모방 학습예제 소스

예제 소스

오목으로 이해하는 자기대국과 모방 학습 · 파일 59개

dataset_omook.py

#!/usr/bin/env python3
"""OMKL JSONL 로그 → VLM SFT 학습 데이터셋.

`vlm_pva_server.py --log-jsonl PATH` 가 누적한 한 수 단위 기록을 읽어,
(보드 이미지, 프롬프트, 정답좌표) 트리플로 변환합니다.

기본 필터:
  - kind == "move" 인 OMKL 레코드만 사용 (서버 predict 응답은 제외 — 모델 자기 응답이라 학습 가치 낮음).
  - 게임이 끝난 (BlackWin/WhiteWin/Draw) 게임만.
  - 그중 *승리한 쪽의 수* 만 학습 타깃 (전문가 시연 가정).
  - 사람/로컬 AI 의 수만 (--all-actors 로 VLM 도 포함 가능).

사용:
  python dataset_omook.py --jsonl logs/omook_vlm.jsonl \\
      --out-dir datasets/omook_sft --save-images
"""

from __future__ import annotations

import argparse
import json
import os
from concurrent.futures import ProcessPoolExecutor
from typing import Iterable, Iterator, List, Optional, Tuple

from PIL import Image, ImageDraw

VALID_SIZES = (13, 15, 19)

ACTOR_HUMAN = "human"
ACTOR_LOCAL_AI = "local_ai"
ACTOR_VLM = "vlm"

STATUS_PLAYING = "Playing"
STATUS_BLACK_WIN = "BlackWin"
STATUS_WHITE_WIN = "WhiteWin"
STATUS_DRAW = "Draw"


def _read_jsonl(path: str) -> Iterator[dict]:
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                yield json.loads(line)
            except json.JSONDecodeError:
                continue


def iter_move_records(path: str) -> Iterator[dict]:
    """OMKL move 레코드만 yield. (서버 predict 로그는 kind 필드 없음 → 자동 제외)"""
    for rec in _read_jsonl(path):
        if rec.get("kind") != "move":
            continue
        yield rec


def group_by_game(records: Iterable[dict]) -> Iterator[List[dict]]:
    """status != Playing 인 레코드를 게임 끝으로 보고 묶음."""
    current: List[dict] = []
    for rec in records:
        current.append(rec)
        if rec.get("status") in (STATUS_BLACK_WIN, STATUS_WHITE_WIN, STATUS_DRAW):
            yield current
            current = []
    # 마지막에 미완 게임이 남으면 버림 (학습엔 완료된 것만 사용).


def winner_stone(game: List[dict]) -> Optional[int]:
    """1=Black, 2=White, None=draw/incomplete."""
    if not game:
        return None
    last = game[-1].get("status")
    if last == STATUS_BLACK_WIN:
        return 1
    if last == STATUS_WHITE_WIN:
        return 2
    return None


# ----- 보드 이미지 렌더링 -----

def render_board_png(board: List[int], size: int, cell_px: int = 32, pad: int = 16) -> Image.Image:
    """클라이언트 BoardWidget 의 모양(나무바닥+격자+화점+돌) 을 모사한 PIL 렌더."""
    side_px = pad * 2 + cell_px * (size - 1)
    img = Image.new("RGB", (side_px, side_px), color=(222, 191, 130))
    draw = ImageDraw.Draw(img)
    grid_color = (120, 80, 30)

    for i in range(size):
        y = pad + i * cell_px
        draw.line([(pad, y), (pad + cell_px * (size - 1), y)], fill=grid_color, width=1)
    for j in range(size):
        x = pad + j * cell_px
        draw.line([(x, pad), (x, pad + cell_px * (size - 1))], fill=grid_color, width=1)

    # 화점 (중앙 + 네 모서리, BoardWidget 와 동일 규칙).
    mid = size // 2
    corner = 3
    far = size - 1 - corner
    hoshi = [(mid, mid)]
    if corner < far:
        hoshi += [(corner, corner), (corner, far), (far, corner), (far, far)]
    hoshi_r = max(2, cell_px // 12)
    for hr, hc in hoshi:
        cx = pad + hc * cell_px
        cy = pad + hr * cell_px
        draw.ellipse([cx - hoshi_r, cy - hoshi_r, cx + hoshi_r, cy + hoshi_r], fill=(60, 40, 18))

    # 돌
    rr = int(cell_px * 0.42)
    for r in range(size):
        for c in range(size):
            v = board[r * size + c]
            if v == 0:
                continue
            cx = pad + c * cell_px
            cy = pad + r * cell_px
            if v == 1:
                draw.ellipse(
                    [cx - rr, cy - rr, cx + rr, cy + rr],
                    fill=(20, 20, 20),
                    outline=(0, 0, 0),
                )
            else:
                draw.ellipse(
                    [cx - rr, cy - rr, cx + rr, cy + rr],
                    fill=(240, 240, 240),
                    outline=(90, 90, 90),
                )
    return img


# ----- 프롬프트 (서버 predict 와 같은 포맷) -----

def board_to_text(board: List[int], size: int) -> str:
    last_idx = size - 1
    lines = []
    for r in range(size):
        cells = []
        for c in range(size):
            v = board[r * size + c]
            cells.append("." if v == 0 else ("X" if v == 1 else "O"))
        lines.append(" ".join(cells))
    occ_b = [(r, c) for r in range(size) for c in range(size) if board[r * size + c] == 1]
    occ_w = [(r, c) for r in range(size) for c in range(size) if board[r * size + c] == 2]
    return (
        f"Board (X=Black, O=White, .=empty), rows top→bottom 0..{last_idx}, "
        f"cols left→right 0..{last_idx}:\n"
        + "\n".join(lines)
        + f"\nBlack stones: {occ_b}\nWhite stones: {occ_w}"
    )


def build_prompt(pre_board: List[int], size: int, ai_side: int) -> str:
    you_label = "Black (X)" if ai_side == 1 else "White (O)"
    opp_label = "White" if ai_side == 1 else "Black"
    return (
        f"You are a {size}x{size} Gomoku (오목) player. {opp_label} has just moved; "
        f"you play {you_label} next.\n"
        f"{board_to_text(pre_board, size)}\n"
        f"Choose your next {you_label} move on an EMPTY cell (a '.'). "
        "Reply with EXACTLY two integers separated by a single space: \"row col\". "
        f"row and col are both in 0..{size - 1}. No other text."
    )


# ----- 학습 예제 생성 -----

def iter_meta_examples(
    path: str,
    only_winners: bool = True,
    actors: Tuple[str, ...] = (ACTOR_HUMAN, ACTOR_LOCAL_AI),
    sizes: Tuple[int, ...] = VALID_SIZES,
) -> Iterator[dict]:
    """Yield {pre_board, size, stone, actor, winner, move_row, move_col} — 가벼운 메타.

    실제 이미지/프롬프트 생성은 _build_one_example 에서 별도로 (선택적으로 워커풀에서) 수행한다.
    """
    for game in group_by_game(iter_move_records(path)):
        winner = winner_stone(game)
        if only_winners and winner is None:
            continue
        for rec in game:
            size = rec.get("size")
            if size not in sizes:
                continue
            actor = rec.get("actor")
            if actors and actor not in actors:
                continue
            stone = rec.get("stone")
            if only_winners and winner is not None and stone != winner:
                continue
            board_post = rec.get("board")
            if not board_post or len(board_post) != size * size:
                continue
            mr = rec.get("move_row")
            mc = rec.get("move_col")
            if mr is None or mc is None:
                continue
            if not (0 <= mr < size and 0 <= mc < size):
                continue
            pre = list(board_post)
            idx = mr * size + mc
            if pre[idx] != stone:
                continue
            pre[idx] = 0
            yield {
                "pre_board": pre,
                "size": size,
                "stone": stone,
                "actor": actor,
                "winner": winner if winner is not None else 0,
                "move_row": mr,
                "move_col": mc,
            }


def _build_and_emit(task: tuple) -> dict:
    """워커 프로세스에서 호출 — 이미지를 디스크에 저장까지 끝내고 *작은* 메타만 반환.

    PIL.Image 를 마스터로 pickle 해 돌려보내는 비용이 PNG 저장보다 훨씬 커서, 워커
    내부에서 저장까지 마무리해야 병렬화 효과가 난다.
    """
    idx, meta, out_dir, save_images = task
    pre = meta["pre_board"]
    size = meta["size"]
    stone = meta["stone"]
    rec: dict = {
        "id": idx,
        "size": size,
        "stone": stone,
        "actor": meta["actor"],
        "winner": meta["winner"],
        "prompt": build_prompt(pre, size, ai_side=stone),
        "answer": f"{meta['move_row']} {meta['move_col']}",
    }
    if save_images:
        img_path = os.path.join(out_dir, f"img_{idx:08d}.png")
        render_board_png(pre, size).save(img_path, optimize=False, compress_level=1)
        rec["image_path"] = img_path
    return rec


def _build_one_example(meta: dict) -> dict:
    """단일 프로세스 호환 헬퍼 (이미지를 메모리로 들고 있는 옛 시그니처)."""
    pre = meta["pre_board"]
    size = meta["size"]
    stone = meta["stone"]
    return {
        "image": render_board_png(pre, size),
        "prompt": build_prompt(pre, size, ai_side=stone),
        "answer": f"{meta['move_row']} {meta['move_col']}",
        "size": size,
        "actor": meta["actor"],
        "winner": meta["winner"],
        "stone": stone,
    }


def make_examples(
    path: str,
    only_winners: bool = True,
    actors: Tuple[str, ...] = (ACTOR_HUMAN, ACTOR_LOCAL_AI),
    sizes: Tuple[int, ...] = VALID_SIZES,
) -> Iterator[dict]:
    """단일-프로세스 generator. 호환성용 — 새 코드는 iter_meta_examples 를 직접 쓰세요."""
    for meta in iter_meta_examples(path, only_winners=only_winners, actors=actors, sizes=sizes):
        yield _build_one_example(meta)


def main() -> None:
    parser = argparse.ArgumentParser(description="Build SFT dataset from omook JSONL log")
    parser.add_argument("--jsonl", required=True, help="OMKL/VLM JSONL 로그 경로")
    parser.add_argument(
        "--out-dir",
        default="sft_data/omook_sft",
        help=(
            "데이터셋 출력 디렉토리. 'datasets' 라는 이름은 PyPI 의 huggingface "
            "`datasets` 패키지와 namespace 충돌이 나므로 피하세요."
        ),
    )
    parser.add_argument(
        "--all-actors",
        action="store_true",
        help="VLM 의 수도 포함 (기본: human + local_ai 만)",
    )
    parser.add_argument(
        "--include-losers",
        action="store_true",
        help="패배한 쪽의 수도 포함 (기본: 승리한 쪽만)",
    )
    parser.add_argument(
        "--save-images",
        action="store_true",
        help="이미지를 PNG 파일로 dump (학습 시 디스크에서 lazy 로드 가능). "
        "끄면 train.jsonl 만 만들고 학습 단계에서 직접 렌더해야 함.",
    )
    parser.add_argument(
        "--workers",
        type=int,
        default=max(2, (os.cpu_count() or 4) // 2),
        help="이미지 렌더+저장을 병렬화할 프로세스 수 (기본: cpu/2). 1=직렬.",
    )
    args = parser.parse_args()

    os.makedirs(args.out_dir, exist_ok=True)
    actors = (
        (ACTOR_HUMAN, ACTOR_LOCAL_AI, ACTOR_VLM) if args.all_actors else (ACTOR_HUMAN, ACTOR_LOCAL_AI)
    )
    only_winners = not args.include_losers

    metas = list(iter_meta_examples(args.jsonl, only_winners=only_winners, actors=actors))
    out_jsonl = os.path.join(args.out_dir, "train.jsonl")

    n = len(metas)
    tasks = [(i, m, args.out_dir, args.save_images) for i, m in enumerate(metas)]
    with open(out_jsonl, "w", encoding="utf-8") as f:
        if args.workers <= 1 or n < 64:
            for task in tasks:
                rec = _build_and_emit(task)
                f.write(json.dumps(rec, ensure_ascii=False) + "\n")
        else:
            chunk = max(8, n // (args.workers * 4) or 1)
            with ProcessPoolExecutor(max_workers=args.workers) as pool:
                for rec in pool.map(_build_and_emit, tasks, chunksize=chunk):
                    f.write(json.dumps(rec, ensure_ascii=False) + "\n")

    summary = {
        "examples": n,
        "out_jsonl": out_jsonl,
        "out_dir": args.out_dir,
        "filters": {
            "only_winners": only_winners,
            "actors": list(actors),
            "save_images": args.save_images,
        },
    }
    with open(os.path.join(args.out_dir, "dataset_info.json"), "w", encoding="utf-8") as f:
        json.dump(summary, f, ensure_ascii=False, indent=2)
    print(f"wrote {n} examples → {out_jsonl}")


if __name__ == "__main__":
    main()