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

예제 소스

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

vlm_text_client.py

#!/usr/bin/env python3
"""OMK3 VLM 텍스트 클라이언트 — 좌표 인식 / 수 품질 수동·자동 테스트.

대화형:
  .venv/bin/python scripts/vlm_text_client.py
  .venv/bin/python scripts/vlm_text_client.py --size 15 --host 127.0.0.1 --port 9299

한 번에 좌표 스모크:
  .venv/bin/python scripts/vlm_text_client.py --probe
  .venv/bin/python scripts/vlm_text_client.py --probe --size 15

명령 (대화형):
  show                 보드 출력
  put R C [B|W]        돌 놓기 (기본: 사람 색)
  del R C              돌 제거
  clear                빈 판
  side B|W|1|2         AI 색 (VLM이 둘 쪽)
  size 13|15|19        보드 크기 (판 초기화)
  ask                  VLM에게 한 수 요청 (돌은 자동으로 안 둠)
  play                 ask 후 빈 칸이면 AI 돌 착수
  auto N               play 를 N수 반복 (사람 랜덤 + AI)
  ping                 PING/PONG
  help / quit
"""

from __future__ import annotations

import argparse
import io
import random
import socket
import struct
import sys
import time
from pathlib import Path
from typing import Optional

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

from PIL import Image, ImageDraw  # noqa: E402

MAGIC_OMK3 = b"OMK3"
MAGIC_PING = b"PING"
MAGIC_RPLY = b"RPLY"
MAGIC_PONG = b"PONG"


def recv_exact(sock: socket.socket, n: int) -> bytes:
    buf = b""
    while len(buf) < n:
        chunk = sock.recv(n - len(buf))
        if not chunk:
            raise ConnectionError("eof")
        buf += chunk
    return buf


