heigon77 commited on
Commit
3f9559b
·
0 Parent(s):

Chess Vision backend (digitization + move prediction)

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.onnx filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ .venv/
5
+
6
+ # Stockfish binary (installed via apt in Docker / downloaded locally)
7
+ stockfish/stockfish
8
+ stockfish/*.tar
9
+
10
+ # Local sample images / scratch
11
+ samples/
12
+ /tmp/
13
+
14
+ # NOTE: models_onnx/ IS committed (the API needs the ONNX weights).
Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Space (Docker SDK) listens on 7860.
2
+ FROM python:3.12-slim
3
+
4
+ # Stockfish (apt build = portable across CPUs) + opencv runtime libs.
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ stockfish libglib2.0-0 \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ WORKDIR /app
10
+
11
+ COPY requirements.txt .
12
+ RUN pip install --no-cache-dir -r requirements.txt
13
+
14
+ COPY app ./app
15
+ COPY models_onnx ./models_onnx
16
+
17
+ ENV STOCKFISH_PATH=/usr/games/stockfish \
18
+ MODELS_DIR=/app/models_onnx
19
+
20
+ EXPOSE 7860
21
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Chess Vision Backend
3
+ emoji: ♟️
4
+ colorFrom: indigo
5
+ colorTo: gray
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # ♟️ Chess Vision — Backend (API)
12
+
13
+ FastAPI service for my MSc project: **chess board digitization** + **human-like
14
+ move prediction**. Upload a board image to get its FEN, then ask for the moves a
15
+ human would likely play (CNN trained on Lichess games) combined with **Stockfish**.
16
+
17
+ Models are served as quantized **ONNX** (the original ~444 MB of PyTorch weights →
18
+ ~47 MB ONNX), so the image is small and CPU inference is fast.
19
+
20
+ ## Endpoints
21
+
22
+ | Method | Path | Description |
23
+ |--------|------|-------------|
24
+ | `GET` | `/health` | Liveness probe |
25
+ | `POST` | `/digitize` | multipart image → FEN board placement |
26
+ | `POST` | `/predict-move` | `{ "fen": "...", "top_n": 3 }` → CNN / Stockfish / hybrid moves |
27
+ | `GET` | `/docs` | Swagger UI |
28
+
29
+ ```bash
30
+ curl -X POST .../predict-move -H 'Content-Type: application/json' \
31
+ -d '{"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1","top_n":3}'
32
+ # -> {"cnn":["e7e5","g8f6","d7d5"], "stockfish":["e7e5","c7c5","e7e6"], "hybrid":[...]}
33
+ ```
34
+
35
+ ## How it works
36
+ - **Digitization** (`app/digitize.py`): fixed crop → Canny → Hough grid (exact MSc
37
+ pipeline) → 64 square crops → ONNX MobileNetV2 piece classifier → FEN.
38
+ - **Move prediction** (`app/predict.py`): board → 12×8×8 tensor → a piece-type CNN +
39
+ six destination-square CNNs → legal human-like moves; black is mirrored; Stockfish
40
+ adds engine moves and a non-blundering hybrid.
41
+
42
+ ## Run locally
43
+ ```bash
44
+ python3.12 -m venv .venv && source .venv/bin/activate
45
+ pip install -r requirements.txt
46
+ # Stockfish: apt install stockfish (Linux) and set STOCKFISH_PATH, or omit for CNN-only
47
+ uvicorn app.main:app --reload --port 7860
48
+ ```
49
+
50
+ ## Models
51
+ `models_onnx/` holds the quantized ONNX weights (digitizer + piece + 6 square
52
+ classifiers). Regenerate from the original `.pth` with `convert_to_onnx.py`.
53
+
54
+ > The demo backend runs on a free Hugging Face Space that sleeps after inactivity;
55
+ > the first request after that triggers a short cold start.
app/chess_models.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Model architectures extracted from the Master's project notebooks.
2
+
3
+ Used to load the trained .pth weights and export them to ONNX. The backend
4
+ serves the ONNX models, so torch is only needed at conversion time.
5
+
6
+ - PieceImageClassifier : MobileNetV2 -> 12 classes (square -> piece, digitization)
7
+ - PieceClassifier : CNN, 12x8x8 -> 6 (which piece type to move)
8
+ - SquareClassifier : CNN, 12x8x8 -> 64 (destination square; one per piece type)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import torch.nn as nn
14
+ import torchvision.models as models
15
+
16
+
17
+ class PieceImageClassifier(nn.Module):
18
+ """Digitization: classify a single board square crop into one of 12 pieces."""
19
+
20
+ def __init__(self):
21
+ super().__init__()
22
+ # weights=None: we load our own trained state_dict, no ImageNet download.
23
+ self.model = models.mobilenet_v2(weights=None)
24
+ self.model.classifier = nn.Sequential(
25
+ nn.AdaptiveAvgPool2d(1),
26
+ nn.Flatten(),
27
+ nn.Linear(1280, 512),
28
+ nn.ReLU(),
29
+ nn.Linear(512, 12),
30
+ )
31
+
32
+ def forward(self, x):
33
+ return self.model.classifier(self.model.features(x))
34
+
35
+
36
+ def _conv_block() -> nn.Sequential:
37
+ return nn.Sequential(
38
+ nn.Conv2d(12, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(),
39
+ nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(),
40
+ nn.MaxPool2d(2, 2),
41
+ nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(),
42
+ nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(),
43
+ nn.MaxPool2d(2, 2),
44
+ nn.Conv2d(256, 512, 3, padding=1), nn.BatchNorm2d(512), nn.ReLU(),
45
+ nn.Conv2d(512, 512, 3, padding=1), nn.BatchNorm2d(512), nn.ReLU(),
46
+ nn.MaxPool2d(2, 2),
47
+ )
48
+
49
+
50
+ class PieceClassifier(nn.Module):
51
+ """Move prediction stage 1: which piece type (P,N,B,R,Q,K) is likely to move."""
52
+
53
+ def __init__(self):
54
+ super().__init__()
55
+ self.conv_layers = _conv_block()
56
+ self.fc_layers = nn.Sequential(
57
+ nn.Dropout(0.25), nn.Linear(512, 1024), nn.BatchNorm1d(1024), nn.ReLU(),
58
+ nn.Dropout(0.25), nn.Linear(1024, 512), nn.BatchNorm1d(512), nn.ReLU(),
59
+ nn.Linear(512, 6),
60
+ )
61
+
62
+ def forward(self, x):
63
+ x = self.conv_layers(x)
64
+ x = x.view(x.size(0), -1)
65
+ return self.fc_layers(x)
66
+
67
+
68
+ class SquareClassifier(nn.Module):
69
+ """Move prediction stage 2: destination square (0-63) for a given piece type."""
70
+
71
+ def __init__(self):
72
+ super().__init__()
73
+ self.conv_layers = _conv_block()
74
+ self.fc_layers = nn.Sequential(
75
+ nn.Dropout(0.25), nn.Linear(512, 1024), nn.BatchNorm1d(1024), nn.ReLU(),
76
+ nn.Dropout(0.25), nn.Linear(1024, 512), nn.BatchNorm1d(512), nn.ReLU(),
77
+ nn.Linear(512, 64),
78
+ )
79
+
80
+ def forward(self, x):
81
+ x = self.conv_layers(x)
82
+ x = x.view(x.size(0), -1)
83
+ return self.fc_layers(x)
app/digitize.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Image -> FEN digitization (the exact Preprocess.ipynb pipeline).
2
+
3
+ Runs on any uploaded image (no corner marking): fixed crop, Canny, Hough,
4
+ group/filter lines, remove the spurious 3rd-from-bottom row, crop each square
5
+ (bbox + 90px), classify with the ONNX piece model, and detect empties by
6
+ model-confidence + edge density.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import cv2 as cv
11
+ import numpy as np
12
+
13
+ IDX2SYM = {0:"P",1:"N",2:"B",3:"R",4:"Q",5:"K",6:"p",7:"n",8:"b",9:"r",10:"q",11:"k"}
14
+ _MEAN = np.array([0.485, 0.456, 0.406], np.float32)
15
+ _STD = np.array([0.229, 0.224, 0.225], np.float32)
16
+
17
+
18
+ # ── exact Preprocess.ipynb grid ──────────────────────────────────────────────
19
+ def _crop_image(im):
20
+ cx, cy = im.shape[1] // 2, im.shape[0] // 2
21
+ return im[cy - 520:cy + 450, cx - 550:cx + 550]
22
+
23
+ def _group(lines):
24
+ g = []
25
+ for l in lines:
26
+ rho, theta = l[0]
27
+ if not any(abs(rho - a[0][0]) < 30 and abs(theta - a[0][1]) < np.pi / 18 for a in g):
28
+ g.append(l)
29
+ return g
30
+
31
+ def _filter(g):
32
+ mr = None
33
+ for l in g:
34
+ rho, theta = l[0]
35
+ if 1 < theta < 3 and (mr is None or rho < mr):
36
+ mr = rho
37
+ return [l for l in g if l[0][0] != mr]
38
+
39
+ def _intersections(board_lines):
40
+ lp = []
41
+ for l in board_lines:
42
+ rho, theta = l[0]; a, b = np.cos(theta), np.sin(theta); x0, y0 = a * rho, b * rho
43
+ lp.append([(int(x0 + 1e4 * -b), int(y0 + 1e4 * a)),
44
+ (int(x0 - 1e4 * -b), int(y0 - 1e4 * a)), theta])
45
+ rows = []
46
+ for i in range(len(lp)):
47
+ pts = []
48
+ for j in range(len(lp)):
49
+ (x1, y1), (x2, y2) = lp[i][0], lp[i][1]
50
+ (x3, y3), (x4, y4) = lp[j][0], lp[j][1]
51
+ det = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
52
+ if det:
53
+ ix = int(((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / det)
54
+ iy = int(((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / det)
55
+ if 0 <= ix <= 1920 and 0 <= iy <= 1080 and 1 < lp[i][2] < 3:
56
+ pts.append((ix, iy))
57
+ if pts:
58
+ pts.sort(key=lambda p: p[0]); rows.append(pts)
59
+ rows.sort(key=lambda p: p[0][0])
60
+ return rows
61
+
62
+ def _squares(rows):
63
+ casas = []
64
+ for i in range(len(rows) - 1):
65
+ for j in range(len(rows[i]) - 1):
66
+ casas.append([rows[i][j], rows[i][j + 1], rows[i + 1][j], rows[i + 1][j + 1]])
67
+ return casas
68
+
69
+
70
+ def _preprocess(bgr):
71
+ rgb = cv.cvtColor(cv.resize(bgr, (224, 224)), cv.COLOR_BGR2RGB).astype(np.float32) / 255.0
72
+ return ((rgb - _MEAN) / _STD).transpose(2, 0, 1)[None]
73
+
74
+
75
+ def _grid_to_fen(grid) -> str:
76
+ out = []
77
+ for row in grid:
78
+ s, e = "", 0
79
+ for cell in row:
80
+ if cell == ".":
81
+ e += 1
82
+ else:
83
+ if e:
84
+ s += str(e); e = 0
85
+ s += cell
86
+ if e:
87
+ s += str(e)
88
+ out.append(s)
89
+ return "/".join(out)
90
+
91
+
92
+ def image_to_fen(bgr, session) -> str | None:
93
+ """Full board state (piece-placement FEN) from a board image, or None if the
94
+ 64-square grid couldn't be detected."""
95
+ im = _crop_image(bgr)
96
+ lines = cv.HoughLines(cv.Canny(im, 100, 150, apertureSize=3), 1, np.pi / 180, 160)
97
+ if lines is None:
98
+ return None
99
+ rows = _intersections(_filter(_group(lines)))
100
+ if len(rows) >= 3: # drop spurious 3rd-from-bottom horizontal line
101
+ by_y = sorted(rows, key=lambda r: np.mean([p[1] for p in r]), reverse=True)
102
+ rows = [r for r in rows if r is not by_y[2]]
103
+ casas = _squares(rows)
104
+ if len(casas) != 64:
105
+ return None
106
+
107
+ syms = []
108
+ for p in casas:
109
+ xs = [q[0] for q in p]; ys = [q[1] for q in p]
110
+ crop = im[max(0, min(ys) - 90):max(ys), min(xs):max(xs)]
111
+ if crop.size == 0:
112
+ syms.append("."); continue
113
+ out = session.run(None, {"input": _preprocess(crop)})[0].flatten()
114
+ sm = np.exp(out) / np.exp(out).sum()
115
+ edge = cv.Canny(cv.cvtColor(crop, cv.COLOR_BGR2GRAY), 100, 150).mean() / 255
116
+ occupied = sm.max() >= 0.8 or edge >= 0.03 # confidence + edge density
117
+ syms.append(IDX2SYM[int(sm.argmax())] if occupied else ".")
118
+
119
+ grid = np.flipud(np.array(syms, object).reshape(8, 8)) # flipUD orientation
120
+ return _grid_to_fen(grid)
app/main.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chess Vision API — board digitization + human-like move prediction.
2
+
3
+ GET /health
4
+ POST /digitize multipart image -> FEN (board placement)
5
+ POST /predict-move { fen, top_n } -> CNN / Stockfish / hybrid moves
6
+ GET /docs Swagger UI
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+ import cv2 as cv
14
+ import numpy as np
15
+ import onnxruntime as ort
16
+ from fastapi import FastAPI, File, HTTPException, UploadFile
17
+ from fastapi.middleware.cors import CORSMiddleware
18
+ from pydantic import BaseModel, Field
19
+
20
+ from .digitize import image_to_fen
21
+ from .predict import MovePredictor
22
+
23
+ ROOT = Path(__file__).parent.parent
24
+ MODELS_DIR = os.environ.get("MODELS_DIR", str(ROOT / "models_onnx"))
25
+ STOCKFISH_PATH = os.environ.get("STOCKFISH_PATH", str(ROOT / "stockfish" / "stockfish"))
26
+
27
+ app = FastAPI(
28
+ title="Chess Vision API",
29
+ description="Digitize a chess board photo to a FEN, then predict human-like moves "
30
+ "(CNN trained on Lichess games) combined with Stockfish.",
31
+ version="0.1.0",
32
+ )
33
+ app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
34
+
35
+ # loaded once at startup
36
+ _digitizer = ort.InferenceSession(str(Path(MODELS_DIR) / "digitizer3d.fp32.onnx"),
37
+ providers=["CPUExecutionProvider"])
38
+ _predictor = MovePredictor(MODELS_DIR, stockfish_path=STOCKFISH_PATH)
39
+
40
+
41
+ class PredictRequest(BaseModel):
42
+ fen: str = Field(..., description="FEN (full or placement-only).",
43
+ examples=["rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1"])
44
+ top_n: int = Field(3, ge=1, le=8)
45
+
46
+
47
+ @app.get("/")
48
+ def root():
49
+ return {"service": "Chess Vision API", "version": app.version,
50
+ "stockfish": _predictor.stockfish_path is not None, "docs": "/docs"}
51
+
52
+
53
+ @app.get("/health")
54
+ def health():
55
+ return {"status": "ok", "stockfish": _predictor.stockfish_path is not None}
56
+
57
+
58
+ @app.post("/digitize")
59
+ async def digitize(file: UploadFile = File(...)):
60
+ """Upload a board image (render-style) and get its FEN board placement."""
61
+ data = await file.read()
62
+ img = cv.imdecode(np.frombuffer(data, np.uint8), cv.IMREAD_COLOR)
63
+ if img is None:
64
+ raise HTTPException(400, "Could not decode the image.")
65
+ placement = image_to_fen(img, _digitizer)
66
+ if placement is None:
67
+ raise HTTPException(422, "Could not detect a full 8x8 board in the image.")
68
+ return {"placement": placement, "fen": f"{placement} w - - 0 1"}
69
+
70
+
71
+ @app.post("/predict-move")
72
+ def predict_move(req: PredictRequest):
73
+ try:
74
+ return _predictor.predict(req.fen, top_n=req.top_n)
75
+ except ValueError as e:
76
+ raise HTTPException(400, f"Invalid FEN: {e}")
app/predict.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Human-like move prediction: CNN (piece + square classifiers) + Stockfish.
2
+
3
+ The CNN models predict the move a *human* would likely play (trained for white to
4
+ move; black positions are mirrored). Stockfish provides engine-strength moves and
5
+ a hybrid that keeps human-like moves which don't blunder.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import chess
12
+ import numpy as np
13
+ import onnxruntime as ort
14
+
15
+ PIECE_ORDER = [chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN, chess.KING]
16
+ SQUARE_NAMES = ["pawn", "knight", "bishop", "rook", "queen", "king"]
17
+
18
+
19
+ def _softmax(x):
20
+ e = np.exp(x - x.max())
21
+ return e / e.sum()
22
+
23
+
24
+ class MovePredictor:
25
+ def __init__(self, models_dir: str, stockfish_path: str | None = None):
26
+ d = Path(models_dir)
27
+ prov = ["CPUExecutionProvider"]
28
+ self.piece = ort.InferenceSession(str(d / "piece.int8.onnx"), providers=prov)
29
+ self.squares = [ort.InferenceSession(str(d / f"square_{s}.int8.onnx"), providers=prov)
30
+ for s in SQUARE_NAMES]
31
+ self.stockfish_path = (stockfish_path if stockfish_path and Path(stockfish_path).exists()
32
+ else None)
33
+
34
+ # ── encoding: 12x8x8, ch 0-5 white P,N,B,R,Q,K; 6-11 black; row0 = rank8 ──
35
+ def _encode(self, board: chess.Board):
36
+ enc = np.zeros((12, 8, 8), np.float32)
37
+ for sq in chess.SQUARES:
38
+ p = board.piece_at(sq)
39
+ if p:
40
+ c = PIECE_ORDER.index(p.piece_type) + (0 if p.color == chess.WHITE else 6)
41
+ enc[c, 7 - (sq >> 3), sq & 7] = 1.0
42
+ return enc[None]
43
+
44
+ def _cnn_white(self, board: chess.Board) -> dict:
45
+ enc = self._encode(board)
46
+ pieces = _softmax(self.piece.run(None, {"input": enc})[0].flatten())
47
+ legal = set(board.legal_moves)
48
+ mp: dict[str, float] = {}
49
+ for i, ptype in enumerate(PIECE_ORDER):
50
+ sq = (_softmax(self.squares[i].run(None, {"input": enc})[0].flatten()) + pieces[i]) / 2
51
+ for fsq in board.pieces(ptype, chess.WHITE):
52
+ fs = chess.square_name(fsq)
53
+ for j in range(64):
54
+ try:
55
+ mv = chess.Move.from_uci(fs + chess.square_name(j))
56
+ except ValueError:
57
+ continue
58
+ if mv in legal:
59
+ mp[mv.uci()] = float(sq[j])
60
+ return mp
61
+
62
+ def cnn_scores(self, board: chess.Board) -> dict:
63
+ """Move->score; handles black by mirroring the board and the moves."""
64
+ if board.turn == chess.WHITE:
65
+ return self._cnn_white(board)
66
+ mirrored = self._cnn_white(board.mirror())
67
+ out = {}
68
+ for uci, s in mirrored.items():
69
+ mv = chess.Move.from_uci(uci)
70
+ real = chess.Move(chess.square_mirror(mv.from_square),
71
+ chess.square_mirror(mv.to_square), mv.promotion)
72
+ out[real.uci()] = s
73
+ return out
74
+
75
+ def predict(self, fen: str, top_n: int = 3) -> dict:
76
+ board = chess.Board(fen)
77
+ cnn = [m for m, _ in sorted(self.cnn_scores(board).items(), key=lambda x: -x[1])]
78
+ out = {"fen": fen, "turn": "white" if board.turn else "black",
79
+ "cnn": cnn[:top_n], "stockfish": [], "hybrid": cnn[:top_n]}
80
+
81
+ if self.stockfish_path:
82
+ from stockfish import Stockfish
83
+ sf = Stockfish(path=self.stockfish_path, parameters={"Threads": 1})
84
+ sf.set_fen_position(fen)
85
+ sf.update_engine_parameters({"MultiPV": top_n})
86
+ out["stockfish"] = [t["Move"] for t in sf.get_top_moves(top_n) if t.get("Move")]
87
+
88
+ # hybrid: among the top human-like CNN moves, prefer the one Stockfish
89
+ # rates best (lowest eval for the opponent after the move).
90
+ scored = []
91
+ for mv in cnn[:max(6, top_n * 2)]:
92
+ board.push_uci(mv)
93
+ sf.set_fen_position(board.fen())
94
+ ev = sf.get_evaluation()
95
+ val = ev.get("value", 0) if ev.get("type") == "cp" else (10000 if ev.get("value", 0) > 0 else -10000)
96
+ board.pop()
97
+ scored.append((mv, val)) # val = opponent's eval after our move (lower = better for us)
98
+ out["hybrid"] = [m for m, _ in sorted(scored, key=lambda x: x[1])][:top_n]
99
+ return out
calibrate.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Corner-calibration helper for digitization.
3
+
4
+ python calibrate.py x1 y1 x2 y2 x3 y3 x4 y4 # TL TR BR BL (8x8 playing area)
5
+
6
+ Draws the chosen corners on the photo and the perspective-warped board with an
7
+ 8x8 grid, so we can eyeball whether the corners are right before classifying.
8
+ """
9
+ import sys
10
+ import cv2 as cv
11
+ import numpy as np
12
+
13
+ SIZE = 800 # warped board side (px)
14
+
15
+ img = cv.imread("samples/board.png")
16
+ H, W = img.shape[:2]
17
+
18
+ if len(sys.argv) == 9:
19
+ pts = list(map(float, sys.argv[1:9]))
20
+ corners = np.array([[pts[0], pts[1]], [pts[2], pts[3]],
21
+ [pts[4], pts[5]], [pts[6], pts[7]]], dtype=np.float32)
22
+ else:
23
+ # first guess (TL, TR, BR, BL) on the 1920x1080 photo
24
+ corners = np.array([[518, 130], [1210, 115], [1370, 900], [455, 915]], dtype=np.float32)
25
+
26
+ # --- overlay on the original ---
27
+ ov = img.copy()
28
+ labels = ["TL", "TR", "BR", "BL"]
29
+ for (x, y), lab in zip(corners, labels):
30
+ cv.circle(ov, (int(x), int(y)), 12, (0, 0, 255), -1)
31
+ cv.putText(ov, lab, (int(x) + 14, int(y)), cv.FONT_HERSHEY_SIMPLEX, 1.2, (0, 0, 255), 3)
32
+ cv.polylines(ov, [corners.astype(int)], True, (0, 255, 0), 3)
33
+ cv.imwrite("/tmp/overlay.png", ov)
34
+
35
+ # --- perspective warp to a square, with 8x8 grid ---
36
+ dst = np.array([[0, 0], [SIZE, 0], [SIZE, SIZE], [0, SIZE]], dtype=np.float32)
37
+ M = cv.getPerspectiveTransform(corners, dst)
38
+ warp = cv.warpPerspective(img, M, (SIZE, SIZE))
39
+ grid = warp.copy()
40
+ for i in range(9):
41
+ p = i * SIZE // 8
42
+ cv.line(grid, (p, 0), (p, SIZE), (0, 0, 255), 2)
43
+ cv.line(grid, (0, p), (SIZE, p), (0, 0, 255), 2)
44
+ cv.imwrite("/tmp/warped_grid.png", grid)
45
+ cv.imwrite("/tmp/warped.png", warp)
46
+ print("wrote /tmp/overlay.png and /tmp/warped_grid.png")
convert_to_onnx.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Convert the trained .pth models to quantized ONNX (INT8) for serving.
3
+
4
+ Reads the weights from the Master's project, exports each model to ONNX and
5
+ applies dynamic INT8 quantization to shrink the ~444 MB of .pth files.
6
+
7
+ python convert_to_onnx.py
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+
14
+ import torch
15
+ from onnxruntime.quantization import QuantType, quantize_dynamic
16
+
17
+ from app.chess_models import PieceClassifier, PieceImageClassifier, SquareClassifier
18
+
19
+ PTH_DIR = Path("../Master-of-Science-Degree-Project/Models")
20
+ OUT = Path("models_onnx")
21
+ OUT.mkdir(exist_ok=True)
22
+
23
+
24
+ def _find_state_dict(obj) -> dict:
25
+ """Accept a bare state_dict or a checkpoint with an arbitrarily-named
26
+ sub-dict of tensors (e.g. 'model_state_dict_PC')."""
27
+ if isinstance(obj, dict) and obj and all(isinstance(v, torch.Tensor) for v in obj.values()):
28
+ return obj
29
+ if isinstance(obj, dict):
30
+ for v in obj.values():
31
+ if isinstance(v, dict) and v and all(isinstance(t, torch.Tensor) for t in v.values()):
32
+ return v
33
+ raise ValueError("no state_dict found in checkpoint")
34
+
35
+
36
+ def load(model: torch.nn.Module, pth: str) -> torch.nn.Module:
37
+ obj = torch.load(PTH_DIR / pth, map_location="cpu", weights_only=False)
38
+ model.load_state_dict(_find_state_dict(obj))
39
+ model.eval()
40
+ return model
41
+
42
+
43
+ def export(model: torch.nn.Module, dummy: torch.Tensor, name: str, dyn: dict):
44
+ fp32 = OUT / f"{name}.onnx"
45
+ torch.onnx.export(
46
+ model, dummy, str(fp32),
47
+ input_names=["input"], output_names=["output"],
48
+ dynamic_axes=dyn, opset_version=17, dynamo=False,
49
+ )
50
+ int8 = OUT / f"{name}.int8.onnx"
51
+ quantize_dynamic(str(fp32), str(int8), weight_type=QuantType.QInt8)
52
+ fp32.unlink() # keep only the quantized one
53
+ return int8.stat().st_size / 1e6
54
+
55
+
56
+ def main() -> None:
57
+ jobs = [
58
+ (PieceImageClassifier(), "imageClassifierReal.pth", "digitizer",
59
+ torch.randn(1, 3, 96, 96), {"input": {0: "b", 2: "h", 3: "w"}}),
60
+ (PieceClassifier(), "pieceClassifier.pth", "piece",
61
+ torch.randn(1, 12, 8, 8), {"input": {0: "b"}}),
62
+ ]
63
+ for piece in ["Pawn", "Knight", "Bishop", "Rook", "Queen", "King"]:
64
+ jobs.append((SquareClassifier(), f"square{piece}Classifier.pth",
65
+ f"square_{piece.lower()}", torch.randn(1, 12, 8, 8), {"input": {0: "b"}}))
66
+
67
+ total = 0.0
68
+ for model, pth, name, dummy, dyn in jobs:
69
+ try:
70
+ load(model, pth)
71
+ mb = export(model, dummy, name, dyn)
72
+ total += mb
73
+ print(f" ✓ {name:16s} <- {pth:28s} {mb:6.1f} MB (int8)")
74
+ except Exception as e:
75
+ print(f" ✗ {name:16s} <- {pth:28s} FAILED: {type(e).__name__}: {e}")
76
+
77
+ print(f"\nTotal ONNX INT8: {total:.1f} MB (from ~444 MB of .pth)")
78
+
79
+
80
+ if __name__ == "__main__":
81
+ main()
digitize_corners.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Decisive test: 4-corner grid (robust) + EXACT Preprocess crop (bbox + 90px top),
3
+ classify the 64 squares, compare to FEN under all 8 orientations and 3 norms."""
4
+ import cv2 as cv
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+
8
+ TRUE = "rn1qkb1r/pb2pppp/5n2/1ppp4/8/3P1NP1/PPP1PPBP/RNBQ1RK1"
9
+ IDX2SYM = {0:"K",1:"Q",2:"R",3:"B",4:"N",5:"P",6:"k",7:"q",8:"r",9:"b",10:"n",11:"p"}
10
+ # corners on the FULL photo (TL,TR,BR,BL of the 8x8 area)
11
+ CORNERS_FULL = np.array([[520,140],[1360,150],[1430,915],[452,918]], np.float32)
12
+
13
+
14
+ def crop_image(im):
15
+ cx, cy = im.shape[1]//2, im.shape[0]//2
16
+ return im[cy-520:cy+450, cx-550:cx+550], (cx-550, cy-520)
17
+
18
+
19
+ def true_grid():
20
+ g=[]
21
+ for row in TRUE.split("/"):
22
+ r=[]
23
+ for ch in row: r += ["."]*int(ch) if ch.isdigit() else [ch]
24
+ g.append(r)
25
+ return np.array(g)
26
+
27
+
28
+ def main():
29
+ img = cv.imread("samples/board.png")
30
+ im, (ox, oy) = crop_image(img)
31
+ corners = CORNERS_FULL - [ox, oy] # into crop space
32
+ unit = np.array([[0,0],[8,0],[8,8],[0,8]], np.float32)
33
+ Hmat = cv.getPerspectiveTransform(unit, corners) # unit grid -> image
34
+
35
+ def node(c, r):
36
+ p = cv.perspectiveTransform(np.array([[[c, r]]], np.float32), Hmat)[0][0]
37
+ return p
38
+
39
+ crops = []
40
+ for r in range(8):
41
+ for c in range(8):
42
+ pts = [node(c, r), node(c+1, r), node(c+1, r+1), node(c, r+1)]
43
+ xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
44
+ x1, x2 = int(min(xs)), int(max(xs))
45
+ y1 = max(0, int(min(ys)) - 90); y2 = int(max(ys))
46
+ crops.append(im[y1:y2, x1:x2])
47
+
48
+ sess = ort.InferenceSession("models_onnx/digitizer.fp32.onnx", providers=["CPUExecutionProvider"])
49
+ T = true_grid()
50
+ occ = (T != ".")
51
+ n_occ = int(occ.sum())
52
+
53
+ for nm, fn in [("/255", lambda r: r/255.0),
54
+ ("imagenet", lambda r: (r/255.0-[0.485,0.456,0.406])/[0.229,0.224,0.225]),
55
+ ("[-1,1]", lambda r: r/127.5-1)]:
56
+ P = np.empty((8,8), object)
57
+ for i, crop in enumerate(crops):
58
+ if crop.size == 0:
59
+ P[i//8, i%8] = "?"; continue
60
+ rgb = cv.cvtColor(cv.resize(crop,(96,96)), cv.COLOR_BGR2RGB).astype(np.float32)
61
+ x = np.asarray(fn(rgb), np.float32).transpose(2,0,1)[None]
62
+ out = sess.run(None, {"input": x})[0].flatten()
63
+ P[i//8, i%8] = IDX2SYM[int(out.argmax())]
64
+ # best over 8 dihedral orientations, counting only TRUE-occupied squares
65
+ best = 0; bestname = ""
66
+ for k, Q in [("id",P),("rot90",np.rot90(P)),("rot180",np.rot90(P,2)),("rot270",np.rot90(P,3)),
67
+ ("flipUD",np.flipud(P)),("flipLR",np.fliplr(P)),
68
+ ("transp",P.T),("anti",np.fliplr(np.rot90(P)))]:
69
+ if Q.shape!=T.shape: continue
70
+ m = int(((Q==T)&occ).sum())
71
+ if m>best: best, bestname = m, k
72
+ print(f"[{nm:9s}] best occupied-match {best:2d}/{n_occ} ({bestname})")
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
digitize_fen.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Full image -> FEN: exact grid + crop + transform + empty detection (binarize
3
+ center) + flipUD orientation. Compares to the known FEN for i10."""
4
+ import sys
5
+ import cv2 as cv
6
+ import numpy as np
7
+ import onnxruntime as ort
8
+ from digitize_full import get_casas, crop_square
9
+
10
+ TRUE = "rn1qkb1r/pb2pppp/5n2/1ppp4/8/3P1NP1/PPP1PPBP/RNBQ1RK1"
11
+ IDX2SYM = {0:"P",1:"N",2:"B",3:"R",4:"Q",5:"K",6:"p",7:"n",8:"b",9:"r",10:"q",11:"k"}
12
+ MEAN, STD = np.array([0.485,0.456,0.406],np.float32), np.array([0.229,0.224,0.225],np.float32)
13
+
14
+
15
+ def preprocess(bgr):
16
+ rgb = cv.cvtColor(cv.resize(bgr,(224,224)),cv.COLOR_BGR2RGB).astype(np.float32)/255.0
17
+ return ((rgb-MEAN)/STD).transpose(2,0,1)[None]
18
+
19
+
20
+ def is_empty(im, quad):
21
+ cx = int(np.mean([p[0] for p in quad])); cy = int(np.mean([p[1] for p in quad]))
22
+ w = max(6, int(0.22*(max(p[0] for p in quad)-min(p[0] for p in quad))))
23
+ patch = im[max(0,cy-w):cy+w, max(0,cx-w):cx+w]
24
+ if patch.size == 0:
25
+ return True
26
+ g = cv.cvtColor(patch, cv.COLOR_BGR2GRAY)
27
+ return g.std() < 18 # flat colour -> empty
28
+
29
+
30
+ def grid_to_fen(grid):
31
+ out=[]
32
+ for row in grid:
33
+ s=""; e=0
34
+ for cell in row:
35
+ if cell==".": e+=1
36
+ else:
37
+ if e: s+=str(e); e=0
38
+ s+=cell
39
+ if e: s+=str(e)
40
+ out.append(s)
41
+ return "/".join(out)
42
+
43
+
44
+ def main():
45
+ path = sys.argv[1] if len(sys.argv) > 1 else "samples/board.png"
46
+ im, casas = get_casas(path)
47
+ sess = ort.InferenceSession("models_onnx/digitizer3d.fp32.onnx", providers=["CPUExecutionProvider"])
48
+ syms=[]
49
+ for p in casas[:64]:
50
+ if is_empty(im, p):
51
+ syms.append("."); continue
52
+ c = crop_square(im, p)
53
+ out = sess.run(None, {"input": preprocess(c)})[0].flatten()
54
+ syms.append(IDX2SYM[int(out.argmax())])
55
+ grid = np.flipud(np.array(syms, object).reshape(8,8)) # flipUD orientation
56
+ fen = grid_to_fen(grid)
57
+ print("PRED FEN:", fen)
58
+ print("TRUE FEN:", TRUE)
59
+ T = np.array([list(r) for r in
60
+ ["".join("." if ch.isdigit() and False else ch for ch in row) for row in TRUE.split("/")]], object)
61
+ # build true grid properly
62
+ tg=[]
63
+ for row in TRUE.split("/"):
64
+ rr=[]
65
+ for ch in row: rr += ["."]*int(ch) if ch.isdigit() else [ch]
66
+ tg.append(rr)
67
+ T=np.array(tg,object)
68
+ print(f"exact-cell match: {int((grid==T).sum())}/64")
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
digitize_final.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Digitization with the EXACT transform from Utils/read_imgs.py:
3
+ Resize(224,224) + ToTensor + ImageNet Normalize, and the correct class order."""
4
+ import sys
5
+ import cv2 as cv
6
+ import numpy as np
7
+ import onnxruntime as ort
8
+ from digitize_full import get_casas, crop_square
9
+
10
+ TRUE = "rn1qkb1r/pb2pppp/5n2/1ppp4/8/3P1NP1/PPP1PPBP/RNBQ1RK1"
11
+ # from read_imgs.py: {'P':0,'N':1,'B':2,'R':3,'Q':4,'K':5,'p':6,'n':7,'b':8,'r':9,'q':10,'k':11}
12
+ IDX2SYM = {0:"P",1:"N",2:"B",3:"R",4:"Q",5:"K",6:"p",7:"n",8:"b",9:"r",10:"q",11:"k"}
13
+ MEAN = np.array([0.485,0.456,0.406], np.float32)
14
+ STD = np.array([0.229,0.224,0.225], np.float32)
15
+
16
+
17
+ def preprocess(bgr):
18
+ rgb = cv.cvtColor(cv.resize(bgr,(224,224)), cv.COLOR_BGR2RGB).astype(np.float32)/255.0
19
+ return ((rgb-MEAN)/STD).transpose(2,0,1)[None]
20
+
21
+
22
+ def true_grid():
23
+ g=[]
24
+ for row in TRUE.split("/"):
25
+ r=[]
26
+ for ch in row: r += ["."]*int(ch) if ch.isdigit() else [ch]
27
+ g.append(r)
28
+ return np.array(g)
29
+
30
+
31
+ def main():
32
+ path = sys.argv[1] if len(sys.argv) > 1 else "samples/board.png"
33
+ im, casas = get_casas(path)
34
+ print("squares:", len(casas))
35
+ crops = [crop_square(im, p) for p in casas[:64]]
36
+ T = true_grid(); occ = (T!="."); n=int(occ.sum())
37
+
38
+ for model in ["digitizer3d.fp32.onnx", "digitizer.fp32.onnx"]:
39
+ sess = ort.InferenceSession(f"models_onnx/{model}", providers=["CPUExecutionProvider"])
40
+ syms=[]
41
+ for c in crops:
42
+ if c.size==0: syms.append("?"); continue
43
+ out = sess.run(None, {"input": preprocess(c)})[0].flatten()
44
+ syms.append(IDX2SYM[int(out.argmax())])
45
+ P = np.array(syms, object).reshape(8,8)
46
+ best = max((int(((Q==T)&occ).sum()), k) for k,Q in
47
+ [("id",P),("rot90",np.rot90(P)),("rot180",np.rot90(P,2)),("rot270",np.rot90(P,3)),
48
+ ("flipUD",np.flipud(P)),("flipLR",np.fliplr(P)),("T",P.T),("anti",np.fliplr(np.rot90(P)))])
49
+ tag = "3D" if "3d" in model else "REAL"
50
+ print(f" {tag:4s}: best occupied-match {best[0]:2d}/{n} ({best[1]})")
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
digitize_full.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Full digitization on samples/board.png (i10): exact Preprocess grid (with the
3
+ 3rd-from-bottom line removed) + exact bbox+90 crop + classify, vs known FEN."""
4
+ import cv2 as cv
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+ import hough_pipeline as hp
8
+
9
+ TRUE = "rn1qkb1r/pb2pppp/5n2/1ppp4/8/3P1NP1/PPP1PPBP/RNBQ1RK1"
10
+ IDX2SYM = {0:"K",1:"Q",2:"R",3:"B",4:"N",5:"P",6:"k",7:"q",8:"r",9:"b",10:"n",11:"p"}
11
+
12
+
13
+ def get_casas(path):
14
+ im = hp.crop_image(cv.imread(path))
15
+ board = hp.filter_board_lines(hp.group_lines(hp.detect_lines(hp.detect_edges(im))))
16
+ rows = hp.compute_intersections(board)
17
+ by_y = sorted(rows, key=lambda r: np.mean([p[1] for p in r]), reverse=True)
18
+ rows = [r for r in rows if r is not by_y[2]]
19
+ return im, hp.build_squares(rows)
20
+
21
+
22
+ def crop_square(im, p):
23
+ xs = [pt[0] for pt in p]; ys = [pt[1] for pt in p]
24
+ x_min, x_max = min(xs), max(xs); y_min, y_max = min(ys), max(ys)
25
+ return im[max(0, y_min - 90):y_max, x_min:x_max]
26
+
27
+
28
+ def true_grid():
29
+ g = []
30
+ for row in TRUE.split("/"):
31
+ r = []
32
+ for ch in row:
33
+ r += ["."] * int(ch) if ch.isdigit() else [ch]
34
+ g.append(r)
35
+ return np.array(g)
36
+
37
+
38
+ def main():
39
+ im, casas = get_casas("samples/board.png")
40
+ print("squares:", len(casas))
41
+ if len(casas) != 64:
42
+ return
43
+ crops = [crop_square(im, p) for p in casas[:64]]
44
+ sess = ort.InferenceSession("models_onnx/digitizer.fp32.onnx", providers=["CPUExecutionProvider"])
45
+ T = true_grid(); occ = (T != "."); n = int(occ.sum())
46
+
47
+ for nm, fn in [("/255", lambda r: r/255.0),
48
+ ("imagenet", lambda r: (r/255.0-[0.485,0.456,0.406])/[0.229,0.224,0.225]),
49
+ ("[-1,1]", lambda r: r/127.5-1)]:
50
+ syms = []
51
+ for c in crops:
52
+ if c.size == 0:
53
+ syms.append("?"); continue
54
+ rgb = cv.cvtColor(cv.resize(c, (96, 96)), cv.COLOR_BGR2RGB).astype(np.float32)
55
+ x = np.asarray(fn(rgb), np.float32).transpose(2, 0, 1)[None]
56
+ out = sess.run(None, {"input": x})[0].flatten()
57
+ syms.append(IDX2SYM[int(out.argmax())])
58
+ P = np.array(syms, object).reshape(8, 8)
59
+ best = max(
60
+ (int(((Q == T) & occ).sum()), k)
61
+ for k, Q in [("id", P), ("rot90", np.rot90(P)), ("rot180", np.rot90(P, 2)),
62
+ ("rot270", np.rot90(P, 3)), ("flipUD", np.flipud(P)),
63
+ ("flipLR", np.fliplr(P)), ("T", P.T), ("anti", np.fliplr(np.rot90(P)))]
64
+ )
65
+ print(f"[{nm:9s}] best occupied-match {best[0]:2d}/{n} ({best[1]})")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
digitize_full_3d.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Full digitization on samples/board.png (i10): exact Preprocess grid (with the
3
+ 3rd-from-bottom line removed) + exact bbox+90 crop + classify, vs known FEN."""
4
+ import cv2 as cv
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+ import hough_pipeline as hp
8
+
9
+ TRUE = "rn1qkb1r/pb2pppp/5n2/1ppp4/8/3P1NP1/PPP1PPBP/RNBQ1RK1"
10
+ IDX2SYM = {0:"K",1:"Q",2:"R",3:"B",4:"N",5:"P",6:"k",7:"q",8:"r",9:"b",10:"n",11:"p"}
11
+
12
+
13
+ def get_casas(path):
14
+ im = hp.crop_image(cv.imread(path))
15
+ board = hp.filter_board_lines(hp.group_lines(hp.detect_lines(hp.detect_edges(im))))
16
+ rows = hp.compute_intersections(board)
17
+ by_y = sorted(rows, key=lambda r: np.mean([p[1] for p in r]), reverse=True)
18
+ rows = [r for r in rows if r is not by_y[2]]
19
+ return im, hp.build_squares(rows)
20
+
21
+
22
+ def crop_square(im, p):
23
+ xs = [pt[0] for pt in p]; ys = [pt[1] for pt in p]
24
+ x_min, x_max = min(xs), max(xs); y_min, y_max = min(ys), max(ys)
25
+ return im[max(0, y_min - 90):y_max, x_min:x_max]
26
+
27
+
28
+ def true_grid():
29
+ g = []
30
+ for row in TRUE.split("/"):
31
+ r = []
32
+ for ch in row:
33
+ r += ["."] * int(ch) if ch.isdigit() else [ch]
34
+ g.append(r)
35
+ return np.array(g)
36
+
37
+
38
+ def main():
39
+ im, casas = get_casas("samples/board.png")
40
+ print("squares:", len(casas))
41
+ if len(casas) != 64:
42
+ return
43
+ crops = [crop_square(im, p) for p in casas[:64]]
44
+ sess = ort.InferenceSession("models_onnx/digitizer3d.fp32.onnx", providers=["CPUExecutionProvider"])
45
+ T = true_grid(); occ = (T != "."); n = int(occ.sum())
46
+
47
+ for nm, fn in [("/255", lambda r: r/255.0),
48
+ ("imagenet", lambda r: (r/255.0-[0.485,0.456,0.406])/[0.229,0.224,0.225]),
49
+ ("[-1,1]", lambda r: r/127.5-1)]:
50
+ syms = []
51
+ for c in crops:
52
+ if c.size == 0:
53
+ syms.append("?"); continue
54
+ rgb = cv.cvtColor(cv.resize(c, (96, 96)), cv.COLOR_BGR2RGB).astype(np.float32)
55
+ x = np.asarray(fn(rgb), np.float32).transpose(2, 0, 1)[None]
56
+ out = sess.run(None, {"input": x})[0].flatten()
57
+ syms.append(IDX2SYM[int(out.argmax())])
58
+ P = np.array(syms, object).reshape(8, 8)
59
+ best = max(
60
+ (int(((Q == T) & occ).sum()), k)
61
+ for k, Q in [("id", P), ("rot90", np.rot90(P)), ("rot180", np.rot90(P, 2)),
62
+ ("rot270", np.rot90(P, 3)), ("flipUD", np.flipud(P)),
63
+ ("flipLR", np.fliplr(P)), ("T", P.T), ("anti", np.fliplr(np.rot90(P)))]
64
+ )
65
+ print(f"[{nm:9s}] best occupied-match {best[0]:2d}/{n} ({best[1]})")
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
digitize_test.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Diagnostic: classify the 64 squares of samples/board.png and compare to the
3
+ known FEN. Helps calibrate crop/normalization and understand the empty-class issue."""
4
+ import sys
5
+ import cv2 as cv
6
+ import numpy as np
7
+ import onnxruntime as ort
8
+
9
+ SIZE = 800
10
+ CORNERS = np.array([[520, 140], [1360, 150], [1430, 915], [452, 918]], dtype=np.float32)
11
+ TRUE_FEN = "rn1qkb1r/pb2pppp/5n2/1ppp4/8/3P1NP1/PPP1PPBP/RNBQ1RK1"
12
+ IDX2SYM = {0: "K", 1: "Q", 2: "R", 3: "B", 4: "N", 5: "P",
13
+ 6: "k", 7: "q", 8: "r", 9: "b", 10: "n", 11: "p"}
14
+
15
+ MEAN = np.array([0.485, 0.456, 0.406], np.float32)
16
+ STD = np.array([0.229, 0.224, 0.225], np.float32)
17
+
18
+
19
+ def preprocess(bgr):
20
+ rgb = cv.cvtColor(cv.resize(bgr, (96, 96)), cv.COLOR_BGR2RGB).astype(np.float32) / 255.0
21
+ rgb = (rgb - MEAN) / STD
22
+ return rgb.transpose(2, 0, 1)[None]
23
+
24
+
25
+ def fen_grid(fen):
26
+ grid = []
27
+ for row in fen.split("/"):
28
+ r = []
29
+ for ch in row:
30
+ if ch.isdigit():
31
+ r += ["."] * int(ch)
32
+ else:
33
+ r.append(ch)
34
+ grid.append(r)
35
+ return grid
36
+
37
+
38
+ def main():
39
+ margin = float(sys.argv[1]) if len(sys.argv) > 1 else 0.6 # top margin (cell fractions)
40
+ img = cv.imread("samples/board.png")
41
+ dst = np.array([[0, 0], [SIZE, 0], [SIZE, SIZE], [0, SIZE]], np.float32)
42
+ Minv = cv.getPerspectiveTransform(dst, CORNERS) # warped -> original
43
+
44
+ sess = ort.InferenceSession("models_onnx/digitizer.int8.onnx",
45
+ providers=["CPUExecutionProvider"])
46
+ cell = SIZE / 8
47
+ pred = [["." for _ in range(8)] for _ in range(8)]
48
+ prob = [[0.0 for _ in range(8)] for _ in range(8)]
49
+
50
+ for r in range(8):
51
+ for c in range(8):
52
+ # cell quad in warped space, extended upward by `margin` cells for the piece
53
+ y0 = (r - margin) * cell
54
+ quad = np.array([[[c * cell, y0], [(c + 1) * cell, y0],
55
+ [(c + 1) * cell, (r + 1) * cell], [c * cell, (r + 1) * cell]]],
56
+ np.float32)
57
+ orig = cv.perspectiveTransform(quad, Minv)[0]
58
+ xs, ys = orig[:, 0], orig[:, 1]
59
+ x1, x2 = max(0, int(xs.min())), min(img.shape[1], int(xs.max()))
60
+ y1, y2 = max(0, int(ys.min())), min(img.shape[0], int(ys.max()))
61
+ crop = img[y1:y2, x1:x2]
62
+ if crop.size == 0:
63
+ continue
64
+ out = sess.run(None, {"input": preprocess(crop)})[0].flatten()
65
+ p = np.exp(out) / np.exp(out).sum()
66
+ k = int(p.argmax())
67
+ pred[r][c] = IDX2SYM[k]
68
+ prob[r][c] = float(p[k])
69
+
70
+ true = fen_grid(TRUE_FEN)
71
+ print(f"margin={margin}\n")
72
+ print("PRED (top row = rank8 assumed): TRUE:")
73
+ hits = 0
74
+ for r in range(8):
75
+ pr = " ".join(pred[r])
76
+ tr = " ".join(true[r])
77
+ hits += sum(1 for a, b in zip(pred[r], true[r]) if a == b)
78
+ print(f" {pr} {tr}")
79
+ print(f"\nexact-cell match (incl. empties): {hits}/64")
80
+ print("avg max-prob per row (low on empties?):")
81
+ for r in range(8):
82
+ print(" " + " ".join(f"{prob[r][c]:.2f}" for c in range(8)))
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()
hough_pipeline.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """EXACT Preprocess.ipynb pipeline on a given image, with per-step visualizations.
3
+
4
+ python hough_pipeline.py samples/i1000.png
5
+ """
6
+ import sys
7
+ import cv2 as cv
8
+ import numpy as np
9
+
10
+
11
+ def crop_image(im):
12
+ cx, cy = im.shape[1] // 2, im.shape[0] // 2
13
+ return im[cy - 520:cy + 450, cx - 550:cx + 550]
14
+
15
+ def detect_edges(im): return cv.Canny(im, 100, 150, apertureSize=3)
16
+ def detect_lines(b): return cv.HoughLines(b, 1, np.pi / 180, 160)
17
+
18
+ def group_lines(linhas):
19
+ g = []
20
+ for linha in linhas:
21
+ rho, theta = linha[0]
22
+ if not any(abs(rho - l[0][0]) < 30 and abs(theta - l[0][1]) < np.pi / 18 for l in g):
23
+ g.append(linha)
24
+ return g
25
+
26
+ def filter_board_lines(g):
27
+ min_rho = None
28
+ for linha in g:
29
+ rho, theta = linha[0]
30
+ if 1 < theta < 3 and (min_rho is None or rho < min_rho):
31
+ min_rho = rho
32
+ return [l for l in g if l[0][0] != min_rho]
33
+
34
+ def line_pts(rho, theta):
35
+ a, b = np.cos(theta), np.sin(theta)
36
+ x0, y0 = a * rho, b * rho
37
+ return ((int(x0 + 10000 * -b), int(y0 + 10000 * a)),
38
+ (int(x0 - 10000 * -b), int(y0 - 10000 * a)))
39
+
40
+ def compute_intersections(linhas_casas):
41
+ lp = [[*line_pts(l[0][0], l[0][1]), l[0][1]] for l in linhas_casas]
42
+ rows = []
43
+ for i in range(len(lp)):
44
+ pts = []
45
+ for j in range(len(lp)):
46
+ (x1, y1), (x2, y2) = lp[i][0], lp[i][1]
47
+ (x3, y3), (x4, y4) = lp[j][0], lp[j][1]
48
+ det = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
49
+ if det != 0:
50
+ ix = int(((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / det)
51
+ iy = int(((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / det)
52
+ if 0 <= ix <= 1920 and 0 <= iy <= 1080 and 1 < lp[i][2] < 3:
53
+ pts.append((ix, iy))
54
+ if pts:
55
+ pts.sort(key=lambda p: p[0]); rows.append(pts)
56
+ rows.sort(key=lambda p: p[0][0])
57
+ return rows
58
+
59
+ def build_squares(rows):
60
+ casas = []
61
+ for i in range(len(rows) - 1):
62
+ for j in range(len(rows[i]) - 1):
63
+ casas.append([rows[i][j], rows[i][j + 1], rows[i + 1][j], rows[i + 1][j + 1]])
64
+ return casas
65
+
66
+
67
+ def main():
68
+ path = sys.argv[1] if len(sys.argv) > 1 else "samples/i1000.png"
69
+ im = crop_image(cv.imread(path))
70
+ edges = detect_edges(im)
71
+ cv.imwrite("/tmp/h_canny.png", edges)
72
+
73
+ lines = detect_lines(edges)
74
+ print("hough lines:", 0 if lines is None else len(lines))
75
+ grouped = group_lines(lines)
76
+ board_lines = filter_board_lines(grouped)
77
+ print("grouped:", len(grouped), " board_lines:", len(board_lines))
78
+
79
+ vis = im.copy()
80
+ for l in board_lines:
81
+ (p1, p2) = line_pts(l[0][0], l[0][1])
82
+ cv.line(vis, p1, p2, (0, 0, 255), 2)
83
+ cv.imwrite("/tmp/h_lines.png", vis)
84
+
85
+ rows = compute_intersections(board_lines)
86
+ print("intersection rows (raw):", len(rows))
87
+ # remove the spurious extra horizontal line: the 3rd counting from the bottom
88
+ by_y = sorted(rows, key=lambda r: np.mean([p[1] for p in r]), reverse=True)
89
+ if len(by_y) >= 3:
90
+ extra = by_y[2]
91
+ rows = [r for r in rows if r is not extra]
92
+ print("intersection rows (after removing 3rd-from-bottom):", len(rows),
93
+ " pts/row:", [len(r) for r in rows])
94
+ vp = im.copy()
95
+ for r in rows:
96
+ for (x, y) in r:
97
+ cv.circle(vp, (x, y), 5, (255, 0, 0), -1)
98
+ cv.imwrite("/tmp/h_points.png", vp)
99
+
100
+ casas = build_squares(rows)
101
+ print("SQUARES:", len(casas))
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main()
models_onnx/digitizer.fp32.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8caf8cf4ae159f51ea8341e4493da2afe7ebb6447fca7b07f7c66c382ab8e9f4
3
+ size 11516960
models_onnx/digitizer.int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:266f19b2ab7892b5c8a1381a3dc353319599985d4cfd56c68d79893d9af09573
3
+ size 3075141
models_onnx/digitizer3d.fp32.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:39ca7e7445d102280aeb117fa2c89a14a965ff13cf137b98366a96305bde230a
3
+ size 11516960
models_onnx/piece.int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eb5c1dc8a615f3ca37c63ce12fe9e35ce15a81ea0df78f92dfdefa48e085cd19
3
+ size 5038398
models_onnx/square_bishop.int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a2ffedd4fc2ad9f4dd4f38ecb71b4a5d9d385638cbd82ab8b151d400700eef7
3
+ size 5068330
models_onnx/square_king.int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da7ce65c37676d4f121778b4ef87866417ab9d96b2b1a168598b203b20de0f7a
3
+ size 5068330
models_onnx/square_knight.int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d9cdf9adcd841a118f59d8cc379827d75340437aee22e5e64e95cb3687a1cec
3
+ size 5068330
models_onnx/square_pawn.int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cb978b0b60b4f54f886b276f518409842ebaaf5716b17f96b567aa2a3f7c9292
3
+ size 5068330
models_onnx/square_queen.int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:337cef4bd297a6c0b719a257fd2200839f796b9c95938e406cdb618daf92b241
3
+ size 5068330
models_onnx/square_rook.int8.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e743d764a203bec196138239424c41c5f6c652a752e9aa311e591ec20943e60c
3
+ size 5068330
move_predict.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Move prediction inference (CNN part) replicating Predict_Human_Move_Train.test_model.
3
+ Sanity-checks the 12x8x8 encoding + class order on the start position."""
4
+ import sys
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+ import chess
8
+
9
+ PIECE_ORDER = [chess.PAWN, chess.KNIGHT, chess.BISHOP, chess.ROOK, chess.QUEEN, chess.KING]
10
+ SQUARE_MODELS = ["pawn", "knight", "bishop", "rook", "queen", "king"]
11
+
12
+
13
+ def encode(board, row0_rank8=True):
14
+ """12x8x8: channels 0-5 white P,N,B,R,Q,K; 6-11 black. (white-to-move board)"""
15
+ enc = np.zeros((12, 8, 8), np.float32)
16
+ for sq in chess.SQUARES:
17
+ p = board.piece_at(sq)
18
+ if p:
19
+ c = PIECE_ORDER.index(p.piece_type) + (0 if p.color == chess.WHITE else 6)
20
+ r = 7 - (sq // 8) if row0_rank8 else (sq // 8)
21
+ enc[c, r, sq % 8] = 1.0
22
+ return enc[None]
23
+
24
+
25
+ def softmax(x):
26
+ e = np.exp(x - x.max()); return e / e.sum()
27
+
28
+
29
+ def cnn_moves(board, piece_sess, square_sesss, row0_rank8=True):
30
+ enc = encode(board, row0_rank8)
31
+ pieces = softmax(piece_sess.run(None, {"input": enc})[0].flatten())
32
+ legal = list(board.legal_moves)
33
+ move_prob = {}
34
+ for i, ptype in enumerate(PIECE_ORDER):
35
+ squares = softmax(square_sesss[i].run(None, {"input": enc})[0].flatten())
36
+ squares = (squares + pieces[i]) / 2
37
+ for from_sq in board.pieces(ptype, chess.WHITE):
38
+ fs = chess.square_name(from_sq)
39
+ for j in range(64):
40
+ try:
41
+ mv = chess.Move.from_uci(fs + chess.square_name(j))
42
+ if mv in legal:
43
+ move_prob[mv.uci()] = squares[j]
44
+ except Exception:
45
+ pass
46
+ return [m for m, _ in sorted(move_prob.items(), key=lambda x: x[1], reverse=True)]
47
+
48
+
49
+ def main():
50
+ piece_sess = ort.InferenceSession("models_onnx/piece.int8.onnx", providers=["CPUExecutionProvider"])
51
+ square_sesss = [ort.InferenceSession(f"models_onnx/square_{s}.int8.onnx",
52
+ providers=["CPUExecutionProvider"]) for s in SQUARE_MODELS]
53
+ board = chess.Board() # start position (white to move)
54
+ for orient in (True, False):
55
+ moves = cnn_moves(board, piece_sess, square_sesss, row0_rank8=orient)
56
+ print(f"row0_rank8={orient}: top CNN moves -> {moves[:8]}")
57
+
58
+
59
+ if __name__ == "__main__":
60
+ main()
preprocess_repro.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Faithful reproduction of Preprocess.ipynb crop pipeline on samples/board.png,
3
+ then classify the 64 squares and compare to the known FEN."""
4
+ import cv2 as cv
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+
8
+ TRUE = "rn1qkb1r/pb2pppp/5n2/1ppp4/8/3P1NP1/PPP1PPBP/RNBQ1RK1"
9
+ IDX2SYM = {0:"K",1:"Q",2:"R",3:"B",4:"N",5:"P",6:"k",7:"q",8:"r",9:"b",10:"n",11:"p"}
10
+
11
+
12
+ # ---- EXACT functions from Preprocess.ipynb ----
13
+ def crop_image(imagem):
14
+ cx, cy = imagem.shape[1] // 2, imagem.shape[0] // 2
15
+ return imagem[cy - 520:cy + 450, cx - 550:cx + 550]
16
+
17
+ def detect_edges(im): return cv.Canny(im, 100, 150, apertureSize=3)
18
+ def detect_lines(b): return cv.HoughLines(b, 1, np.pi / 180, 160)
19
+
20
+ def group_lines(linhas):
21
+ g = []
22
+ for linha in linhas:
23
+ rho, theta = linha[0]
24
+ if not any(abs(rho - l[0][0]) < 30 and abs(theta - l[0][1]) < np.pi / 18 for l in g):
25
+ g.append(linha)
26
+ return g
27
+
28
+ def filter_board_lines(g):
29
+ min_rho = None
30
+ for linha in g:
31
+ rho, theta = linha[0]
32
+ if 1 < theta < 3 and (min_rho is None or rho < min_rho):
33
+ min_rho = rho
34
+ return [l for l in g if l[0][0] != min_rho]
35
+
36
+ def compute_intersections(linhas_casas):
37
+ lp = []
38
+ for linha in linhas_casas:
39
+ rho, theta = linha[0]
40
+ a, b = np.cos(theta), np.sin(theta)
41
+ x0, y0 = a * rho, b * rho
42
+ lp.append([(int(x0 + 10000 * -b), int(y0 + 10000 * a)),
43
+ (int(x0 - 10000 * -b), int(y0 - 10000 * a)), theta])
44
+ rows = []
45
+ for i in range(len(lp)):
46
+ pts = []
47
+ for j in range(len(lp)):
48
+ x1, y1 = lp[i][0]; x2, y2 = lp[i][1]
49
+ x3, y3 = lp[j][0]; x4, y4 = lp[j][1]
50
+ det = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
51
+ if det != 0:
52
+ ix = int(((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / det)
53
+ iy = int(((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / det)
54
+ if 0 <= ix <= 1920 and 0 <= iy <= 1080 and 1 < lp[i][2] < 3:
55
+ pts.append((ix, iy))
56
+ if pts:
57
+ pts.sort(key=lambda p: p[0]); rows.append(pts)
58
+ rows.sort(key=lambda p: p[0][0])
59
+ return rows
60
+
61
+ def build_squares(rows):
62
+ casas = []
63
+ for i in range(len(rows) - 1):
64
+ for j in range(len(rows[i]) - 1):
65
+ casas.append([rows[i][j], rows[i][j + 1], rows[i + 1][j], rows[i + 1][j + 1]])
66
+ return casas
67
+
68
+
69
+ def crop_square(imagem, p):
70
+ xs = [pt[0] for pt in p]; ys = [pt[1] for pt in p]
71
+ x_min, x_max = min(xs), max(xs); y_min, y_max = min(ys), max(ys)
72
+ if y_min - 80 < 0:
73
+ y_min = 80
74
+ return imagem[y_min - 90:y_max, x_min:x_max]
75
+
76
+
77
+ def main():
78
+ img = cv.imread("samples/board.png")
79
+ im = crop_image(img)
80
+ print("cropped:", im.shape)
81
+ lines = detect_lines(detect_edges(im))
82
+ print("hough lines:", 0 if lines is None else len(lines))
83
+ g = group_lines(lines)
84
+ casas = build_squares(compute_intersections(filter_board_lines(g)))
85
+ print("squares detected:", len(casas))
86
+
87
+ # montage of crops in detected order
88
+ tiles = []
89
+ for p in casas[:64]:
90
+ c = crop_square(im, p)
91
+ tiles.append(cv.resize(c, (90, 90)) if c.size else np.zeros((90, 90, 3), np.uint8))
92
+ while len(tiles) < 64:
93
+ tiles.append(np.zeros((90, 90, 3), np.uint8))
94
+ mont = np.vstack([np.hstack(tiles[r * 8:r * 8 + 8]) for r in range(8)])
95
+ cv.imwrite("/tmp/sq_montage.png", mont)
96
+ print("saved /tmp/sq_montage.png")
97
+
98
+ if len(casas) != 64:
99
+ print("!! not 64 squares — pipeline needs tuning for this image"); return
100
+
101
+ sess = ort.InferenceSession("models_onnx/digitizer.fp32.onnx", providers=["CPUExecutionProvider"])
102
+ for nm, fn in [("/255", lambda r: r / 255.0),
103
+ ("imagenet", lambda r: (r / 255.0 - [0.485,0.456,0.406]) / [0.229,0.224,0.225])]:
104
+ import chess
105
+ board = chess.Board(None)
106
+ for i, p in enumerate(casas[:64]):
107
+ c = crop_square(im, p)
108
+ if c.size == 0:
109
+ continue
110
+ rgb = cv.cvtColor(cv.resize(c, (96, 96)), cv.COLOR_BGR2RGB).astype(np.float32)
111
+ x = np.asarray(fn(rgb), np.float32).transpose(2, 0, 1)[None]
112
+ out = sess.run(None, {"input": x})[0].flatten()
113
+ board.set_piece_at(i, chess.Piece.from_symbol(IDX2SYM[int(out.argmax())]))
114
+ print(f"\n[{nm}] FEN: {board.board_fen()}")
115
+ print(f" TRUE: {TRUE}")
116
+
117
+
118
+ if __name__ == "__main__":
119
+ main()
probe_norm.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Probe: is the digitizer responding at all? Try fp32 vs int8 and a few
3
+ normalizations on the 64 squares; report match-count and mean confidence."""
4
+ import cv2 as cv
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+ import torch
8
+ from app.chess_models import PieceImageClassifier
9
+
10
+ SIZE, MARGIN = 800, 0.6
11
+ CORNERS = np.array([[520, 140], [1360, 150], [1430, 915], [452, 918]], np.float32)
12
+ TRUE = "rn1qkb1r/pb2pppp/5n2/1ppp4/8/3P1NP1/PPP1PPBP/RNBQ1RK1"
13
+ IDX2SYM = {0:"K",1:"Q",2:"R",3:"B",4:"N",5:"P",6:"k",7:"q",8:"r",9:"b",10:"n",11:"p"}
14
+ IMEAN, ISTD = np.array([0.485,0.456,0.406],np.float32), np.array([0.229,0.224,0.225],np.float32)
15
+
16
+ # re-export an fp32 digitizer (no quantization) for comparison
17
+ m = PieceImageClassifier()
18
+ ck = torch.load("../Master-of-Science-Degree-Project/Models/imageClassifierReal.pth", map_location="cpu", weights_only=False)
19
+ sd = ck if all(isinstance(v, torch.Tensor) for v in ck.values()) else next(v for v in ck.values() if isinstance(v, dict))
20
+ m.load_state_dict(sd); m.eval()
21
+ torch.onnx.export(m, torch.randn(1,3,96,96), "models_onnx/digitizer.fp32.onnx",
22
+ input_names=["input"], output_names=["output"],
23
+ dynamic_axes={"input":{0:"b",2:"h",3:"w"}}, opset_version=17, dynamo=False)
24
+
25
+ def grid_true():
26
+ g=[]
27
+ for row in TRUE.split("/"):
28
+ r=[]
29
+ for ch in row: r += ["."]*int(ch) if ch.isdigit() else [ch]
30
+ g.append(r)
31
+ return g
32
+
33
+ def crops(img):
34
+ dst=np.array([[0,0],[SIZE,0],[SIZE,SIZE],[0,SIZE]],np.float32)
35
+ Minv=cv.getPerspectiveTransform(dst,CORNERS); cell=SIZE/8; out=[]
36
+ for r in range(8):
37
+ for c in range(8):
38
+ y0=(r-MARGIN)*cell
39
+ q=np.array([[[c*cell,y0],[(c+1)*cell,y0],[(c+1)*cell,(r+1)*cell],[c*cell,(r+1)*cell]]],np.float32)
40
+ o=cv.perspectiveTransform(q,Minv)[0]; xs,ys=o[:,0],o[:,1]
41
+ x1,x2=max(0,int(xs.min())),min(img.shape[1],int(xs.max()))
42
+ y1,y2=max(0,int(ys.min())),min(img.shape[0],int(ys.max()))
43
+ out.append(img[y1:y2,x1:x2])
44
+ return out
45
+
46
+ def norms(bgr):
47
+ rgb=cv.cvtColor(cv.resize(bgr,(96,96)),cv.COLOR_BGR2RGB).astype(np.float32)
48
+ return {
49
+ "/255": (rgb/255.0).transpose(2,0,1)[None],
50
+ "imagenet": ((rgb/255.0-IMEAN)/ISTD).transpose(2,0,1)[None],
51
+ "raw255": rgb.transpose(2,0,1)[None],
52
+ "[-1,1]": (rgb/127.5-1).transpose(2,0,1)[None],
53
+ }
54
+
55
+ img=cv.imread("samples/board.png"); cs=crops(img); true=[s for row in grid_true() for s in row]
56
+ for model in ["digitizer.int8.onnx","digitizer.fp32.onnx"]:
57
+ sess=ort.InferenceSession(f"models_onnx/{model}",providers=["CPUExecutionProvider"])
58
+ for nm in ["/255","imagenet","raw255","[-1,1]"]:
59
+ hits=0; probs=[]
60
+ for crop,t in zip(cs,true):
61
+ if crop.size==0: continue
62
+ x=norms(crop)[nm]
63
+ out=sess.run(None,{"input":x.astype(np.float32)})[0].flatten()
64
+ p=np.exp(out)/np.exp(out).sum(); k=int(p.argmax()); probs.append(p[k])
65
+ if IDX2SYM[k]==t: hits+=1
66
+ print(f"{model:22s} {nm:9s} match {hits:2d}/64 meanconf {np.mean(probs):.2f}")
requirements-convert.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ # Only needed to convert the .pth models to ONNX (convert_to_onnx.py).
2
+ # The runtime backend does NOT need torch — it serves the ONNX models.
3
+ torch>=2.2
4
+ torchvision>=0.17
5
+ onnx>=1.16
6
+ onnxruntime>=1.18
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime serving stack (no torch — serves the ONNX models).
2
+ fastapi>=0.110
3
+ uvicorn[standard]>=0.29
4
+ onnxruntime>=1.18
5
+ opencv-python-headless>=4.9
6
+ numpy>=1.26
7
+ python-chess>=1.999
8
+ stockfish>=3.28
9
+ python-multipart>=0.0.9