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

Create 02_profile_pdfs.py

Browse files
smoke_signal/scripts/02_profile_pdfs.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Smoke Signal — Stage 3: PDF Profiler & Renderer
4
+ =================================================
5
+ For every PDF in the source manifest (status=pending or profiled),
6
+ this script:
7
+ 1. Opens each PDF and inspects every page
8
+ 2. Detects whether a page has embedded text, is image-only, or hybrid
9
+ 3. Detects rotation, skew hints, two-page spreads, dimensions
10
+ 4. Routes each page: embedded_text | ocr | hybrid
11
+ 5. Renders OCR-candidate pages to PNG at 300 DPI (450 DPI fallback)
12
+ 6. Saves page_profile.json per book
13
+ 7. Updates source_manifest.csv status to 'rendered'
14
+
15
+ Usage:
16
+ python scripts/02_profile_pdfs.py
17
+ python scripts/02_profile_pdfs.py --book-id SS-BOOK-0001
18
+ python scripts/02_profile_pdfs.py --batch-id SS-BATCH-001
19
+ python scripts/02_profile_pdfs.py --dry-run
20
+ """
21
+
22
+ import argparse
23
+ import csv
24
+ import json
25
+ import sys
26
+ import time
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ ROOT = Path(__file__).resolve().parents[1]
32
+ SOURCE_DIR = ROOT / "source_pdfs"
33
+ MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
34
+ RENDERS_DIR = ROOT / "renders"
35
+ PROFILES_DIR = ROOT / "manifest" / "page_profiles"
36
+ LOGS_DIR = ROOT / "logs"
37
+
38
+ PROFILES_DIR.mkdir(parents=True, exist_ok=True)
39
+ RENDERS_DIR.mkdir(parents=True, exist_ok=True)
40
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
41
+
42
+ CONFIG = {
43
+ "config_version": "ss_profiler_v0.1",
44
+ "render_dpi_baseline": 300,
45
+ "render_dpi_fallback": 450,
46
+ "text_char_threshold": 20,
47
+ "spread_aspect_ratio": 1.6,
48
+ "image_coverage_threshold": 0.15,
49
+ "skip_statuses": ["exported", "quarantined"],
50
+ "eligible_statuses": ["pending", "profiled"],
51
+ }
52
+
53
+
54
+ def load_manifest() -> dict:
55
+ records = {}
56
+ if not MANIFEST_CSV.exists():
57
+ return records
58
+ with open(MANIFEST_CSV, newline="", encoding="utf-8") as f:
59
+ for row in csv.DictReader(f):
60
+ if row.get("book_id"):
61
+ records[row["book_id"]] = row
62
+ return records
63
+
64
+
65
+ def save_manifest(records: dict) -> None:
66
+ fields = [
67
+ "book_id", "source_id", "filename", "sha256", "file_size_bytes",
68
+ "page_count", "rights_class", "source_location", "acquisition_date",
69
+ "status", "allowed_use", "notes"
70
+ ]
71
+ rows = sorted(records.values(), key=lambda r: r.get("book_id", ""))
72
+ with open(MANIFEST_CSV, "w", newline="", encoding="utf-8") as f:
73
+ writer = csv.DictWriter(f, fieldnames=fields)
74
+ writer.writeheader()
75
+ writer.writerows(rows)
76
+
77
+
78
+ def _import_fitz():
79
+ try:
80
+ import fitz
81
+ return fitz
82
+ except ImportError:
83
+ print(" [error] PyMuPDF (fitz) not installed. Run: pip install pymupdf")
84
+ sys.exit(1)
85
+
86
+
87
+ def analyse_page(page, fitz, config: dict) -> dict:
88
+ rect = page.rect
89
+ width = rect.width
90
+ height = rect.height
91
+ rotation = page.rotation
92
+ text = page.get_text("text").strip()
93
+ char_count = len(text)
94
+ has_embedded_text = char_count >= config["text_char_threshold"]
95
+ image_list = page.get_images(full=True)
96
+ has_images = len(image_list) > 0
97
+ image_coverage = 0.0
98
+ page_area = width * height
99
+ if has_images and page_area > 0:
100
+ for img in image_list:
101
+ try:
102
+ rects = page.get_image_rects(img[0])
103
+ for r in rects:
104
+ image_coverage += abs(r.width * r.height) / page_area
105
+ except Exception:
106
+ pass
107
+ image_coverage = min(image_coverage, 1.0)
108
+ if has_embedded_text and not has_images:
109
+ route = "embedded_text"
110
+ elif has_embedded_text and has_images:
111
+ route = "hybrid" if image_coverage >= config["image_coverage_threshold"] else "embedded_text"
112
+ else:
113
+ route = "ocr"
114
+ aspect = width / height if height > 0 else 0
115
+ is_spread = aspect >= config["spread_aspect_ratio"]
116
+ warnings = []
117
+ if rotation not in (0, 360):
118
+ warnings.append(f"rotation_{rotation}deg")
119
+ if is_spread:
120
+ warnings.append("possible_two_page_spread")
121
+ if route == "ocr" and char_count == 0 and not has_images:
122
+ warnings.append("blank_or_undetectable_page")
123
+ return {
124
+ "width_pt": round(width, 2), "height_pt": round(height, 2),
125
+ "rotation_deg": rotation, "char_count": char_count,
126
+ "has_embedded_text": has_embedded_text, "has_images": has_images,
127
+ "image_count": len(image_list), "image_coverage": round(image_coverage, 3),
128
+ "is_spread": is_spread, "route": route, "warnings": warnings,
129
+ }
130
+
131
+
132
+ def render_page(page, book_id: str, page_num: int, dpi: int, render_dir: Path) -> Optional[str]:
133
+ book_render_dir = render_dir / book_id
134
+ book_render_dir.mkdir(parents=True, exist_ok=True)
135
+ filename = f"{book_id}_page_{page_num:04d}_{dpi}dpi.png"
136
+ out_path = book_render_dir / filename
137
+ try:
138
+ mat = page.fitz_module.Matrix(dpi / 72, dpi / 72)
139
+ pix = page.get_pixmap(matrix=mat, alpha=False)
140
+ pix.save(str(out_path))
141
+ return str(out_path.relative_to(ROOT))
142
+ except Exception:
143
+ return None
144
+
145
+
146
+ def profile_book(record: dict, dry_run: bool = False):
147
+ book_id = record["book_id"]
148
+ filename = record["filename"]
149
+ pdf_path = SOURCE_DIR / filename
150
+ print(f"\n [{book_id}] {filename}")
151
+ if not pdf_path.exists():
152
+ return record, None, {"error": "file_not_found"}
153
+ if record.get("rights_class") in ("unknown", "excluded"):
154
+ return record, None, {"error": "rights_blocked"}
155
+ fitz = _import_fitz()
156
+ try:
157
+ doc = fitz.open(str(pdf_path))
158
+ except Exception as e:
159
+ return record, None, {"error": str(e)}
160
+ page_count = doc.page_count
161
+ print(f" Pages: {page_count}")
162
+ pages = []
163
+ render_errors = []
164
+ route_counts = {"embedded_text": 0, "ocr": 0, "hybrid": 0}
165
+ for i in range(page_count):
166
+ page_num = i + 1
167
+ page = doc[i]
168
+ page.fitz_module = fitz
169
+ profile = analyse_page(page, fitz, CONFIG)
170
+ profile["page_number"] = page_num
171
+ render_path = None
172
+ render_dpi = None
173
+ if not dry_run and profile["route"] in ("ocr", "hybrid"):
174
+ dpi = CONFIG["render_dpi_baseline"]
175
+ render_path = render_page(page, book_id, page_num, dpi, RENDERS_DIR)
176
+ if render_path is None:
177
+ dpi = CONFIG["render_dpi_fallback"]
178
+ render_path = render_page(page, book_id, page_num, dpi, RENDERS_DIR)
179
+ if render_path is None:
180
+ render_errors.append(page_num)
181
+ else:
182
+ render_dpi = dpi
183
+ else:
184
+ render_dpi = dpi
185
+ profile["render_path"] = render_path
186
+ profile["render_dpi"] = render_dpi
187
+ pages.append(profile)
188
+ route_counts[profile["route"]] += 1
189
+ doc.close()
190
+ book_profile = {
191
+ "book_id": book_id, "filename": filename,
192
+ "source_hash": record.get("sha256", ""),
193
+ "page_count": page_count, "config_version": CONFIG["config_version"],
194
+ "profiled_at": datetime.utcnow().isoformat() + "Z",
195
+ "route_summary": route_counts, "render_errors": render_errors, "pages": pages,
196
+ }
197
+ if not dry_run:
198
+ profile_path = PROFILES_DIR / f"{book_id}_page_profile.json"
199
+ with open(profile_path, "w", encoding="utf-8") as f:
200
+ json.dump(book_profile, f, indent=2)
201
+ print(f" Profile saved -> {profile_path.relative_to(ROOT)}")
202
+ total_ocr = route_counts["ocr"] + route_counts["hybrid"]
203
+ print(f" Routes: embedded={route_counts['embedded_text']} | ocr={route_counts['ocr']} | hybrid={route_counts['hybrid']}")
204
+ record["page_count"] = page_count
205
+ record["status"] = "profiled" if not dry_run else record["status"]
206
+ return record, book_profile, {}
207
+
208
+
209
+ def main():
210
+ parser = argparse.ArgumentParser(description="Smoke Signal Stage 3: PDF Profiler")
211
+ parser.add_argument("--book-id", help="Profile a single book by ID")
212
+ parser.add_argument("--batch-id", help="Tag this run with a batch ID")
213
+ parser.add_argument("--dry-run", action="store_true")
214
+ parser.add_argument("--all", action="store_true")
215
+ args = parser.parse_args()
216
+ run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
217
+ dry_run = args.dry_run
218
+ print(f"\nSmoke Signal Stage 3 | {run_id} | config={CONFIG['config_version']}")
219
+ manifest = load_manifest()
220
+ if not manifest:
221
+ print("Manifest empty. Run 01_register_sources.py first.")
222
+ sys.exit(1)
223
+ eligible = CONFIG["eligible_statuses"] if not args.all else ["pending","profiled","rendered"]
224
+ books = [manifest[args.book_id]] if args.book_id else [r for r in manifest.values() if r.get("status") in eligible]
225
+ if not books:
226
+ print(f"No books with status in {eligible}.")
227
+ sys.exit(0)
228
+ print(f"Books to profile: {len(books)}")
229
+ results = []
230
+ t_start = time.time()
231
+ for record in books:
232
+ updated, profile, error = profile_book(record, dry_run=dry_run)
233
+ result = {"book_id": record["book_id"], "filename": record["filename"], "error": error}
234
+ if profile:
235
+ result.update({"route_summary": profile["route_summary"], "page_count": profile["page_count"]})
236
+ manifest[record["book_id"]] = updated
237
+ results.append(result)
238
+ if not dry_run:
239
+ save_manifest(manifest)
240
+ log_path = LOGS_DIR / f"{run_id}_profile_run.json"
241
+ with open(log_path, "w") as f:
242
+ json.dump({"run_id": run_id, "config": CONFIG, "results": results}, f, indent=2)
243
+ elapsed = round(time.time() - t_start, 1)
244
+ succeeded = sum(1 for r in results if not r.get("error"))
245
+ print(f"\nDone: {succeeded}/{len(results)} succeeded in {elapsed}s")
246
+ print("Next: run 03_ocr_bakeoff.py on calibration corpus")
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()