22 KiB
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
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
"""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
"""Font matching debug visualization.
When UNREDACT_DEBUG=1, saves visual comparisons of font candidates
to debug/font-match-<timestamp>/ 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
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:
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:
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
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:
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:
_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
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:
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():
-
detect_font_for_line_from_crop()(line 283):results = _full_search(line.h, scorer) if not results: raise RuntimeError("No matching font found. Check system fonts.") return _fine_search(results[0], scorer) -
detect_font_masked()(line 436):results = _full_search(line.h, scorer) if not results: raise RuntimeError("No matching font found. Check system fonts.") return _fine_search(results[0], scorer) -
detect_font_for_line()(line 482):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
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.
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:
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:
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:
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):
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:
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
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
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
# 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():
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():
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
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
pytest tests/ -v
Expected: all existing tests PASS (debug disabled by default).
Step 2: Run with debug enabled on real test data
UNREDACT_DEBUG=1 pytest tests/test_font_detect_real.py -v
Verify:
debug/font-match-*directory created- Contains
line-NN_crop.pngfiles - Contains
line-NN_rank-N_*.pngcandidate composites - Contains
line-NN_summary.pngtiled summaries - Composite images show: header with font info, page ink, rendered ink, color overlap map
Step 3: Clean up debug output
rm -rf debug/