font debug analysis

This commit is contained in:
Alex-Gilbert committed 2026-02-23 21:17:22 -06:00
1 parent 672bdc3223
commit 74b9820a48
11 files changed
+1465 -28

No files matched your search

+1
View File
@@ -9,3 +9,4 @@ build/
.env
solver_rs/target/
scripts/.cache/
debug/
+5 -1
View File
@@ -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 ──
@@ -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-<timestamp>/
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`.
+750
View File
@@ -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-<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**
```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/
```
@@ -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
@@ -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"
```
+97
View File
@@ -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
+6 -4
View File
@@ -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}"
)
+16 -7
View File
@@ -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)
+212
View File
@@ -0,0 +1,212 @@
"""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
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")
+66 -16
View File
@@ -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)
if best is None or score > best.score:
best = FontMatch(
match = FontMatch(
font_name=font_name,
font_path=font_path,
font_size=size,
score=score,
)
return best
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 [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