From 043aae5abbb01653b7fb809ae2a1e68537eae164 Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Tue, 17 Feb 2026 11:16:20 -0600 Subject: [PATCH] feat: add pixel-based font scoring function Add _score_font_line_pixel() which renders candidate text and compares pixels against the actual page image using Dice coefficient overlap. This captures glyph shape differences that the old word-width MAE approach cannot distinguish (e.g. serif vs sans-serif). Co-Authored-By: Claude Opus 4.6 --- tests/test_pixel_scoring.py | 110 +++++++++++++++++++++++++++++++ unredact/pipeline/font_detect.py | 78 +++++++++++++++++++++- 2 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 tests/test_pixel_scoring.py diff --git a/tests/test_pixel_scoring.py b/tests/test_pixel_scoring.py new file mode 100644 index 0000000..d699a46 --- /dev/null +++ b/tests/test_pixel_scoring.py @@ -0,0 +1,110 @@ +"""Test pixel-based font scoring — render known text and verify detection.""" +import numpy as np +from PIL import Image, ImageDraw, ImageFont + +from unredact.pipeline.font_detect import _find_font_path, _score_font_line_pixel +from unredact.pipeline.ocr import OcrChar, OcrLine + + +def _make_line_and_crop(font_name: str, font_size: int, text: str): + """Render text with a known font and return (OcrLine, grayscale_crop).""" + font_path = _find_font_path(font_name) + assert font_path is not None, f"Font {font_name} not found" + 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] + + # Render onto a white canvas with some padding + pad = 10 + img = Image.new("L", (text_w + pad * 2, text_h + pad * 2), 255) + draw = ImageDraw.Draw(img) + draw.text((pad - bbox[0], pad - bbox[1]), text, font=font, fill=0) + + # Build a fake OcrLine with word-level bounding boxes + words = text.split() + chars = [] + cursor_x = pad + 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=pad, + w=max(1, int(char_w)), + h=text_h, + conf=95.0, + )) + cursor_x += word_w + # Add space between words + if wi < len(words) - 1: + space_w = font.getlength(" ") + chars.append(OcrChar( + text=" ", x=int(cursor_x), y=pad, + w=max(1, int(space_w)), h=text_h, conf=95.0, + )) + cursor_x += space_w + + line = OcrLine( + chars=chars, + x=pad, y=pad, + w=int(cursor_x - pad), h=text_h, + ) + + crop = np.array(img) + return line, crop + + +def test_pixel_scoring_correct_font_scores_highest(): + """The correct font+size should score higher than a wrong font.""" + text = "Got it. Sent an email." + line, crop = _make_line_and_crop("Times New Roman", 50, text) + + tnr_path = _find_font_path("Times New Roman") + arial_path = _find_font_path("Arial") + assert tnr_path and arial_path + + tnr_font = ImageFont.truetype(str(tnr_path), 50) + arial_font = ImageFont.truetype(str(arial_path), 50) + arial_44_font = ImageFont.truetype(str(arial_path), 44) + + score_tnr = _score_font_line_pixel(tnr_font, line, crop) + score_arial = _score_font_line_pixel(arial_font, line, crop) + score_arial_44 = _score_font_line_pixel(arial_44_font, line, crop) + + # TNR at correct size should beat Arial at any size + assert score_tnr > score_arial, ( + f"TNR@50 ({score_tnr:.3f}) should beat Arial@50 ({score_arial:.3f})" + ) + assert score_tnr > score_arial_44, ( + f"TNR@50 ({score_tnr:.3f}) should beat Arial@44 ({score_arial_44:.3f})" + ) + + +def test_pixel_scoring_wrong_size_scores_lower(): + """The correct font at the wrong size should score lower.""" + text = "Got it. Sent an email." + line, crop = _make_line_and_crop("Times New Roman", 50, text) + + tnr_path = _find_font_path("Times New Roman") + assert tnr_path + + font_50 = ImageFont.truetype(str(tnr_path), 50) + font_44 = ImageFont.truetype(str(tnr_path), 44) + font_60 = ImageFont.truetype(str(tnr_path), 60) + + score_50 = _score_font_line_pixel(font_50, line, crop) + score_44 = _score_font_line_pixel(font_44, line, crop) + score_60 = _score_font_line_pixel(font_60, line, crop) + + assert score_50 > score_44, ( + f"TNR@50 ({score_50:.3f}) should beat TNR@44 ({score_44:.3f})" + ) + assert score_50 > score_60, ( + f"TNR@50 ({score_50:.3f}) should beat TNR@60 ({score_60:.3f})" + ) diff --git a/unredact/pipeline/font_detect.py b/unredact/pipeline/font_detect.py index afa08b5..70e4e55 100644 --- a/unredact/pipeline/font_detect.py +++ b/unredact/pipeline/font_detect.py @@ -2,7 +2,8 @@ from dataclasses import dataclass from functools import lru_cache from pathlib import Path -from PIL import Image, ImageFont +import numpy as np +from PIL import Image, ImageDraw, ImageFont from unredact.pipeline.ocr import OcrLine @@ -53,6 +54,81 @@ class FontMatch: return ImageFont.truetype(str(self.font_path), self.font_size) +def _shift_2d(arr: np.ndarray, dx: int, dy: int) -> np.ndarray: + """Shift a 2D boolean array by (dx, dy), filling edges with False.""" + h, w = arr.shape + result = np.zeros_like(arr) + # Y slices + if dy >= 0: + src_y, dst_y = slice(0, h - dy), slice(dy, h) + else: + src_y, dst_y = slice(-dy, h), slice(0, h + dy) + # X slices + if dx >= 0: + src_x, dst_x = slice(0, w - dx), slice(dx, w) + else: + src_x, dst_x = slice(-dx, w), slice(0, w + dx) + result[dst_y, dst_x] = arr[src_y, src_x] + return result + + +def _score_font_line_pixel( + font: ImageFont.FreeTypeFont, + line: OcrLine, + line_crop: np.ndarray, +) -> float: + """Score how well a font matches using pixel overlap. + + Args: + font: Candidate PIL font at a specific size. + line: OCR'd line with text and bounding box. + line_crop: Grayscale numpy array of the line region from the page image. + + Returns: + Overlap score from 0.0 to 1.0. Higher is better. + """ + h, w = line_crop.shape + if h < 5 or w < 10: + return 0.0 + + # Binarize page crop (ink pixels = True) + page_bin = line_crop < 128 + + page_ink = int(page_bin.sum()) + if page_ink < 10: + return 0.0 + + # Render line text with this font onto same-size canvas + rendered_img = Image.new("L", (w, h), 255) + draw = ImageDraw.Draw(rendered_img) + + # Position text so ink aligns with the crop. + # line.x, line.y give the line's position within the crop coordinate + # system; bbox gives the font's built-in offset from the origin. + bbox = font.getbbox(line.text) + draw.text((line.x - bbox[0], line.y - bbox[1]), line.text, font=font, fill=0) + + rendered_arr = np.array(rendered_img) + rendered_bin = rendered_arr < 128 + + rendered_ink = int(rendered_bin.sum()) + if rendered_ink < 10: + return 0.0 + + # Try small shifts to find best alignment, using Dice coefficient + # to penalise both missing and extra ink. + best_score = 0.0 + total_ink = page_ink + rendered_ink + 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()) + score = 2.0 * intersection / total_ink if total_ink > 0 else 0.0 + if score > best_score: + best_score = score + return best_score + + def _score_font_line( font: ImageFont.FreeTypeFont, line: OcrLine,