Spaces:
Sleeping
Sleeping
File size: 14,029 Bytes
20d7fde 1b69166 20d7fde 3955727 9d8cafd 1b69166 3955727 20d7fde 1b69166 cfc041e 1b69166 20d7fde 1b69166 20d7fde 1b69166 cfc041e 1b69166 cfc041e 20d7fde 1b69166 20d7fde 1b69166 20d7fde 1b69166 20d7fde cfc041e 1b69166 20d7fde cfc041e 1b69166 20d7fde 1b69166 cfc041e 1b69166 20d7fde cfc041e 20d7fde 1b69166 cfc041e 1b69166 20d7fde cfc041e 20d7fde cfc041e 20d7fde 1b69166 cfc041e 20d7fde cfc041e 20d7fde 1b69166 20d7fde cfc041e 20d7fde cfc041e 20d7fde cfc041e 20d7fde 69bf17c 20d7fde cfc041e 1b69166 cfc041e 20d7fde 1b69166 20d7fde cfc041e 20d7fde cfc041e 20d7fde 1b69166 9a0144f 20d7fde cfc041e 1b69166 20d7fde 9a0144f 20d7fde 9d8cafd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 | """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) |