feat: add detect_font_masked for redaction-aware font detection

Adds a new detect_font_masked() function that masks redaction boxes to
white (255) before running pixel-based font detection on the full line
crop. This replaces the fragile approach of cropping just the left/right
portion of a line, which lost signal from narrow crops and coordinate
mismatches. The Dice-based scoring naturally ignores masked (white)
regions since they contain no ink pixels.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-17 15:26:14 -06:00
1 parent c39d74e46d
commit 8b5511e7fd
2 files changed
+211

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
"""Test that detect_font_masked correctly identifies fonts even with redaction boxes."""
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from unredact.pipeline.font_detect import (
_find_font_path,
detect_font_masked,
)
from unredact.pipeline.ocr import OcrChar, OcrLine
def _render_page_with_redaction(
font_name: str,
font_size: int,
text: str,
redaction_frac: tuple[float, float] = (0.4, 0.7),
) -> tuple[OcrLine, Image.Image, list[tuple[int, int, int, int]]]:
"""Render text onto a page image with a black redaction box.
Returns (OcrLine, page_image, redaction_boxes) where redaction_boxes
are (rx, ry, rw, rh) in page-relative coordinates.
The OcrLine bounding box is set to tightly wrap the rendered text
(mimicking what OCR would produce), so detect_font_masked can create
a scoring_line with x=0, y=0 that aligns correctly.
"""
font_path = _find_font_path(font_name)
assert font_path is not None, f"Font {font_name} not found on system"
font = ImageFont.truetype(str(font_path), font_size)
# Measure the text
bbox = font.getbbox(text)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
# Place the line at an offset on a larger "page" image.
# The line bounding box starts exactly where the text ink begins,
# matching what OCR would detect.
page_margin_x = 50
page_margin_y = 80
# Text is rendered at (render_x, render_y) such that ink starts at
# (line_x, line_y) in page coordinates.
line_x = page_margin_x
line_y = page_margin_y
render_x = line_x - bbox[0]
render_y = line_y - bbox[1]
line_w = text_w
line_h = text_h
page_w = line_x + line_w + 50
page_h = line_y + line_h + 100
# Render the text onto the page
page_img = Image.new("L", (page_w, page_h), 255)
draw = ImageDraw.Draw(page_img)
draw.text((render_x, render_y), text, font=font, fill=0)
# Build an OcrLine with approximate character positions (page-relative)
words = text.split()
chars: list[OcrChar] = []
cursor_x = line_x
for wi, word in enumerate(words):
word_bbox = font.getbbox(word)
word_w = word_bbox[2] - word_bbox[0]
char_w = word_w / len(word)
for ci, ch in enumerate(word):
chars.append(OcrChar(
text=ch,
x=int(cursor_x + ci * char_w),
y=line_y,
w=max(1, int(char_w)),
h=text_h,
conf=95.0,
))
cursor_x += word_w
if wi < len(words) - 1:
space_w = font.getlength(" ")
chars.append(OcrChar(
text=" ",
x=int(cursor_x),
y=line_y,
w=max(1, int(space_w)),
h=text_h,
conf=95.0,
))
cursor_x += space_w
line = OcrLine(
chars=chars,
x=line_x,
y=line_y,
w=line_w,
h=line_h,
)
# Draw a black redaction box covering a fraction of the line
redact_x = line_x + int(line_w * redaction_frac[0])
redact_y = line_y
redact_w = int(line_w * (redaction_frac[1] - redaction_frac[0]))
redact_h = line_h
draw.rectangle(
[redact_x, redact_y, redact_x + redact_w, redact_y + redact_h],
fill=0,
)
redaction_boxes = [(redact_x, redact_y, redact_w, redact_h)]
# Convert to RGB page image (detect_font_masked expects PIL Image)
page_rgb = page_img.convert("RGB")
return line, page_rgb, redaction_boxes
def test_masked_detection_finds_correct_font():
"""Render TNR@50, add a black redaction box, and verify detection still finds TNR."""
text = "On Aug 27 2012 at 12:52 PM wrote ot"
line, page_img, redaction_boxes = _render_page_with_redaction(
font_name="Times New Roman",
font_size=50,
text=text,
)
result = detect_font_masked(line, page_img, redaction_boxes)
assert "Times" in result.font_name or "Liberation Serif" in result.font_name, (
f"Expected serif font, got {result.font_name}"
)
assert 47 <= result.font_size <= 53, (
f"Expected size ~50, got {result.font_size}"
)
assert result.score > 0.3, (
f"Expected reasonable score, got {result.score:.3f}"
)
def test_masked_detection_not_confused_by_redaction():
"""Render Arial@40, add a black redaction box, and verify it finds Arial."""
text = "On Aug 27 2012 at 12:52 PM wrote ot"
line, page_img, redaction_boxes = _render_page_with_redaction(
font_name="Arial",
font_size=40,
text=text,
)
result = detect_font_masked(line, page_img, redaction_boxes)
assert "Arial" in result.font_name or "Liberation Sans" in result.font_name, (
f"Expected sans-serif font, got {result.font_name}"
)
assert 37 <= result.font_size <= 43, (
f"Expected size ~40, got {result.font_size}"
)
assert result.score > 0.3, (
f"Expected reasonable score, got {result.score:.3f}"
)
+56
View File
@@ -274,6 +274,62 @@ def detect_font_for_line_from_crop(
return _fine_search(line, line_crop, best)
def detect_font_masked(
line: OcrLine,
page_image: Image.Image,
redaction_boxes: list[tuple[int, int, int, int]],
) -> FontMatch:
"""Detect the best font by masking redaction boxes to white before scoring.
Instead of cropping just the unredacted portion of a line (which loses
signal from narrow crops and coordinate mismatches), this masks each
redaction box to white (255) in the full line crop. The Dice-based pixel
scoring naturally ignores the masked (white) regions since they contain
no ink pixels.
Args:
line: OCR line with page-relative coordinates.
page_image: Full page as a PIL Image (any mode; converted to grayscale).
redaction_boxes: List of (rx, ry, rw, rh) rectangles in page-relative
coordinates. Each rectangle is painted white before scoring.
Returns:
FontMatch with the best font name, path, size, and score.
"""
# 1. Crop the line region from the page image
line_crop = np.array(
page_image.convert("L").crop((line.x, line.y, line.x + line.w, line.y + line.h))
)
# 2. Mask each redaction box to white (255) — convert page-relative to crop-relative
for rx, ry, rw, rh in redaction_boxes:
# Convert from page coordinates to crop-relative coordinates
cx = rx - line.x
cy = ry - line.y
# Clip to the crop bounds
x0 = max(0, cx)
y0 = max(0, cy)
x1 = min(line.w, cx + rw)
y1 = min(line.h, cy + rh)
if x0 < x1 and y0 < y1:
line_crop[y0:y1, x0:x1] = 255
# 3. Create a scoring line with crop-relative coordinates (x=0, y=0)
scoring_line = OcrLine(
chars=line.chars,
x=0, y=0,
w=line.w, h=line.h,
)
# 4. Full search then fine search
best = _full_search(scoring_line, line_crop)
if best is None:
raise RuntimeError("No matching font found. Check system fonts.")
return _fine_search(scoring_line, line_crop, best)
def detect_font_for_line(
line: OcrLine,
page_image: Image.Image,