Pointf5ive commited on
Commit
ace839d
Β·
verified Β·
1 Parent(s): a3735e7

Update smoke_signal/scripts/03_ocr_bakeoff.py

Browse files
Files changed (1) hide show
  1. smoke_signal/scripts/03_ocr_bakeoff.py +406 -139
smoke_signal/scripts/03_ocr_bakeoff.py CHANGED
@@ -1,8 +1,20 @@
1
  #!/usr/bin/env python3
2
  """
3
- Smoke Signal - Stage 4: OCR Bake-Off
4
- Run Surya (primary) and Tesseract (fallback) on rendered pages.
5
- Freezes baseline OCR config after comparison.
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  Usage:
8
  python scripts/03_ocr_bakeoff.py
@@ -22,6 +34,7 @@ from datetime import datetime
22
  from pathlib import Path
23
  from typing import Optional
24
 
 
25
  ROOT = Path(__file__).resolve().parents[1]
26
  MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
27
  PROFILES_DIR = ROOT / "manifest" / "page_profiles"
@@ -30,9 +43,11 @@ OCR_RAW_DIR = ROOT / "ocr_raw"
30
  CONFIGS_DIR = ROOT / "configs"
31
  LOGS_DIR = ROOT / "logs"
32
 
33
- for d in [OCR_RAW_DIR, CONFIGS_DIR, LOGS_DIR]:
34
- d.mkdir(parents=True, exist_ok=True)
 
35
 
 
36
  OCR_CONFIG = {
37
  "config_version": "ss_ocr_config_v0.1",
38
  "primary_engine": "surya",
@@ -41,7 +56,7 @@ OCR_CONFIG = {
41
  "surya_det_batch": 4,
42
  "surya_rec_batch": 4,
43
  "tesseract_lang": "eng",
44
- "tesseract_psm": 6,
45
  "confidence_threshold_auto_accept": 0.85,
46
  "confidence_threshold_review": 0.60,
47
  "confidence_threshold_quarantine": 0.40,
@@ -49,7 +64,8 @@ OCR_CONFIG = {
49
  }
50
 
51
 
52
- def load_manifest():
 
53
  records = {}
54
  if not MANIFEST_CSV.exists():
55
  return records
@@ -60,187 +76,438 @@ def load_manifest():
60
  return records
61
 
62
 
63
- def save_manifest(records):
64
- fields = ["book_id","source_id","filename","sha256","file_size_bytes",
65
- "page_count","rights_class","source_location","acquisition_date",
66
- "status","allowed_use","notes"]
67
- rows = sorted(records.values(), key=lambda r: r.get("book_id",""))
 
 
68
  with open(MANIFEST_CSV, "w", newline="", encoding="utf-8") as f:
69
  writer = csv.DictWriter(f, fieldnames=fields)
70
  writer.writeheader()
71
  writer.writerows(rows)
72
 
73
 
74
- def load_page_profile(book_id):
75
- p = PROFILES_DIR / f"{book_id}_page_profile.json"
76
- if not p.exists():
77
  return None
78
- with open(p, encoding="utf-8") as f:
79
  return json.load(f)
80
 
81
 
82
- def run_surya(image_path, langs):
 
 
 
 
 
83
  try:
84
  from PIL import Image
85
  from surya.ocr import run_ocr
86
- from surya.model.detection.model import load_model as load_det
87
- from surya.model.detection.processor import load_processor as load_det_proc
88
- from surya.model.recognition.model import load_model as load_rec
89
- from surya.model.recognition.processor import load_processor as load_rec_proc
90
  except ImportError as e:
91
- return {"engine":"surya","error":str(e),"text":"","words":[],"confidence":0.0}
 
 
 
 
 
 
 
92
  try:
93
- img = Image.open(str(image_path)).convert("RGB")
94
- det_m, det_p = load_det(), load_det_proc()
95
- rec_m, rec_p = load_rec(), load_rec_proc()
96
- results = run_ocr([img], [langs], det_m, det_p, rec_m, rec_p)
97
- page = results[0]
98
- words, confs, lines = [], [], []
99
- for line in page.text_lines:
100
- t = line.text.strip()
101
- c = float(line.confidence) if hasattr(line,"confidence") else 1.0
102
- if t:
103
- lines.append(t)
104
- confs.append(c)
105
- words.append({"text":t,"confidence":round(c,4),"bbox":getattr(line,"bbox",None)})
106
- avg = round(sum(confs)/len(confs),4) if confs else 0.0
107
- return {"engine":"surya","text":"\n".join(lines),"words":words,"confidence":avg,"error":None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  except Exception as e:
109
- return {"engine":"surya","error":str(e),"text":"","words":[],"confidence":0.0}
 
 
 
 
 
 
110
 
111
 
112
- def run_tesseract(image_path, lang, psm):
 
 
 
 
 
113
  try:
114
  import pytesseract
115
  from PIL import Image
116
  except ImportError as e:
117
- return {"engine":"tesseract","error":str(e),"text":"","words":[],"confidence":0.0}
 
 
 
 
 
 
 
118
  try:
119
- img = Image.open(str(image_path)).convert("RGB")
120
- cfg = f"--psm {psm}"
121
- data = pytesseract.image_to_data(img, lang=lang, config=cfg, output_type=pytesseract.Output.DICT)
122
- words, confs = [], []
123
- for i, t in enumerate(data["text"]):
124
- t = str(t).strip()
125
- c = int(data["conf"][i])
126
- if t and c > 0:
127
- cn = c/100.0
128
- words.append({"text":t,"confidence":round(cn,4),"bbox":[data["left"][i],data["top"][i],data["left"][i]+data["width"][i],data["top"][i]+data["height"][i]]})
129
- confs.append(cn)
130
- avg = round(sum(confs)/len(confs),4) if confs else 0.0
131
- text = pytesseract.image_to_string(img, lang=lang, config=cfg).strip()
132
- return {"engine":"tesseract","text":text,"words":words,"confidence":avg,"error":None}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  except Exception as e:
134
- return {"engine":"tesseract","error":str(e),"text":"","words":[],"confidence":0.0}
 
 
 
 
 
 
 
135
 
 
 
 
 
 
 
 
 
 
 
 
136
 
137
- def confidence_gate(conf, config):
138
- if conf >= config["confidence_threshold_auto_accept"]: return "auto-accept"
139
- if conf >= config["confidence_threshold_review"]: return "review-required"
140
- return "quarantine"
141
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
- def ocr_page(image_path, book_id, page_num, engine, config, dry_run=False):
144
- result = {"book_id":book_id,"page_number":page_num,"image_path":str(image_path),
145
- "engine":engine,"ocr_at":datetime.utcnow().isoformat()+"Z",
146
- "config_version":config["config_version"]}
147
  if dry_run:
148
- result.update({"text":"[dry-run]","confidence":0.0,"gate":"dry-run","error":None,"words":[]})
 
 
 
149
  return result
150
- ocr_out = run_surya(image_path, config["surya_langs"]) if engine == "surya" else \
151
- run_tesseract(image_path, config["tesseract_lang"], config["tesseract_psm"])
 
 
 
 
 
 
152
  result.update(ocr_out)
153
- result["gate"] = confidence_gate(result.get("confidence",0.0), config)
154
- out_dir = OCR_RAW_DIR / book_id
155
- out_dir.mkdir(parents=True, exist_ok=True)
156
- with open(out_dir / f"{book_id}_page_{page_num:04d}_{engine}_ocr.json","w",encoding="utf-8") as f:
 
 
 
157
  json.dump(result, f, indent=2, ensure_ascii=False)
 
158
  return result
159
 
160
 
161
- def ocr_book(record, engine, dry_run=False):
162
- book_id = record["book_id"]
163
- print(f"\n [{book_id}] {record['filename']} β€” {engine}")
 
 
164
  profile = load_page_profile(book_id)
165
  if not profile:
166
- print(" No profile. Run 02_profile_pdfs.py first.")
167
- return {"book_id":book_id,"error":"no_profile","pages":[]}
168
- eligible = [p for p in profile["pages"] if p.get("route") in OCR_CONFIG["eligible_routes"]]
169
- print(f" OCR pages: {len(eligible)} / {profile['page_count']}")
170
- results, confs, gates, errors = [], [], {"auto-accept":0,"review-required":0,"quarantine":0,"dry-run":0}, []
171
- for p in eligible:
172
- pn = p["page_number"]
173
- rp = p.get("render_path")
174
- if not rp:
175
- cands = list((RENDERS_DIR/book_id).glob(f"{book_id}_page_{pn:04d}_*.png")) if (RENDERS_DIR/book_id).exists() else []
176
- rp = str(cands[0]) if cands else None
177
- if not rp:
178
- errors.append({"page":pn,"error":"no_render"}); continue
179
- ip = Path(rp) if Path(rp).is_absolute() else ROOT/rp
180
- if not ip.exists():
181
- errors.append({"page":pn,"error":"render_missing"}); continue
182
- r = ocr_page(ip, book_id, pn, engine, OCR_CONFIG, dry_run)
183
- results.append(r)
184
- c = r.get("confidence",0.0)
185
- g = r.get("gate","quarantine")
186
- confs.append(c)
187
- gates[g] = gates.get(g,0)+1
188
- sym = "v" if g=="auto-accept" else "!" if g=="review-required" else "x"
189
- print(f" {sym} p{pn:03d} conf={c:.2f} gate={g}")
190
- avg = round(sum(confs)/len(confs),4) if confs else 0.0
191
- print(f" Avg={avg:.2f} | {gates}")
192
- return {"book_id":book_id,"engine":engine,"pages_ocred":len(results),"avg_confidence":avg,"gate_counts":gates,"errors":errors,"error":None}
193
-
194
-
195
- def save_config(config):
196
- p = CONFIGS_DIR / f"{config['config_version']}.json"
197
- if not p.exists():
198
- with open(p,"w",encoding="utf-8") as f:
199
- json.dump({**config,"frozen_at":datetime.utcnow().isoformat()+"Z"},f,indent=2)
200
- print(f" Config frozen -> {p.relative_to(ROOT)}")
201
 
 
 
 
 
 
 
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
  def main():
204
- parser = argparse.ArgumentParser(description="Smoke Signal Stage 4: OCR Bake-Off")
205
- parser.add_argument("--book-id")
206
- parser.add_argument("--batch-id")
207
- parser.add_argument("--engine", choices=["surya","tesseract","both"], default="surya")
208
- parser.add_argument("--dry-run", action="store_true")
209
- parser.add_argument("--all", action="store_true")
 
210
  args = parser.parse_args()
 
211
  run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
212
- engines = ["surya","tesseract"] if args.engine == "both" else [args.engine]
213
- print(f"\nSmoke Signal Stage 4 | {run_id} | engines={engines}")
 
 
 
 
 
 
 
 
 
214
  manifest = load_manifest()
215
  if not manifest:
216
- print("Manifest empty. Run stage 1 first."); sys.exit(1)
217
- eligible = ["profiled","rendered"] if not args.all else ["profiled","rendered","ocred"]
218
- books = [manifest[args.book_id]] if args.book_id else [r for r in manifest.values() if r.get("status") in eligible]
 
 
 
 
 
 
 
 
 
 
 
219
  if not books:
220
- print(f"No books in status {eligible}. Run stage 3 first."); sys.exit(0)
221
- print(f"Books: {len(books)}")
 
 
 
 
222
  all_results = []
223
- t0 = time.time()
224
- for rec in books:
225
- for eng in engines:
226
- res = ocr_book(rec, eng, args.dry_run)
227
- all_results.append(res)
228
- if not res.get("error") and not args.dry_run:
229
- manifest[rec["book_id"]]["status"] = "ocred"
 
 
 
 
 
230
  if not args.dry_run:
231
  save_manifest(manifest)
232
- save_config(OCR_CONFIG)
233
- log = LOGS_DIR / f"{run_id}_ocr_bakeoff.json"
234
- with open(log,"w") as f:
235
- json.dump({"run_id":run_id,"engines":engines,"config":OCR_CONFIG,"results":all_results},f,indent=2)
236
- print(f" Log -> {log.relative_to(ROOT)}")
237
- elapsed = round(time.time()-t0,1)
 
 
 
 
 
 
 
 
 
238
  succeeded = sum(1 for r in all_results if not r.get("error"))
239
- total_pages = sum(r.get("pages_ocred",0) for r in all_results)
240
- avg = sum(r.get("avg_confidence",0) for r in all_results if not r.get("error"))/max(succeeded,1)
241
- print(f"\nDone: {succeeded}/{len(all_results)} books | {total_pages} pages | avg_conf={avg:.2f} | {elapsed}s")
242
- print("Next: run 04_region_detector.py (Stage 5)")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
243
 
244
 
245
  if __name__ == "__main__":
246
- main()
 
1
  #!/usr/bin/env python3
2
  """
