Codex_Extractor / smoke_signal /scripts /02_profile_pdfs.py
Pointf5ive's picture
Create 02_profile_pdfs.py
d4ee6c9 verified
Raw
History Blame Contribute Delete
9.82 kB
#!/usr/bin/env python3
"""
Smoke Signal — Stage 3: PDF Profiler & Renderer
=================================================
For every PDF in the source manifest (status=pending or profiled),
this script:
1. Opens each PDF and inspects every page
2. Detects whether a page has embedded text, is image-only, or hybrid
3. Detects rotation, skew hints, two-page spreads, dimensions
4. Routes each page: embedded_text | ocr | hybrid
5. Renders OCR-candidate pages to PNG at 300 DPI (450 DPI fallback)
6. Saves page_profile.json per book
7. Updates source_manifest.csv status to 'rendered'
Usage:
python scripts/02_profile_pdfs.py
python scripts/02_profile_pdfs.py --book-id SS-BOOK-0001
python scripts/02_profile_pdfs.py --batch-id SS-BATCH-001
python scripts/02_profile_pdfs.py --dry-run
"""
import argparse
import csv
import json
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Optional
ROOT = Path(__file__).resolve().parents[1]
SOURCE_DIR = ROOT / "source_pdfs"
MANIFEST_CSV = ROOT / "manifest" / "source_manifest.csv"
RENDERS_DIR = ROOT / "renders"
PROFILES_DIR = ROOT / "manifest" / "page_profiles"
LOGS_DIR = ROOT / "logs"
PROFILES_DIR.mkdir(parents=True, exist_ok=True)
RENDERS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
CONFIG = {
"config_version": "ss_profiler_v0.1",
"render_dpi_baseline": 300,
"render_dpi_fallback": 450,
"text_char_threshold": 20,
"spread_aspect_ratio": 1.6,
"image_coverage_threshold": 0.15,
"skip_statuses": ["exported", "quarantined"],
"eligible_statuses": ["pending", "profiled"],
}
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 _import_fitz():
try:
import fitz
return fitz
except ImportError:
print(" [error] PyMuPDF (fitz) not installed. Run: pip install pymupdf")
sys.exit(1)
def analyse_page(page, fitz, config: dict) -> dict:
rect = page.rect
width = rect.width
height = rect.height
rotation = page.rotation
text = page.get_text("text").strip()
char_count = len(text)
has_embedded_text = char_count >= config["text_char_threshold"]
image_list = page.get_images(full=True)
has_images = len(image_list) > 0
image_coverage = 0.0
page_area = width * height
if has_images and page_area > 0:
for img in image_list:
try:
rects = page.get_image_rects(img[0])
for r in rects:
image_coverage += abs(r.width * r.height) / page_area
except Exception:
pass
image_coverage = min(image_coverage, 1.0)
if has_embedded_text and not has_images:
route = "embedded_text"
elif has_embedded_text and has_images:
route = "hybrid" if image_coverage >= config["image_coverage_threshold"] else "embedded_text"
else:
route = "ocr"
aspect = width / height if height > 0 else 0
is_spread = aspect >= config["spread_aspect_ratio"]
warnings = []
if rotation not in (0, 360):
warnings.append(f"rotation_{rotation}deg")
if is_spread:
warnings.append("possible_two_page_spread")
if route == "ocr" and char_count == 0 and not has_images:
warnings.append("blank_or_undetectable_page")
return {
"width_pt": round(width, 2), "height_pt": round(height, 2),
"rotation_deg": rotation, "char_count": char_count,
"has_embedded_text": has_embedded_text, "has_images": has_images,
"image_count": len(image_list), "image_coverage": round(image_coverage, 3),
"is_spread": is_spread, "route": route, "warnings": warnings,
}
def render_page(page, book_id: str, page_num: int, dpi: int, render_dir: Path) -> Optional[str]:
book_render_dir = render_dir / book_id
book_render_dir.mkdir(parents=True, exist_ok=True)
filename = f"{book_id}_page_{page_num:04d}_{dpi}dpi.png"
out_path = book_render_dir / filename
try:
mat = page.fitz_module.Matrix(dpi / 72, dpi / 72)
pix = page.get_pixmap(matrix=mat, alpha=False)
pix.save(str(out_path))
return str(out_path.relative_to(ROOT))
except Exception:
return None
def profile_book(record: dict, dry_run: bool = False):
book_id = record["book_id"]
filename = record["filename"]
pdf_path = SOURCE_DIR / filename
print(f"\n [{book_id}] {filename}")
if not pdf_path.exists():
return record, None, {"error": "file_not_found"}
if record.get("rights_class") in ("unknown", "excluded"):
return record, None, {"error": "rights_blocked"}
fitz = _import_fitz()
try:
doc = fitz.open(str(pdf_path))
except Exception as e:
return record, None, {"error": str(e)}
page_count = doc.page_count
print(f" Pages: {page_count}")
pages = []
render_errors = []
route_counts = {"embedded_text": 0, "ocr": 0, "hybrid": 0}
for i in range(page_count):
page_num = i + 1
page = doc[i]
page.fitz_module = fitz
profile = analyse_page(page, fitz, CONFIG)
profile["page_number"] = page_num
render_path = None
render_dpi = None
if not dry_run and profile["route"] in ("ocr", "hybrid"):
dpi = CONFIG["render_dpi_baseline"]
render_path = render_page(page, book_id, page_num, dpi, RENDERS_DIR)
if render_path is None:
dpi = CONFIG["render_dpi_fallback"]
render_path = render_page(page, book_id, page_num, dpi, RENDERS_DIR)
if render_path is None:
render_errors.append(page_num)
else:
render_dpi = dpi
else:
render_dpi = dpi
profile["render_path"] = render_path
profile["render_dpi"] = render_dpi
pages.append(profile)
route_counts[profile["route"]] += 1
doc.close()
book_profile = {
"book_id": book_id, "filename": filename,
"source_hash": record.get("sha256", ""),
"page_count": page_count, "config_version": CONFIG["config_version"],
"profiled_at": datetime.utcnow().isoformat() + "Z",
"route_summary": route_counts, "render_errors": render_errors, "pages": pages,
}
if not dry_run:
profile_path = PROFILES_DIR / f"{book_id}_page_profile.json"
with open(profile_path, "w", encoding="utf-8") as f:
json.dump(book_profile, f, indent=2)
print(f" Profile saved -> {profile_path.relative_to(ROOT)}")
total_ocr = route_counts["ocr"] + route_counts["hybrid"]
print(f" Routes: embedded={route_counts['embedded_text']} | ocr={route_counts['ocr']} | hybrid={route_counts['hybrid']}")
record["page_count"] = page_count
record["status"] = "profiled" if not dry_run else record["status"]
return record, book_profile, {}
def main():
parser = argparse.ArgumentParser(description="Smoke Signal Stage 3: PDF Profiler")
parser.add_argument("--book-id", help="Profile a single book by ID")
parser.add_argument("--batch-id", help="Tag this run with a batch ID")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--all", action="store_true")
args = parser.parse_args()
run_id = args.batch_id or f"SS-RUN-{datetime.utcnow().strftime('%Y%m%d-%H%M%S')}"
dry_run = args.dry_run
print(f"\nSmoke Signal Stage 3 | {run_id} | config={CONFIG['config_version']}")
manifest = load_manifest()
if not manifest:
print("Manifest empty. Run 01_register_sources.py first.")
sys.exit(1)
eligible = CONFIG["eligible_statuses"] if not args.all else ["pending","profiled","rendered"]
books = [manifest[args.book_id]] if args.book_id else [r for r in manifest.values() if r.get("status") in eligible]
if not books:
print(f"No books with status in {eligible}.")
sys.exit(0)
print(f"Books to profile: {len(books)}")
results = []
t_start = time.time()
for record in books:
updated, profile, error = profile_book(record, dry_run=dry_run)
result = {"book_id": record["book_id"], "filename": record["filename"], "error": error}
if profile:
result.update({"route_summary": profile["route_summary"], "page_count": profile["page_count"]})
manifest[record["book_id"]] = updated
results.append(result)
if not dry_run:
save_manifest(manifest)
log_path = LOGS_DIR / f"{run_id}_profile_run.json"
with open(log_path, "w") as f:
json.dump({"run_id": run_id, "config": CONFIG, "results": results}, f, indent=2)
elapsed = round(time.time() - t_start, 1)
succeeded = sum(1 for r in results if not r.get("error"))
print(f"\nDone: {succeeded}/{len(results)} succeeded in {elapsed}s")
print("Next: run 03_ocr_bakeoff.py on calibration corpus")
if __name__ == "__main__":
main()