Add camera gutter detection via vertical continuity analysis
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 45s
CI / test-go-edu-search (push) Successful in 32s
CI / test-python-klausur (push) Failing after 2m49s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 32s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 45s
CI / test-go-edu-search (push) Successful in 32s
CI / test-python-klausur (push) Failing after 2m49s
CI / test-python-agent-core (push) Successful in 30s
CI / test-nodejs-website (push) Successful in 32s
Scanner shadow detection (range > 40, darkest < 180) fails on camera book scans where the gutter shadow is subtle (range ~25, darkest ~214). New _detect_gutter_continuity() detects gutters by their unique property: the shadow runs continuously from top to bottom without interruption. Divides the image into horizontal strips and checks what fraction of strips are darker than the page median at each column. A gutter column has >= 75% of strips darker. The transition point where the smoothed dark fraction drops below 50% marks the crop boundary. Integrated as fallback between scanner shadow and binary projection. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ from page_crop import (
|
||||
detect_page_splits,
|
||||
_detect_format,
|
||||
_detect_edge_projection,
|
||||
_detect_gutter_continuity,
|
||||
_detect_left_edge_shadow,
|
||||
_detect_right_edge_shadow,
|
||||
_detect_spine_shadow,
|
||||
@@ -564,3 +565,110 @@ class TestDetectPageSplits:
|
||||
assert pages[0]["x"] == 0
|
||||
total_w = sum(p["width"] for p in pages)
|
||||
assert total_w == w, f"Total page width {total_w} != image width {w}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: _detect_gutter_continuity (camera book scans)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_camera_book_scan(h: int = 2400, w: int = 1700, gutter_side: str = "right") -> np.ndarray:
|
||||
"""Create a synthetic camera book scan with a subtle gutter shadow.
|
||||
|
||||
Camera gutter shadows are much subtler than scanner shadows:
|
||||
- Page brightness ~250 (well-lit)
|
||||
- Gutter brightness ~210-230 (slight shadow)
|
||||
- Shadow runs continuously from top to bottom
|
||||
- Gradient is ~40px wide
|
||||
"""
|
||||
img = np.full((h, w, 3), 250, dtype=np.uint8)
|
||||
|
||||
# Add some variation to make it realistic
|
||||
rng = np.random.RandomState(99)
|
||||
|
||||
# Subtle gutter gradient at the specified side
|
||||
gutter_w = int(w * 0.04) # ~4% of width
|
||||
gradient_w = int(w * 0.03) # transition zone
|
||||
|
||||
if gutter_side == "right":
|
||||
gutter_start = w - gutter_w - gradient_w
|
||||
for x in range(gutter_start, w):
|
||||
dist_from_start = x - gutter_start
|
||||
# Linear gradient from 250 down to 210
|
||||
brightness = int(250 - 40 * min(dist_from_start / (gutter_w + gradient_w), 1.0))
|
||||
img[:, x] = brightness
|
||||
else:
|
||||
gutter_end = gutter_w + gradient_w
|
||||
for x in range(gutter_end):
|
||||
dist_from_edge = gutter_end - x
|
||||
brightness = int(250 - 40 * min(dist_from_edge / (gutter_w + gradient_w), 1.0))
|
||||
img[:, x] = brightness
|
||||
|
||||
# Scatter some text (dark pixels) in the content area
|
||||
content_left = gutter_end + 20 if gutter_side == "left" else 50
|
||||
content_right = gutter_start - 20 if gutter_side == "right" else w - 50
|
||||
for _ in range(800):
|
||||
y = rng.randint(h // 10, h - h // 10)
|
||||
x = rng.randint(content_left, content_right)
|
||||
y2 = min(y + 3, h)
|
||||
x2 = min(x + 15, w)
|
||||
img[y:y2, x:x2] = 20
|
||||
|
||||
return img
|
||||
|
||||
|
||||
class TestDetectGutterContinuity:
|
||||
"""Tests for camera gutter shadow detection via vertical continuity."""
|
||||
|
||||
def test_detects_right_gutter(self):
|
||||
"""Should detect a subtle gutter shadow on the right side."""
|
||||
img = _make_camera_book_scan(gutter_side="right")
|
||||
h, w = img.shape[:2]
|
||||
gray = np.mean(img, axis=2).astype(np.uint8)
|
||||
search_w = w // 4
|
||||
right_start = w - search_w
|
||||
result = _detect_gutter_continuity(
|
||||
gray, gray[:, right_start:], right_start, w, "right",
|
||||
)
|
||||
assert result is not None
|
||||
# Gutter starts roughly at 93% of width (w - 4% - 3%)
|
||||
assert result > w * 0.85, f"Gutter x={result} too far left"
|
||||
assert result < w * 0.98, f"Gutter x={result} too close to edge"
|
||||
|
||||
def test_detects_left_gutter(self):
|
||||
"""Should detect a subtle gutter shadow on the left side."""
|
||||
img = _make_camera_book_scan(gutter_side="left")
|
||||
h, w = img.shape[:2]
|
||||
gray = np.mean(img, axis=2).astype(np.uint8)
|
||||
search_w = w // 4
|
||||
result = _detect_gutter_continuity(
|
||||
gray, gray[:, :search_w], 0, w, "left",
|
||||
)
|
||||
assert result is not None
|
||||
assert result > w * 0.02, f"Gutter x={result} too close to edge"
|
||||
assert result < w * 0.15, f"Gutter x={result} too far right"
|
||||
|
||||
def test_no_gutter_on_clean_page(self):
|
||||
"""Should NOT detect a gutter on a uniformly bright page."""
|
||||
img = np.full((2000, 1600, 3), 250, dtype=np.uint8)
|
||||
# Add some text but no gutter
|
||||
rng = np.random.RandomState(42)
|
||||
for _ in range(500):
|
||||
y = rng.randint(100, 1900)
|
||||
x = rng.randint(100, 1500)
|
||||
img[y:min(y+3, 2000), x:min(x+15, 1600)] = 20
|
||||
gray = np.mean(img, axis=2).astype(np.uint8)
|
||||
w = 1600
|
||||
search_w = w // 4
|
||||
right_start = w - search_w
|
||||
result_r = _detect_gutter_continuity(gray, gray[:, right_start:], right_start, w, "right")
|
||||
result_l = _detect_gutter_continuity(gray, gray[:, :search_w], 0, w, "left")
|
||||
assert result_r is None, f"False positive on right: x={result_r}"
|
||||
assert result_l is None, f"False positive on left: x={result_l}"
|
||||
|
||||
def test_integrated_with_crop(self):
|
||||
"""End-to-end: detect_and_crop_page should crop at the gutter."""
|
||||
img = _make_camera_book_scan(gutter_side="right")
|
||||
cropped, result = detect_and_crop_page(img)
|
||||
# The right border should be > 0 (gutter cropped)
|
||||
right_border = result["border_fractions"]["right"]
|
||||
assert right_border > 0.01, f"Right border {right_border} — gutter not cropped"
|
||||
|
||||
Reference in New Issue
Block a user