3
+ Smoke Signal β€” Stage 4: OCR Bake-Off
4
+ ======================================
5
+ Runs Surya (primary) and Tesseract (fallback) on rendered page images
6
+ from the calibration corpus. Compares outputs, measures accuracy against
7
+ gold set if available, and freezes a baseline OCR config.
8
+
9
+ Inputs:
10
+ renders/<BOOK_ID>/<BOOK_ID>_page_NNNN_300dpi.png
11
+ manifest/page_profiles/<BOOK_ID>_page_profile.json
12
+ manifest/source_manifest.csv
13
+
14
+ Outputs:
15
+ ocr_raw/<BOOK_ID>/<BOOK_ID>_page_NNNN_ocr.json β€” per-page OCR result
16
+ manifest/ocr_run_<RUN_ID>.json β€” run summary
17
+ configs/ss_ocr_config_v0.1.json β€” frozen baseline config
18
 
19
  Usage:
20
  python scripts/03_ocr_bakeoff.py
 
34
  from pathlib import Path
35
  from typing import Optional
36
 
37
+ # ── Paths ──────────────────────────────────────────────────────────────────────
38
  ROOT = Path(__file__).resolve().parents[1]
39
  MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
40
  PROFILES_DIR = ROOT / "manifest" / "page_profiles"
 
