mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-10-27 08:40:29 +00:00
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:
1 parent
03333e212c
commit
8857793759
3 files changed
+217
No files matched your search
@@ -12,3 +12,11 @@ def sample_pdf() -> Path:
|
||||
if not pdf.exists():
|
||||
pytest.skip("Sample PDF not available")
|
||||
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]
|
||||
@@ -15,6 +15,7 @@ from unredact.pipeline.analyze_page import (
|
||||
RedactionAnalysis,
|
||||
PageAnalysis,
|
||||
analyze_page,
|
||||
analyze_spot_redaction,
|
||||
)
|
||||
|
||||
|
||||
@@ -348,3 +349,130 @@ async def test_analyze_page_uses_precomputed_ocr_lines():
|
||||
m_ocr.assert_not_called()
|
||||
# The returned lines must be the exact same object (not a copy)
|
||||
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)
|
||||
@@ -200,3 +200,84 @@ async def analyze_page(
|
||||
on_progress("analysis_complete", {"count": len(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,
|
||||
)
|
||||
Reference in new issue
Block a user