Download pipeline/recognize_bina02_two_layer.py from Reza2kn/Bina-0.2-Rizeh: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/Reza2kn/Bina-0.2-Rizeh/resolve/main/pipeline/recognize_bina02_two_layer.py
- Command line
-
hf download hf://Reza2kn/Bina-0.2-Rizeh/pipeline/recognize_bina02_two_layer.py
-
curl -L -o recognize_bina02_two_layer.py https://huggingface.co/Reza2kn/Bina-0.2-Rizeh/resolve/main/pipeline/recognize_bina02_two_layer.py
13.6 kB
| #!/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() | |