43
  CONFIGS_DIR = ROOT / "configs"
44
  LOGS_DIR = ROOT / "logs"
45
 
46
+ OCR_RAW_DIR.mkdir(parents=True, exist_ok=True)
47
+ CONFIGS_DIR.mkdir(parents=True, exist_ok=True)
48
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
49
 
50
+ # ── Frozen OCR config (do not change mid-batch) ────────────────────────────────
51
  OCR_CONFIG = {
52
  "config_version": "ss_ocr_config_v0.1",
53
  "primary_engine": "surya",
 
56
  "surya_det_batch": 4,
57
  "surya_rec_batch": 4,
58
  "tesseract_lang": "eng",
59
+ "tesseract_psm": 6, # assume uniform block of text
60
  "confidence_threshold_auto_accept": 0.85,
61
  "confidence_threshold_review": 0.60,
62
  "confidence_threshold_quarantine": 0.40,
 
64
  }
65
 
66
 
67
+ # ── Manifest / profile loaders ─────────────────────────────────────────────────
68
+ def load_manifest() -> dict:
69
  records = {}
70
  if not MANIFEST_CSV.exists():
71
  return records
 
76
  return records
77
 
78
 
79
+ def save_manifest(records: dict) -> None:
80
+ fields = [
81
+ "book_id", "source_id", "filename", "sha256", "file_size_bytes",
82
+ "page_count", "rights_class", "source_location", "acquisition_date",
83
+ "status", "allowed_use", "notes"
84
+ ]
85
+ rows = sorted(records.values(), key=lambda r: r.get("book_id", ""))
86
  with open(MANIFEST_CSV, "w", newline="", encoding="utf-8") as f:
