feat: add analyze_spot_redaction for single-box analysis

Add a synchronous function that runs the analysis mini-pipeline on a
single known bounding box using cached OCR data. This enables spot
redaction endpoints to get full analysis (font, left/right context text,
pixel alignment offsets) without needing LLM detection.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-20 12:39:57 -06:00
1 parent 03333e212c
commit 8857793759
3 files changed
+217

No files matched your search

+8
View File
@@ -12,3 +12,11 @@ def sample_pdf() -> Path:
if not pdf.exists(): if not pdf.exists():
pytest.skip("Sample PDF not available") pytest.skip("Sample PDF not available")
return pdf return pdf
@pytest.fixture
def sample_page_image(sample_pdf):
"""Rasterize the first page of the sample PDF as a PIL Image."""
from unredact.pipeline.rasterize import rasterize_pdf
pages = rasterize_pdf(sample_pdf)
return pages[0]
+128
View File
@@ -15,6 +15,7 @@ from unredact.pipeline.analyze_page import (
RedactionAnalysis, RedactionAnalysis,
PageAnalysis, PageAnalysis,
analyze_page, analyze_page,
analyze_spot_redaction,
) )
@@ -348,3 +349,130 @@ async def test_analyze_page_uses_precomputed_ocr_lines():
m_ocr.assert_not_called() m_ocr.assert_not_called()
# The returned lines must be the exact same object (not a copy) # The returned lines must be the exact same object (not a copy)
assert result.lines is precomputed_lines assert result.lines is precomputed_lines
# ---------------------------------------------------------------------------
# test_analyze_spot_redaction
# ---------------------------------------------------------------------------
def test_analyze_spot_redaction_returns_analysis():
"""analyze_spot_redaction should return a RedactionAnalysis for a box
that overlaps an OCR line, with correct font, text, and offsets."""
line = _make_line_from_words(["Hello", "|||", "world"], y=100, h=20)
lines = [line]
box = Redaction(
id="spot1",
x=100,
y=98,
w=30,
h=22,
)
font_match = _dummy_font_match()
page_image = _dummy_page_image()
with (
patch(
"unredact.pipeline.analyze_page.detect_font_masked",
return_value=font_match,
),
patch(
"unredact.pipeline.analyze_page.align_text_to_page",
return_value=(3, -1),
),
):
result = analyze_spot_redaction(page_image, lines, box)
assert result is not None
assert isinstance(result, RedactionAnalysis)
assert result.box is box
assert result.line is line
assert result.font is font_match
assert "Hello" in result.left_text
assert "world" in result.right_text
assert isinstance(result.offset_x, float)
assert isinstance(result.offset_y, float)
def test_analyze_spot_redaction_no_matching_line():
"""analyze_spot_redaction should return None if no OCR line overlaps."""
# Line at y=100, box at y=500 — no overlap
line = _make_line_from_words(["Hello", "world"], y=100, h=20)
lines = [line]
box = Redaction(id="spot2", x=50, y=500, w=100, h=20)
page_image = _dummy_page_image()
result = analyze_spot_redaction(page_image, lines, box)
assert result is None
def test_analyze_spot_redaction_empty_lines():
"""analyze_spot_redaction should return None for empty OCR lines list."""
box = Redaction(id="spot3", x=50, y=100, w=100, h=20)
page_image = _dummy_page_image()
result = analyze_spot_redaction(page_image, [], box)
assert result is None
def test_analyze_spot_redaction_no_left_text_skips_alignment():
"""When the box covers the start of the line (no left text),
alignment should be skipped and offsets should be 0.0."""
# Box starts at x=50 (same as line start), so no chars to the left
line = _make_line_from_words(["|||", "world"], start_x=50, y=100, h=20)
lines = [line]
box = Redaction(id="spot4", x=50, y=98, w=30, h=22)
font_match = _dummy_font_match()
page_image = _dummy_page_image()
with patch(
"unredact.pipeline.analyze_page.detect_font_masked",
return_value=font_match,
):
result = analyze_spot_redaction(page_image, lines, box)
assert result is not None
assert result.left_text == ""
assert result.offset_x == 0.0
assert result.offset_y == 0.0
def test_analyze_spot_redaction_integration(sample_page_image):
"""analyze_spot_redaction should return full analysis for a known bbox."""
from unredact.pipeline.ocr import ocr_page
# Get real OCR data
lines = ocr_page(sample_page_image)
if not lines:
pytest.skip("No OCR lines found in sample image")
# Find a line with enough text to put a box in the middle
line = None
for l in lines:
if l.w > 100 and len(l.chars) > 5:
line = l
break
if line is None:
pytest.skip("No suitable OCR line found")
# Create a synthetic redaction box in the middle of the line
box = Redaction(
id="test123",
x=line.x + line.w // 3,
y=line.y,
w=line.w // 3,
h=line.h,
)
result = analyze_spot_redaction(sample_page_image, lines, box)
assert result is not None
assert result.box is box
assert result.line is line
assert result.font is not None
assert result.font.font_size > 0
assert isinstance(result.offset_x, float)
assert isinstance(result.offset_y, float)
+81
View File
@@ -200,3 +200,84 @@ async def analyze_page(
on_progress("analysis_complete", {"count": len(results)}) on_progress("analysis_complete", {"count": len(results)})
return PageAnalysis(lines=lines, redactions=results) return PageAnalysis(lines=lines, redactions=results)
def analyze_spot_redaction(
page_image: Image.Image,
ocr_lines: list[OcrLine],
box: Redaction,
) -> RedactionAnalysis | None:
"""Run analysis on a single known redaction bounding box.
Uses cached OCR data to:
1. Find the OCR line containing the redaction
2. Detect the font (with redaction masking)
3. Extract left/right context text
4. Compute pixel alignment offsets
Args:
page_image: PIL Image of the document page.
ocr_lines: Pre-computed OCR lines for the page.
box: Known redaction bounding box.
Returns:
RedactionAnalysis or None if no suitable OCR line found.
"""
# Find the OCR line that best contains this redaction box.
# Use vertical overlap: the line whose vertical range overlaps most with the box.
best_line = None
best_overlap = 0
box_top = box.y
box_bottom = box.y + box.h
for line in ocr_lines:
line_top = line.y
line_bottom = line.y + line.h
overlap = max(0, min(box_bottom, line_bottom) - max(box_top, line_top))
if overlap > best_overlap:
best_overlap = overlap
best_line = line
if best_line is None:
return None
line = best_line
redaction_boxes = [(box.x, box.y, box.w, box.h)]
# Font detection with masking
font = detect_font_masked(line, page_image, redaction_boxes)
# Extract left/right text using center-point char filtering
left_chars = [c for c in line.chars if c.x + c.w / 2 < box.x]
right_chars = [c for c in line.chars if c.x + c.w / 2 > box.x + box.w]
left_text = "".join(c.text for c in left_chars).strip()
right_text = "".join(c.text for c in right_chars).strip()
# Pixel alignment
offset_x = 0.0
offset_y = 0.0
if left_text:
pil_font = font.to_pil_font()
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_crop = np.array(page_image.convert("L").crop(
(text_region_x1, text_region_y1, text_region_x2, text_region_y2)
))
align_dx, align_dy = align_text_to_page(
left_text, pil_font, text_crop,
)
offset_x = float(text_region_x1 + align_dx - line.x)
offset_y = float(text_region_y1 + align_dy - line.y)
return RedactionAnalysis(
box=box,
line=line,
font=font,
left_text=left_text,
right_text=right_text,
offset_x=offset_x,
offset_y=offset_y,
)