Bina-0.2-Rizeh / pipeline /recognize_bina02_two_layer.py
Reza2kn's picture
Publish Bina 0.2 Rizeh Stage-8 release
feef108 verified
Raw
History Blame Contribute Delete
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()