87
  writer = csv.DictWriter(f, fieldnames=fields)
88
  writer.writeheader()
89
  writer.writerows(rows)
90
 
91
 
92
+ def load_page_profile(book_id: str) -> Optional[dict]:
93
+ profile_path = PROFILES_DIR / f"{book_id}_page_profile.json"
94
+ if not profile_path.exists():
95
  return None
96
+ with open(profile_path, encoding="utf-8") as f:
97
  return json.load(f)
98
 
99
 
100
+ # ── Surya OCR ──────────────────────────────────────────────────────────────────
101
+ def _run_surya(image_path: Path, langs: list) -> dict:
102
+ """
103
+ Run Surya OCR on a single page image.
104
+ Returns standardised result dict.
105
+ """
106
  try:
107
  from PIL import Image
108
  from surya.ocr import run_ocr
109
+ from surya.model.detection.model import load_model as load_det_model
110
+ from surya.model.detection.processor import load_processor as load_det_processor
111
+ from surya.model.recognition.model import load_model as load_rec_model
112
+ from surya.model.recognition.processor import load_processor as load_rec_processor
113
  except ImportError as e:
114
+ return {
115
+ "engine": "surya",
116
+ "error": f"Import error: {e}. Run: pip install surya-ocr",
117
+ "text": "",
118
+ "words": [],
119
+ "confidence": 0.0,
120
+ }
121
+
122
  try:
