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)