mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
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 <[email protected]>
This commit is contained in:
1 parent
cb15bb068b
commit
043aae5abb
2 files changed
+187
-1
No files matched your search
@@ -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})"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user