Spaces:
Sleeping
Sleeping
| #!/usr/bin/env python3 | |
| """ | |
| Smoke Signal β Stage 4: OCR Bake-Off | |
| ====================================== | |
| Runs Surya (primary) and Tesseract (fallback) on rendered page images | |
| from the calibration corpus. Compares outputs, measures accuracy against | |
| gold set if available, and freezes a baseline OCR config. | |
| Inputs: | |
| renders/<BOOK_ID>/<BOOK_ID>_page_NNNN_300dpi.png | |
| manifest/page_profiles/<BOOK_ID>_page_profile.json | |
| manifest/source_manifest.csv | |
| Outputs: | |
| ocr_raw/<BOOK_ID>/<BOOK_ID>_page_NNNN_ocr.json β per-page OCR result | |
| manifest/ocr_run_<RUN_ID>.json β run summary | |
| configs/ss_ocr_config_v0.1.json β frozen baseline config | |
| Usage: | |
| python scripts/03_ocr_bakeoff.py | |
| python scripts/03_ocr_bakeoff.py --book-id SS-BOOK-0001 | |
| python scripts/03_ocr_bakeoff.py --engine surya | |
| python scripts/03_ocr_bakeoff.py --engine tesseract | |
| python scripts/03_ocr_bakeoff.py --engine both | |
| python scripts/03_ocr_bakeoff.py --dry-run | |
| """ | |
| import argparse | |
| import csv | |
| import hashlib | |
| import json | |
| import sys | |
| import time | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Optional | |
| # ββ Paths ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ROOT = Path(__file__).resolve().parents[1] | |
| MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv" | |
| PROFILES_DIR = ROOT / "manifest" / "page_profiles" | |
| RENDERS_DIR = ROOT / "renders" | |
| OCR_RAW_DIR = ROOT / "ocr_raw" | |
| CONFIGS_DIR = ROOT / "configs" | |
| LOGS_DIR = ROOT / "logs" | |
| OCR_RAW_DIR.mkdir(parents=True, exist_ok=True) | |
| CONFIGS_DIR.mkdir(parents=True, exist_ok=True) | |
| LOGS_DIR.mkdir(parents=True, exist_ok=True) | |
| # ββ Frozen OCR config (do not change mid-batch) ββββββββββββββββββββββββββββββββ | |
| OCR_CONFIG = { | |
| "config_version": "ss_ocr_config_v0.1", | |
| "primary_engine": "surya", | |
| "fallback_engine": "tesseract", | |
| "surya_langs": ["en"], | |
| "surya_det_batch": 4, | |
| "surya_rec_batch": 4, | |
| "tesseract_lang": "eng", | |
| "tesseract_psm": 6, # assume uniform block of text | |
| "confidence_threshold_auto_accept": 0.85, | |
| "confidence_threshold_review": 0.60, | |
| "confidence_threshold_quarantine": 0.40, | |
| "eligible_routes": ["ocr", "hybrid"], | |
| } | |
| # ββ Manifest / profile loaders βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def load_manifest() -> dict: | |
| records = {} | |
| if not MANIFEST_CSV.exists(): | |
| return records | |
| with open(MANIFEST_CSV, newline="", encoding="utf-8") as f: | |
| for row in csv.DictReader(f): | |
| if row.get("book_id"): | |
| records[row["book_id"]] = row | |
| return records | |
| def save_manifest(records: dict) -> None: | |
| fields = [ | |
| "book_id", "source_id", "filename", "sha256", "file_size_bytes", | |
| "page_count", "rights_class", "source_location", "acquisition_date", | |
| "status", "allowed_use", "notes" | |
| ] | |
| rows = sorted(records.values(), key=lambda r: r.get("book_id", "")) | |
| with open(MANIFEST_CSV, "w", newline="", encoding="utf-8") as f: | |
| writer = csv.DictWriter(f, fieldnames=fields) | |
| writer.writeheader() | |
| writer.writerows(rows) | |
| def load_page_profile(book_id: str) -> Optional[dict]: | |
| profile_path = PROFILES_DIR / f"{book_id}_page_profile.json" | |
| if not profile_path.exists(): | |
| return None | |
| with open(profile_path, encoding="utf-8") as f: | |
| return json.load(f) | |
| # ββ Surya OCR ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _safe_load_surya_component(loader, checkpoint: Optional[str]): | |
| """ | |
| Attempt to pass checkpoint to Surya loader, with safe fallback for older APIs. | |
| """ | |
| if not checkpoint: | |
| return loader() | |
| try: | |
| return loader(checkpoint=checkpoint) | |
| except TypeError: | |
| return loader() | |
| def load_surya_context(checkpoint: Optional[str] = None) -> Optional[dict]: | |
| """ | |
| Load Surya OCR models once per run. | |
| Tries modern predictor API first, then legacy API. | |
| Returns context dict or None if Surya import/loading fails. | |
| """ | |
| # New API (surya-ocr>=0.17 style) | |
| try: | |
| from surya.foundation import FoundationPredictor | |
| from surya.detection import DetectionPredictor | |
| from surya.recognition import RecognitionPredictor | |
| try: | |
| from surya.common.surya.schema import TaskNames | |
| task_name = TaskNames.ocr_with_boxes | |
| except Exception: | |
| task_name = "ocr_with_boxes" | |
| foundation_predictor = _safe_load_surya_component(FoundationPredictor, checkpoint) | |
| det_predictor = DetectionPredictor() | |
| rec_predictor = RecognitionPredictor(foundation_predictor) | |
| return { | |
| "api": "predictor-v2", | |
| "task_name": task_name, | |
| "det_predictor": det_predictor, | |
| "rec_predictor": rec_predictor, | |
| "checkpoint": checkpoint, | |
| } | |
| except Exception: | |
| pass | |
| # Legacy API (surya-ocr<=0.6 style) | |
| try: | |
| from surya.ocr import run_ocr | |
| from surya.model.detection.model import load_model as load_det_model | |
| from surya.model.detection.processor import load_processor as load_det_processor | |
| from surya.model.recognition.model import load_model as load_rec_model | |
| from surya.model.recognition.processor import load_processor as load_rec_processor | |
| except ImportError: | |
| return None | |
| try: | |
| det_model = _safe_load_surya_component(load_det_model, checkpoint) | |
| det_processor = _safe_load_surya_component(load_det_processor, checkpoint) | |
| rec_model = _safe_load_surya_component(load_rec_model, checkpoint) | |
| rec_processor = _safe_load_surya_component(load_rec_processor, checkpoint) | |
| return { | |
| "run": run_ocr, | |
| "det_model": det_model, | |
| "det_processor": det_processor, | |
| "rec_model": rec_model, | |
| "rec_processor": rec_processor, | |
| "checkpoint": checkpoint, | |
| } | |
| except Exception: | |
| return None | |
| def _run_surya(image_path: Path, langs: list, surya_ctx: Optional[dict] = None) -> dict: | |
| """ | |
| Run Surya OCR on a single page image. | |
| Returns standardised result dict. | |
| """ | |
| try: | |
| from PIL import Image | |
| except ImportError as e: | |
| return { | |
| "engine": "surya", | |
| "error": f"Import error: {e}. Run: pip install surya-ocr", | |
| "text": "", | |
| "words": [], | |
| "confidence": 0.0, | |
| } | |
| ctx = surya_ctx or load_surya_context() | |
| if not ctx: | |
| return { | |
| "engine": "surya", | |
| "error": "Surya model load failed. Check surya-ocr install and checkpoint path.", | |
| "text": "", | |
| "words": [], | |
| "confidence": 0.0, | |
| } | |
| try: | |
| image = Image.open(str(image_path)).convert("RGB") | |
| if ctx.get("api") == "predictor-v2": | |
| results = ctx["rec_predictor"]( | |
| [image], | |
| task_names=[ctx["task_name"]], | |
| det_predictor=ctx["det_predictor"], | |
| highres_images=[image], | |
| math_mode=True, | |
| ) | |
| else: | |
| results = ctx["run"]( | |
| [image], | |
| [langs], | |
| ctx["det_model"], | |
| ctx["det_processor"], | |
| ctx["rec_model"], | |
| ctx["rec_processor"], | |
| ) | |
| page_result = results[0] | |
| # Extract text and confidence from Surya's TextLine objects | |
| words = [] | |
| full_text = [] | |
| confidences = [] | |
| for line in page_result.text_lines: | |
| text = line.text.strip() | |
| conf = float(line.confidence) if hasattr(line, "confidence") else 1.0 | |
| if text: | |
| full_text.append(text) | |
| confidences.append(conf) | |
| bbox = getattr(line, "bbox", None) | |
| if bbox is None: | |
| bbox = getattr(line, "polygon", None) | |
| words.append({ | |
| "text": text, | |
| "confidence": round(conf, 4), | |
| "bbox": bbox, | |
| }) | |
| avg_conf = round(sum(confidences) / len(confidences), 4) if confidences else 0.0 | |
| return { | |
| "engine": "surya", | |
| "text": "\n".join(full_text), | |
| "words": words, | |
| "confidence": avg_conf, | |
| "line_count": len(words), | |
| "model_checkpoint": ctx.get("checkpoint") or "base", | |
| "error": None, | |
| } | |
| except Exception as e: | |
| return { | |
| "engine": "surya", | |
| "error": str(e), | |
| "text": "", | |
| "words": [], | |
| "confidence": 0.0, | |
| "model_checkpoint": ctx.get("checkpoint") or "base", | |
| } | |
| # ββ Tesseract OCR (fallback) βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _run_tesseract(image_path: Path, lang: str = "eng", psm: int = 6) -> dict: | |
| """ | |
| Run Tesseract on a single page image. | |
| Returns standardised result dict. | |
| """ | |
| try: | |
| import pytesseract | |
| from PIL import Image | |
| except ImportError as e: | |
| return { | |
| "engine": "tesseract", | |
| "error": f"Import error: {e}. Run: pip install pytesseract pillow", | |
| "text": "", | |
| "words": [], | |
| "confidence": 0.0, | |
| } | |
| try: | |
| image = Image.open(str(image_path)).convert("RGB") | |
| config = f"--psm {psm}" | |
| # Get word-level data with confidence | |
| data = pytesseract.image_to_data( | |
| image, | |
| lang=lang, | |
| config=config, | |
| output_type=pytesseract.Output.DICT, | |
| ) | |
| words = [] | |
| confidences = [] | |
| full_text_parts = [] | |
| for i, word_text in enumerate(data["text"]): | |
| word_text = str(word_text).strip() | |
| conf = int(data["conf"][i]) | |
| if word_text and conf > 0: | |
| conf_norm = conf / 100.0 | |
| words.append({ | |
| "text": word_text, | |
| "confidence": round(conf_norm, 4), | |
| "bbox": [ | |
| data["left"][i], data["top"][i], | |
| data["left"][i] + data["width"][i], | |
| data["top"][i] + data["height"][i], | |
| ], | |
| }) | |
| confidences.append(conf_norm) | |
| full_text_parts.append(word_text) | |
| avg_conf = round(sum(confidences) / len(confidences), 4) if confidences else 0.0 | |
| full_text = pytesseract.image_to_string(image, lang=lang, config=config).strip() | |
| return { | |
| "engine": "tesseract", | |
| "text": full_text, | |
| "words": words, | |
| "confidence": avg_conf, | |
| "line_count": len(words), | |
| "error": None, | |
| } | |
| except Exception as e: | |
| return { | |
| "engine": "tesseract", | |
| "error": str(e), | |
| "text": "", | |
| "words": [], | |
| "confidence": 0.0, | |
| } | |
| # ββ Confidence gate ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def confidence_gate(confidence: float, config: dict) -> str: | |
| """Return auto-accept | review-required | quarantine based on thresholds.""" | |
| if confidence >= config["confidence_threshold_auto_accept"]: | |
| return "auto-accept" | |
| elif confidence >= config["confidence_threshold_review"]: | |
| return "review-required" | |
| elif confidence >= config["confidence_threshold_quarantine"]: | |
| return "quarantine" | |
| else: | |
| return "quarantine" | |
| # ββ Per-page OCR βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def ocr_page( | |
| image_path: Path, | |
| book_id: str, | |
| page_num: int, | |
| engine: str, | |
| config: dict, | |
| surya_ctx: Optional[dict] = None, | |
| dry_run: bool = False, | |
| ) -> dict: | |
| """Run OCR on one page, save result, return summary.""" | |
| result = { | |
| "book_id": book_id, | |
| "page_number": page_num, | |
| "image_path": str(image_path), | |
| "engine": engine, | |
| "ocr_at": datetime.utcnow().isoformat() + "Z", | |
| "config_version": config["config_version"], | |
| } | |
| if dry_run: | |
| result.update({ | |
| "text": "[dry-run]", "confidence": 0.0, | |
| "gate": "dry-run", "error": None, "words": [], | |
| }) | |
| return result | |
| if engine == "surya": | |
| ocr_out = _run_surya(image_path, config["surya_langs"], surya_ctx=surya_ctx) | |
| elif engine == "tesseract": | |
| ocr_out = _run_tesseract(image_path, config["tesseract_lang"], config["tesseract_psm"]) | |
| else: | |
| ocr_out = {"engine": engine, "error": f"Unknown engine: {engine}", "text": "", "words": [], "confidence": 0.0} | |
| result.update(ocr_out) | |
| result["gate"] = confidence_gate(result.get("confidence", 0.0), config) | |
| # Save per-page OCR JSON | |
| book_ocr_dir = OCR_RAW_DIR / book_id | |
| book_ocr_dir.mkdir(parents=True, exist_ok=True) | |
| out_path = book_ocr_dir / f"{book_id}_page_{page_num:04d}_{engine}_ocr.json" | |
| with open(out_path, "w", encoding="utf-8") as f: | |
| json.dump(result, f, indent=2, ensure_ascii=False) | |
| return result | |
| # ββ Per-book OCR runner ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def ocr_book( | |
| record: dict, | |
| engine: str, | |
| config: dict, | |
| surya_ctx: Optional[dict] = None, | |
| dry_run: bool = False, | |
| ) -> dict: | |
| book_id = record["book_id"] | |
| print(f"\n [{book_id}] {record['filename']} β engine: {engine}") | |
| profile = load_page_profile(book_id) | |
| if not profile: | |
| print(f" β No page profile found. Run 02_profile_pdfs.py first.") | |
| return {"book_id": book_id, "error": "no_profile", "pages": []} | |
| eligible_routes = config["eligible_routes"] | |
| ocr_pages = [p for p in profile["pages"] if p.get("route") in eligible_routes] | |
| print(f" OCR-eligible pages: {len(ocr_pages)} / {profile['page_count']}") | |
| if not ocr_pages: | |
| print(f" β No OCR pages β all embedded text.") | |
| return {"book_id": book_id, "error": None, "pages": [], "skipped": True} | |
| page_results = [] | |
| confidences = [] | |
| gate_counts = {"auto-accept": 0, "review-required": 0, "quarantine": 0, "dry-run": 0} | |
| errors = [] | |
| for page_info in ocr_pages: | |
| page_num = page_info["page_number"] | |
| render_path = page_info.get("render_path") | |
| if not render_path: | |
| # Try to find render file | |
| render_path_candidates = list((RENDERS_DIR / book_id).glob( | |
| f"{book_id}_page_{page_num:04d}_*.png" | |
| )) if (RENDERS_DIR / book_id).exists() else [] | |
| render_path = str(render_path_candidates[0]) if render_path_candidates else None | |
| if not render_path: | |
| print(f" β Page {page_num}: no render found β skipping") | |
| errors.append({"page": page_num, "error": "no_render"}) | |
| continue | |
| image_path = Path(render_path) if Path(render_path).is_absolute() else ROOT / render_path | |
| if not image_path.exists(): | |
| print(f" β Page {page_num}: render file missing β {image_path}") | |
| errors.append({"page": page_num, "error": "render_missing"}) | |
| continue | |
| result = ocr_page(image_path, book_id, page_num, engine, config, surya_ctx=surya_ctx, dry_run=dry_run) | |
| page_results.append(result) | |
| conf = result.get("confidence", 0.0) | |
| gate = result.get("gate", "quarantine") | |
| confidences.append(conf) | |
| gate_counts[gate] = gate_counts.get(gate, 0) + 1 | |
| status = "β" if gate == "auto-accept" else "β " if gate == "review-required" else "β" | |
| print(f" {status} p{page_num:03d} conf={conf:.2f} gate={gate}") | |
| avg_conf = round(sum(confidences) / len(confidences), 4) if confidences else 0.0 | |
| print(f" Avg confidence : {avg_conf:.2f}") | |
| print(f" Gates : {gate_counts}") | |
| if errors: | |
| print(f" Errors : {len(errors)}") | |
| return { | |
| "book_id": book_id, | |
| "engine": engine, | |
| "pages_ocred": len(page_results), | |
| "avg_confidence": avg_conf, | |
| "gate_counts": gate_counts, | |
| "errors": errors, | |
| "error": None, | |
| } | |
| # ββ Save frozen config βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def save_ocr_config(config: dict) -> None: | |
| config_path = CONFIGS_DIR / f"{config['config_version']}.json" | |
| if not config_path.exists(): | |
| with open(config_path, "w", encoding="utf-8") as f: | |
| json.dump({ | |
| **config, | |
| "frozen_at": datetime.utcnow().isoformat() + "Z", | |
| "note": "DO NOT change this file mid-batch. Create a new version instead.", | |
| }, f, indent=2) | |
| print(f"\n Config frozen β {config_path.relative_to(ROOT)}") | |
| else: | |
| print(f"\n Config already exists β {config_path.relative_to(ROOT)} (not overwritten)") | |
| # ββ Main βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def main(): | |
| parser = argparse.ArgumentParser(description="Smoke Signal β Stage 4: OCR Bake-Off") | |
| parser.add_argument("--book-id", help="OCR a single book by ID") | |
| parser.add_argument("--batch-id", help="Tag this run with a batch ID") | |
| parser.add_argument("--engine", choices=["surya", "tesseract", "both"], | |
| default="surya", help="OCR engine to use (default: surya)") | |
| parser.add_argument("--model", default=None, | |
| help="Optional Surya checkpoint/model path for OCR engine=surya") | |
| parser.add_argument("--dry-run", action="store_true", help="No files written") | |
| parser.add_argument("--all", action="store_true", help="Include already-OCRed books") | |
| args = parser.parse_args() | |
| run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}" | |
| engines = ["surya", "tesseract"] if args.engine == "both" else [args.engine] | |
| run_config = dict(OCR_CONFIG) | |
| if args.model: | |
| run_config["surya_model_checkpoint"] = args.model | |
| # Governance: checkpoint changes require a new config version. | |
| model_tag = hashlib.sha256(args.model.encode("utf-8")).hexdigest()[:8] | |
| run_config["config_version"] = f"{OCR_CONFIG['config_version']}_ft_{model_tag}" | |
| print(f"\n{'='*60}") | |
| print(f" Smoke Signal β Stage 4: OCR Bake-Off") | |
| print(f" Run ID : {run_id}") | |
| print(f" Engines : {engines}") | |
| print(f" Config : {run_config['config_version']}") | |
| if args.model: | |
| print(f" Surya model override : {args.model}") | |
| if args.dry_run: | |
| print(f" Mode : DRY RUN") | |
| print(f"{'='*60}") | |
| manifest = load_manifest() | |
| if not manifest: | |
| print("\n [error] Manifest empty. Run 01_register_sources.py first.") | |
| sys.exit(1) | |
| eligible_statuses = ["profiled", "rendered"] if not args.all else \ | |
| ["profiled", "rendered", "ocred"] | |
| if args.book_id: | |
| books = [manifest[args.book_id]] if args.book_id in manifest else [] | |
| if not books: | |
| print(f" [error] Book {args.book_id} not in manifest.") | |
| sys.exit(1) | |
| else: | |
| books = [r for r in manifest.values() if r.get("status") in eligible_statuses] | |
| # Governance: never process unknown/excluded rights in OCR batches. | |
| books = [r for r in books if r.get("rights_class") not in ("unknown", "excluded")] | |
| if not books: | |
| print(f"\n No books eligible after status/rights filters.") | |
| print(f" Eligible statuses: {eligible_statuses}") | |
| print(" Rights blocked: unknown, excluded") | |
| sys.exit(0) | |
| print(f"\n Books to OCR: {len(books)}") | |
| all_results = [] | |
| t_start = time.time() | |
| surya_ctx = None | |
| if "surya" in engines and not args.dry_run: | |
| surya_ctx = load_surya_context(args.model) | |
| if not surya_ctx: | |
| print("\n [error] Could not load Surya models/checkpoint.") | |
| print(" Check surya-ocr install and --model path.") | |
| sys.exit(1) | |
| for record in books: | |
| for engine in engines: | |
| result = ocr_book(record, engine, run_config, surya_ctx=surya_ctx, dry_run=args.dry_run) | |
| all_results.append(result) | |
| # Update manifest status | |
| if not result.get("error") and not args.dry_run: | |
| manifest[record["book_id"]]["status"] = "ocred" | |
| # Save manifest + config + run log | |
| if not args.dry_run: | |
| save_manifest(manifest) | |
| save_ocr_config(run_config) | |
| log_path = LOGS_DIR / f"{run_id}_ocr_bakeoff.json" | |
| with open(log_path, "w", encoding="utf-8") as f: | |
| json.dump({ | |
| "run_id": run_id, | |
| "run_at": datetime.utcnow().isoformat() + "Z", | |
| "engines": engines, | |
| "config": run_config, | |
| "surya_model_checkpoint": args.model or "base", | |
| "results": all_results, | |
| }, f, indent=2) | |
| print(f"\n Run log β {log_path.relative_to(ROOT)}") | |
| # ββ Summary βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| elapsed = round(time.time() - t_start, 1) | |
| succeeded = sum(1 for r in all_results if not r.get("error")) | |
| total_pages = sum(r.get("pages_ocred", 0) for r in all_results) | |
| avg_conf = ( | |
| sum(r.get("avg_confidence", 0) for r in all_results if not r.get("error")) / max(succeeded, 1) | |
| ) | |
| print(f"\n{'β'*60}") | |
| print(f" Books processed : {len(books)}") | |
| print(f" Runs succeeded : {succeeded}") | |
| print(f" Pages OCR-ed : {total_pages}") | |
| print(f" Avg confidence : {avg_conf:.2f}") | |
| print(f" Time : {elapsed}s") | |
| print(f"{'β'*60}") | |
| # Gate breakdown across all runs | |
| total_gates = {"auto-accept": 0, "review-required": 0, "quarantine": 0} | |
| for r in all_results: | |
| for gate, count in r.get("gate_counts", {}).items(): | |
| if gate in total_gates: | |
| total_gates[gate] += count | |
| print(f"\n Gate breakdown:") | |
| for gate, count in total_gates.items(): | |
| pct = round(count / max(total_pages, 1) * 100, 1) | |
| flag = " β ACTION REQUIRED" if gate != "auto-accept" and count > 0 else "" | |
| print(f" {gate:<20} {count:>4} ({pct}%){flag}") | |
| print(f"\n Next steps:") | |
| print(f" 1. Inspect ocr_raw/ outputs for quality") | |
| print(f" 2. Run 04_region_detector.py (Stage 5)") | |
| print(f" 3. Review quarantined pages manually\n") | |
| if __name__ == "__main__": | |
| main() | |