mirror of
https://github.com/PR0M3TH3AN/unredact.git
synced 2026-11-03 04:00:29 +00:00
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:
1 parent
51ef030322
commit
c39d74e46d
2 files changed
+135
No files matched your search
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
Reference in new issue
Block a user