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>
675 lines
26 KiB
Python
675 lines
26 KiB
Python
"""
|
|
Tests for page_crop.py — content-based crop algorithm.
|
|
|
|
Tests cover:
|
|
- Edge detection via ink projections
|
|
- Spine shadow detection for book scans
|
|
- Narrow run filtering
|
|
- Paper format detection
|
|
- Sanity checks (min area, min border)
|
|
- End-to-end crop on synthetic images
|
|
"""
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from page_crop import (
|
|
detect_and_crop_page,
|
|
detect_page_splits,
|
|
_detect_format,
|
|
_detect_edge_projection,
|
|
_detect_gutter_continuity,
|
|
_detect_left_edge_shadow,
|
|
_detect_right_edge_shadow,
|
|
_detect_spine_shadow,
|
|
_filter_narrow_runs,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: create synthetic images
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_white_image(h: int, w: int) -> np.ndarray:
|
|
"""Create a white BGR image."""
|
|
return np.full((h, w, 3), 255, dtype=np.uint8)
|
|
|
|
|
|
def _make_image_with_content(
|
|
h: int, w: int,
|
|
content_rect: tuple, # (y1, y2, x1, x2)
|
|
bg_color: int = 255,
|
|
content_color: int = 0,
|
|
) -> np.ndarray:
|
|
"""Create an image with a dark content rectangle on a light background."""
|
|
img = np.full((h, w, 3), bg_color, dtype=np.uint8)
|
|
y1, y2, x1, x2 = content_rect
|
|
img[y1:y2, x1:x2] = content_color
|
|
return img
|
|
|
|
|
|
def _make_book_scan(h: int = 1000, w: int = 800) -> np.ndarray:
|
|
"""Create a synthetic book scan with V-shaped spine shadow on the left.
|
|
|
|
Left region has a V-shaped brightness dip (spine center at ~5% of width):
|
|
x=0..spine_center: scanner bed or page edge (bright ~200) → spine (dark ~60)
|
|
x=spine_center..shadow_end: spine (dark ~60) → white paper (bright ~240)
|
|
Content area: scattered dark pixels (simulate text lines)
|
|
Top/bottom 5%: white margins
|
|
"""
|
|
img = np.full((h, w, 3), 240, dtype=np.uint8)
|
|
|
|
# V-shaped spine shadow: center at ~5% of width
|
|
spine_center = w * 5 // 100 # e.g. 40 for 800px
|
|
shadow_half_w = w * 6 // 100 # e.g. 48 for 800px
|
|
|
|
for x in range(spine_center + shadow_half_w + 1):
|
|
dist = abs(x - spine_center)
|
|
# Brightness dips from 200 (edge) to 60 (spine center)
|
|
brightness = int(60 + (200 - 60) * min(dist / shadow_half_w, 1.0))
|
|
img[:, x] = brightness
|
|
|
|
# Content area: scatter some dark pixels (simulate text)
|
|
content_top = h // 20 # 5% top margin
|
|
content_bottom = h - h // 20 # 5% bottom margin
|
|
content_left = spine_center + shadow_half_w + w // 20 # past shadow + margin
|
|
content_right = w - w // 20 # 5% right margin
|
|
|
|
rng = np.random.RandomState(42)
|
|
for _ in range(500):
|
|
y = rng.randint(content_top, content_bottom)
|
|
x = rng.randint(content_left, content_right)
|
|
# Small text-like blob
|
|
y2 = min(y + 3, h)
|
|
x2 = min(x + 10, w)
|
|
img[y:y2, x:x2] = 20
|
|
|
|
return img
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _filter_narrow_runs
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestFilterNarrowRuns:
|
|
def test_removes_short_runs(self):
|
|
mask = np.array([False, True, True, False, False, True, False])
|
|
result = _filter_narrow_runs(mask, min_run=3)
|
|
# The run [True, True] (length 2) and [True] (length 1) should be removed
|
|
assert not result.any()
|
|
|
|
def test_keeps_long_runs(self):
|
|
mask = np.array([False, True, True, True, True, False])
|
|
result = _filter_narrow_runs(mask, min_run=3)
|
|
expected = np.array([False, True, True, True, True, False])
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
def test_min_run_1_keeps_all(self):
|
|
mask = np.array([True, False, True])
|
|
result = _filter_narrow_runs(mask, min_run=1)
|
|
np.testing.assert_array_equal(result, mask)
|
|
|
|
def test_empty_mask(self):
|
|
mask = np.array([], dtype=bool)
|
|
result = _filter_narrow_runs(mask, min_run=5)
|
|
assert len(result) == 0
|
|
|
|
def test_mixed_runs(self):
|
|
mask = np.array([True, False, True, True, True, True, True, False, True, True])
|
|
result = _filter_narrow_runs(mask, min_run=3)
|
|
# Run of 1 at [0]: removed
|
|
# Run of 5 at [2:7]: kept
|
|
# Run of 2 at [8:10]: removed
|
|
expected = np.array([False, False, True, True, True, True, True, False, False, False])
|
|
np.testing.assert_array_equal(result, expected)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _detect_format
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDetectFormat:
|
|
def test_a4_portrait(self):
|
|
fmt, conf = _detect_format(210, 297)
|
|
assert fmt == "A4"
|
|
assert conf > 0.8
|
|
|
|
def test_a4_landscape(self):
|
|
fmt, conf = _detect_format(297, 210)
|
|
assert fmt == "A4"
|
|
assert conf > 0.8
|
|
|
|
def test_letter(self):
|
|
fmt, conf = _detect_format(850, 1100)
|
|
assert fmt == "Letter"
|
|
assert conf > 0.5
|
|
|
|
def test_unknown_square(self):
|
|
fmt, conf = _detect_format(100, 100)
|
|
# Aspect ratio 1.0 doesn't match any paper format well
|
|
assert fmt == "unknown" or conf < 0.5
|
|
|
|
def test_zero_dimensions(self):
|
|
fmt, conf = _detect_format(0, 100)
|
|
assert fmt == "unknown"
|
|
assert conf == 0.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _detect_edge_projection
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDetectEdgeProjection:
|
|
def test_finds_first_ink_column(self):
|
|
"""Binary image with ink starting at column 50."""
|
|
binary = np.zeros((100, 200), dtype=np.uint8)
|
|
binary[10:90, 50:180] = 255 # Content from x=50 to x=180
|
|
|
|
edge = _detect_edge_projection(binary, axis=0, from_start=True, dim=200)
|
|
assert edge == 50
|
|
|
|
def test_finds_last_ink_column(self):
|
|
binary = np.zeros((100, 200), dtype=np.uint8)
|
|
binary[10:90, 50:180] = 255
|
|
|
|
edge = _detect_edge_projection(binary, axis=0, from_start=False, dim=200)
|
|
assert edge == 179 # last column with ink
|
|
|
|
def test_finds_first_ink_row(self):
|
|
binary = np.zeros((200, 100), dtype=np.uint8)
|
|
binary[30:170, 10:90] = 255
|
|
|
|
edge = _detect_edge_projection(binary, axis=1, from_start=True, dim=200)
|
|
assert edge == 30
|
|
|
|
def test_finds_last_ink_row(self):
|
|
binary = np.zeros((200, 100), dtype=np.uint8)
|
|
binary[30:170, 10:90] = 255
|
|
|
|
edge = _detect_edge_projection(binary, axis=1, from_start=False, dim=200)
|
|
assert edge == 169
|
|
|
|
def test_empty_image_returns_boundary(self):
|
|
binary = np.zeros((100, 100), dtype=np.uint8)
|
|
assert _detect_edge_projection(binary, axis=0, from_start=True, dim=100) == 0
|
|
assert _detect_edge_projection(binary, axis=0, from_start=False, dim=100) == 100
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: _detect_left_edge_shadow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDetectSpineShadow:
|
|
def test_detects_real_spine_v_shape(self):
|
|
"""V-shaped brightness dip (real spine shadow) should be detected."""
|
|
h, w = 500, 800
|
|
gray = np.full((h, w), 240, dtype=np.uint8)
|
|
# Create a V-shaped spine shadow in the left 25% (200px)
|
|
# Center of spine at x=30, brightness dips to 80
|
|
for x in range(80):
|
|
dist_from_center = abs(x - 30)
|
|
brightness = int(80 + (240 - 80) * min(dist_from_center / 40, 1.0))
|
|
gray[:, x] = brightness
|
|
|
|
search_region = gray[:, :200]
|
|
result = _detect_spine_shadow(gray, search_region, 0, w, "left")
|
|
# Should find the spine near x=30
|
|
assert result is not None
|
|
assert 20 <= result <= 40
|
|
|
|
def test_rejects_text_content_edge(self):
|
|
"""Sharp text edge (white margin → dense text) should NOT trigger."""
|
|
h, w = 500, 800
|
|
gray = np.full((h, w), 240, dtype=np.uint8)
|
|
# Simulate text content starting at x=60: columns 60+ have
|
|
# alternating bright/dark rows (text lines) → mean ~170
|
|
for x in range(60, 200):
|
|
for y_start in range(0, h, 20):
|
|
gray[y_start:min(y_start + 8, h), x] = 30 # text line
|
|
|
|
search_region = gray[:, :200]
|
|
result = _detect_spine_shadow(gray, search_region, 0, w, "left")
|
|
# Should NOT detect a spine — this is text content, not a shadow
|
|
assert result is None
|
|
|
|
def test_rejects_uniform_region(self):
|
|
"""Uniform brightness region (no shadow) should NOT trigger."""
|
|
h, w = 500, 800
|
|
gray = np.full((h, w), 230, dtype=np.uint8)
|
|
search_region = gray[:, :200]
|
|
result = _detect_spine_shadow(gray, search_region, 0, w, "left")
|
|
assert result is None
|
|
|
|
def test_rejects_bright_minimum(self):
|
|
"""Region where darkest column is still bright (>180) should NOT trigger."""
|
|
h, w = 500, 800
|
|
gray = np.full((h, w), 240, dtype=np.uint8)
|
|
# Slight variation but everything stays bright
|
|
gray[:, 50:80] = 195
|
|
search_region = gray[:, :200]
|
|
result = _detect_spine_shadow(gray, search_region, 0, w, "left")
|
|
assert result is None
|
|
|
|
def test_right_side_spine(self):
|
|
"""V-shaped spine shadow in right search region should be detected."""
|
|
h, w = 500, 800
|
|
gray = np.full((h, w), 240, dtype=np.uint8)
|
|
# Spine shadow at x=750 (right side)
|
|
for x in range(680, 800):
|
|
dist_from_center = abs(x - 750)
|
|
brightness = int(80 + (240 - 80) * min(dist_from_center / 40, 1.0))
|
|
gray[:, x] = brightness
|
|
|
|
right_start = w - w // 4 # 600
|
|
search_region = gray[:, right_start:]
|
|
result = _detect_spine_shadow(gray, search_region, right_start, w, "right")
|
|
assert result is not None
|
|
assert 740 <= result <= 760
|
|
|
|
|
|
class TestDetectLeftEdgeShadow:
|
|
def test_detects_shadow_gradient(self):
|
|
"""Synthetic image with left-side V-shaped shadow gradient."""
|
|
h, w = 500, 400
|
|
gray = np.full((h, w), 240, dtype=np.uint8)
|
|
binary = np.zeros((h, w), dtype=np.uint8)
|
|
|
|
# V-shaped shadow: center at x=20, dips to brightness 60
|
|
shadow_center = 20
|
|
shadow_half_w = 30
|
|
for x in range(shadow_center + shadow_half_w):
|
|
dist = abs(x - shadow_center)
|
|
brightness = int(60 + (240 - 60) * min(dist / shadow_half_w, 1.0))
|
|
gray[:, x] = brightness
|
|
|
|
# Content starts after shadow
|
|
binary[:, shadow_center + shadow_half_w + 10:w - 10] = 255
|
|
|
|
edge = _detect_left_edge_shadow(gray, binary, w, h)
|
|
# Edge should be near the spine center (x~20)
|
|
assert 10 <= edge <= 35
|
|
|
|
def test_no_shadow_uses_binary_fallback(self):
|
|
"""When shadow range is small, falls back to binary projection."""
|
|
h, w = 400, 400
|
|
gray = np.full((h, w), 200, dtype=np.uint8)
|
|
binary = np.zeros((h, w), dtype=np.uint8)
|
|
# Content block from x=80 onward (large enough to survive noise filtering)
|
|
binary[50:350, 80:380] = 255
|
|
|
|
edge = _detect_left_edge_shadow(gray, binary, w, h)
|
|
# Should find content start via projection fallback (near x=80)
|
|
assert edge <= 85
|
|
|
|
def test_text_content_uses_binary_fallback(self):
|
|
"""Dense text in left region should NOT trigger spine detection."""
|
|
h, w = 500, 800
|
|
gray = np.full((h, w), 240, dtype=np.uint8)
|
|
binary = np.zeros((h, w), dtype=np.uint8)
|
|
|
|
# Simulate text content from x=50 onward
|
|
for x in range(50, w - 20):
|
|
for y_start in range(20, h - 20, 20):
|
|
gray[y_start:min(y_start + 8, h), x] = 30
|
|
binary[y_start:min(y_start + 8, h), x] = 255
|
|
|
|
edge = _detect_left_edge_shadow(gray, binary, w, h)
|
|
# Should use binary fallback and find content at ~x=50
|
|
assert 40 <= edge <= 60
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: detect_and_crop_page (end-to-end)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestDetectAndCropPage:
|
|
def test_no_crop_needed_all_content(self):
|
|
"""Image that is all content — no borders to crop."""
|
|
img = np.full((100, 80, 3), 40, dtype=np.uint8) # Dark content everywhere
|
|
cropped, result = detect_and_crop_page(img)
|
|
# Should return original (all borders < 2%)
|
|
assert not result["crop_applied"]
|
|
assert result["cropped_size"] == {"width": 80, "height": 100}
|
|
|
|
def test_crops_white_borders(self):
|
|
"""Image with white borders around dark content."""
|
|
h, w = 400, 300
|
|
# Content area big enough to pass the 40% sanity check
|
|
img = _make_image_with_content(h, w, (40, 360, 30, 270))
|
|
|
|
cropped, result = detect_and_crop_page(img)
|
|
assert result["crop_applied"]
|
|
# Cropped size should be close to the content area (with margin)
|
|
assert result["cropped_size"]["width"] < w
|
|
assert result["cropped_size"]["height"] < h
|
|
|
|
def test_book_scan_detects_spine_shadow(self):
|
|
"""Synthetic book scan with spine shadow on left."""
|
|
img = _make_book_scan(1000, 800)
|
|
cropped, result = detect_and_crop_page(img)
|
|
|
|
# Should crop the spine shadow area
|
|
left_border = result["border_fractions"]["left"]
|
|
# Spine center is at ~5% of width, so left border should be >= 4%
|
|
assert left_border >= 0.04 # At least 4% left border detected
|
|
|
|
def test_sanity_check_too_small_crop(self):
|
|
"""If detected content area is too small, skip crop."""
|
|
h, w = 500, 500
|
|
# Tiny content area (5x5 pixels) — should fail sanity check
|
|
img = _make_white_image(h, w)
|
|
# Add tiny dark spot
|
|
img[248:253, 248:253] = 0
|
|
|
|
cropped, result = detect_and_crop_page(img)
|
|
# Should either not crop or crop is too small (< 40%)
|
|
if result["crop_applied"]:
|
|
crop_area = result["cropped_size"]["width"] * result["cropped_size"]["height"]
|
|
assert crop_area >= 0.4 * h * w
|
|
|
|
def test_crop_preserves_content(self):
|
|
"""Verify that content is preserved after cropping."""
|
|
h, w = 300, 200
|
|
img = _make_image_with_content(h, w, (50, 250, 40, 160))
|
|
cropped, result = detect_and_crop_page(img)
|
|
|
|
if result["crop_applied"]:
|
|
# Cropped image should contain dark pixels (content)
|
|
gray = np.mean(cropped, axis=2)
|
|
assert np.min(gray) < 50 # Content is dark
|
|
|
|
def test_result_structure(self):
|
|
"""Verify all expected keys are present in result dict."""
|
|
img = _make_white_image(100, 100)
|
|
_, result = detect_and_crop_page(img)
|
|
|
|
assert "crop_applied" in result
|
|
assert "original_size" in result
|
|
assert "cropped_size" in result
|
|
assert "border_fractions" in result
|
|
assert "detected_format" in result
|
|
assert "format_confidence" in result
|
|
assert "aspect_ratio" in result
|
|
|
|
def test_margin_parameter(self):
|
|
"""Custom margin_frac should affect crop bounds."""
|
|
h, w = 400, 300
|
|
img = _make_image_with_content(h, w, (80, 320, 60, 240))
|
|
|
|
_, result_small = detect_and_crop_page(img, margin_frac=0.005)
|
|
_, result_large = detect_and_crop_page(img, margin_frac=0.05)
|
|
|
|
if result_small["crop_applied"] and result_large["crop_applied"]:
|
|
# Larger margin should produce a larger crop
|
|
small_area = result_small["cropped_size"]["width"] * result_small["cropped_size"]["height"]
|
|
large_area = result_large["cropped_size"]["width"] * result_large["cropped_size"]["height"]
|
|
assert large_area >= small_area
|
|
|
|
def test_crop_rect_pct_values(self):
|
|
"""crop_rect_pct values should be in 0-100 range."""
|
|
h, w = 400, 300
|
|
img = _make_image_with_content(h, w, (80, 320, 60, 240))
|
|
_, result = detect_and_crop_page(img)
|
|
|
|
if result["crop_applied"] and result["crop_rect_pct"]:
|
|
pct = result["crop_rect_pct"]
|
|
assert 0 <= pct["x"] <= 100
|
|
assert 0 <= pct["y"] <= 100
|
|
assert 0 < pct["width"] <= 100
|
|
assert 0 < pct["height"] <= 100
|
|
|
|
|
|
class TestCropDeterminism:
|
|
"""A3: Verify that page crop produces identical results across N runs."""
|
|
|
|
@pytest.mark.parametrize("image_factory,desc", [
|
|
(
|
|
lambda: _make_image_with_content(800, 600, (100, 700, 80, 520)),
|
|
"standard content",
|
|
),
|
|
(
|
|
lambda: _make_book_scan(1000, 800),
|
|
"book scan with spine shadow",
|
|
),
|
|
])
|
|
def test_determinism_10_runs(self, image_factory, desc):
|
|
"""Same image must produce identical crops in 10 consecutive runs."""
|
|
img = image_factory()
|
|
results = []
|
|
for _ in range(10):
|
|
cropped, result = detect_and_crop_page(img.copy())
|
|
results.append({
|
|
"crop_applied": result["crop_applied"],
|
|
"cropped_size": result["cropped_size"],
|
|
"border_fractions": result["border_fractions"],
|
|
"shape": cropped.shape,
|
|
})
|
|
|
|
first = results[0]
|
|
for i, r in enumerate(results[1:], 1):
|
|
assert r["crop_applied"] == first["crop_applied"], (
|
|
f"Run {i} crop_applied differs from run 0 ({desc})"
|
|
)
|
|
assert r["cropped_size"] == first["cropped_size"], (
|
|
f"Run {i} cropped_size differs from run 0 ({desc})"
|
|
)
|
|
assert r["shape"] == first["shape"], (
|
|
f"Run {i} output shape differs from run 0 ({desc})"
|
|
)
|
|
|
|
def test_determinism_pixel_identical(self):
|
|
"""Crop output pixels must be identical across runs."""
|
|
img = _make_image_with_content(800, 600, (100, 700, 80, 520))
|
|
ref_crop, _ = detect_and_crop_page(img.copy())
|
|
|
|
for i in range(5):
|
|
crop, _ = detect_and_crop_page(img.copy())
|
|
assert np.array_equal(ref_crop, crop), (
|
|
f"Run {i} produced different pixel output"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests: detect_page_splits — spine scoring logic
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_book_spread(h: int = 1616, w: int = 2288) -> np.ndarray:
|
|
"""Create a synthetic landscape book spread (two pages side by side).
|
|
|
|
Simulates the ad810209 failure case:
|
|
- A narrow spine shadow near the center (~50% of width)
|
|
- A wider dark area off-center (~35% of width), simulating a text column
|
|
- Bright paper flanking the spine on both sides
|
|
"""
|
|
img = np.full((h, w, 3), 230, dtype=np.uint8)
|
|
|
|
# --- Spine shadow: narrow dark valley centered at x = w/2 (1144) ---
|
|
spine_center = w // 2
|
|
spine_half_w = 30 # ~60px wide total
|
|
for x in range(spine_center - spine_half_w, spine_center + spine_half_w + 1):
|
|
dist = abs(x - spine_center)
|
|
# Brightness dips from 230 (paper) to 130 (spine)
|
|
brightness = int(130 + (230 - 130) * min(dist / spine_half_w, 1.0))
|
|
img[:, x] = brightness
|
|
|
|
# --- Off-center dark area at ~35% of width (x=799), wider than spine ---
|
|
dark_center = int(w * 0.35)
|
|
dark_half_w = 80 # ~160px wide total (wider than spine)
|
|
for x in range(dark_center - dark_half_w, dark_center + dark_half_w + 1):
|
|
dist = abs(x - dark_center)
|
|
# Brightness dips from 230 to 140 (slightly less dark than spine)
|
|
brightness = int(140 + (230 - 140) * min(dist / dark_half_w, 1.0))
|
|
img[:, x] = min(img[0, x, 0], brightness) # don't overwrite spine if overlapping
|
|
|
|
return img
|
|
|
|
|
|
class TestDetectPageSplits:
|
|
def test_portrait_image_returns_empty(self):
|
|
"""Portrait images (width < height * 1.15) should not be split."""
|
|
img = np.full((1000, 800, 3), 200, dtype=np.uint8)
|
|
assert detect_page_splits(img) == []
|
|
|
|
def test_uniform_image_returns_empty(self):
|
|
"""Uniform brightness image should not detect any spine."""
|
|
img = np.full((800, 1600, 3), 220, dtype=np.uint8)
|
|
assert detect_page_splits(img) == []
|
|
|
|
def test_prefers_centered_spine_over_wider_offcenter_dark(self):
|
|
"""Scoring should pick the centered narrow spine over a wider off-center dark area.
|
|
|
|
This is the regression test for session ad810209 where the old algorithm
|
|
picked x=799 (35%) instead of x=1144 (50%).
|
|
"""
|
|
img = _make_book_spread(h=1616, w=2288)
|
|
pages = detect_page_splits(img)
|
|
|
|
assert len(pages) == 2, f"Expected 2 pages, got {len(pages)}"
|
|
|
|
# Split point should be near the center (x ~ 1144), not at ~799
|
|
split_x = pages[0]["width"] # pages[0] width = split point
|
|
center = 2288 / 2 # 1144
|
|
|
|
assert abs(split_x - center) < 100, (
|
|
f"Split at x={split_x}, expected near center {center:.0f}. "
|
|
f"Old bug would have split at ~799."
|
|
)
|
|
|
|
def test_split_produces_two_reasonable_pages(self):
|
|
"""Both pages should be at least 15% of total width."""
|
|
img = _make_book_spread()
|
|
pages = detect_page_splits(img)
|
|
|
|
if len(pages) == 2:
|
|
w = img.shape[1]
|
|
for p in pages:
|
|
assert p["width"] >= w * 0.15, (
|
|
f"Page {p['page_index']} too narrow: {p['width']}px "
|
|
f"(< {w * 0.15:.0f}px)"
|
|
)
|
|
|
|
def test_page_indices_sequential(self):
|
|
"""Page indices should be 0, 1, ..."""
|
|
img = _make_book_spread()
|
|
pages = detect_page_splits(img)
|
|
if pages:
|
|
indices = [p["page_index"] for p in pages]
|
|
assert indices == list(range(len(pages)))
|
|
|
|
def test_pages_cover_full_width(self):
|
|
"""Pages should cover the full image width without gaps or overlaps."""
|
|
img = _make_book_spread()
|
|
pages = detect_page_splits(img)
|
|
if len(pages) >= 2:
|
|
w = img.shape[1]
|
|
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"
|