123
+ image = Image.open(str(image_path)).convert("RGB")
124
+
125
+ det_model = load_det_model()
126
+ det_processor = load_det_processor()
127
+ rec_model = load_rec_model()
128
+ rec_processor = load_rec_processor()
129
+
130
+ results = run_ocr(
131
+ [image],
132
+ [langs],
133
+ det_model,
134
+ det_processor,
135
+ rec_model,
136
+ rec_processor,
137
+ )
138
+
139
+ page_result = results[0]
140
+
141
+ # Extract text and confidence from Surya's TextLine objects
142
+ words = []
143
+ full_text = []
144
+ confidences = []
145
+
146
+ for line in page_result.text_lines:
147
+ text = line.text.strip()
148
+ conf = float(line.confidence) if hasattr(line, "confidence") else 1.0
149
+ if text:
150
+ full_text.append(text)
151
+ confidences.append(conf)
152
+ words.append({
153
+ "text": text,
154
+ "confidence": round(conf, 4),
155
+ "bbox": line.bbox if hasattr(line, "bbox") else None,
156
+ })
157
+
158
+ avg_conf = round(sum(confidences) / len(confidences), 4) if confidences else 0.0
159
+
160
+ return {
161
+ "engine": "surya",
162
+ "text": "\n".join(full_text),
163
+ "words": words,
164
+ "confidence": avg_conf,
165
+ "line_count": len(words),
166
+ "error": None,
167
+ }
168
+
169
  except Exception as e:
170
+ return {
171
+ "engine": "surya",
172
+ "error": str(e),
173
+ "text": "",
174
+ "words": [],
175
+ "confidence": 0.0,
176
+ }
177
 
178
 
179
+ # ── Tesseract OCR (fallback) ───────────────────────────────────────────────────
180
+ def _run_tesseract(image_path: Path, lang: str = "eng", psm: int = 6) -> dict:
181
+ """
182
+ Run Tesseract on a single page image.
183
+ Returns standardised result dict.
184
+ """
185
  try:
186
  import pytesseract
187
  from PIL import Image
188
  except ImportError as e:
189
+ return {
190
+ "engine": "tesseract",
191
+ "error": f"Import error: {e}. Run: pip install pytesseract pillow",
192
+ "text": "",
193
+ "words": [],
194
+ "confidence": 0.0,
195
+ }
196
+
197
  try:
198
+ image = Image.open(str(image_path)).convert("RGB")
199
+ config = f"--psm {psm}"
200
+
201
+ # Get word-level data with confidence
202
+ data = pytesseract.image_to_data(
203
+ image,
204
+ lang=lang,
205
+ config=config,
206
+ output_type=pytesseract.Output.DICT,
207
+ )
208
+
209
+ words = []
210
+ confidences = []
211
+ full_text_parts = []
212
+
213
+ for i, word_text in enumerate(data["text"]):
214
+ word_text = str(word_text).strip()
215
+ conf = int(data["conf"][i])
216
+ if word_text and conf > 0:
217
+ conf_norm = conf / 100.0
218
+ words.append({
219
+ "text": word_text,
220
+ "confidence": round(conf_norm, 4),
221
+ "bbox": [
222
+ data["left"][i], data["top"][i],
223
+ data["left"][i] + data["width"][i],
224
+ data["top"][i] + data["height"][i],
225
+ ],
226
+ })
227
+ confidences.append(conf_norm)
228
+ full_text_parts.append(word_text)
229
+
230
+ avg_conf = round(sum(confidences) / len(confidences), 4) if confidences else 0.0
231
+ full_text = pytesseract.image_to_string(image, lang=lang, config=config).strip()
232
+
233
+ return {
234
+ "engine": "tesseract",
235
+ "text": full_text,
236
+ "words": words,
237
+ "confidence": avg_conf,
238
+ "line_count": len(words),
239
+ "error": None,
240
+ }
241
+
242
  except Exception as e:
