Spaces:
Sleeping
Sleeping
Commit ·
3f9559b
0
Parent(s):
Chess Vision backend (digitization + move prediction)
Browse files- .gitattributes +1 -0
- .gitignore +14 -0
- Dockerfile +21 -0
- README.md +55 -0
- app/chess_models.py +83 -0
- app/digitize.py +120 -0
- app/main.py +76 -0
- app/predict.py +99 -0
- calibrate.py +46 -0
- convert_to_onnx.py +81 -0
- digitize_corners.py +76 -0
- digitize_fen.py +72 -0
- digitize_final.py +54 -0
- digitize_full.py +69 -0
- digitize_full_3d.py +69 -0
- digitize_test.py +86 -0
- hough_pipeline.py +105 -0
- models_onnx/digitizer.fp32.onnx +3 -0
- models_onnx/digitizer.int8.onnx +3 -0
- models_onnx/digitizer3d.fp32.onnx +3 -0
- models_onnx/piece.int8.onnx +3 -0
- models_onnx/square_bishop.int8.onnx +3 -0
- models_onnx/square_king.int8.onnx +3 -0
- models_onnx/square_knight.int8.onnx +3 -0
- models_onnx/square_pawn.int8.onnx +3 -0
- models_onnx/square_queen.int8.onnx +3 -0
- models_onnx/square_rook.int8.onnx +3 -0
- move_predict.py +60 -0
- preprocess_repro.py +119 -0
- probe_norm.py +66 -0
- requirements-convert.txt +6 -0
- requirements.txt +9 -0
.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
|