| |
| """Extract tiled OCR from source-resolution crops and project it to model pixels.""" |
|
|
| from __future__ import annotations |
|
|
| import argparse |
| import hashlib |
| import json |
| import re |
| import subprocess |
| import tempfile |
| from concurrent.futures import ThreadPoolExecutor, as_completed |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Any |
|
|
| from PIL import Image |
|
|
| from extract_cartolegend_ocr import parse_tsv |
|
|
|
|
| ROOT = Path(__file__).resolve().parents[1] |
| Image.MAX_IMAGE_PIXELS = None |
|
|
|
|
| def sha256(path: Path) -> str: |
| digest = hashlib.sha256() |
| with path.open("rb") as stream: |
| for block in iter(lambda: stream.read(1024 * 1024), b""): |
| digest.update(block) |
| return digest.hexdigest() |
|
|
|
|
| def read_jsonl(path: Path) -> list[dict[str, Any]]: |
| return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] |
|
|
|
|
| def resolve_path(value: str | Path) -> Path: |
| path = Path(value).expanduser() |
| return (path if path.is_absolute() else ROOT / path).resolve() |
|
|
|
|
| def row_image(row: dict[str, Any], image_field: str) -> Path: |
| if image_field == "images": |
| values = row.get("images") or [] |
| if len(values) != 1: |
| raise ValueError("each input row must contain exactly one image") |
| value = values[0] |
| else: |
| value = str(row.get(image_field) or "") |
| if not value: |
| raise ValueError(f"input row is missing {image_field}") |
| image = resolve_path(value) |
| if not image.is_file(): |
| raise FileNotFoundError(image) |
| return image |
|
|
|
|
| def safe_name(value: str) -> str: |
| return re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("_") or "image" |
|
|
|
|
| def axis_starts(length: int, tile_size: int, overlap: int) -> list[int]: |
| if length <= 0 or tile_size <= 0: |
| raise ValueError("length and tile_size must be positive") |
| if overlap < 0 or overlap >= tile_size: |
| raise ValueError("overlap must be in [0, tile_size)") |
| if length <= tile_size: |
| return [0] |
| step = tile_size - overlap |
| starts = list(range(0, max(1, length - tile_size + 1), step)) |
| final = length - tile_size |
| if starts[-1] != final: |
| starts.append(final) |
| return starts |
|
|
|
|
| def tile_boxes(width: int, height: int, tile_size: int, overlap: int) -> list[list[int]]: |
| return [ |
| [x, y, min(width, x + tile_size), min(height, y + tile_size)] |
| for y in axis_starts(height, tile_size, overlap) |
| for x in axis_starts(width, tile_size, overlap) |
| ] |
|
|
|
|
| def project_bbox( |
| bbox: list[int | float], |
| stage_size: tuple[int, int], |
| target_size: tuple[int, int], |
| ) -> list[int]: |
| stage_width, stage_height = stage_size |
| target_width, target_height = target_size |
| if len(bbox) != 4 or stage_width <= 0 or stage_height <= 0: |
| raise ValueError("invalid projection geometry") |
| x1 = round(float(bbox[0]) * target_width / stage_width) |
| y1 = round(float(bbox[1]) * target_height / stage_height) |
| x2 = round(float(bbox[2]) * target_width / stage_width) |
| y2 = round(float(bbox[3]) * target_height / stage_height) |
| x1 = min(max(0, x1), max(0, target_width - 1)) |
| y1 = min(max(0, y1), max(0, target_height - 1)) |
| x2 = min(max(x1 + 1, x2), target_width) |
| y2 = min(max(y1 + 1, y2), target_height) |
| return [x1, y1, x2, y2] |
|
|
|
|
| def source_crop( |
| row: dict[str, Any], target: Path |
| ) -> tuple[Path, list[int] | None, str]: |
| bbox = row.get("crop_bbox") |
| candidates = [row.get("crop_source_image"), row.get("original_image")] |
| if ( |
| isinstance(bbox, list) |
| and len(bbox) == 4 |
| and all(isinstance(value, (int, float)) for value in bbox) |
| and float(bbox[0]) < float(bbox[2]) |
| and float(bbox[1]) < float(bbox[3]) |
| ): |
| for value in candidates: |
| if not value: |
| continue |
| candidate = resolve_path(str(value)) |
| if candidate.is_file(): |
| return candidate, [round(float(value)) for value in bbox], "source_crop" |
| return target, None, "target_upscale" |
|
|
|
|
| def stage_row_image( |
| row: dict[str, Any], |
| target: Path, |
| output: Path, |
| requested_scale: float, |
| max_dimension: int, |
| ) -> dict[str, Any]: |
| with Image.open(target) as image: |
| target_size = (image.width, image.height) |
| source, requested_bbox, mode = source_crop(row, target) |
| with Image.open(source) as opened: |
| source_size = (opened.width, opened.height) |
| if requested_bbox is None: |
| clipped_bbox = [0, 0, opened.width, opened.height] |
| else: |
| clipped_bbox = [ |
| min(max(0, requested_bbox[0]), max(0, opened.width - 1)), |
| min(max(0, requested_bbox[1]), max(0, opened.height - 1)), |
| min(max(1, requested_bbox[2]), opened.width), |
| min(max(1, requested_bbox[3]), opened.height), |
| ] |
| if clipped_bbox[0] >= clipped_bbox[2] or clipped_bbox[1] >= clipped_bbox[3]: |
| raise ValueError(f"crop_bbox does not intersect source image: {source}") |
| crop = opened.crop(tuple(clipped_bbox)).convert("RGB") |
| effective_scale = min( |
| float(requested_scale), |
| float(max_dimension) / max(target_size), |
| ) |
| effective_scale = max(1.0, effective_scale) |
| stage_size = ( |
| max(1, round(target_size[0] * effective_scale)), |
| max(1, round(target_size[1] * effective_scale)), |
| ) |
| if crop.size != stage_size: |
| crop = crop.resize(stage_size, Image.Resampling.LANCZOS) |
| output.parent.mkdir(parents=True, exist_ok=True) |
| crop.save(output, compress_level=3) |
| return { |
| "mode": mode, |
| "target_image": str(target), |
| "target_size": list(target_size), |
| "source_image": str(source), |
| "source_size": list(source_size), |
| "source_crop_bbox": clipped_bbox, |
| "requested_scale": requested_scale, |
| "effective_scale": effective_scale, |
| "stage_image": str(output), |
| "stage_size": list(stage_size), |
| } |
|
|
|
|
| def run_tesseract( |
| tile_path: Path, psm: int, timeout_seconds: int |
| ) -> tuple[int, str, str]: |
| try: |
| result = subprocess.run( |
| [ |
| "tesseract", |
| str(tile_path), |
| "stdout", |
| "--oem", |
| "1", |
| "--psm", |
| str(psm), |
| "tsv", |
| ], |
| capture_output=True, |
| text=True, |
| timeout=timeout_seconds, |
| check=False, |
| ) |
| return result.returncode, result.stdout, result.stderr.strip()[:500] |
| except subprocess.TimeoutExpired as error: |
| stderr = (error.stderr or "") if isinstance(error.stderr, str) else "" |
| return 124, "", (stderr + " tesseract timeout").strip()[:500] |
|
|
|
|
| def offset_and_project( |
| values: list[dict[str, Any]], |
| tile_bbox: list[int], |
| tile_id: str, |
| stage_size: tuple[int, int], |
| target_size: tuple[int, int], |
| ) -> list[dict[str, Any]]: |
| projected = [] |
| for value in values: |
| local = value["bbox"] |
| stage_bbox = [ |
| local[0] + tile_bbox[0], |
| local[1] + tile_bbox[1], |
| local[2] + tile_bbox[0], |
| local[3] + tile_bbox[1], |
| ] |
| projected.append( |
| { |
| **value, |
| "bbox": project_bbox(stage_bbox, stage_size, target_size), |
| "stage_bbox": stage_bbox, |
| "tile_id": tile_id, |
| "tile_bbox": tile_bbox, |
| } |
| ) |
| return projected |
|
|
|
|
| def parse_args() -> argparse.Namespace: |
| parser = argparse.ArgumentParser(description=__doc__) |
| parser.add_argument("--input", type=Path, required=True) |
| parser.add_argument("--output", type=Path, required=True) |
| parser.add_argument("--stage-dir", type=Path, required=True) |
| parser.add_argument("--image-field", default="images") |
| parser.add_argument("--scale", type=float, default=4.0) |
| parser.add_argument("--max-dimension", type=int, default=5200) |
| parser.add_argument("--tile-size", type=int, default=1400) |
| parser.add_argument("--tile-overlap", type=int, default=220) |
| parser.add_argument("--psm", type=int, action="append", default=[]) |
| parser.add_argument("--workers", type=int, default=4) |
| parser.add_argument("--timeout-seconds", type=int, default=120) |
| return parser.parse_args() |
|
|
|
|
| def main() -> int: |
| args = parse_args() |
| input_path = args.input.expanduser().resolve() |
| output_path = args.output.expanduser().resolve() |
| stage_dir = args.stage_dir.expanduser().resolve() |
| psm_modes = sorted(set(args.psm or [6, 11])) |
| if args.scale < 1.0 or args.max_dimension <= 0 or args.workers <= 0: |
| raise ValueError("invalid scale, max dimension, or worker count") |
| rows = read_jsonl(input_path) |
| targets = [row_image(row, args.image_field) for row in rows] |
| if len(set(targets)) != len(targets): |
| raise ValueError("input image paths must be unique") |
| version = subprocess.run( |
| ["tesseract", "--version"], capture_output=True, text=True, check=True |
| ).stdout.splitlines()[0] |
| output_path.parent.mkdir(parents=True, exist_ok=True) |
| stage_dir.mkdir(parents=True, exist_ok=True) |
| failures: list[dict[str, Any]] = [] |
| artifact_rows: list[dict[str, Any]] = [] |
| hash_cache: dict[Path, str] = {} |
|
|
| with output_path.open("w") as output_stream: |
| for row_index, (row, target) in enumerate(zip(rows, targets, strict=True), start=1): |
| stage_path = stage_dir / ( |
| f"{row_index:04d}_{safe_name(target.stem)}__source_ocr.png" |
| ) |
| stage = stage_row_image( |
| row, |
| target, |
| stage_path, |
| args.scale, |
| args.max_dimension, |
| ) |
| stage_size = tuple(stage["stage_size"]) |
| target_size = tuple(stage["target_size"]) |
| boxes = tile_boxes( |
| stage_size[0], |
| stage_size[1], |
| args.tile_size, |
| args.tile_overlap, |
| ) |
| tasks: dict[Any, tuple[str, list[int], int]] = {} |
| all_words: list[dict[str, Any]] = [] |
| all_lines: list[dict[str, Any]] = [] |
| mode_rows: list[dict[str, Any]] = [] |
| with tempfile.TemporaryDirectory(prefix="cartolegend_source_ocr_") as raw_tmp: |
| temporary = Path(raw_tmp) |
| with Image.open(stage_path) as staged_image: |
| for tile_index, box in enumerate(boxes, start=1): |
| tile_id = f"tile_{tile_index:03d}" |
| tile_path = temporary / f"{tile_id}.png" |
| staged_image.crop(tuple(box)).save(tile_path, compress_level=1) |
| for psm in psm_modes: |
| tasks[(tile_id, psm)] = (str(tile_path), box, psm) |
| with ThreadPoolExecutor(max_workers=args.workers) as executor: |
| futures = { |
| executor.submit( |
| run_tesseract, |
| Path(tile_path), |
| psm, |
| args.timeout_seconds, |
| ): (tile_id, box, psm) |
| for (tile_id, psm), (tile_path, box, _mode) in tasks.items() |
| } |
| for future in as_completed(futures): |
| tile_id, box, psm = futures[future] |
| returncode, stdout, stderr = future.result() |
| words, lines = parse_tsv(stdout, psm) |
| all_words.extend( |
| offset_and_project( |
| words, box, tile_id, stage_size, target_size |
| ) |
| ) |
| all_lines.extend( |
| offset_and_project( |
| lines, box, tile_id, stage_size, target_size |
| ) |
| ) |
| mode_rows.append( |
| { |
| "tile_id": tile_id, |
| "tile_bbox": box, |
| "psm": psm, |
| "returncode": returncode, |
| "words": len(words), |
| "lines": len(lines), |
| "stderr": stderr, |
| } |
| ) |
| if returncode: |
| failures.append( |
| { |
| "image": str(target), |
| "tile_id": tile_id, |
| "psm": psm, |
| "returncode": returncode, |
| } |
| ) |
| all_words.sort( |
| key=lambda value: ( |
| value["bbox"][1], |
| value["bbox"][0], |
| value["psm"], |
| value["tile_id"], |
| ) |
| ) |
| all_lines.sort( |
| key=lambda value: ( |
| value["bbox"][1], |
| value["bbox"][0], |
| value["psm"], |
| value["tile_id"], |
| ) |
| ) |
| source_path = Path(stage["source_image"]) |
| for path in (target, source_path, stage_path): |
| hash_cache.setdefault(path, sha256(path)) |
| output_row = { |
| "schema": "cartolegend_source_projected_ocr_v1", |
| "image": str(target), |
| "image_sha256": hash_cache[target], |
| "source_image": str(source_path), |
| "source_image_sha256": hash_cache[source_path], |
| "source_crop_bbox": stage["source_crop_bbox"], |
| "source_mode": stage["mode"], |
| "target_size": stage["target_size"], |
| "stage_image": str(stage_path), |
| "stage_image_sha256": hash_cache[stage_path], |
| "stage_size": stage["stage_size"], |
| "effective_scale": stage["effective_scale"], |
| "tiles": len(boxes), |
| "psm_modes": psm_modes, |
| "runs": sorted(mode_rows, key=lambda value: (value["tile_id"], value["psm"])), |
| "words": all_words, |
| "lines": all_lines, |
| "model_outputs_are_proposals_not_annotations": True, |
| "training_allowed": False, |
| } |
| output_stream.write( |
| json.dumps(output_row, sort_keys=True, separators=(",", ":")) + "\n" |
| ) |
| artifact_rows.append( |
| { |
| key: output_row[key] |
| for key in ( |
| "image", |
| "image_sha256", |
| "source_image", |
| "source_image_sha256", |
| "source_crop_bbox", |
| "source_mode", |
| "stage_image", |
| "stage_image_sha256", |
| "target_size", |
| "stage_size", |
| "effective_scale", |
| "tiles", |
| ) |
| } |
| ) |
| print( |
| f"source-ocr={row_index}/{len(rows)} image={target.name} " |
| f"tiles={len(boxes)} words={len(all_words)} lines={len(all_lines)}", |
| flush=True, |
| ) |
|
|
| manifest = { |
| "schema": "cartolegend_source_projected_ocr_manifest_v1", |
| "generated_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"), |
| "tesseract_version": version, |
| "input": str(input_path), |
| "input_sha256": sha256(input_path), |
| "output": str(output_path), |
| "output_sha256": sha256(output_path), |
| "rows": len(rows), |
| "settings": { |
| "image_field": args.image_field, |
| "requested_scale": args.scale, |
| "max_dimension": args.max_dimension, |
| "tile_size": args.tile_size, |
| "tile_overlap": args.tile_overlap, |
| "psm_modes": psm_modes, |
| "workers": args.workers, |
| "timeout_seconds": args.timeout_seconds, |
| }, |
| "artifacts": artifact_rows, |
| "failures": failures, |
| "model_outputs_are_proposals_not_annotations": True, |
| "training_allowed": False, |
| } |
| manifest_path = output_path.with_suffix(output_path.suffix + ".manifest.json") |
| manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n") |
| print(json.dumps(manifest, indent=2, sort_keys=True)) |
| return 1 if failures else 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|