cartolegend-rc4-modern / scripts /extract_cartolegend_source_projected_ocr.py
LockNToad's picture
RC4-Modern: unchanged RC4 adapter + modern full-map pipeline (BENCH-WIDE 77/167 vs 52, FP 0)
35b0bfe verified
Raw
History Blame Contribute Delete
17 kB
#!/usr/bin/env python3
"""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())