From 74b9820a48d7f1b69c622b46c352875d3cc8a7e0 Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Mon, 23 Feb 2026 21:17:22 -0600 Subject: [PATCH] font debug analysis --- .gitignore | 1 + Makefile | 6 +- docs/plans/2026-02-20-font-debug-design.md | 65 ++ docs/plans/2026-02-20-font-debug-impl.md | 750 ++++++++++++++++++ ...02-22-known-start-end-casing-fix-design.md | 48 ++ ...6-02-22-known-start-end-casing-fix-impl.md | 199 +++++ tests/test_font_debug.py | 97 +++ tests/test_pixel_full_search.py | 10 +- unredact/pipeline/analyze_page.py | 23 +- unredact/pipeline/font_debug.py | 212 +++++ unredact/pipeline/font_detect.py | 90 ++- 11 files changed, 1469 insertions(+), 32 deletions(-) create mode 100644 docs/plans/2026-02-20-font-debug-design.md create mode 100644 docs/plans/2026-02-20-font-debug-impl.md create mode 100644 docs/plans/2026-02-22-known-start-end-casing-fix-design.md create mode 100644 docs/plans/2026-02-22-known-start-end-casing-fix-impl.md create mode 100644 tests/test_font_debug.py create mode 100644 unredact/pipeline/font_debug.py diff --git a/.gitignore b/.gitignore index 9230927..2b543fd 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ build/ .env solver_rs/target/ scripts/.cache/ +debug/ diff --git a/Makefile b/Makefile index 9c49fb3..5770814 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-solver run stop restart solver app logs status clean dev kill build-associates build-emails build-word-lists check-api-key +.PHONY: build build-solver run debug stop restart solver app logs status clean dev kill build-associates build-emails build-word-lists check-api-key SOLVER_BIN = solver_rs/target/release/unredact-solver SOLVER_PORT ?= 3100 @@ -121,6 +121,10 @@ kill: @fuser -k $(SOLVER_PORT)/tcp 2>/dev/null && echo "Killed port $(SOLVER_PORT)" || echo "Port $(SOLVER_PORT) free" @rm -f $(PID_DIR)/solver.pid $(PID_DIR)/app.pid +debug: export UNREDACT_DEBUG=1 +debug: run + @echo "Debug images will be saved to debug/font-match-*/" + restart: stop run # ── Status / Logs ── diff --git a/docs/plans/2026-02-20-font-debug-design.md b/docs/plans/2026-02-20-font-debug-design.md new file mode 100644 index 0000000..a949229 --- /dev/null +++ b/docs/plans/2026-02-20-font-debug-design.md @@ -0,0 +1,65 @@ +# Font Matching Debug Visualization + +## Problem + +Font detection sometimes works well and sometimes poorly, but there's no visibility into what the pixel scoring is doing — which candidates were tried, how they compared, or why the winner was chosen. + +## Design + +### Activation + +- Env var: `UNREDACT_DEBUG=1` +- When set, the font detection pipeline saves debug images to `debug/` in the project root +- When unset, zero overhead — no image generation + +### Output Structure + +``` +debug/ + font-match-/ + line-00_crop.png # raw page crop + line-00_rank-1_TimesNewRoman_14px_0.87.png # top candidate composite + line-00_rank-2_Georgia_14px_0.82.png + line-00_rank-3_Arial_13px_0.71.png + line-00_rank-4_LiberationSerif_14px_0.70.png + line-00_rank-5_DejaVuSerif_15px_0.68.png + line-00_summary.png # all 5 tiled horizontally + line-01_crop.png + ... +``` + +### Composite Image Layout (per candidate) + +Each candidate image is a vertical stack: + +1. **Header**: font name, size, score — white text on dark background +2. **Page crop**: binarized ink (white on black) +3. **Rendered candidate**: binarized ink (white on black) +4. **Overlap map**: color-coded comparison + - Green pixels: both page and rendered have ink (correct match) + - Red pixels: page has ink but rendered doesn't (missing ink) + - Blue pixels: rendered has ink but page doesn't (extra ink) + +### Summary Image + +All top 5 composites tiled side-by-side in a single image, ranked left-to-right by score. Quick visual comparison at a glance. + +### Implementation Location + +New module: `unredact/pipeline/font_debug.py` + +- `_debug_enabled()` — check env var +- `_init_debug_dir()` — create timestamped subfolder, return Path +- `_save_candidate_image()` — generate one composite for a candidate +- `_save_summary_image()` — tile top N composites horizontally +- `_save_crop_image()` — save the raw page crop + +### Integration Points + +Modify `_full_search()` to collect top-5 candidates (not just the best). When debug is enabled, after each scoring function completes its search, call the debug save functions. + +Both `_score_font_line_pixel()` and `_score_font_masked_pixel()` produce the same intermediate data (page_bin, rendered_bin) needed for the overlap visualization. The debug module will re-render the top 5 candidates to generate the images (slight re-work, but keeps the hot path clean). + +### .gitignore + +Add `debug/` to `.gitignore`. diff --git a/docs/plans/2026-02-20-font-debug-impl.md b/docs/plans/2026-02-20-font-debug-impl.md new file mode 100644 index 0000000..7c33566 --- /dev/null +++ b/docs/plans/2026-02-20-font-debug-impl.md @@ -0,0 +1,750 @@ +# Font Matching Debug Visualization — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** When `UNREDACT_DEBUG=1` is set, save visual debug images showing the top 5 font candidates per line with pixel overlap visualizations. + +**Architecture:** New `font_debug.py` module handles all image generation. `_full_search()` in `font_detect.py` is modified to collect top-N candidates (not just the best). After search completes, debug functions re-render the top 5 and save composite images. Zero overhead when debug is off. + +**Tech Stack:** PIL/Pillow for image generation, numpy for pixel operations (both already in use). + +--- + +### Task 1: Add `debug/` to .gitignore + +**Files:** +- Modify: `.gitignore` + +**Step 1: Add debug directory to gitignore** + +Append `debug/` to `.gitignore`. + +**Step 2: Commit** + +```bash +git add .gitignore +git commit -m "chore: gitignore debug/ directory" +``` + +--- + +### Task 2: Create `font_debug.py` with debug-enabled check and directory setup + +**Files:** +- Create: `unredact/pipeline/font_debug.py` +- Test: `tests/test_font_debug.py` + +**Step 1: Write tests for debug utilities** + +```python +"""Tests for font matching debug utilities.""" + +import os +from pathlib import Path +from unittest.mock import patch + +from unredact.pipeline.font_debug import debug_enabled, init_debug_dir + + +class TestDebugEnabled: + def test_disabled_by_default(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("UNREDACT_DEBUG", None) + assert debug_enabled() is False + + def test_enabled_with_env_var(self): + with patch.dict(os.environ, {"UNREDACT_DEBUG": "1"}): + assert debug_enabled() is True + + def test_disabled_with_zero(self): + with patch.dict(os.environ, {"UNREDACT_DEBUG": "0"}): + assert debug_enabled() is False + + +class TestInitDebugDir: + def test_creates_timestamped_dir(self, tmp_path): + result = init_debug_dir(base=tmp_path) + assert result.exists() + assert result.parent == tmp_path + assert result.name.startswith("font-match-") + + def test_returns_path(self, tmp_path): + result = init_debug_dir(base=tmp_path) + assert isinstance(result, Path) +``` + +**Step 2: Run tests to verify they fail** + +Run: `pytest tests/test_font_debug.py -v` +Expected: FAIL — module does not exist + +**Step 3: Write minimal implementation** + +```python +"""Font matching debug visualization. + +When UNREDACT_DEBUG=1, saves visual comparisons of font candidates +to debug/font-match-/ in the project root. +""" + +import os +from datetime import datetime +from pathlib import Path + +PROJECT_ROOT = Path(__file__).parent.parent.parent + + +def debug_enabled() -> bool: + return os.environ.get("UNREDACT_DEBUG", "0") == "1" + + +def init_debug_dir(base: Path | None = None) -> Path: + if base is None: + base = PROJECT_ROOT / "debug" + stamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + d = base / f"font-match-{stamp}" + d.mkdir(parents=True, exist_ok=True) + return d +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_font_debug.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add unredact/pipeline/font_debug.py tests/test_font_debug.py +git commit -m "feat(debug): add font_debug module with env var check and dir setup" +``` + +--- + +### Task 3: Implement candidate image generation + +**Files:** +- Modify: `unredact/pipeline/font_debug.py` +- Test: `tests/test_font_debug.py` + +**Step 1: Write test for composite image generation** + +Add to `tests/test_font_debug.py`: + +```python +import numpy as np +from PIL import Image + +from unredact.pipeline.font_debug import render_candidate_composite + + +class TestRenderCandidateComposite: + def test_returns_rgb_image(self): + page_bin = np.zeros((20, 100), dtype=bool) + rendered_bin = np.zeros((20, 100), dtype=bool) + result = render_candidate_composite( + page_bin, rendered_bin, + font_name="Arial", font_size=14, score=0.85, rank=1, + ) + assert isinstance(result, Image.Image) + assert result.mode == "RGB" + + def test_image_wider_than_crop(self): + page_bin = np.zeros((20, 100), dtype=bool) + rendered_bin = np.zeros((20, 100), dtype=bool) + result = render_candidate_composite( + page_bin, rendered_bin, + font_name="Arial", font_size=14, score=0.85, rank=1, + ) + # Must include header + 3 rows (page, rendered, overlap) + assert result.height > 20 * 3 + + def test_overlap_colors(self): + # Create known overlap: both have ink at (5, 50) + page_bin = np.zeros((20, 100), dtype=bool) + rendered_bin = np.zeros((20, 100), dtype=bool) + page_bin[5, 50] = True + rendered_bin[5, 50] = True + # Page-only ink at (10, 50) + page_bin[10, 50] = True + # Rendered-only ink at (15, 50) + rendered_bin[15, 50] = True + + result = render_candidate_composite( + page_bin, rendered_bin, + font_name="Test", font_size=12, score=0.5, rank=1, + ) + assert result is not None +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_font_debug.py::TestRenderCandidateComposite -v` +Expected: FAIL — function not defined + +**Step 3: Implement `render_candidate_composite()`** + +Add to `unredact/pipeline/font_debug.py`: + +```python +import numpy as np +from PIL import Image, ImageDraw, ImageFont as PilFont + + +# Row label height + padding +_HEADER_H = 20 +_LABEL_W = 60 +_PAD = 2 + + +def render_candidate_composite( + page_bin: np.ndarray, + rendered_bin: np.ndarray, + font_name: str, + font_size: int, + score: float, + rank: int, +) -> Image.Image: + """Build a composite debug image for one font candidate. + + Layout (vertical stack): + - Header: rank, font name, size, score + - Row 1: page crop (white ink on black) + - Row 2: rendered candidate (white ink on black) + - Row 3: overlap map (green=both, red=page-only, blue=rendered-only) + """ + h, w = page_bin.shape + + row_h = h + _PAD * 2 + total_h = _HEADER_H + row_h * 3 + img = Image.new("RGB", (w, total_h), (30, 30, 30)) + draw = ImageDraw.Draw(img) + + # Header + header = f"#{rank} {font_name} {font_size}px score={score:.3f}" + draw.text((4, 2), header, fill=(255, 255, 255)) + + # Row 1: page crop + y_off = _HEADER_H + _PAD + page_rgb = np.zeros((h, w, 3), dtype=np.uint8) + page_rgb[page_bin] = [255, 255, 255] + img.paste(Image.fromarray(page_rgb), (0, y_off)) + + # Row 2: rendered candidate + y_off += row_h + rend_rgb = np.zeros((h, w, 3), dtype=np.uint8) + rend_rgb[rendered_bin] = [255, 255, 255] + img.paste(Image.fromarray(rend_rgb), (0, y_off)) + + # Row 3: overlap map + y_off += row_h + overlap_rgb = np.zeros((h, w, 3), dtype=np.uint8) + both = page_bin & rendered_bin + page_only = page_bin & ~rendered_bin + rend_only = ~page_bin & rendered_bin + overlap_rgb[both] = [0, 200, 0] # green = match + overlap_rgb[page_only] = [220, 50, 50] # red = missing from render + overlap_rgb[rend_only] = [50, 100, 220] # blue = extra in render + img.paste(Image.fromarray(overlap_rgb), (0, y_off)) + + return img +``` + +**Step 4: Run tests to verify they pass** + +Run: `pytest tests/test_font_debug.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add unredact/pipeline/font_debug.py tests/test_font_debug.py +git commit -m "feat(debug): implement candidate composite image generation" +``` + +--- + +### Task 4: Implement summary image (top 5 tiled) + +**Files:** +- Modify: `unredact/pipeline/font_debug.py` +- Test: `tests/test_font_debug.py` + +**Step 1: Write test for summary tiling** + +Add to `tests/test_font_debug.py`: + +```python +from unredact.pipeline.font_debug import render_summary_image + + +class TestRenderSummaryImage: + def test_tiles_horizontally(self): + imgs = [Image.new("RGB", (100, 80), (i * 50, 0, 0)) for i in range(3)] + result = render_summary_image(imgs) + assert result.width == 100 * 3 + 2 * 2 # 2px gap between + assert result.height == 80 + + def test_single_image(self): + imgs = [Image.new("RGB", (100, 80))] + result = render_summary_image(imgs) + assert result.width == 100 + assert result.height == 80 + + def test_handles_different_heights(self): + imgs = [Image.new("RGB", (100, 60)), Image.new("RGB", (100, 80))] + result = render_summary_image(imgs) + assert result.height == 80 # tallest +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_font_debug.py::TestRenderSummaryImage -v` +Expected: FAIL + +**Step 3: Implement `render_summary_image()`** + +Add to `unredact/pipeline/font_debug.py`: + +```python +_GAP = 2 # pixels between tiled images + + +def render_summary_image(composites: list[Image.Image]) -> Image.Image: + """Tile candidate composites horizontally into a single summary image.""" + if not composites: + return Image.new("RGB", (1, 1)) + max_h = max(img.height for img in composites) + total_w = sum(img.width for img in composites) + _GAP * (len(composites) - 1) + summary = Image.new("RGB", (total_w, max_h), (20, 20, 20)) + x = 0 + for img in composites: + summary.paste(img, (x, 0)) + x += img.width + _GAP + return summary +``` + +**Step 4: Run tests** + +Run: `pytest tests/test_font_debug.py -v` +Expected: PASS + +**Step 5: Commit** + +```bash +git add unredact/pipeline/font_debug.py tests/test_font_debug.py +git commit -m "feat(debug): implement summary image tiling" +``` + +--- + +### Task 5: Modify `_full_search()` to collect top-N candidates + +**Files:** +- Modify: `unredact/pipeline/font_detect.py:206-245` + +**Step 1: Refactor `_full_search()` to return a list of top candidates** + +Currently `_full_search()` only tracks the single best match. Change it to collect the top N candidates using a sorted list. The function signature changes to return `list[FontMatch]` instead of `FontMatch | None`. + +Modify `_full_search()` in `font_detect.py`: + +```python +import heapq + +def _full_search( + line_h: int, + scorer: Callable[[ImageFont.FreeTypeFont], float], + top_n: int = 1, +) -> list[FontMatch]: + """Full search across all candidate fonts and sizes. + + Args: + line_h: OCR line height in pixels (used to bound the size search). + scorer: Callable that takes a PIL font and returns a score (0.0-1.0). + top_n: Number of top candidates to return (default 1 for backwards compat). + + Returns: + List of up to top_n FontMatch results, sorted by score descending. + """ + # Min-heap of (-score, FontMatch) — we negate score so heapq gives us lowest + # (i.e. worst among our top-N) at index 0 for efficient replacement. + heap: list[tuple[float, int, FontMatch]] = [] + counter = 0 # tiebreaker for heap ordering + + min_size = max(12, int(line_h * 0.6)) + max_size = min(120, int(line_h * 1.4)) + + for font_name in CANDIDATE_FONTS: + font_path = _find_font_path(font_name) + if font_path is None: + continue + + for size in range(min_size, max_size + 1): + try: + font = ImageFont.truetype(str(font_path), size) + except Exception: + continue + + score = scorer(font) + match = FontMatch( + font_name=font_name, + font_path=font_path, + font_size=size, + score=score, + ) + + if len(heap) < top_n: + heapq.heappush(heap, (score, counter, match)) + elif score > heap[0][0]: + heapq.heapreplace(heap, (score, counter, match)) + counter += 1 + + # Return sorted by score descending + result = [m for (_, _, m) in sorted(heap, reverse=True)] + return result +``` + +**Step 2: Update all callers to use `result[0]` instead of the raw return** + +There are 3 callers of `_full_search()`: + +1. `detect_font_for_line_from_crop()` (line 283): + ```python + results = _full_search(line.h, scorer) + if not results: + raise RuntimeError("No matching font found. Check system fonts.") + return _fine_search(results[0], scorer) + ``` + +2. `detect_font_masked()` (line 436): + ```python + results = _full_search(line.h, scorer) + if not results: + raise RuntimeError("No matching font found. Check system fonts.") + return _fine_search(results[0], scorer) + ``` + +3. `detect_font_for_line()` (line 482): + ```python + results = _full_search(line.h, scorer) + if not results: + if prior is not None: + return prior + raise RuntimeError("No matching font found. Check system fonts.") + best = _fine_search(results[0], scorer) + ``` + +**Step 3: Run all existing tests to verify nothing broke** + +Run: `pytest tests/test_font_detect.py tests/test_pixel_scoring.py tests/test_pixel_full_search.py tests/test_font_masking.py tests/test_font_detect_real.py -v` +Expected: all PASS + +**Step 4: Commit** + +```bash +git add unredact/pipeline/font_detect.py +git commit -m "refactor: _full_search returns top-N candidates list" +``` + +--- + +### Task 6: Wire debug image saving into font detection + +**Files:** +- Modify: `unredact/pipeline/font_debug.py` +- Modify: `unredact/pipeline/font_detect.py` + +**Step 1: Add the `save_line_debug()` function to `font_debug.py`** + +This is the main entry point called from `font_detect.py`. It re-renders the top candidates and saves everything. + +```python +def save_line_debug( + debug_dir: Path, + line_idx: int, + line_crop: np.ndarray, + top_candidates: list, # list[FontMatch] — avoid circular import + scorer_data: dict, +) -> None: + """Save debug images for one line's font matching. + + Args: + debug_dir: Timestamped debug output directory. + line_idx: Line index (for filenames). + line_crop: Grayscale numpy crop of the line. + top_candidates: Top-N FontMatch results from _full_search. + scorer_data: Dict with keys needed to re-render: + - "line" (OcrLine) and "type" = "line" for line scoring, OR + - "char_runs", "line_x", "line_y" and "type" = "masked" for masked scoring. + """ + from PIL import ImageFont as PilFont + + page_bin = line_crop < 128 + + # Save raw crop + crop_img = Image.fromarray(line_crop) + crop_img.save(debug_dir / f"line-{line_idx:02d}_crop.png") + + composites = [] + for rank, match in enumerate(top_candidates, 1): + try: + font = PilFont.truetype(str(match.font_path), match.font_size) + except Exception: + continue + + # Re-render to get rendered_bin + h, w = line_crop.shape + rendered_img = Image.new("L", (w, h), 255) + draw = ImageDraw.Draw(rendered_img) + + if scorer_data["type"] == "masked": + for run in scorer_data["char_runs"]: + text = "".join(c.text for c in run) + if not text.strip(): + continue + cx = run[0].x - scorer_data["line_x"] + cy = run[0].y - scorer_data["line_y"] + bbox = font.getbbox(text) + draw.text((cx - bbox[0], cy - bbox[1]), text, font=font, fill=0) + else: + line = scorer_data["line"] + bbox = font.getbbox(line.text) + draw.text((line.x - bbox[0], line.y - bbox[1]), line.text, font=font, fill=0) + + rendered_bin = np.array(rendered_img) < 128 + + # Find best shift (same as scoring code) + best_shifted = rendered_bin + best_dice = 0.0 + page_ink = int(page_bin.sum()) + rend_ink = int(rendered_bin.sum()) + total = page_ink + rend_ink + if total > 0: + for dy in range(-3, 4): + for dx in range(-3, 4): + shifted = _shift_2d(rendered_bin, dx, dy) + intersection = int((page_bin & shifted).sum()) + dice = 2.0 * intersection / total + if dice > best_dice: + best_dice = dice + best_shifted = shifted + + comp = render_candidate_composite( + page_bin, best_shifted, + match.font_name, match.font_size, match.score, rank, + ) + safe_name = match.font_name.replace(" ", "") + comp.save(debug_dir / f"line-{line_idx:02d}_rank-{rank}_{safe_name}_{match.font_size}px_{match.score:.2f}.png") + composites.append(comp) + + if composites: + summary = render_summary_image(composites) + summary.save(debug_dir / f"line-{line_idx:02d}_summary.png") +``` + +Note: import `_shift_2d` from `font_detect` at the top of `font_debug.py`: +```python +from unredact.pipeline.font_detect import _shift_2d +``` + +**Step 2: Wire debug into `detect_font_masked()`** + +Modify `detect_font_masked()` in `font_detect.py` (around line 390-439). + +After the `_full_search()` call, add: + +```python +from unredact.pipeline.font_debug import debug_enabled, save_line_debug + +# Inside detect_font_masked, after _full_search: +top_n = 5 if debug_enabled() else 1 +results = _full_search(line.h, scorer, top_n=top_n) +if not results: + raise RuntimeError("No matching font found. Check system fonts.") +best = _fine_search(results[0], scorer) + +if debug_enabled() and hasattr(detect_font_masked, '_debug_dir'): + save_line_debug( + detect_font_masked._debug_dir, + detect_font_masked._debug_line_idx, + line_crop, + results, + {"type": "masked", "char_runs": char_runs, "line_x": line.x, "line_y": line.y}, + ) + detect_font_masked._debug_line_idx += 1 +``` + +**Step 3: Wire debug into `detect_font_for_line()`** + +Similarly modify `detect_font_for_line()` (around line 442-496). + +After the `_full_search()` call: + +```python +top_n = 5 if debug_enabled() else 1 +results = _full_search(line.h, scorer, top_n=top_n) +# ... existing None check ... +best = _fine_search(results[0], scorer) + +if debug_enabled() and hasattr(detect_font_for_line, '_debug_dir'): + scoring_line_for_debug = scoring_line # already defined above + save_line_debug( + detect_font_for_line._debug_dir, + detect_font_for_line._debug_line_idx, + line_crop, + results, + {"type": "line", "line": scoring_line_for_debug}, + ) + detect_font_for_line._debug_line_idx += 1 +``` + +**Step 4: Initialize debug dir in `detect_fonts()` and `detect_font_masked()`** + +At the top of `detect_fonts()` (line 499): +```python +if debug_enabled(): + from unredact.pipeline.font_debug import init_debug_dir + detect_font_for_line._debug_dir = init_debug_dir() + detect_font_for_line._debug_line_idx = 0 +``` + +For `detect_font_masked()`, the debug dir should be initialized once per page analysis. The simplest approach: add the init at the top of `detect_font_masked()` if not already set: +```python +if debug_enabled(): + from unredact.pipeline.font_debug import init_debug_dir + if not hasattr(detect_font_masked, '_debug_dir'): + detect_font_masked._debug_dir = init_debug_dir() + detect_font_masked._debug_line_idx = 0 +``` + +**Step 5: Run all font tests** + +Run: `pytest tests/test_font_detect.py tests/test_pixel_scoring.py tests/test_pixel_full_search.py tests/test_font_masking.py tests/test_font_detect_real.py -v` +Expected: all PASS (debug is off by default) + +**Step 6: Manual smoke test with debug enabled** + +```bash +UNREDACT_DEBUG=1 pytest tests/test_font_detect_real.py -v +ls debug/font-match-*/ +``` + +Verify: debug images are created, summary images show tiled candidates. + +**Step 7: Commit** + +```bash +git add unredact/pipeline/font_debug.py unredact/pipeline/font_detect.py +git commit -m "feat(debug): save font matching debug images when UNREDACT_DEBUG=1" +``` + +--- + +### Task 7: Clean up — use a context manager instead of function attributes + +**Files:** +- Modify: `unredact/pipeline/font_debug.py` +- Modify: `unredact/pipeline/font_detect.py` + +The function-attribute approach in Task 6 is a quick hack. Replace it with a module-level context variable for cleanliness. + +**Step 1: Add a simple debug context to `font_debug.py`** + +```python +# Module-level debug state (set during a detection run) +_debug_ctx: dict | None = None + + +def start_debug_session() -> Path: + """Start a debug session. Returns the debug directory.""" + global _debug_ctx + d = init_debug_dir() + _debug_ctx = {"dir": d, "line_idx": 0} + return d + + +def end_debug_session(): + global _debug_ctx + _debug_ctx = None + + +def get_debug_ctx() -> dict | None: + return _debug_ctx + + +def next_line_idx() -> int: + if _debug_ctx is None: + return 0 + idx = _debug_ctx["line_idx"] + _debug_ctx["line_idx"] = idx + 1 + return idx +``` + +**Step 2: Replace function attributes in `font_detect.py`** + +In `detect_fonts()`: +```python +if debug_enabled(): + from unredact.pipeline.font_debug import start_debug_session + start_debug_session() +# ... loop ... +if debug_enabled(): + from unredact.pipeline.font_debug import end_debug_session + end_debug_session() +``` + +In `detect_font_masked()`: +```python +if debug_enabled(): + from unredact.pipeline.font_debug import get_debug_ctx, start_debug_session, next_line_idx + if get_debug_ctx() is None: + start_debug_session() + # after search: + save_line_debug(get_debug_ctx()["dir"], next_line_idx(), ...) +``` + +**Step 3: Remove all `_debug_dir` / `_debug_line_idx` function attributes** + +**Step 4: Run tests** + +Run: `pytest tests/test_font_detect.py tests/test_font_masking.py tests/test_font_detect_real.py -v` +Expected: all PASS + +**Step 5: Commit** + +```bash +git add unredact/pipeline/font_debug.py unredact/pipeline/font_detect.py +git commit -m "refactor(debug): replace function attributes with module-level debug context" +``` + +--- + +### Task 8: End-to-end verification + +**Step 1: Run the full test suite** + +```bash +pytest tests/ -v +``` + +Expected: all existing tests PASS (debug disabled by default). + +**Step 2: Run with debug enabled on real test data** + +```bash +UNREDACT_DEBUG=1 pytest tests/test_font_detect_real.py -v +``` + +Verify: +- `debug/font-match-*` directory created +- Contains `line-NN_crop.png` files +- Contains `line-NN_rank-N_*.png` candidate composites +- Contains `line-NN_summary.png` tiled summaries +- Composite images show: header with font info, page ink, rendered ink, color overlap map + +**Step 3: Clean up debug output** + +```bash +rm -rf debug/ +``` diff --git a/docs/plans/2026-02-22-known-start-end-casing-fix-design.md b/docs/plans/2026-02-22-known-start-end-casing-fix-design.md new file mode 100644 index 0000000..fc641af --- /dev/null +++ b/docs/plans/2026-02-22-known-start-end-casing-fix-design.md @@ -0,0 +1,48 @@ +# Fix known_start/known_end pixel width calculation + +**Date**: 2026-02-22 + +## Problem + +When `known_start` or `known_end` is set and casing is `"capitalized"`, both +`solve_full_name_dictionary` and `solve_name_dictionary` force the entire +unknown portion to lowercase. This causes incorrect pixel width measurements +for multi-word names because subsequent words lose their capital letters. + +**Example**: Full name "John Doe", `known_start = "Jo"`, casing = `"capitalized"` + +- `unknown_raw = "hn doe"` +- Current: `unknown_display = "hn doe"` (all lowercase) +- Correct: `unknown_display = "hn Doe"` (mid-word lowercase, word-boundary title case) + +The width of "hn doe" vs "hn Doe" differs by several pixels due to the D/d +difference, causing valid matches to be rejected or wrong matches to be accepted. + +### Secondary issue + +`effective_left = known_start[-1]` uses the user's raw input casing. If the +user types "JO" but the rendered text is "Jo", the kerning context character +("O" vs "o") is wrong. + +## Fix + +### Smart word-boundary casing + +Replace the blanket `.lower()` override with logic that: + +1. If `known_start` doesn't end with a space (unknown starts mid-word): + lowercase the first word fragment, title-case subsequent words. +2. If `known_start` ends with a space (unknown starts at a word boundary): + apply normal `.title()` casing. + +### Cased effective_left + +Apply the same casing logic to `known_start` before extracting the last +character for kerning context. + +## Scope + +- `unredact/pipeline/dictionary.py`: + - `solve_full_name_dictionary` (lines 121-123) + - `solve_name_dictionary` (lines 218-223) +- No frontend changes needed diff --git a/docs/plans/2026-02-22-known-start-end-casing-fix-impl.md b/docs/plans/2026-02-22-known-start-end-casing-fix-impl.md new file mode 100644 index 0000000..0667374 --- /dev/null +++ b/docs/plans/2026-02-22-known-start-end-casing-fix-impl.md @@ -0,0 +1,199 @@ +# Known Start/End Casing Fix Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix incorrect pixel width calculation when using known_start/known_end in full name and single name dictionary solving. + +**Architecture:** Extract a helper `_case_unknown_portion()` that applies capitalized casing with word-boundary awareness to the unknown portion of a name. Replace the blanket `.lower()` override in both solve functions. + +**Tech Stack:** Python, PIL/Pillow fonts, pytest + +--- + +### Task 1: Add failing test for multi-word full name casing + +**Files:** +- Modify: `tests/test_full_name_dictionary.py` + +**Step 1: Write the failing test** + +Add to `TestSolveFullNameDictionary`: + +```python +@patch("unredact.pipeline.word_filter._get_associate_variants") +def test_known_start_multiword_casing(self, mock_variants): + """Unknown portion of multi-word name should preserve word-boundary casing. + + Name: 'john doe', known_start='jo', unknown='hn Doe' (not 'hn doe'). + The width measurement must use 'hn Doe' not 'hn doe'. + """ + mock_variants.return_value = ["john doe"] + + # Mock: measure "ohn Doe" with left context "o" from known_start + # font.getlength("ohn Doe") should be called, NOT "ohn doe" + font = _mock_font({ + "ohn Doe": 42.0, # correct cased unknown + "ohn doe": 38.0, # wrong (current bug: all-lowercase) + }) + + results = solve_full_name_dictionary( + font, 42.0, 1.0, known_start="o", casing="capitalized", + ) + texts = [r.text for r in results] + assert "John Doe" in texts +``` + +**Step 2: Run test to verify it fails** + +Run: `pytest tests/test_full_name_dictionary.py::TestSolveFullNameDictionary::test_known_start_multiword_casing -v` +Expected: FAIL — currently measures "ohn doe" (38.0) which is outside tolerance of 42.0 ± 1.0 + +--- + +### Task 2: Add helper `_case_unknown_portion` and fix `solve_full_name_dictionary` + +**Files:** +- Modify: `unredact/pipeline/dictionary.py` + +**Step 1: Add the helper function after `_apply_casing`** + +```python +def _case_unknown_portion(unknown: str, known_start: str, casing: str) -> str: + """Apply casing to the unknown portion of a name, respecting word boundaries. + + When known_start is set and casing is 'capitalized', the unknown text + starts mid-word — its first fragment should be lowercase, but subsequent + words (after spaces) should be title-cased. + """ + if casing == "uppercase": + return unknown.upper() + if casing != "capitalized": + return unknown # lowercase — no change + + if not known_start: + return unknown.title() + + # If known_start ends with a space, unknown starts a new word + if known_start.endswith(" "): + return unknown.title() + + # Unknown starts mid-word: first fragment lowercase, rest title-cased + parts = unknown.split(" ") + result = [parts[0].lower()] + for part in parts[1:]: + result.append(part.title() if part else "") + return " ".join(result) +``` + +**Step 2: Replace lines 121-123 in `solve_full_name_dictionary`** + +Change: +```python + unknown_display = _apply_casing(unknown_raw, casing) + if known_start and casing == "capitalized": + unknown_display = unknown_raw.lower() +``` + +To: +```python + unknown_display = _case_unknown_portion(unknown_raw, known_start, casing) +``` + +**Step 3: Fix `effective_left` to use cased known_start (line 125)** + +Change: +```python + effective_left = known_start[-1] if known_start else left_context +``` + +To: +```python + effective_left = _apply_casing(known_start, casing)[-1] if known_start else left_context +``` + +**Step 4: Run the failing test to verify it passes** + +Run: `pytest tests/test_full_name_dictionary.py -v` +Expected: ALL PASS + +--- + +### Task 3: Add failing test for single name casing and fix `solve_name_dictionary` + +**Files:** +- Modify: `tests/test_name_dictionary.py` +- Modify: `unredact/pipeline/dictionary.py` + +**Step 1: Write the failing test** + +Add to `TestSolveNameDictionary`: + +```python +@patch("unredact.pipeline.word_filter._get_associate_firsts") +@patch("unredact.pipeline.word_filter._get_associate_lasts") +def test_known_start_capitalized_casing(self, mock_lasts, mock_firsts): + """With known_start and capitalized casing, unknown part is mid-word lowercase.""" + mock_firsts.return_value = ["john"] + mock_lasts.return_value = [] + + # known_start="j", unknown="ohn", should measure "ohn" (lowercase) + font = _mock_font({"ohn": 21.0}) + + results = solve_name_dictionary( + font, 21.0, 1.0, known_start="j", casing="capitalized", + ) + texts = [r.text for r in results] + assert "John" in texts +``` + +**Step 2: Run test to verify it passes (existing behavior is correct for single names)** + +Run: `pytest tests/test_name_dictionary.py::TestSolveNameDictionary::test_known_start_capitalized_casing -v` +Expected: PASS (single-word lowercase already works) + +**Step 3: Replace lines 218-225 in `solve_name_dictionary`** + +Change: +```python + # Apply same casing to the unknown portion + if casing == "uppercase": + unknown_display = unknown.upper() + elif casing == "capitalized": + # Mid-word after known_start: lowercase. At word start: title case. + unknown_display = unknown.lower() if known_start else unknown.title() + else: + unknown_display = unknown +``` + +To: +```python + unknown_display = _case_unknown_portion(unknown, known_start, casing) +``` + +**Step 4: Fix `effective_left` to use cased known_start (line 229)** + +Change: +```python + effective_left = known_start[-1] if known_start else left_context +``` + +To: +```python + effective_left = _apply_casing(known_start, casing)[-1] if known_start else left_context +``` + +**Step 5: Run all tests to verify nothing broke** + +Run: `pytest tests/test_name_dictionary.py tests/test_full_name_dictionary.py -v` +Expected: ALL PASS + +--- + +### Task 4: Commit + +**Step 1: Commit the changes** + +```bash +git add unredact/pipeline/dictionary.py tests/test_full_name_dictionary.py tests/test_name_dictionary.py +git commit -m "fix: known_start/end casing for multi-word name width calculation" +``` diff --git a/tests/test_font_debug.py b/tests/test_font_debug.py new file mode 100644 index 0000000..ecda8a9 --- /dev/null +++ b/tests/test_font_debug.py @@ -0,0 +1,97 @@ +"""Tests for font matching debug utilities.""" + +import os +from pathlib import Path +from unittest.mock import patch + +import numpy as np +from PIL import Image + +from unredact.pipeline.font_debug import ( + debug_enabled, + init_debug_dir, + render_candidate_composite, + render_summary_image, +) + + +class TestDebugEnabled: + def test_disabled_by_default(self): + with patch.dict(os.environ, {}, clear=True): + os.environ.pop("UNREDACT_DEBUG", None) + assert debug_enabled() is False + + def test_enabled_with_env_var(self): + with patch.dict(os.environ, {"UNREDACT_DEBUG": "1"}): + assert debug_enabled() is True + + def test_disabled_with_zero(self): + with patch.dict(os.environ, {"UNREDACT_DEBUG": "0"}): + assert debug_enabled() is False + + +class TestInitDebugDir: + def test_creates_timestamped_dir(self, tmp_path): + result = init_debug_dir(base=tmp_path) + assert result.exists() + assert result.parent == tmp_path + assert result.name.startswith("font-match-") + + def test_returns_path(self, tmp_path): + result = init_debug_dir(base=tmp_path) + assert isinstance(result, Path) + + +class TestRenderCandidateComposite: + def test_returns_rgb_image(self): + page_bin = np.zeros((20, 100), dtype=bool) + rendered_bin = np.zeros((20, 100), dtype=bool) + result = render_candidate_composite( + page_bin, rendered_bin, + font_name="Arial", font_size=14, score=0.85, rank=1, + ) + assert isinstance(result, Image.Image) + assert result.mode == "RGB" + + def test_image_height_includes_header_and_rows(self): + page_bin = np.zeros((20, 100), dtype=bool) + rendered_bin = np.zeros((20, 100), dtype=bool) + result = render_candidate_composite( + page_bin, rendered_bin, + font_name="Arial", font_size=14, score=0.85, rank=1, + ) + # Header (20) + 3 rows of (20 + 2*2 padding) = 20 + 72 = 92 + assert result.height > 20 * 3 + + def test_overlap_colors(self): + page_bin = np.zeros((20, 100), dtype=bool) + rendered_bin = np.zeros((20, 100), dtype=bool) + page_bin[5, 50] = True + rendered_bin[5, 50] = True + page_bin[10, 50] = True + rendered_bin[15, 50] = True + + result = render_candidate_composite( + page_bin, rendered_bin, + font_name="Test", font_size=12, score=0.5, rank=1, + ) + assert result is not None + + +class TestRenderSummaryImage: + def test_tiles_horizontally(self): + imgs = [Image.new("RGB", (100, 80), (i * 50, 0, 0)) for i in range(3)] + result = render_summary_image(imgs) + assert result.width == 100 * 3 + 2 * 2 # 2px gap between + assert result.height == 80 + + def test_single_image(self): + imgs = [Image.new("RGB", (100, 80))] + result = render_summary_image(imgs) + assert result.width == 100 + assert result.height == 80 + + def test_handles_different_heights(self): + imgs = [Image.new("RGB", (100, 60)), Image.new("RGB", (100, 80))] + result = render_summary_image(imgs) + assert result.height == 80 diff --git a/tests/test_pixel_full_search.py b/tests/test_pixel_full_search.py index 3caced9..de26d8c 100644 --- a/tests/test_pixel_full_search.py +++ b/tests/test_pixel_full_search.py @@ -62,8 +62,9 @@ def test_full_search_finds_correct_serif(): line, crop = _make_line_and_crop("Times New Roman", 50, text) scorer = lambda font: _score_font_line_pixel(font, line, crop) - best = _full_search(line.h, scorer) - assert best is not None + results = _full_search(line.h, scorer) + assert results + best = results[0] assert "Times" in best.font_name or "Liberation Serif" in best.font_name, ( f"Expected serif font, got {best.font_name}" ) @@ -78,8 +79,9 @@ def test_full_search_finds_correct_sans(): line, crop = _make_line_and_crop("Arial", 44, text) scorer = lambda font: _score_font_line_pixel(font, line, crop) - best = _full_search(line.h, scorer) - assert best is not None + results = _full_search(line.h, scorer) + assert results + best = results[0] assert "Arial" in best.font_name or "Liberation Sans" in best.font_name, ( f"Expected sans-serif font, got {best.font_name}" ) diff --git a/unredact/pipeline/analyze_page.py b/unredact/pipeline/analyze_page.py index 50c5e66..ab9afd7 100644 --- a/unredact/pipeline/analyze_page.py +++ b/unredact/pipeline/analyze_page.py @@ -166,15 +166,19 @@ async def analyze_page( right_text = "".join(c.text for c in right_chars).strip() # 3e: Pixel alignment + # Use the actual char positions to center the crop on the text, + # not the full OCR line (which may span multiple physical lines). offset_x = 0.0 offset_y = 0.0 - if left_text: + if left_text and left_chars: pil_font = font.to_pil_font() + char_y = min(c.y for c in left_chars) + char_h = max(c.y + c.h for c in left_chars) - char_y text_region_x1 = max(0, line.x - 20) text_region_x2 = min(page_image.width, box.x + 20) - text_region_y1 = max(0, line.y - 10) - text_region_y2 = min(page_image.height, line.y + line.h + 10) + text_region_y1 = max(0, char_y - 10) + text_region_y2 = min(page_image.height, char_y + char_h + 10) text_crop = np.array(page_image.convert("L").crop( (text_region_x1, text_region_y1, text_region_x2, text_region_y2) )) @@ -254,16 +258,21 @@ async def analyze_spot_redaction( left_text = boundary.left_text right_text = boundary.right_text - # Pixel alignment + # Pixel alignment — use char positions to center the crop on the + # actual text, not the full OCR line (which may span multiple lines). offset_x = 0.0 offset_y = 0.0 - if left_text: + left_chars = [c for c in line.chars if c.x + c.w / 2 < box.x] + + if left_text and left_chars: pil_font = font.to_pil_font() + char_y = min(c.y for c in left_chars) + char_h = max(c.y + c.h for c in left_chars) - char_y text_region_x1 = max(0, line.x - 20) text_region_x2 = min(page_image.width, box.x + 20) - text_region_y1 = max(0, line.y - 10) - text_region_y2 = min(page_image.height, line.y + line.h + 10) + text_region_y1 = max(0, char_y - 10) + text_region_y2 = min(page_image.height, char_y + char_h + 10) text_crop = await asyncio.to_thread( lambda: np.array(page_image.convert("L").crop( (text_region_x1, text_region_y1, text_region_x2, text_region_y2) diff --git a/unredact/pipeline/font_debug.py b/unredact/pipeline/font_debug.py new file mode 100644 index 0000000..ede0151 --- /dev/null +++ b/unredact/pipeline/font_debug.py @@ -0,0 +1,212 @@ +"""Font matching debug visualization. + +When UNREDACT_DEBUG=1, saves visual comparisons of font candidates +to debug/font-match-/ in the project root. +""" + +import os +from datetime import datetime +from pathlib import Path + +import numpy as np +from PIL import Image, ImageDraw + +PROJECT_ROOT = Path(__file__).parent.parent.parent + +_HEADER_H = 20 +_PAD = 2 +_GAP = 2 + + +def debug_enabled() -> bool: + return os.environ.get("UNREDACT_DEBUG", "0") == "1" + + +def init_debug_dir(base: Path | None = None) -> Path: + if base is None: + base = PROJECT_ROOT / "debug" + stamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S") + d = base / f"font-match-{stamp}" + d.mkdir(parents=True, exist_ok=True) + return d + + +def render_candidate_composite( + page_bin: np.ndarray, + rendered_bin: np.ndarray, + font_name: str, + font_size: int, + score: float, + rank: int, +) -> Image.Image: + """Build a composite debug image for one font candidate. + + Layout (vertical stack): + - Header: rank, font name, size, score + - Row 1: page crop (white ink on black) + - Row 2: rendered candidate (white ink on black) + - Row 3: overlap map (green=both, red=page-only, blue=rendered-only) + """ + h, w = page_bin.shape + + row_h = h + _PAD * 2 + total_h = _HEADER_H + row_h * 3 + img = Image.new("RGB", (w, total_h), (30, 30, 30)) + draw = ImageDraw.Draw(img) + + # Header + header = f"#{rank} {font_name} {font_size}px score={score:.3f}" + draw.text((4, 2), header, fill=(255, 255, 255)) + + # Row 1: page crop + y_off = _HEADER_H + _PAD + page_rgb = np.zeros((h, w, 3), dtype=np.uint8) + page_rgb[page_bin] = [255, 255, 255] + img.paste(Image.fromarray(page_rgb), (0, y_off)) + + # Row 2: rendered candidate + y_off += row_h + rend_rgb = np.zeros((h, w, 3), dtype=np.uint8) + rend_rgb[rendered_bin] = [255, 255, 255] + img.paste(Image.fromarray(rend_rgb), (0, y_off)) + + # Row 3: overlap map + y_off += row_h + overlap_rgb = np.zeros((h, w, 3), dtype=np.uint8) + both = page_bin & rendered_bin + page_only = page_bin & ~rendered_bin + rend_only = ~page_bin & rendered_bin + overlap_rgb[both] = [0, 200, 0] # green = match + overlap_rgb[page_only] = [220, 50, 50] # red = missing from render + overlap_rgb[rend_only] = [50, 100, 220] # blue = extra in render + img.paste(Image.fromarray(overlap_rgb), (0, y_off)) + + return img + + +def render_summary_image(composites: list[Image.Image]) -> Image.Image: + """Tile candidate composites horizontally into a single summary image.""" + if not composites: + return Image.new("RGB", (1, 1)) + max_h = max(img.height for img in composites) + total_w = sum(img.width for img in composites) + _GAP * (len(composites) - 1) + summary = Image.new("RGB", (total_w, max_h), (20, 20, 20)) + x = 0 + for img in composites: + summary.paste(img, (x, 0)) + x += img.width + _GAP + return summary + + +from unredact.pipeline.font_detect import _shift_2d + + +# Module-level debug state (set during a detection run) +_debug_ctx: dict | None = None + + +def start_debug_session() -> Path: + """Start a debug session. Returns the debug directory.""" + global _debug_ctx + d = init_debug_dir() + _debug_ctx = {"dir": d, "line_idx": 0} + return d + + +def end_debug_session(): + global _debug_ctx + _debug_ctx = None + + +def get_debug_ctx() -> dict | None: + return _debug_ctx + + +def next_line_idx() -> int: + if _debug_ctx is None: + return 0 + idx = _debug_ctx["line_idx"] + _debug_ctx["line_idx"] = idx + 1 + return idx + + +def save_line_debug( + debug_dir: Path, + line_idx: int, + line_crop: np.ndarray, + top_candidates: list, + scorer_data: dict, +) -> None: + """Save debug images for one line's font matching. + + Args: + debug_dir: Timestamped debug output directory. + line_idx: Line index (for filenames). + line_crop: Grayscale numpy crop of the line. + top_candidates: Top-N FontMatch results from _full_search. + scorer_data: Dict with keys needed to re-render: + - "line" (OcrLine) and "type" = "line" for line scoring, OR + - "char_runs", "line_x", "line_y" and "type" = "masked". + """ + from PIL import ImageFont as PilFont + + page_bin = line_crop < 128 + + # Save raw crop + crop_img = Image.fromarray(line_crop) + crop_img.save(debug_dir / f"line-{line_idx:02d}_crop.png") + + composites = [] + for rank, match in enumerate(top_candidates, 1): + try: + font = PilFont.truetype(str(match.font_path), match.font_size) + except Exception: + continue + + h, w = line_crop.shape + rendered_img = Image.new("L", (w, h), 255) + draw = ImageDraw.Draw(rendered_img) + + if scorer_data["type"] == "masked": + for run in scorer_data["char_runs"]: + text = "".join(c.text for c in run) + if not text.strip(): + continue + cx = run[0].x - scorer_data["line_x"] + cy = run[0].y - scorer_data["line_y"] + bbox = font.getbbox(text) + draw.text((cx - bbox[0], cy - bbox[1]), text, font=font, fill=0) + else: + line_obj = scorer_data["line"] + bbox = font.getbbox(line_obj.text) + draw.text((line_obj.x - bbox[0], line_obj.y - bbox[1]), line_obj.text, font=font, fill=0) + + rendered_bin = np.array(rendered_img) < 128 + + # Find best shift (same logic as scoring code) + best_shifted = rendered_bin + best_dice = 0.0 + page_ink = int(page_bin.sum()) + rend_ink = int(rendered_bin.sum()) + total = page_ink + rend_ink + if total > 0: + for dy in range(-3, 4): + for dx in range(-3, 4): + shifted = _shift_2d(rendered_bin, dx, dy) + intersection = int((page_bin & shifted).sum()) + dice = 2.0 * intersection / total + if dice > best_dice: + best_dice = dice + best_shifted = shifted + + comp = render_candidate_composite( + page_bin, best_shifted, + match.font_name, match.font_size, match.score, rank, + ) + safe_name = match.font_name.replace(" ", "") + comp.save(debug_dir / f"line-{line_idx:02d}_rank-{rank}_{safe_name}_{match.font_size}px_{match.score:.2f}.png") + composites.append(comp) + + if composites: + summary = render_summary_image(composites) + summary.save(debug_dir / f"line-{line_idx:02d}_summary.png") diff --git a/unredact/pipeline/font_detect.py b/unredact/pipeline/font_detect.py index 31bcb83..255c54c 100644 --- a/unredact/pipeline/font_detect.py +++ b/unredact/pipeline/font_detect.py @@ -1,3 +1,4 @@ +import heapq from dataclasses import dataclass from functools import lru_cache from pathlib import Path @@ -14,6 +15,9 @@ CANDIDATE_FONTS: list[str] = [ "Arial", "Courier New", "Georgia", + "Verdana", + "Carlito", + "Trebuchet MS", "Liberation Serif", "Liberation Sans", "DejaVu Serif", @@ -206,14 +210,17 @@ def align_text_to_page( def _full_search( line_h: int, scorer: Callable[[ImageFont.FreeTypeFont], float], -) -> FontMatch | None: + top_n: int = 1, +) -> list[FontMatch]: """Full search across all candidate fonts and sizes. Args: line_h: OCR line height in pixels (used to bound the size search). scorer: Callable that takes a PIL font and returns a score (0.0–1.0). + top_n: Number of top candidates to return. """ - best: FontMatch | None = None + heap: list[tuple[float, int, FontMatch]] = [] + counter = 0 # Constrain size range using the OCR line height. # Pixel scoring has sharp peaks at the exact size, so we scan @@ -233,16 +240,20 @@ def _full_search( continue score = scorer(font) + match = FontMatch( + font_name=font_name, + font_path=font_path, + font_size=size, + score=score, + ) - if best is None or score > best.score: - best = FontMatch( - font_name=font_name, - font_path=font_path, - font_size=size, - score=score, - ) + if len(heap) < top_n: + heapq.heappush(heap, (score, counter, match)) + elif score > heap[0][0]: + heapq.heapreplace(heap, (score, counter, match)) + counter += 1 - return best + return [m for (_, _, m) in sorted(heap, reverse=True)] def _fine_search( @@ -280,10 +291,10 @@ def detect_font_for_line_from_crop( be 0,0 (crop-relative coordinates). """ scorer = lambda font: _score_font_line_pixel(font, line, line_crop) - best = _full_search(line.h, scorer) - if best is None: + results = _full_search(line.h, scorer) + if not results: raise RuntimeError("No matching font found. Check system fonts.") - return _fine_search(best, scorer) + return _fine_search(results[0], scorer) def _filter_clean_chars( @@ -433,10 +444,29 @@ def detect_font_masked( scorer = lambda font: _score_font_masked_pixel( font, char_runs, line.x, line.y, line_crop, ) - best = _full_search(line.h, scorer) - if best is None: + from unredact.pipeline.font_debug import debug_enabled, get_debug_ctx, start_debug_session, next_line_idx, save_line_debug + top_n = 5 if debug_enabled() else 1 + + # Use median character height for size estimation — line.h can span + # multiple physical text lines, making the search range far too large. + char_heights = [c.h for c in clean_chars if c.text.strip()] + estimated_h = int(np.median(char_heights)) if char_heights else line.h + results = _full_search(estimated_h, scorer, top_n=top_n) + if not results: raise RuntimeError("No matching font found. Check system fonts.") - return _fine_search(best, scorer) + best = _fine_search(results[0], scorer) + + if debug_enabled(): + ctx = get_debug_ctx() + if ctx is None: + start_debug_session() + ctx = get_debug_ctx() + save_line_debug( + ctx["dir"], next_line_idx(), line_crop, results, + {"type": "masked", "char_runs": char_runs, "line_x": line.x, "line_y": line.y}, + ) + + return best def detect_font_for_line( @@ -478,15 +508,28 @@ def detect_font_for_line( except Exception: pass - # Full search - best = _full_search(line.h, scorer) - if best is None: + # Full search — use median char height for size estimation so that + # multi-line OCR blocks don't inflate the search range. + from unredact.pipeline.font_debug import debug_enabled, get_debug_ctx, next_line_idx, save_line_debug + top_n = 5 if debug_enabled() else 1 + char_heights = [c.h for c in line.chars if c.text.strip()] + estimated_h = int(np.median(char_heights)) if char_heights else line.h + results = _full_search(estimated_h, scorer, top_n=top_n) + if not results: if prior is not None: return prior raise RuntimeError("No matching font found. Check system fonts.") # Fine-tune the full search winner - best = _fine_search(best, scorer) + best = _fine_search(results[0], scorer) + + if debug_enabled(): + ctx = get_debug_ctx() + if ctx is not None: + save_line_debug( + ctx["dir"], next_line_idx(), line_crop, results, + {"type": "line", "line": scoring_line}, + ) # If we have a prior and it's close enough, prefer it for consistency if prior is not None and prior_score >= best.score * (1.0 / PRIOR_BIAS): @@ -507,6 +550,10 @@ def detect_fonts( consistent unless a line is clearly different (e.g. bold header vs regular body text). """ + from unredact.pipeline.font_debug import debug_enabled, start_debug_session, end_debug_session + if debug_enabled(): + start_debug_session() + results: list[FontMatch] = [] prior: FontMatch | None = None @@ -515,6 +562,9 @@ def detect_fonts( results.append(match) prior = match + if debug_enabled(): + end_debug_session() + return results