mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
font debug analysis
This commit is contained in:
1 parent
672bdc3223
commit
74b9820a48
11 files changed
+1465
-28
No files matched your search
@@ -9,3 +9,4 @@ build/
|
||||
.env
|
||||
solver_rs/target/
|
||||
scripts/.cache/
|
||||
debug/
|
||||
@@ -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`.
|
||||
@@ -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"
|
||||
```
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user