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

Upload 04_region_detector.py

Browse files
smoke_signal/scripts/04_region_detector.py ADDED
@@ -0,0 +1,452 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smoke Signal β€” Stage 5: Region Detection & Reading Order Resolver
4
+ ==================================================================
5
+ Takes OCR raw output from Stage 4 and:
6
+ 1. Classifies each detected region (narration, speech bubble, caption, etc.)
7
+ 2. Assigns reading order using picture-book page logic
8
+ 3. Detects two-page spreads and handles them correctly
9
+ 4. Flags uncertain ordering rather than forcing false certainty
10
+ 5. Saves ordered_regions.json per book
11
+ 6. Creates visual overlay metadata for human review
12
+
13
+ Region classes:
14
+ narration | dialogue-speech-bubble | title | subtitle | caption |
15
+ sign-label | page-number | copyright-legal | publisher-imprint | decorative-uncertain
16
+
17
+ Usage:
18
+ python scripts/04_region_detector.py
19
+ python scripts/04_region_detector.py --book-id SS-BOOK-0001
20
+ python scripts/04_region_detector.py --dry-run
21
+ """
22
+
23
+ import argparse
24
+ import csv
25
+ import json
26
+ import sys
27
+ import time
28
+ from datetime import datetime
29
+ from pathlib import Path
30
+ from typing import Optional
31
+
32
+ # ── Paths ──────────────────────────────────────────────────────────────────────
33
+ ROOT = Path(__file__).resolve().parents[1]
34
+ MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
35
+ PROFILES_DIR = ROOT / "manifest" / "page_profiles"
36
+ OCR_RAW_DIR = ROOT / "ocr_raw"
37
+ REGIONS_DIR = ROOT / "regions"
38
+ LOGS_DIR = ROOT / "logs"
39
+ REVIEW_DIR = ROOT / "review"
40
+
41
+ REGIONS_DIR.mkdir(parents=True, exist_ok=True)
42
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
43
+
44
+ # ── Config ────────────────────────────────────────────────────────────────────
45
+ CONFIG = {
46
+ "config_version": "ss_regions_v0.1",
47
+ "page_number_max_chars": 4, # regions with <= 4 chars near top/bottom = page number
48
+ "copyright_keywords": ["Β©", "copyright", "all rights reserved", "isbn", "printed in"],
49
+ "publisher_keywords": ["published by", "first published", "edition", "press", "publishers"],
50
+ "title_y_threshold": 0.20, # top 20% of page = likely title zone
51
+ "footer_y_threshold": 0.85, # bottom 15% of page = likely footer zone
52
+ "spread_gap_threshold": 0.45, # x-center between 0.45-0.55 = possible spread gutter
53
+ "min_story_chars": 10, # below this = not story text
54
+ "eligible_statuses": ["ocred"],
55
+ }
56
+
57
+ REGION_CLASSES = [
58
+ "narration", "dialogue-speech-bubble", "title", "subtitle",
59
+ "caption", "sign-label", "page-number", "copyright-legal",
60
+ "publisher-imprint", "decorative-uncertain",
61
+ ]
62
+
63
+
64
+ # ── Manifest I/O ───────────────────────────────────────────────────────────────
65
+ def load_manifest() -> dict:
66
+ records = {}
67
+ if not MANIFEST_CSV.exists():
68
+ return records
69
+ with open(MANIFEST_CSV, newline="", encoding="utf-8") as f:
70
+ for row in csv.DictReader(f):
71
+ if row.get("book_id"):
72
+ records[row["book_id"]] = row
73
+ return records
74
+
75
+
76
+ def save_manifest(records: dict) -> None:
77
+ fields = [
78
+ "book_id", "source_id", "filename", "sha256", "file_size_bytes",
79
+ "page_count", "rights_class", "source_location", "acquisition_date",
80
+ "status", "allowed_use", "notes"
81
+ ]
82
+ rows = sorted(records.values(), key=lambda r: r.get("book_id", ""))
83
+ with open(MANIFEST_CSV, "w", newline="", encoding="utf-8") as f:
84
+ writer = csv.DictWriter(f, fieldnames=fields)
85
+ writer.writeheader()
86
+ writer.writerows(rows)
87
+
88
+
89
+ def load_page_profile(book_id: str) -> Optional[dict]:
90
+ path = PROFILES_DIR / f"{book_id}_page_profile.json"
91
+ return json.load(open(path)) if path.exists() else None
92
+
93
+
94
+ def load_ocr_raw(book_id: str) -> Optional[dict]:
95
+ path = OCR_RAW_DIR / book_id / f"{book_id}_ocr_raw.json"
96
+ return json.load(open(path)) if path.exists() else None
97
+
98
+
99
+ # ── Region classifier ─────────────────────────────────────────────────────────
100
+ def classify_region(region: dict, page_height: float, page_width: float) -> str:
101
+ """
102
+ Heuristic region classification based on:
103
+ - Text content (keywords)
104
+ - Position on page (y-coordinate normalised 0-1)
105
+ - Text length
106
+ """
107
+ text = region.get("text", "").strip()
108
+ lower = text.lower()
109
+ bbox = region.get("bbox", [0, 0, 0, 0]) # [x0, y0, x1, y1]
110
+
111
+ if not text:
112
+ return "decorative-uncertain"
113
+
114
+ # Normalise bbox to 0-1 relative to page dimensions
115
+ if page_height > 0 and page_width > 0:
116
+ y_center = ((bbox[1] + bbox[3]) / 2) / page_height
117
+ x_center = ((bbox[0] + bbox[2]) / 2) / page_width
118
+ else:
119
+ y_center = 0.5
120
+ x_center = 0.5
121
+
122
+ char_count = len(text)
123
+
124
+ # ── Page number ──────────────────────────────────────────────────────────
125
+ if (char_count <= CONFIG["page_number_max_chars"] and text.strip().isdigit() and
126
+ (y_center < 0.12 or y_center > CONFIG["footer_y_threshold"])):
127
+ return "page-number"
128
+
129
+ # ── Copyright / legal ────────────────────────────────────────────────────
130
+ if any(kw in lower for kw in CONFIG["copyright_keywords"]):
131
+ return "copyright-legal"
132
+
133
+ # ── Publisher imprint ─────────────────────────────────────────────────────
134
+ if any(kw in lower for kw in CONFIG["publisher_keywords"]):
135
+ return "publisher-imprint"
136
+
137
+ # ── Title zone (top of page, short text) ─────────────────────────────────
138
+ if y_center < CONFIG["title_y_threshold"] and char_count < 60:
139
+ return "title"
140
+
141
+ # ── Speech bubble heuristic ───────────────────────────────────────────────
142
+ # Dialogue typically has quotes, said verbs, or is short and mid-page
143
+ has_quotes = any(c in text for c in ('"', '"', '"', "'", "Β«", "Β»"))
144
+ if has_quotes and char_count < 120:
145
+ return "dialogue-speech-bubble"
146
+
147
+ # ── Caption (short, bottom portion of page) ───────────────────────────────
148
+ if y_center > 0.75 and char_count < 80 and not text.isdigit():
149
+ return "caption"
150
+
151
+ # ── Sign / label (very short, anywhere) ──────────────────────────────────
152
+ if char_count < 20 and not text.isdigit():
153
+ return "sign-label"
154
+
155
+ # ── Decorative / uncertain (too short for story) ─────────────────────────
156
+ if char_count < CONFIG["min_story_chars"]:
157
+ return "decorative-uncertain"
158
+
159
+ # ── Default: narration ────────────────────────────────────────────────────
160
+ return "narration"
161
+
162
+
163
+ # ── Reading order resolver ────────────────────────────────────────────────────
164
+ def resolve_reading_order(regions: list, page_width: float, is_spread: bool) -> list:
165
+ """
166
+ Sort regions into picture-book reading order.
167
+
168
+ Rules:
169
+ - Standard page: top-to-bottom, left-to-right within same y-band
170
+ - Two-page spread: left page top-to-bottom, then right page top-to-bottom
171
+ - Page numbers and copyright always last
172
+ - Uncertain order is flagged, not forced
173
+ """
174
+ LAST_CLASSES = {"page-number", "copyright-legal", "publisher-imprint"}
175
+
176
+ story_regions = [r for r in regions if r.get("region_class") not in LAST_CLASSES]
177
+ footer_regions = [r for r in regions if r.get("region_class") in LAST_CLASSES]
178
+
179
+ if is_spread and page_width > 0:
180
+ # Split into left and right page
181
+ mid_x = page_width / 2
182
+ left = [r for r in story_regions if ((r["bbox"][0] + r["bbox"][2]) / 2) < mid_x]
183
+ right = [r for r in story_regions if ((r["bbox"][0] + r["bbox"][2]) / 2) >= mid_x]
184
+
185
+ def sort_key(r):
186
+ return (r["bbox"][1], r["bbox"][0]) # y then x
187
+
188
+ ordered = sorted(left, key=sort_key) + sorted(right, key=sort_key)
189
+ else:
190
+ # Standard: top-to-bottom with left-to-right tiebreak
191
+ y_band_size = 50 # points β€” regions within this y-band are on same "line"
192
+
193
+ def sort_key(r):
194
+ y_top = r["bbox"][1]
195
+ band = round(y_top / y_band_size)
196
+ return (band, r["bbox"][0]) # band then x
197
+
198
+ ordered = sorted(story_regions, key=sort_key)
199
+
200
+ # Add reading order index
201
+ full_ordered = []
202
+ for i, region in enumerate(ordered + footer_regions):
203
+ region = dict(region)
204
+ region["reading_order"] = i + 1
205
+ region["reading_order_uncertain"] = False
206
+ full_ordered.append(region)
207
+
208
+ # Flag uncertainty for overlapping regions
209
+ for i in range(1, len(full_ordered)):
210
+ prev = full_ordered[i - 1]
211
+ curr = full_ordered[i]
212
+ # If bboxes overlap significantly in y, order is uncertain
213
+ prev_y_bottom = prev["bbox"][3]
214
+ curr_y_top = curr["bbox"][1]
215
+ if curr_y_top < prev_y_bottom - 10: # significant overlap
216
+ curr["reading_order_uncertain"] = True
217
+
218
+ return full_ordered
219
+
220
+
221
+ # ── Per-page region processing ────────────────────────────────────────────────
222
+ def process_page_regions(
223
+ book_id: str,
224
+ page_num: int,
225
+ ocr_page: dict,
226
+ page_profile: dict,
227
+ ) -> dict:
228
+ """Process regions for a single page."""
229
+
230
+ page_width = page_profile.get("width_pt", 595)
231
+ page_height = page_profile.get("height_pt", 842)
232
+ is_spread = page_profile.get("is_spread", False)
233
+ route = page_profile.get("route", "ocr")
234
+
235
+ raw_regions = ocr_page.get("regions", [])
236
+
237
+ # Classify each region
238
+ classified = []
239
+ for region in raw_regions:
240
+ region = dict(region)
241
+ region["region_class"] = classify_region(region, page_height, page_width)
242
+ region["region_id"] = f"{book_id}_p{page_num:04d}_r{len(classified)+1:03d}"
243
+ region["source_book"] = book_id
244
+ region["page_number"] = page_num
245
+ classified.append(region)
246
+
247
+ # Resolve reading order
248
+ ordered = resolve_reading_order(classified, page_width, is_spread)
249
+
250
+ # Page-level stats
251
+ story_text = " ".join(
252
+ r["text"] for r in ordered
253
+ if r.get("region_class") in ("narration", "dialogue-speech-bubble", "caption")
254
+ )
255
+
256
+ return {
257
+ "book_id": book_id,
258
+ "page_number": page_num,
259
+ "route": route,
260
+ "is_spread": is_spread,
261
+ "page_width_pt": page_width,
262
+ "page_height_pt": page_height,
263
+ "region_count": len(ordered),
264
+ "story_char_count": len(story_text),
265
+ "page_confidence": ocr_page.get("page_confidence", 0),
266
+ "confidence_class": ocr_page.get("confidence_class", "review-required"),
267
+ "regions": ordered,
268
+ "warnings": page_profile.get("warnings", []),
269
+ "config_version": CONFIG["config_version"],
270
+ "processed_at": datetime.utcnow().isoformat() + "Z",
271
+ }
272
+
273
+
274
+ # ── Per-book region detection ─────────────────────────────────────────────────
275
+ def detect_regions_for_book(record: dict, dry_run: bool = False) -> tuple:
276
+ book_id = record["book_id"]
277
+ filename = record["filename"]
278
+
279
+ print(f"\n [{book_id}] {filename}")
280
+
281
+ if record.get("rights_class") in ("unknown", "excluded"):
282
+ return record, None, {"error": "rights_blocked"}
283
+
284
+ page_profile_data = load_page_profile(book_id)
285
+ if page_profile_data is None:
286
+ print(f" βœ— No page profile. Run 02_profile_pdfs.py first.")
287
+ return record, None, {"error": "no_page_profile"}
288
+
289
+ ocr_data = load_ocr_raw(book_id)
290
+ if ocr_data is None:
291
+ print(f" βœ— No OCR data. Run 03_ocr_bakeoff.py first.")
292
+ return record, None, {"error": "no_ocr_data"}
293
+
294
+ # Index page profiles by page number
295
+ page_profiles_by_num = {p["page_number"]: p for p in page_profile_data["pages"]}
296
+
297
+ # Index OCR pages by page number
298
+ ocr_pages_by_num = {p["page_number"]: p for p in ocr_data.get("pages", [])}
299
+
300
+ all_page_results = []
301
+ uncertain_pages = []
302
+ empty_pages = []
303
+
304
+ total_pages = page_profile_data["page_count"]
305
+
306
+ for page_num in range(1, total_pages + 1):
307
+ page_prof = page_profiles_by_num.get(page_num, {})
308
+ route = page_prof.get("route", "embedded_text")
309
+
310
+ if route == "embedded_text":
311
+ # For embedded text pages, create minimal region record
312
+ all_page_results.append({
313
+ "book_id": book_id,
314
+ "page_number": page_num,
315
+ "route": "embedded_text",
316
+ "region_count": 0,
317
+ "story_char_count": 0,
318
+ "regions": [],
319
+ "note": "embedded_text_page_regions_not_processed_here",
320
+ })
321
+ continue
322
+
323
+ ocr_page = ocr_pages_by_num.get(page_num)
324
+ if ocr_page is None:
325
+ empty_pages.append(page_num)
326
+ continue
327
+
328
+ if not dry_run:
329
+ page_result = process_page_regions(book_id, page_num, ocr_page, page_prof)
330
+ else:
331
+ page_result = {
332
+ "book_id": book_id, "page_number": page_num,
333
+ "route": route, "region_count": 0,
334
+ "regions": [], "note": "dry-run",
335
+ }
336
+
337
+ # Check for uncertain ordering
338
+ uncertain = [r for r in page_result.get("regions", []) if r.get("reading_order_uncertain")]
339
+ if uncertain:
340
+ uncertain_pages.append(page_num)
341
+
342
+ if page_result.get("story_char_count", 0) == 0 and route != "embedded_text":
343
+ empty_pages.append(page_num)
344
+
345
+ all_page_results.append(page_result)
346
+
347
+ region_count = page_result.get("region_count", 0)
348
+ conf = page_result.get("page_confidence", 0)
349
+ print(f" Page {page_num:3d}: {region_count} regions | conf={conf:.2f} | route={route}")
350
+
351
+ # ── Save ordered_regions.json ─────────────────────────────────────────────
352
+ book_regions = {
353
+ "book_id": book_id,
354
+ "filename": filename,
355
+ "source_hash": record.get("sha256", ""),
356
+ "page_count": total_pages,
357
+ "config_version": CONFIG["config_version"],
358
+ "processed_at": datetime.utcnow().isoformat() + "Z",
359
+ "uncertain_pages": uncertain_pages,
360
+ "empty_story_pages": empty_pages,
361
+ "pages": all_page_results,
362
+ }
363
+
364
+ if not dry_run:
365
+ regions_path = REGIONS_DIR / f"{book_id}_ordered_regions.json"
366
+ with open(regions_path, "w", encoding="utf-8") as f:
367
+ json.dump(book_regions, f, indent=2)
368
+ print(f" Regions saved β†’ {regions_path.relative_to(ROOT)}")
369
+
370
+ if uncertain_pages:
371
+ print(f" ⚠️ Uncertain reading order on pages: {uncertain_pages}")
372
+ if empty_pages:
373
+ print(f" ⚠️ Empty story pages: {empty_pages}")
374
+
375
+ record["status"] = "ocred" # keep at ocred β€” reviewed/exported set later
376
+ return record, book_regions, {}
377
+
378
+
379
+ # ── Main ───────────────────────────────────────────────────────────────────────
380
+ def main():
381
+ parser = argparse.ArgumentParser(description="Smoke Signal β€” Stage 5: Region Detector")
382
+ parser.add_argument("--book-id", help="Process a single book by ID")
383
+ parser.add_argument("--batch-id", help="Tag this run with a batch ID")
384
+ parser.add_argument("--dry-run", action="store_true")
385
+ args = parser.parse_args()
386
+
387
+ run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
388
+ dry_run = args.dry_run
389
+
390
+ print(f"\n{'='*60}")
391
+ print(f" Smoke Signal β€” Stage 5: Region Detector")
392
+ print(f" Run ID : {run_id}")
393
+ print(f" Config : {CONFIG['config_version']}")
394
+ if dry_run:
395
+ print(f" Mode : DRY RUN")
396
+ print(f"{'='*60}")
397
+
398
+ manifest = load_manifest()
399
+ if not manifest:
400
+ print("\n Manifest empty. Run earlier stages first.")
401
+ sys.exit(1)
402
+
403
+ books = (
404
+ [manifest[args.book_id]] if args.book_id and args.book_id in manifest
405
+ else [r for r in manifest.values() if r.get("status") in CONFIG["eligible_statuses"]]
406
+ )
407
+
408
+ if not books:
409
+ print(f"\n No books with status in {CONFIG['eligible_statuses']}.")
410
+ print(" Run 03_ocr_bakeoff.py first.")
411
+ sys.exit(0)
412
+
413
+ print(f"\n Books to process: {len(books)}")
414
+
415
+ results = []
416
+ t_start = time.time()
417
+
418
+ for record in books:
419
+ updated, book_regions, error = detect_regions_for_book(record, dry_run=dry_run)
420
+
421
+ result = {
422
+ "book_id": record["book_id"],
423
+ "filename": record["filename"],
424
+ "error": error,
425
+ }
426
+ if book_regions:
427
+ result["uncertain_pages"] = book_regions.get("uncertain_pages", [])
428
+ result["empty_story_pages"] = book_regions.get("empty_story_pages", [])
429
+ manifest[record["book_id"]] = updated
430
+
431
+ results.append(result)
432
+
433
+ if not dry_run:
434
+ save_manifest(manifest)
435
+ log_path = LOGS_DIR / f"{run_id}_regions_run.json"
436
+ with open(log_path, "w") as f:
437
+ json.dump({"run_id": run_id, "config": CONFIG, "results": results}, f, indent=2)
438
+ print(f"\n Run log β†’ {log_path.relative_to(ROOT)}")
439
+
440
+ elapsed = round(time.time() - t_start, 1)
441
+ succeeded = sum(1 for r in results if not r.get("error"))
442
+
443
+ print(f"\n{'─'*60}")
444
+ print(f" Books processed : {len(results)}")
445
+ print(f" Succeeded : {succeeded}")
446
+ print(f" Time : {elapsed}s")
447
+ print(f"{'─'*60}")
448
+ print(f"\n Next: Run 05_llm_normalise.py (Stage 7)\n")
449
+
450
+
451
+ if __name__ == "__main__":
452
+ main()