243
+ return {
244
+ "engine": "tesseract",
245
+ "error": str(e),
246
+ "text": "",
247
+ "words": [],
248
+ "confidence": 0.0,
249
+ }
250
+
251
 
252
+ # ── Confidence gate ────────────────────────────────────────────────────────────
253
+ def confidence_gate(confidence: float, config: dict) -> str:
254
+ """Return auto-accept | review-required | quarantine based on thresholds."""
255
+ if confidence >= config["confidence_threshold_auto_accept"]:
256
+ return "auto-accept"
257
+ elif confidence >= config["confidence_threshold_review"]:
258
+ return "review-required"
259
+ elif confidence >= config["confidence_threshold_quarantine"]:
260
+ return "quarantine"
261
+ else:
262
+ return "quarantine"
263
 
 
 
 
 
264
 
265
+ # ── Per-page OCR ───────────────────────────────────────────────────────────────
266
+ def ocr_page(
267
+ image_path: Path,
268
+ book_id: str,
269
+ page_num: int,
270
+ engine: str,
271
+ config: dict,
272
+ dry_run: bool = False,
273
+ ) -> dict:
274
+ """Run OCR on one page, save result, return summary."""
275
+
276
+ result = {
277
+ "book_id": book_id,
278
+ "page_number": page_num,
279
+ "image_path": str(image_path),
280
+ "engine": engine,
281
+ "ocr_at": datetime.utcnow().isoformat() + "Z",
282
+ "config_version": config["config_version"],
283
+ }
284
 
 
 
 
 
285
  if dry_run:
286
+ result.update({
287
+ "text": "[dry-run]", "confidence": 0.0,
288
+ "gate": "dry-run", "error": None, "words": [],
289
+ })
290
  return result
291
+
292
+ if engine == "surya":
293
+ ocr_out = _run_surya(image_path, config["surya_langs"])
294
+ elif engine == "tesseract":
295
+ ocr_out = _run_tesseract(image_path, config["tesseract_lang"], config["tesseract_psm"])
296
+ else:
297
+ ocr_out = {"engine": engine, "error": f"Unknown engine: {engine}", "text": "", "words": [], "confidence": 0.0}
298
+
299
  result.update(ocr_out)
300
+ result["gate"] = confidence_gate(result.get("confidence", 0.0), config)
301
+
302
+ # Save per-page OCR JSON
303
+ book_ocr_dir = OCR_RAW_DIR / book_id
304
+ book_ocr_dir.mkdir(parents=True, exist_ok=True)
305
+ out_path = book_ocr_dir / f"{book_id}_page_{page_num:04d}_{engine}_ocr.json"
306
+ with open(out_path, "w", encoding="utf-8") as f:
307
  json.dump(result, f, indent=2, ensure_ascii=False)
308
+
309
  return result
310
 
311
 
312
+ # ── Per-book OCR runner ──��─────────────────────────────────────────────────────
313
+ def ocr_book(record: dict, engine: str, dry_run: bool = False) -> dict:
314
+ book_id = record["book_id"]
315
+ print(f"\n [{book_id}] {record['filename']} β€” engine: {engine}")
316
+
317
  profile = load_page_profile(book_id)
318
  if not profile:
319
+ print(f" βœ— No page profile found. Run 02_profile_pdfs.py first.")
320
+ return {"book_id": book_id, "error": "no_profile", "pages": []}
321
+
322
+ eligible_routes = OCR_CONFIG["eligible_routes"]
323
+ ocr_pages = [p for p in profile["pages"] if p.get("route") in eligible_routes]
324
+
325
+ print(f" OCR-eligible pages: {len(ocr_pages)} / {profile['page_count']}")
326
+
327
+ if not ocr_pages:
328
+ print(f" βœ“ No OCR pages β€” all embedded text.")
329
+ return {"book_id": book_id, "error": None, "pages": [], "skipped": True}
330
+
331
+ page_results = []
332
+ confidences = []
333
+ gate_counts = {"auto-accept": 0, "review-required": 0, "quarantine": 0, "dry-run": 0}
334
+ errors = []
335
+
336
+ for page_info in ocr_pages:
337
+ page_num = page_info["page_number"]
338
+ render_path = page_info.get("render_path")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
339
 
