#!/usr/bin/env python3 """Recognize cached Surya boxes from the same isolated handwriting layer.""" from __future__ import annotations import argparse import fcntl import io import json import os import re import time import traceback from pathlib import Path from typing import Any import numpy as np import pyarrow.parquet as pq from paddleocr import TextRecognition from PIL import Image from bina02_template_layers import TemplateCatalog, index_from_id ROWS = 6_669 LTR_RUN = re.compile(r"[a-zA-Z0-9 :*./%+-]") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--rank", type=int, required=True) parser.add_argument("--world-size", type=int, required=True) parser.add_argument("--data", type=Path, required=True) parser.add_argument("--background-root", type=Path, required=True) parser.add_argument("--ocr-root", type=Path, required=True) parser.add_argument("--detections-dir", type=Path, required=True) parser.add_argument("--model-dir", type=Path, required=True) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--variant", required=True) parser.add_argument("--device", default="gpu:0") parser.add_argument("--crop-buffer", type=int, default=512) parser.add_argument("--recognition-batch-size", type=int, default=64) parser.add_argument("--pad-x-ratio", type=float, default=0.01) parser.add_argument("--pad-y-ratio", type=float, default=0.08) parser.add_argument("--row-factor", type=float, default=0.70) return parser.parse_args() def atomic_json(path: Path, payload: dict[str, Any]) -> None: temporary = path.with_suffix(path.suffix + f".{os.getpid()}.tmp") temporary.write_text( json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) os.replace(temporary, path) def pred_reverse(text: str) -> str: segments: list[str] = [] current = "" for character in text: if LTR_RUN.search(character): current += character else: if current: segments.append(current) current = "" segments.append(character) if current: segments.append(current) return "".join(reversed(segments)) def load_rows(data: Path, wanted: set[int]) -> dict[int, dict[str, Any]]: rows: dict[int, dict[str, Any]] = {} offset = 0 paths = sorted((data / "data").glob("handwriting-*.parquet")) for path in paths: count = pq.ParquetFile(path).metadata.num_rows local = sorted(index - offset for index in wanted if offset <= index < offset + count) if local: table = pq.read_table(path, columns=["image", "label"]) for index in local: rows[offset + index] = table.slice(index, 1).to_pylist()[0] offset += count if offset != ROWS or rows.keys() != wanted: raise RuntimeError("benchmark row load contract failed") return rows def decode_image(row: dict[str, Any]) -> Image.Image: value = row["image"] encoded = value.get("bytes") if isinstance(value, dict) else value if encoded is None and isinstance(value, dict) and value.get("path"): encoded = Path(value["path"]).read_bytes() if not encoded: raise ValueError("dataset row has no image bytes") return Image.open(io.BytesIO(encoded)).convert("RGB") def center(line: dict[str, Any]) -> tuple[float, float]: x0, y0, x1, y1 = (float(value) for value in line["bbox"]) return (x0 + x1) / 2.0, (y0 + y1) / 2.0 def height(line: dict[str, Any]) -> float: return max(float(line["bbox"][3]) - float(line["bbox"][1]), 1.0) def group_rows(lines: list[dict[str, Any]], factor: float) -> list[list[dict[str, Any]]]: ordered = sorted(lines, key=lambda line: center(line)[1]) rows: list[list[dict[str, Any]]] = [] for line in ordered: if not rows: rows.append([line]) continue previous = rows[-1] mean_y = sum(center(item)[1] for item in previous) / len(previous) mean_height = sum(height(item) for item in previous) / len(previous) if abs(center(line)[1] - mean_y) <= factor * max(height(line), mean_height): previous.append(line) else: rows.append([line]) for row in rows: row.sort(key=lambda line: center(line)[0], reverse=True) return rows def grouped_lines( lines: list[dict[str, Any]], width: int, height_value: int, factor: float, ) -> list[dict[str, Any]]: aspect = width / max(height_value, 1) if 1.03 <= aspect <= 1.42: midpoint = width / 2.0 groups = group_rows( [line for line in lines if center(line)[0] >= midpoint], factor, ) + group_rows( [line for line in lines if center(line)[0] < midpoint], factor, ) else: groups = group_rows(lines, factor) return [ { "bbox": [ min(float(line["bbox"][0]) for line in row), min(float(line["bbox"][1]) for line in row), max(float(line["bbox"][2]) for line in row), max(float(line["bbox"][3]) for line in row), ], "confidence": sum(float(line.get("confidence", 0.0)) for line in row) / len(row), "member_count": len(row), } for row in groups ] def crop( image: Image.Image, bbox: list[float], pad_x_ratio: float, pad_y_ratio: float, ) -> np.ndarray: x0, y0, x1, y1 = (float(value) for value in bbox) width = max(x1 - x0, 1.0) height_value = max(y1 - y0, 1.0) pad_x = max(2, int(width * pad_x_ratio)) pad_y = max(2, int(height_value * pad_y_ratio)) box = ( max(0, int(x0) - pad_x), max(0, int(y0) - pad_y), min(image.width, int(x1) + pad_x), min(image.height, int(y1) + pad_y), ) if box[2] <= box[0] or box[3] <= box[1]: raise ValueError(f"invalid crop: {bbox}") return np.asarray(image.crop(box).convert("RGB")) def recognize( model: TextRecognition, crops: list[np.ndarray], batch_size: int, ) -> list[dict[str, Any]]: if not crops: return [] last_error: Exception | None = None for attempt in range(1, 4): try: results = list(model.predict(input=crops, batch_size=batch_size)) if len(results) != len(crops): raise RuntimeError("recognizer returned wrong result count") output = [] for result in results: payload = result.json if callable(payload): payload = payload() payload = payload.get("res", payload) raw = str(payload.get("rec_text", "")) output.append( { "text": pred_reverse(raw), "raw_text": raw, "rec_score": float(payload.get("rec_score", 0.0)), } ) return output except Exception as exc: last_error = exc traceback.print_exc() time.sleep(attempt) raise RuntimeError(f"recognition failed after retries: {last_error}") def merge_if_complete(output_dir: Path, detections_dir: Path, world_size: int) -> None: with (output_dir / "merge.lock").open("a+") as lock: fcntl.flock(lock, fcntl.LOCK_EX) for rank in range(world_size): path = output_dir / f"status-rank{rank:02d}.json" if not path.is_file() or json.loads(path.read_text()).get("state") != "complete": return expected = [ json.loads(line)["id"] for line in (detections_dir / "detections.jsonl").read_text( encoding="utf-8" ).splitlines() if line.strip() ] rows = {} for rank in range(world_size): path = output_dir / f"recognized-rank{rank:02d}.jsonl" for line in path.read_text(encoding="utf-8").splitlines(): if line.strip(): row = json.loads(line) rows[row["id"]] = row if set(rows) != set(expected): raise RuntimeError("recognition merge contract failed") temporary = output_dir / "recognized-lines.jsonl.tmp" with temporary.open("w", encoding="utf-8") as handle: for row_id in expected: handle.write(json.dumps(rows[row_id], ensure_ascii=False) + "\n") os.replace(temporary, output_dir / "recognized-lines.jsonl") atomic_json( output_dir / "run-summary.json", {"state": "complete", "rows": len(rows), "completed_unix": time.time()}, ) def main() -> None: args = parse_args() args.output_dir.mkdir(parents=True, exist_ok=True) detections_path = args.detections_dir / f"detections-rank{args.rank:02d}.jsonl" detections = [ json.loads(line) for line in detections_path.read_text(encoding="utf-8").splitlines() if line.strip() ] indices = {index_from_id(row["id"]) for row in detections} rows = load_rows(args.data, indices) catalog = TemplateCatalog(args.background_root, args.ocr_root) model = TextRecognition(model_dir=str(args.model_dir), device=args.device) output_path = args.output_dir / f"recognized-rank{args.rank:02d}.jsonl" status_path = args.output_dir / f"status-rank{args.rank:02d}.json" started = time.monotonic() failures = 0 recognized_lines = 0 page_buffer: list[dict[str, Any]] = [] crop_buffer: list[np.ndarray] = [] with output_path.open("w", encoding="utf-8", buffering=1) as output: def flush() -> None: nonlocal failures, recognized_lines if not page_buffer: return try: values = recognize(model, crop_buffer, args.recognition_batch_size) recognition_error = None except Exception as exc: values = [ {"text": "", "raw_text": "", "rec_score": 0.0} for _ in crop_buffer ] recognition_error = f"{type(exc).__name__}: {exc}" for page in page_buffer: if recognition_error and not page.get("error"): page["error"] = recognition_error if page.get("error"): failures += 1 for line in page["lines"]: result_index = line.pop("_result_index", None) if result_index is not None: line.update(values[result_index]) recognized_lines += 1 output.write(json.dumps(page, ensure_ascii=False) + "\n") page_buffer.clear() crop_buffer.clear() for detection in detections: index = index_from_id(detection["id"]) page = { key: detection.get(key) for key in ( "id", "reference", "width", "height", "variant", "template_id", "template_file", "template_text", "template_placeholder_count", "match_median_abs_delta", "match_p90_abs_delta", "mask_threshold", "mask_kept_fraction", ) } page["lines"] = [] if detection.get("error"): page["error"] = detection["error"] try: image = decode_image(rows[index]) template = catalog.by_id[str(detection["template_id"])] isolated, _ = catalog.isolate_with_template( image, template, args.variant, ) for source in grouped_lines( detection.get("lines", []), isolated.width, isolated.height, args.row_factor, ): line = dict(source) line["_result_index"] = len(crop_buffer) crop_buffer.append( crop( isolated, source["bbox"], args.pad_x_ratio, args.pad_y_ratio, ) ) page["lines"].append(line) except Exception as exc: traceback.print_exc() page["error"] = f"{type(exc).__name__}: {exc}" page_buffer.append(page) if len(crop_buffer) >= args.crop_buffer: flush() flush() elapsed = max(time.monotonic() - started, 1e-9) atomic_json( status_path, { "state": "complete", "rank": args.rank, "assigned": len(detections), "failures": failures, "recognized_lines": recognized_lines, "rate_pages_per_second": len(detections) / elapsed, "eta_seconds": 0, }, ) merge_if_complete(args.output_dir, args.detections_dir, args.world_size) if __name__ == "__main__": main()