From c39d74e46d41bcc6dc0c49dabd7c7a9980763fb0 Mon Sep 17 00:00:00 2001 From: Alex-Gilbert Date: Tue, 17 Feb 2026 15:22:28 -0600 Subject: [PATCH] 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 --- tests/test_guided_detect.py | 62 ++++++++++++++++++++++ unredact/pipeline/detect_redactions.py | 73 ++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 tests/test_guided_detect.py diff --git a/tests/test_guided_detect.py b/tests/test_guided_detect.py new file mode 100644 index 0000000..14c0106 --- /dev/null +++ b/tests/test_guided_detect.py @@ -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 diff --git a/unredact/pipeline/detect_redactions.py b/unredact/pipeline/detect_redactions.py index 2ebf181..7ef6a87 100644 --- a/unredact/pipeline/detect_redactions.py +++ b/unredact/pipeline/detect_redactions.py @@ -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), + )