feat: add guided OpenCV search for LLM-directed redaction regions

Add find_redaction_in_region() that searches for a black rectangle
within a specific page region identified by the LLM. Uses a lower
minimum area threshold (100px vs 500px) since the LLM provides
spatial guidance, preventing merged redactions and false positives.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Alex-GilbertandClaude Opus 4.6 committed 2026-02-17 15:22:28 -06:00
1 parent 51ef030322
commit c39d74e46d
2 files changed
+135

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import numpy as np
from PIL import Image
from unredact.pipeline.detect_redactions import find_redaction_in_region
def test_finds_redaction_in_region():
"""A black rectangle at a known position is found when the search region covers it."""
img = Image.new("RGB", (800, 600), "white")
pixels = np.array(img)
# Place a black rectangle at y=200..220, x=150..350 (w=200, h=20)
pixels[200:220, 150:350] = [0, 0, 0]
img = Image.fromarray(pixels)
result = find_redaction_in_region(img, search_x1=130, search_y1=180, search_x2=370, search_y2=240)
assert result is not None
assert abs(result.x - 150) <= 3
assert abs(result.y - 200) <= 3
assert abs(result.w - 200) <= 3
assert abs(result.h - 20) <= 3
def test_returns_none_when_no_redaction():
"""A white page with no black content returns None."""
img = Image.new("RGB", (800, 600), "white")
result = find_redaction_in_region(img, search_x1=100, search_y1=100, search_x2=400, search_y2=300)
assert result is None
def test_does_not_find_outside_region():
"""A redaction exists but lies entirely outside the search region, so None is returned."""
img = Image.new("RGB", (800, 600), "white")
pixels = np.array(img)
# Redaction at the bottom of the page
pixels[500:520, 150:350] = [0, 0, 0]
img = Image.fromarray(pixels)
# Search the top-left quadrant which contains no redaction
result = find_redaction_in_region(img, search_x1=0, search_y1=0, search_x2=400, search_y2=200)
assert result is None
def test_finds_small_redaction():
"""A small redaction (area < 500px old MIN_AREA) is found because guided mode uses a lower threshold."""
img = Image.new("RGB", (800, 600), "white")
pixels = np.array(img)
# 40x15 = 600 area, above the 100 guided threshold but below old 500 MIN_AREA
# (The old detector also requires aspect ratio >= 1.5, but this function has no such filter)
pixels[300:315, 200:240] = [0, 0, 0]
img = Image.fromarray(pixels)
result = find_redaction_in_region(img, search_x1=180, search_y1=280, search_x2=260, search_y2=340)
assert result is not None
assert abs(result.x - 200) <= 3
assert abs(result.y - 300) <= 3
assert abs(result.w - 40) <= 3
assert abs(result.h - 15) <= 3
+73
View File
@@ -105,3 +105,76 @@ def spot_redaction(image: Image.Image, click_x: int, click_y: int) -> Redaction
return None
return Redaction(id=uuid.uuid4().hex[:8], x=int(x), y=int(y), w=int(w), h=int(h))
# Minimum area for guided (LLM-directed) region search — lower than the
# full-page MIN_AREA because the LLM already told us roughly where to look.
_GUIDED_MIN_AREA = 100
def find_redaction_in_region(
image: Image.Image,
search_x1: int,
search_y1: int,
search_x2: int,
search_y2: int,
padding: int = 10,
) -> Redaction | None:
"""Search for a black rectangle within a specific region of the page.
Used after an LLM has identified approximate redaction locations so we
search only where it told us to look, preventing merged redactions and
false positives.
Args:
image: PIL Image of a document page.
search_x1: Left edge of the search region (page pixels).
search_y1: Top edge of the search region (page pixels).
search_x2: Right edge of the search region (page pixels).
search_y2: Bottom edge of the search region (page pixels).
padding: Extra pixels to add around the search region.
Returns:
Redaction with page-relative coordinates, or None if nothing found.
"""
img_w, img_h = image.size
# Clamp and pad the search region within image bounds
x1 = max(0, search_x1 - padding)
y1 = max(0, search_y1 - padding)
x2 = min(img_w, search_x2 + padding)
y2 = min(img_h, search_y2 + padding)
# Crop just the search region
crop = image.crop((x1, y1, x2, y2))
arr = np.array(crop.convert("RGB"))
gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)
# Threshold: pixels darker than 40 are "black"
_, binary = cv2.threshold(gray, 40, 255, cv2.THRESH_BINARY_INV)
# Morphological close to merge fragments
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 3))
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
return None
# Pick the largest contour — most likely the redaction
best = max(contours, key=cv2.contourArea)
bx, by, bw, bh = cv2.boundingRect(best)
if bw * bh < _GUIDED_MIN_AREA:
return None
# Convert crop-relative coordinates back to page-relative
return Redaction(
id=uuid.uuid4().hex[:8],
x=int(bx + x1),
y=int(by + y1),
w=int(bw),
h=int(bh),
)