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

Create 03_ocr_bakeoff.py

Browse files
smoke_signal/scripts/03_ocr_bakeoff.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
9
+ python scripts/03_ocr_bakeoff.py --book-id SS-BOOK-0001
10
+ python scripts/03_ocr_bakeoff.py --engine surya
11
+ python scripts/03_ocr_bakeoff.py --engine tesseract
12
+ python scripts/03_ocr_bakeoff.py --engine both
13
+ python scripts/03_ocr_bakeoff.py --dry-run
14
+ """
15
+
16
+ import argparse
17
+ import csv
18
+ import json
19
+ import sys
20
+ import time
21
+ 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"
28
+ RENDERS_DIR = ROOT / "renders"
29
+ 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",
39
+ "fallback_engine": "tesseract",
40
+ "surya_langs": ["en"],
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,
48
+ "eligible_routes": ["ocr", "hybrid"],
49
+ }
50
+
51
+
52
+ def load_manifest():
53
+ records = {}
54
+ if not MANIFEST_CSV.exists():
55
+ return records
56
+ with open(MANIFEST_CSV, newline="", encoding="utf-8") as f:
57
+ for row in csv.DictReader(f):
58
+ if row.get("book_id"):
59
+ records[row["book_id"]] = row
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()