feat: auto-detect multi-page spreads and split into sub-sessions
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 28s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 2m0s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 19s

When a book scan (double-page spread) is detected during the crop step,
the system automatically:
1. Detects vertical center gaps (spine area) via ink density projection
2. Splits into N page sub-sessions (reusing existing sub-session mechanism)
3. Individually crops each page (removing its own borders)
4. Returns sub-session IDs for downstream pipeline processing

Detection: landscape images (w > h * 1.15), vertical gap < 15% peak
density in center region (25-75%), gap width >= 0.8% of image width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-17 16:34:06 +01:00
parent b1cdb2531c
commit 902de027f4
2 changed files with 247 additions and 2 deletions

View File

@@ -32,6 +32,109 @@ _INK_THRESHOLD = 0.003 # 0.3%
_MIN_RUN_FRAC = 0.005 # 0.5%
def detect_page_splits(
img_bgr: np.ndarray,
min_gap_frac: float = 0.008,
) -> list:
"""Detect if the image is a multi-page spread and return split rectangles.
Checks for wide vertical gaps (spine area) that indicate the image
contains multiple pages side by side (e.g. book on scanner).
Returns a list of page dicts ``{x, y, width, height, page_index}``
or an empty list if only one page is detected.
"""
h, w = img_bgr.shape[:2]
# Only check landscape-ish images (width > height * 0.85)
if w < h * 1.15:
return []
gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
binary = cv2.adaptiveThreshold(
gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, blockSize=51, C=15,
)
# Vertical projection: mean ink density per column
v_proj = np.mean(binary, axis=0) / 255.0
# Smooth with boxcar (width = 0.5% of image width, min 5)
kern = max(5, w // 200)
if kern % 2 == 0:
kern += 1
v_smooth = np.convolve(v_proj, np.ones(kern) / kern, mode="same")
peak = float(np.max(v_smooth))
if peak < 0.005:
return []
# Look for valleys in center region (25-75% of width)
gap_thresh = peak * 0.15 # valley must be < 15% of peak density
center_lo = int(w * 0.25)
center_hi = int(w * 0.75)
min_gap_px = max(5, int(w * min_gap_frac))
# Find contiguous gap runs in the center region
gaps: list = []
in_gap = False
gap_start = 0
for x in range(center_lo, center_hi):
if v_smooth[x] < gap_thresh:
if not in_gap:
gap_start = x
in_gap = True
else:
if in_gap:
gap_w = x - gap_start
if gap_w >= min_gap_px:
gaps.append({"x": gap_start, "width": gap_w,
"center": gap_start + gap_w // 2})
in_gap = False
if in_gap:
gap_w = center_hi - gap_start
if gap_w >= min_gap_px:
gaps.append({"x": gap_start, "width": gap_w,
"center": gap_start + gap_w // 2})
if not gaps:
return []
# Sort gaps by width (largest = most likely spine)
gaps.sort(key=lambda g: g["width"], reverse=True)
# Use the widest gap(s) as split points
# For now: support up to N-1 gaps → N pages
split_points = sorted(g["center"] for g in gaps[:3]) # max 4 pages
# Build page rectangles
pages: list = []
prev_x = 0
for i, sx in enumerate(split_points):
pages.append({"x": prev_x, "y": 0, "width": sx - prev_x,
"height": h, "page_index": i})
prev_x = sx
pages.append({"x": prev_x, "y": 0, "width": w - prev_x,
"height": h, "page_index": len(split_points)})
# Filter out tiny pages (< 15% of total width)
pages = [p for p in pages if p["width"] >= w * 0.15]
if len(pages) < 2:
return []
# Re-index
for i, p in enumerate(pages):
p["page_index"] = i
logger.info(
"Page split detected: %d pages, gap widths=%s, split_points=%s",
len(pages),
[g["width"] for g in gaps[:len(split_points)]],
split_points,
)
return pages
def detect_and_crop_page(
img_bgr: np.ndarray,
margin_frac: float = 0.01,