340
+ if not render_path:
341
+ # Try to find render file
342
+ render_path_candidates = list((RENDERS_DIR / book_id).glob(
343
+ f"{book_id}_page_{page_num:04d}_*.png"
344
+ )) if (RENDERS_DIR / book_id).exists() else []
345
+ render_path = str(render_path_candidates[0]) if render_path_candidates else None
346
 
347
+ if not render_path:
348
+ print(f" ⚠ Page {page_num}: no render found β€” skipping")
349
+ errors.append({"page": page_num, "error": "no_render"})
350
+ continue
351
+
352
+ image_path = Path(render_path) if Path(render_path).is_absolute() else ROOT / render_path
353
+
354
+ if not image_path.exists():
355
+ print(f" ⚠ Page {page_num}: render file missing β€” {image_path}")
356
+ errors.append({"page": page_num, "error": "render_missing"})
357
+ continue
358
+
359
+ result = ocr_page(image_path, book_id, page_num, engine, OCR_CONFIG, dry_run)
360
+ page_results.append(result)
361
+
362
+ conf = result.get("confidence", 0.0)
363
+ gate = result.get("gate", "quarantine")
364
+ confidences.append(conf)
365
+ gate_counts[gate] = gate_counts.get(gate, 0) + 1
366
+
367
+ status = "βœ“" if gate == "auto-accept" else "⚠" if gate == "review-required" else "βœ—"
368
+ print(f" {status} p{page_num:03d} conf={conf:.2f} gate={gate}")
369
+
370
+ avg_conf = round(sum(confidences) / len(confidences), 4) if confidences else 0.0
371
+
372
+ print(f" Avg confidence : {avg_conf:.2f}")
373
+ print(f" Gates : {gate_counts}")
374
+ if errors:
375
+ print(f" Errors : {len(errors)}")
376
+
377
+ return {
378
+ "book_id": book_id,
379
+ "engine": engine,
380
+ "pages_ocred": len(page_results),
381
+ "avg_confidence": avg_conf,
382
+ "gate_counts": gate_counts,
383
+ "errors": errors,
384
+ "error": None,
385
+ }
386
+
387
+
388
+ # ── Save frozen config ─────────────────────────────────────────────────────────
389
+ def save_ocr_config(config: dict) -> None:
390
+ config_path = CONFIGS_DIR / f"{config['config_version']}.json"
391
+ if not config_path.exists():
392
+ with open(config_path, "w", encoding="utf-8") as f:
393
+ json.dump({
394
+ **config,
395
+ "frozen_at": datetime.utcnow().isoformat() + "Z",
396
+ "note": "DO NOT change this file mid-batch. Create a new version instead.",
397
+ }, f, indent=2)
398
+ print(f"\n Config frozen β†’ {config_path.relative_to(ROOT)}")
399
+ else:
400
+ print(f"\n Config already exists β†’ {config_path.relative_to(ROOT)} (not overwritten)")
401
+
402
+
403
+ # ── Main ───────────────────────────────────────────────────────────────────────
404
  def main():
405
+ parser = argparse.ArgumentParser(description="Smoke Signal β€” Stage 4: OCR Bake-Off")
406
+ parser.add_argument("--book-id", help="OCR a single book by ID")
407
+ parser.add_argument("--batch-id", help="Tag this run with a batch ID")
408
+ parser.add_argument("--engine", choices=["surya", "tesseract", "both"],
409
+ default="surya", help="OCR engine to use (default: surya)")
410
+ parser.add_argument("--dry-run", action="store_true", help="No files written")
411
+ parser.add_argument("--all", action="store_true", help="Include already-OCRed books")
412
  args = parser.parse_args()
413
+
414
  run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
