Convert a 2D image of a chess board into a FEN piece placement string.
Target use case: a user uploads a PDF of a chess book, and the model reads any shown position into FEN, so a diagram can be dropped straight into an engine or database.
A full FEN has six fields:
rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
|____________ piece placement ____________| | | | | |
side _| | | | |_ fullmove
castling ____| | |____ halfmove
en passant_|
Only the piece placement field is visible in a picture of a board. Side to move, castling rights, en passant, and the move clocks are not, so this project predicts the placement field only and never tries to hallucinate the rest.
Real inputs are a mix of clean screenshots, photocopied scans, and photos of a page (with perspective warp and glare). Rather than force one network to handle all of that, the hard geometric part is split out:
input image
|
v
+-----------------+ four board corners
| Stage 1 |------------------------+
| CornerDetector | |
+-----------------+ v
perspective dewarp
|
v
canonical 8x8 board image
|
v
+--------------------+
| Stage 2 | 64 x 13 logits
| SquareClassifier |------------------+
+--------------------+ |
v
assemble placement field
|
v
rnbqkbnr/pppppppp/...
- Stage 1 (
models/stage1_detector.py) regresses the four board corners. A perspective transform then dewarps the board into a canonical, axis aligned image. This absorbs all the "photo" difficulty, so a warped phone photo and a clean screenshot look the same to Stage 2. - Stage 2 (
models/stage2_classifier.py) classifies each of the 64 squares into one of 13 classes (empty plus the 12 piece types). The predictions are assembled into the placement field.
This decomposition matches the input mix (screenshots mostly skip Stage 1; photos lean on it), gives near uniform per class coverage for the classifier, and is interpretable: a wrong result points at a specific square and a specific stage.
Training data is generated on the fly and never persisted. Each sample:
- Generate a random placement (
fen/generator.py). Positions need not be legal; random placement gives excellent per class coverage. Only the piece grid is produced, never a full FEN. - Render to a canonical board (
render/board.py). Uses python-chess plus colour themes and pluggable piece sets. The board fills the image, so the four corner labels are exact by construction. - Augment into a domain (
augment/domains.py) at one of three noise levels (augment/noise.py):SCREENSHOT,SCAN, orPHOTO. Geometric transforms also move the corner labels with the same matrix, keeping Stage 1 targets exact. - Feed to training (
data/dataset.py). Stage 2 sees residual screenshot and scan noise on a canonical board; Stage 1 sees the full warped scene and the corner targets.
Training draws from an infinite IterableDataset stream: every step generates
a fresh batch of never seen positions, learns from it, and discards it, so a
run can consume billions of images while only checkpoints touch the HDD (see
"Training" below). Validation instead uses a fixed, reproducible map dataset
with a reserved seed, so the metric is comparable across checkpoints.
A frozen evaluation split of real book scans and photos is intentionally not generated here. Synthetic only training hides its domain gap unless the eval set is real; that split is the early warning system.
chessfengen/
pieces.py piece vocabulary and the 13 class space
coretypes.py Grid alias and RenderedBoard container
demo.py dependency free smoke demo
fen/
generator.py random placement generation
placement.py grid <-> placement field <-> class indices
render/
piece_sets.py board themes and piece set registry
board.py placement -> canonical board image + corners
augment/
noise.py NoiseLevel: none / some / heavy
domains.py screenshot / scan / photo augmentation
data/
dataset.py infinite training streams and fixed validation sets
models/
stage1_detector.py CornerDetector (corner regression)
stage2_classifier.py SquareClassifier (64 square classification)
train/
phase.py streaming step based training phase (both stages)
stage1.py Stage 1 training entry point (wraps phase)
stage2.py Stage 2 training entry point (wraps phase)
eval/
metrics.py framework free accuracy and corner metrics
evaluate.py grade a checkpoint over a held out split
inference/
geometry.py perspective dewarp (cv2, framework free)
pipeline.py BoardRecognizer: image to FEN placement
assets/piece_sets/ 13 FOSS lichess piece sets (see ATTRIBUTION.md)
tests/ core, generator, augmentation, metric, and model tests
The pure FEN core (generation, placement, class indices) needs only the standard library:
python -m chessfengen.demo
Everything else needs the pipeline dependencies:
pip install -r requirements.txt
Training is step based over an infinite stream of freshly generated positions
(chessfengen.train.phase). Each step learns from a new batch and throws it
away, so the only thing written to disk is checkpoints.
python -m chessfengen.train.stage2 --total-steps 20000 --checkpoint-dir checkpoints
python -m chessfengen.train.stage1 --total-steps 20000 --checkpoint-dir checkpoints
Every --eval-every steps the model is scored on a fixed validation split and
two checkpoints are written under --checkpoint-dir:
stage{N}_best.ptthe bare model weights at the best validation metric, ready forchessfengen.eval.evaluateand the inference pipeline.stage{N}_last.ptthe full training state (model, optimizer, step) for--resume, which continues the run from where it stopped.
Useful flags: --total-steps, --batch-size, --image-size, --learning-rate,
--num-workers (data generation is CPU bound, so more workers means more
images per second), --eval-every, --val-size, --seed, --resume. The
equivalent single command is python -m chessfengen.train.phase --stage 2 ....
Run the tests:
pytest
BoardRecognizer ties the two stages together: Stage 1 detects the board
corners, a perspective transform dewarps the board to a canonical image, Stage 2
classifies the 64 squares, and the result is assembled into a FEN placement
field. Corner detection runs on a resized copy but the corners are normalised,
so the dewarp samples the original full resolution image.
python -m chessfengen.inference.pipeline \
--image board.png --stage1 stage1.pt --stage2 stage2.pt
Or from Python:
from pathlib import Path
from PIL import Image
from chessfengen.inference.pipeline import BoardRecognizer
recognizer = BoardRecognizer.from_checkpoints(Path("stage1.pt"), Path("stage2.pt"))
prediction = recognizer.predict(Image.open("board.png"))
print(prediction.placement) # FEN piece placement field
print(prediction.square_confidence) # per square softmax confidence, shape (64,)predict returns a BoardPrediction with the placement string, detected
corners, a per square confidence (to flag uncertain squares), and the dewarped
board image for debugging.
Grade a checkpoint over a fresh held out synthetic split (seed disjoint from training):
python -m chessfengen.eval.evaluate --stage 2 --checkpoint stage2.pt
python -m chessfengen.eval.evaluate --stage 1 --checkpoint stage1.pt
Stage 2 reports per square accuracy, full board exact match rate, and per class
recall; Stage 1 reports mean corner localisation error (normalised and in
pixels). The same harness backs the quality gate tests in
tests/test_trained_model.py, which load a checkpoint and assert the metrics
clear a threshold. Those tests skip when no checkpoint exists, so the suite
stays green before training. Once a model is trained, point them at it and tune
the gates via environment variables:
CHESSFENGEN_STAGE2_CKPT stage 2 checkpoint path (default stage2.pt)
CHESSFENGEN_STAGE1_CKPT stage 1 checkpoint path (default stage1.pt)
CHESSFENGEN_MIN_SQUARE_ACC min stage 2 square accuracy (default 0.95)
CHESSFENGEN_MIN_EXACT_MATCH min stage 2 exact board rate (default 0.30)
CHESSFENGEN_MAX_CORNER_ERR max stage 1 normalised error (default 0.03)
CHESSFENGEN_EVAL_SIZE eval boards per test (default 512)
The test layers are: pure metric tests and model contract tests (output shapes, that a prediction always assembles into a valid FEN placement) run with no training; the quality gates above run once a checkpoint exists. Note that exact board match compounds per square error over 64 squares, so even a strong 99% square accuracy yields only about a 53% exact board rate; gate the two independently.