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():
|
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]
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
Reference in new issue
Block a user