415
+ engines = ["surya", "tesseract"] if args.engine == "both" else [args.engine]
416
+
417
+ print(f"\n{'='*60}")
418
+ print(f" Smoke Signal β€” Stage 4: OCR Bake-Off")
419
+ print(f" Run ID : {run_id}")
420
+ print(f" Engines : {engines}")
421
+ print(f" Config : {OCR_CONFIG['config_version']}")
422
+ if args.dry_run:
423
+ print(f" Mode : DRY RUN")
424
+ print(f"{'='*60}")
425
+
426
  manifest = load_manifest()
427
  if not manifest:
428
+ print("\n [error] Manifest empty. Run 01_register_sources.py first.")
429
+ sys.exit(1)
430
+
431
+ eligible_statuses = ["profiled", "rendered"] if not args.all else \
432
+ ["profiled", "rendered", "ocred"]
433
+
434
+ if args.book_id:
435
+ books = [manifest[args.book_id]] if args.book_id in manifest else []
436
+ if not books:
437
+ print(f" [error] Book {args.book_id} not in manifest.")
438
+ sys.exit(1)
439
+ else:
440
+ books = [r for r in manifest.values() if r.get("status") in eligible_statuses]
441
+
442
  if not books:
443
+ print(f"\n No books with status in {eligible_statuses}.")
444
+ print(" Run 02_profile_pdfs.py first to render pages.")
445
+ sys.exit(0)
446
+
447
+ print(f"\n Books to OCR: {len(books)}")
448
+
449
  all_results = []
450
+ t_start = time.time()
451
+
452
+ for record in books:
453
+ for engine in engines:
454
+ result = ocr_book(record, engine, dry_run=args.dry_run)
455
+ all_results.append(result)
456
+
457
+ # Update manifest status
458
+ if not result.get("error") and not args.dry_run:
459
+ manifest[record["book_id"]]["status"] = "ocred"
460
+
461
+ # Save manifest + config + run log
462
  if not args.dry_run:
463
  save_manifest(manifest)
464
+ save_ocr_config(OCR_CONFIG)
465
+
466
+ log_path = LOGS_DIR / f"{run_id}_ocr_bakeoff.json"
467
+ with open(log_path, "w", encoding="utf-8") as f:
468
+ json.dump({
469
+ "run_id": run_id,
470
+ "run_at": datetime.utcnow().isoformat() + "Z",
471
+ "engines": engines,
472
+ "config": OCR_CONFIG,
473
+ "results": all_results,
474
+ }, f, indent=2)
475
+ print(f"\n Run log β†’ {log_path.relative_to(ROOT)}")
476
+
477
+ # ── Summary ───────────────────────────────────────────────────────────────
478
+ elapsed = round(time.time() - t_start, 1)
479
  succeeded = sum(1 for r in all_results if not r.get("error"))
480
+ total_pages = sum(r.get("pages_ocred", 0) for r in all_results)
481
+ avg_conf = (
482
+ sum(r.get("avg_confidence", 0) for r in all_results if not r.get("error")) / max(succeeded, 1)
483
+ )
484
+
485
+ print(f"\n{'─'*60}")
486
+ print(f" Books processed : {len(books)}")
487
+ print(f" Runs succeeded : {succeeded}")
488
+ print(f" Pages OCR-ed : {total_pages}")
489
+ print(f" Avg confidence : {avg_conf:.2f}")
490
+ print(f" Time : {elapsed}s")
491
+ print(f"{'─'*60}")
492
+
493
+ # Gate breakdown across all runs
494
+ total_gates = {"auto-accept": 0, "review-required": 0, "quarantine": 0}
495
+ for r in all_results:
496
+ for gate, count in r.get("gate_counts", {}).items():
497
+ if gate in total_gates:
498
+ total_gates[gate] += count
499
+
500
+ print(f"\n Gate breakdown:")
501
+ for gate, count in total_gates.items():
502
+ pct = round(count / max(total_pages, 1) * 100, 1)
503
+ flag = " ← ACTION REQUIRED" if gate != "auto-accept" and count > 0 else ""
504
+ print(f" {gate:<20} {count:>4} ({pct}%){flag}")
505
+
506
+ print(f"\n Next steps:")
507
+ print(f" 1. Inspect ocr_raw/ outputs for quality")
508
+ print(f" 2. Run 04_region_detector.py (Stage 5)")
509
+ print(f" 3. Review quarantined pages manually\n")
510
 
511
 
512
  if __name__ == "__main__":
513
+ main()