9OCR / app.py
ansarzeinulla's picture
Speed up beam, stream per-image results with progress, single upload
cfc041e
Raw
History Blame Contribute Delete
14 kB
"""Gradio demo for the Togyzkumalak scoresheet reader (HuggingFace Space).
Upload up to 5 scoresheet photos of one round as a single batch, describe the
tournament and the games in two CSV text fields, and download the
reconstructed PGNs (with proper PGN tags). Inference runs on the exported
ONNX models (torch-free) via `togyz.pipeline.run_pipeline`.
This is a demo, not production: state is per-session, work is capped at 5
images per run, and requests are serialized through Gradio's queue so a shared
free Space degrades into a wait rather than a flurry of 429s.
"""
import sys
print("!!! APP IS STARTING !!!", file=sys.stderr)
sys.stderr.flush()
# --- MONKEY PATCH GRADIO CLIENT BUG ---
try:
import gradio_client.utils as client_utils
orig_get_type = client_utils.get_type
def patched_get_type(schema):
if isinstance(schema, bool):
return "boolean"
return orig_get_type(schema)
client_utils.get_type = patched_get_type
print("[app] Applied monkey-patch to gradio_client.utils.get_type", flush=True)
except Exception as e:
print(f"[app] Failed to apply monkey-patch: {e}", flush=True)
# --------------------------------------
import csv
import io
import os
import tempfile
import zipfile
from pathlib import Path
# Unbuffered stdout so boot progress actually shows in the Space container logs
# (otherwise a slow import/model-load looks like a silent hang).
try:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
except Exception:
pass
def _log(msg):
print(f"[app] {msg}", flush=True)
_log("importing gradio ...")
import gradio as gr
_log("importing pipeline ...")
from togyz.pipeline import load_classifier, run_pipeline
MAX_IMAGES = 5
MODEL_DIR = Path(__file__).parent / "models"
BEAM_CHOICES = [str(2**k) for k in range(10, 31)] # 1024 ... 2^30
# A full ~160-ply game costs roughly (width/1024) x 12s of beam search, so the
# default stays modest; the board-diagram evidence prunes well at this width.
DEFAULT_BEAM = "2048"
# accepted spellings of a game result -> pipeline result code
RESULT_MAP = {
"": None, "*": None,
"1": "1-0", "1-0": "1-0",
"0": "0-1", "0-1": "0-1",
"5": "draw", "0.5-0.5": "draw", "1/2-1/2": "draw", "draw": "draw",
}
RESULT_TAGS = {"1-0": "1-0", "0-1": "0-1", "draw": "1/2-1/2", None: "*"}
# Load the ONNX sessions once at import - warm for the whole process lifetime.
_log(f"loading move model from {MODEL_DIR / 'best.onnx'} ...")
_MOVES = load_classifier(MODEL_DIR / "best.onnx")
_DIAGRAM = None
_diagram_path = MODEL_DIR / "diagram.onnx"
if _diagram_path.exists():
_log("loading diagram model ...")
_DIAGRAM = load_classifier(_diagram_path)
else:
# the old kazan.onnx has incompatible classes - do not fall back to it
_log("no models/diagram.onnx - checkpoint evidence disabled")
_log("models loaded")
def _safe_slug(text: str) -> str:
keep = "".join(c if c.isalnum() else "_" for c in (text or "").strip())
return keep.strip("_")
def _csv_fields(line: str) -> list[str]:
"""One CSV line -> stripped fields (handles quoted commas)."""
rows = list(csv.reader(io.StringIO(line)))
return [f.strip() for f in rows[0]] if rows else []
def _parse_meta(text: str) -> dict:
"""Shared metadata line: Tournament,Location,Date,Round (all optional)."""
fields = _csv_fields((text or "").strip())
if len(fields) > 4:
raise gr.Error(
"Metadata must be one CSV line: Tournament,Location,Date,Round "
f"(got {len(fields)} fields). Quote fields that contain commas."
)
fields += [""] * (4 - len(fields))
meta = {"event": fields[0], "site": fields[1], "date": fields[2],
"round": fields[3]}
if meta["round"]:
try:
rnd = int(meta["round"])
except ValueError:
raise gr.Error(f"Round must be a number 1-20, got {meta['round']!r}.")
if not 1 <= rnd <= 20:
raise gr.Error(f"Round must be between 1 and 20, got {rnd}.")
meta["round"] = str(rnd)
return meta
def _parse_games(text: str, n_images: int) -> list[dict]:
"""Per-game lines: WhiteName,BlackName,Result,WhiteTime,BlackTime.
One line per uploaded image, in order. Fewer lines than images is fine
(missing games get empty metadata); more lines is an error.
"""
lines = [ln for ln in (text or "").splitlines() if ln.strip()]
if len(lines) > n_images:
raise gr.Error(
f"{len(lines)} game lines for {n_images} image(s). "
"Provide at most one line per uploaded image, in order."
)
games = []
for lineno, line in enumerate(lines, start=1):
fields = _csv_fields(line)
if len(fields) > 5:
raise gr.Error(
f"Game line {lineno}: expected at most 5 CSV fields "
"(White,Black,Result,WhiteTime,BlackTime), got "
f"{len(fields)}. Quote fields that contain commas."
)
fields += [""] * (5 - len(fields))
raw_result = fields[2]
if raw_result not in RESULT_MAP:
raise gr.Error(
f"Game line {lineno}: unknown result {raw_result!r}. Accepted: "
"1 or 1-0 (White won), 0 or 0-1 (Black won), "
"5 / 0.5-0.5 / 1/2-1/2 (draw), or empty."
)
games.append({"white": fields[0], "black": fields[1],
"result": RESULT_MAP[raw_result],
"white_time": fields[3], "black_time": fields[4]})
games += [{"white": "", "black": "", "result": None,
"white_time": "", "black_time": ""}] * (n_images - len(games))
return games
def _pgn_tags(meta: dict, game: dict) -> str:
"""Standard PGN tag section from the shared + per-game metadata."""
tags = [
("Event", meta["event"] or "?"),
("Site", meta["site"] or "?"),
("Date", meta["date"] or "?"),
("Round", meta["round"] or "?"),
("White", game["white"] or "?"),
("Black", game["black"] or "?"),
("Result", RESULT_TAGS[game["result"]]),
]
if game["white_time"]:
tags.append(("WhiteClock", game["white_time"]))
if game["black_time"]:
tags.append(("BlackClock", game["black_time"]))
return "".join(f'[{k} "{v}"]\n' for k, v in tags) + "\n"
def _process_one(image_path, game: dict, meta: dict, beam_width: int,
base_name: str, out_dir: Path, progress_cb=None):
"""Run the pipeline on one image; return (row, gallery_item, files, warnings)."""
out = run_pipeline(image_path, _MOVES, _DIAGRAM,
result=game["result"], beam_width=beam_width,
progress_cb=progress_cb)
stop = out["stopped"]
stop_txt = stop.get("reason", "")
if "winner" in stop:
stop_txt += f" ({stop['winner']})"
note = " ⚠ low-res" if out["low_resolution"] else ""
tags = _pgn_tags(meta, game)
files = []
for kind in ("beam", "raw", "legal"):
f = out_dir / f"{base_name}_{kind}.pgn"
f.write_text(tags + out[f"{kind}_pgn"])
files.append(str(f))
row = [base_name, out["beam_plies"], stop_txt + note, out["beam_pgn"].strip()]
caption = f"{base_name}: {out['beam_plies']} plies"
warnings = [f"{base_name}: {w}" for w in out["warnings"]]
return row, (out["annotated_image"], caption), files, warnings
def _as_paths(files) -> list[str]:
"""Normalize the multi-file uploader value into a list of file paths."""
if not files:
return []
if isinstance(files, (str, os.PathLike)):
files = [files]
paths = []
for f in files:
# gr.File yields str paths (type="filepath") or objects with .name
paths.append(f if isinstance(f, str) else getattr(f, "name", str(f)))
return paths
def convert(meta_text, games_text, beam_choice, files, progress=gr.Progress()):
"""One batch: up to 5 images sharing tournament metadata.
A generator: it yields (table, gallery, zip, warnings) after each image so
results stream in one by one; the zip download is assembled only at the
end and contains every game's PGNs appended together.
"""
images = _as_paths(files)
if not images:
raise gr.Error("Please upload at least one scoresheet image.")
if len(images) > MAX_IMAGES:
raise gr.Error(f"This demo handles at most {MAX_IMAGES} images per run "
f"(got {len(images)}).")
# parse everything up front - all input errors surface before any heavy work
meta = _parse_meta(meta_text)
games = _parse_games(games_text, len(images))
try:
beam_width = int(beam_choice)
except (TypeError, ValueError):
beam_width = int(DEFAULT_BEAM)
out_dir = Path(tempfile.mkdtemp(prefix="togyz_"))
round_slug = _safe_slug(meta["round"])
n = len(images)
rows, gallery, all_files, all_warnings = [], [], [], []
def warn_md():
return "\n".join(f"⚠ {w}" for w in dict.fromkeys(all_warnings))
for i, (img, game) in enumerate(zip(images, games)):
name = Path(img).name
prefix = f"round{round_slug}_game{i + 1}" if round_slug else f"game{i + 1}"
def cb(frac, desc, i=i, name=name):
# blend per-image progress into an overall 0..1 bar
progress((i + frac) / n, desc=f"Image {i + 1}/{n} ({name}): {desc}")
cb(0.0, "starting")
try:
row, gal, files, warns = _process_one(
img, game, meta, beam_width, prefix, out_dir, progress_cb=cb
)
except Exception as exc: # one bad image must not kill the batch
rows.append([f"{prefix} ({name})", 0, f"error: {exc}", ""])
yield rows[:], gallery[:], None, warn_md()
continue
row[0] = f"{row[0]} ({name})"
rows.append(row)
gallery.append(gal)
all_files.extend(files)
all_warnings.extend(warns)
# stream this image's result immediately; zip is built only at the end
yield rows[:], gallery[:], None, warn_md()
if not all_files:
# every image errored - still return the table so the user sees why
yield rows[:], gallery[:], None, warn_md()
return
zip_path = out_dir / (f"round{round_slug}_pgns.zip" if round_slug else "pgns.zip")
with zipfile.ZipFile(zip_path, "w") as zf:
for f in all_files:
zf.write(f, arcname=Path(f).name)
yield rows[:], gallery[:], str(zip_path), warn_md()
def _busy_wrapper(meta_text, games_text, beam_choice, files, progress=gr.Progress()):
"""Turn infrastructure overload into a friendly message instead of a 500."""
try:
yield from convert(meta_text, games_text, beam_choice, files, progress)
except gr.Error:
raise
except Exception as exc: # noqa: BLE001 - surface anything else gracefully
msg = str(exc).lower()
if "429" in msg or "too many" in msg or "rate" in msg:
raise gr.Error("Server busy — please retry in a moment.")
raise gr.Error(f"Something went wrong: {exc}")
with gr.Blocks(title="Togyzkumalak Scoresheet Reader") as demo:
gr.Markdown(
"# Togyzkumalak Scoresheet Reader\n"
"Upload up to **5** scoresheet photos of one round in a single batch, "
"describe the round and the games in the two text fields, then "
"**Convert**. Results stream in image by image with a live progress "
"bar; the combined PGN download appears once every image is done.\n\n"
"Outputs per game: `beam` (best legal reconstruction), `raw` (pure OCR), "
"`legal` (strict replay). Free demo — a first run may wake the Space, and "
"images are processed one at a time."
)
meta_box = gr.Textbox(
label="Tournament metadata (CSV): Tournament,Location,Date,Round — all optional",
placeholder="World Championship among boys, Astana city, 2026 7 July, 7",
max_lines=1,
)
games_box = gr.Textbox(
label="Games (one CSV line per image, in order): "
"WhiteName,BlackName,Result,WhiteTime,BlackTime — all optional; "
"Result: 1 / 1-0, 0 / 0-1, 5 / 0.5-0.5 / 1/2-1/2",
placeholder="Zhanabay Korkem, Kubzhasar Marhabat, 0-1, 0:07:55, 0:24:05",
lines=MAX_IMAGES,
)
beam_dd = gr.Dropdown(
BEAM_CHOICES, value=DEFAULT_BEAM,
label="Beam width (game hypotheses kept; larger = slower but more "
"thorough; very large values are trimmed to fit memory)",
)
image_files = gr.File(
label=f"Scoresheet photos (up to {MAX_IMAGES}, in the same order as the "
"game lines above)",
file_count="multiple",
file_types=["image"],
type="filepath",
)
convert_btn = gr.Button("Convert", variant="primary")
results_table = gr.Dataframe(
headers=["game", "legal plies", "stopped", "beam PGN"],
label="Results (stream in as each image finishes)",
wrap=True, interactive=False,
)
warnings_md = gr.Markdown()
gallery = gr.Gallery(label="Annotated reconstruction", columns=2, height="auto")
zip_out = gr.File(label="Download all PGNs (zip)")
convert_btn.click(
_busy_wrapper,
inputs=[meta_box, games_box, beam_dd, image_files],
outputs=[results_table, gallery, zip_out, warnings_md],
)
# Serialize CPU-heavy runs: callers wait in a bounded queue instead of
# overloading the shared Space (which is what triggers 429s).
demo.queue(max_size=16, default_concurrency_limit=1)
if __name__ == "__main__":
# Bind explicitly to 0.0.0.0 and the Space's port so HF can detect the
# running app (the default 127.0.0.1 bind can leave a Space stuck "Starting").
port = int(os.environ.get("GRADIO_SERVER_PORT", os.environ.get("PORT", 7860)))
_log(f"launching gradio on 0.0.0.0:{port} ...")
demo.launch(server_name="0.0.0.0", server_port=port)