def render_png(board: bytes, size: int, cell: int = 28, pad: int = 14) -> bytes:
    side = pad * 2 + cell * (size - 1)
    img = Image.new("RGB", (side, side), (222, 191, 130))
    draw = ImageDraw.Draw(img)
    for i in range(size):
        a = pad + i * cell
        draw.line([(pad, a), (pad + cell * (size - 1), a)], fill=(40, 30, 20), width=1)
        draw.line([(a, pad), (a, pad + cell * (size - 1))], fill=(40, 30, 20), width=1)
    rad = max(4, cell // 3)
    for r in range(size):
        for c in range(size):
            v = board[r * size + c]
            if v == 0:
                continue
            x = pad + c * cell
            y = pad + r * cell
            color = (20, 20, 20) if v == 1 else (245, 245, 245)
            draw.ellipse([x - rad, y - rad, x + rad, y + rad], fill=color, outline=(0, 0, 0))
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


def board_text(board: bytes, size: int, mark: Optional[tuple[int, int]] = None) -> str:
    col_hdr = "   " + " ".join(f"{c:2d}" for c in range(size))
    lines = [col_hdr]
    for r in range(size):
        cells = []
        for c in range(size):
            if mark == (r, c):
                cells.append(" *")
                continue
            v = board[r * size + c]
            cells.append(" ." if v == 0 else (" X" if v == 1 else " O"))
        lines.append(f"{r:2d} " + "".join(cells))
    return "\n".join(lines)


class VlmTextClient:
    def __init__(self, host: str, port: int, size: int = 15, ai_side: int = 2, timeout: float = 300.0):
        self.host = host
        self.port = port
        self.size = size
        self.ai_side = ai_side  # 1=B 2=W
        self.timeout = timeout
        self.board = bytearray(size * size)
        self.history: list[tuple[int, int, int]] = []  # r,c,color

    @property
    def human_side(self) -> int:
        return 1 if self.ai_side == 2 else 2

    def reset(self, size: Optional[int] = None) -> None:
        if size is not None:
            self.size = size
        self.board = bytearray(self.size * self.size)
        self.history.clear()

    def put(self, r: int, c: int, color: int) -> None:
        if not (0 <= r < self.size and 0 <= c < self.size):
            raise ValueError(f"범위 밖: ({r},{c})")
        if self.board[r * self.size + c] != 0:
            raise ValueError(f"이미 돌 있음: ({r},{c})")
        self.board[r * self.size + c] = color
        self.history.append((r, c, color))

    def remove(self, r: int, c: int) -> None:
        self.board[r * self.size + c] = 0

    def empty_cells(self) -> list[tuple[int, int]]:
        s = self.size
        return [(r, c) for r in range(s) for c in range(s) if self.board[r * s + c] == 0]

    def ping(self) -> float:
        t0 = time.perf_counter()
        with socket.create_connection((self.host, self.port), timeout=5) as sock:
            sock.settimeout(5)
            sock.sendall(MAGIC_PING)
            head = recv_exact(sock, 4)
            if head != MAGIC_PONG:
                raise RuntimeError(f"bad ping reply {head!r}")
        return time.perf_counter() - t0

    def ask(self) -> tuple[bool, str, float]:
        """(ok, 'r c' or error, elapsed_sec)."""
        png = render_png(bytes(self.board), self.size)
        body = (
            bytes([self.size & 0xFF, self.ai_side & 0xFF])
            + struct.pack(">I", len(png))
            + png
            + bytes(self.board)
        )
        frame = MAGIC_OMK3 + struct.pack(">I", len(body)) + body
        t0 = time.perf_counter()
        with socket.create_connection((self.host, self.port), timeout=10) as sock:
            sock.settimeout(self.timeout)
            sock.sendall(frame)
            head = recv_exact(sock, 4)
            if head != MAGIC_RPLY:
                return False, f"bad header {head!r}", time.perf_counter() - t0
            flag = recv_exact(sock, 1)[0]
            if flag == 1:
                rc = recv_exact(sock, 2)
                return True, f"{rc[0]} {rc[1]}", time.perf_counter() - t0
            if flag == 0:
                n = struct.unpack(">H", recv_exact(sock, 2))[0]
                msg = recv_exact(sock, n).decode("utf-8", errors="replace")
                return False, msg, time.perf_counter() - t0
            return False, f"unknown flag {flag}", time.perf_counter() - t0

    def judge(self, r: int, c: int) -> str:
        if not (0 <= r < self.size and 0 <= c < self.size):
            return "OUT_OF_RANGE"
        if self.board[r * self.size + c] != 0:
            return "OCCUPIED"
        return "LEGAL_EMPTY"


def parse_rc(parts: list[str], start: int = 0) -> tuple[int, int]:
    if len(parts) < start + 2:
        raise ValueError("행 열 필요")
    return int(parts[start]), int(parts[start + 1])


def parse_color(tok: str, default: int) -> int:
    t = tok.strip().upper()
    if t in ("B", "X", "1", "BLACK"):
        return 1
    if t in ("W", "O", "2", "WHITE"):
        return 2
    raise ValueError(f"색 모름: {tok}")


def run_probe(cli: VlmTextClient) -> int:
    size = cli.size
    mid = size // 2
    cases: list[tuple[str, list[tuple[int, int, int]]]] = [
        ("empty", []),
        ("black@center", [(mid, mid, 1)]),
        (
            "cluster",
            [
                (mid, mid, 1),
                (mid, mid + 1, 1),
                (mid + 1, mid, 2),
                (mid - 1, mid, 2),
            ],
        ),
    ]
    if size >= 15:
        cases.append(
            (
                "edge_trap_13",
                [(13, 12, 2), (13, 13, 2), (13, 14, 2), (7, 7, 1)],
            )
        )

    print(f"=== probe {cli.host}:{cli.port} size={size} ai_side={cli.ai_side} ===")
    try:
        ms = cli.ping() * 1000
        print(f"PING ok ({ms:.1f} ms)\n")
    except Exception as e:
        print(f"PING FAIL: {e}")
        return 2

    ok_n = 0
    suspect_n = 0
    for name, stones in cases:
        cli.reset()
        for r, c, color in stones:
            cli.put(r, c, color)
        print(f"-- {name} --")
        print(board_text(bytes(cli.board), size))
        try:
            ok, msg, elapsed = cli.ask()
        except Exception as e:
            print(f"  transport FAIL: {e}\n")
            continue
        if not ok:
            print(f"  server error ({elapsed:.2f}s): {msg}\n")
            continue
        r_s, c_s = msg.split()
        r, c = int(r_s), int(c_s)
        verdict = cli.judge(r, c)
        # 재추론 3회(~0.4s×3)면 보통 1초+. RPLY는 보정 후 좌표라 LEGAL_EMPTY여도
        # 모델 raw가 점유 칸이었을 수 있음 → 서버 로그 '불법 좌표' 확인.
        slow = elapsed >= 0.9
        if verdict == "LEGAL_EMPTY":
            ok_n += 1
            tag = "OK"
            if slow:
                suspect_n += 1
                tag = "OK(slow≈retry/fallback?)"
        else:
            tag = "BAD"
        print(f"  reply=({r},{c}) {verdict} → {tag}  ({elapsed:.2f}s)")
        print(board_text(bytes(cli.board), size, mark=(r, c) if verdict != "OUT_OF_RANGE" else None))
        print()

    print(f"summary: {ok_n}/{len(cases)} LEGAL_EMPTY  (slow/suspect={suspect_n})")
    print("note: RPLY는 서버 보정 후 좌표. raw는 VLM 터미널 '불법 좌표'/'VLM raw reply' 로그 확인.")
    return 0 if ok_n == len(cases) else 1


def repl(cli: VlmTextClient) -> int:
    print(
        f"VLM text client → {cli.host}:{cli.port}  size={cli.size}  "
        f"AI={'W' if cli.ai_side == 2 else 'B'}  human={'B' if cli.human_side == 1 else 'W'}"
    )
    print("help 입력. Ctrl-D / quit 종료.\n")
    try:
        ms = cli.ping() * 1000
        print(f"PING ok ({ms:.1f} ms)")
    except Exception as e:
        print(f"PING FAIL: {e}  (서버 켠 뒤 다시 ping)")

    print(board_text(bytes(cli.board), cli.size))

    while True:
        try:
            line = input("> ").strip()
        except (EOFError, KeyboardInterrupt):
            print()
            return 0
        if not line:
            continue
        parts = line.split()
        cmd = parts[0].lower()

        try:
            if cmd in ("q", "quit", "exit"):
                return 0
            if cmd in ("h", "help", "?"):
                print(__doc__)
                continue
            if cmd in ("show", "board", "b"):
                side = "W" if cli.ai_side == 2 else "B"
                print(f"size={cli.size} AI={side} stones={sum(1 for x in cli.board if x)}")
                print(board_text(bytes(cli.board), cli.size))
                continue
            if cmd == "clear":
                cli.reset()
                print(board_text(bytes(cli.board), cli.size))
                continue
            if cmd == "size":
                n = int(parts[1])
                if n not in (13, 15, 19):
                    raise ValueError("size는 13/15/19")
                cli.reset(n)
                print(board_text(bytes(cli.board), cli.size))
                continue
            if cmd == "side":
                cli.ai_side = parse_color(parts[1], cli.ai_side)
                print(f"AI={'W' if cli.ai_side == 2 else 'B'}")
                continue
            if cmd in ("put", "p", "place"):
                r, c = parse_rc(parts, 1)
                color = parse_color(parts[3], cli.human_side) if len(parts) > 3 else cli.human_side
                cli.put(r, c, color)
                print(board_text(bytes(cli.board), cli.size, mark=(r, c)))
                continue
            if cmd in ("del", "rm"):
                r, c = parse_rc(parts, 1)
                cli.remove(r, c)
                print(board_text(bytes(cli.board), cli.size))
                continue
            if cmd == "ping":
                ms = cli.ping() * 1000
                print(f"PONG {ms:.1f} ms")
                continue
            if cmd == "ask":
                print("VLM 요청 중…")
                ok, msg, elapsed = cli.ask()
                if not ok:
                    print(f"FAIL ({elapsed:.2f}s): {msg}")
                    continue
                r, c = map(int, msg.split())
                verdict = cli.judge(r, c)
                print(f"reply=({r},{c}) {verdict}  ({elapsed:.2f}s)")
                print(board_text(bytes(cli.board), cli.size, mark=(r, c) if verdict != "OUT_OF_RANGE" else None))
                continue
            if cmd == "play":
                print("VLM 요청 중…")
                ok, msg, elapsed = cli.ask()
                if not ok:
                    print(f"FAIL ({elapsed:.2f}s): {msg}")
                    continue
                r, c = map(int, msg.split())
                verdict = cli.judge(r, c)
                print(f"reply=({r},{c}) {verdict}  ({elapsed:.2f}s)")
                if verdict == "LEGAL_EMPTY":
                    cli.put(r, c, cli.ai_side)
                    print("(착수됨)")
                else:
                    print("(착수 안 함 — 불법 좌표)")
                print(board_text(bytes(cli.board), cli.size, mark=(r, c) if verdict != "OUT_OF_RANGE" else None))
                continue
            if cmd == "auto":
                n = int(parts[1]) if len(parts) > 1 else 3
                rng = random.Random()
                for i in range(n):
                    empties = cli.empty_cells()
                    if not empties:
                        print("빈 칸 없음")
                        break
                    # 사람이 근처 랜덤
                    hr, hc = rng.choice(empties)
                    cli.put(hr, hc, cli.human_side)
                    print(f"\n[{i+1}/{n}] human ({hr},{hc})")
                    ok, msg, elapsed = cli.ask()
                    if not ok:
                        print(f"  FAIL: {msg}")
                        break
                    r, c = map(int, msg.split())
                    verdict = cli.judge(r, c)
                    print(f"  AI ({r},{c}) {verdict} ({elapsed:.2f}s)")
                    if verdict != "LEGAL_EMPTY":
                        print(board_text(bytes(cli.board), cli.size, mark=(r, c) if verdict != "OUT_OF_RANGE" else None))
                        print("중단: 불법 좌표")
                        break
                    cli.put(r, c, cli.ai_side)
                print(board_text(bytes(cli.board), cli.size))
                continue
            if cmd == "probe":
                return run_probe(cli)
            # 단축: "7 8" = put human
            if len(parts) == 2 and parts[0].lstrip("-").isdigit():
                r, c = int(parts[0]), int(parts[1])
                cli.put(r, c, cli.human_side)
                print(board_text(bytes(cli.board), cli.size, mark=(r, c)))
                continue
            print(f"모르는 명령: {cmd}  (help)")
        except Exception as e:
            print(f"! {e}")


def main() -> int:
    p = argparse.ArgumentParser(description="OMK3 VLM text client")
    p.add_argument("--host", default="127.0.0.1")
    p.add_argument("--port", type=int, default=9299)
    p.add_argument("--size", type=int, default=15, choices=(13, 15, 19))
    p.add_argument("--ai-side", type=int, default=2, choices=(1, 2), help="1=Black 2=White")
    p.add_argument("--timeout", type=float, default=300.0)
    p.add_argument("--probe", action="store_true", help="좌표 스모크 테스트 후 종료")
    args = p.parse_args()

    cli = VlmTextClient(
        host=args.host,
        port=args.port,
        size=args.size,
        ai_side=args.ai_side,
        timeout=args.timeout,
    )
    if args.probe:
        return run_probe(cli)
    return repl(cli)


if __name__ == "__main__":
    raise SystemExit(main())