Auto-filter decorative margin strips and header junk
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 26s
CI / test-go-edu-search (push) Successful in 26s
CI / test-python-klausur (push) Failing after 1m45s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 15s

- _filter_decorative_margin: Phase 2 now also removes short words (<=3
  chars) in the same narrow x-range as the detected single-char strip,
  catching multi-char OCR artifacts like "Vv" from alphabet graphics.
- _filter_header_junk: New filter detects the content start (first row
  with 3+ high-confidence words) and removes low-conf short fragments
  above it that are OCR artifacts from header illustrations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-19 09:38:24 +01:00
parent 8e4cbd84c2
commit 432eee3694

View File

@@ -663,11 +663,15 @@ def _filter_decorative_margin(
character word. These decorative elements are not content and confuse
column/row detection.
Detection criteria:
Detection criteria (phase 1 — find the strip using single-char words):
- Words are in the outer 30% of the page (left or right)
- Nearly all words are single characters (letters or digits)
- At least 8 such words form a vertical strip (≥8 unique Y positions)
- Average horizontal spread of the strip is small (< 60px)
- Average horizontal spread of the strip is small (< 80px)
Phase 2 — once a strip is confirmed, also remove any short word (≤3
chars) in the same narrow x-range. This catches multi-char OCR
artifacts like "Vv" that belong to the same decorative element.
Modifies *words* in place.
"""
@@ -675,7 +679,7 @@ def _filter_decorative_margin(
return
margin_cutoff = img_w * 0.30
# Candidate margin words: single char, in left or right 30%
# Phase 1: find candidate strips using single-char words
left_strip = [
w for w in words
if len((w.get("text") or "").strip()) == 1
@@ -699,18 +703,34 @@ def _filter_decorative_margin(
continue
# Check horizontal compactness
x_positions = [w["left"] for w in strip]
x_spread = max(x_positions) - min(x_positions)
x_min = min(x_positions)
x_max = max(x_positions)
x_spread = x_max - x_min
if x_spread > 80:
continue
# This looks like a decorative alphabet strip — remove these words
strip_set = set(id(w) for w in strip)
# Phase 2: strip confirmed — also collect short words in same x-range
# Expand x-range slightly to catch neighbors (e.g. "Vv" next to "U")
strip_x_lo = x_min - 20
strip_x_hi = x_max + 60 # word width + tolerance
all_strip_words = [
w for w in words
if len((w.get("text") or "").strip()) <= 3
and strip_x_lo <= w["left"] <= strip_x_hi
and (w["left"] + w.get("width", 0) / 2 < margin_cutoff
if side == "left"
else w["left"] + w.get("width", 0) / 2 > img_w - margin_cutoff)
]
strip_set = set(id(w) for w in all_strip_words)
before = len(words)
words[:] = [w for w in words if id(w) not in strip_set]
removed = before - len(words)
if removed:
log.info(
"build-grid session %s: removed %d decorative %s-margin chars",
session_id, removed, side,
"build-grid session %s: removed %d decorative %s-margin words "
"(strip x=%d-%d)",
session_id, removed, side, strip_x_lo, strip_x_hi,
)
@@ -744,6 +764,82 @@ def _filter_footer_words(
)
def _filter_header_junk(
words: List[Dict],
img_h: int,
log: Any,
session_id: str,
) -> None:
"""Remove OCR junk from header illustrations above the real content.
Textbook pages often have decorative header graphics (illustrations,
icons) that OCR reads as low-confidence junk characters. Real content
typically starts further down the page.
Algorithm:
1. Find the "content start" — the first Y position where a dense
horizontal row of 3+ high-confidence words begins.
2. Above that line, remove words with conf < 75 and text ≤ 3 chars.
These are almost certainly OCR artifacts from illustrations.
Modifies *words* in place.
"""
if not words or img_h <= 0:
return
# --- Find content start: first horizontal row with ≥3 high-conf words ---
# Sort words by Y
sorted_by_y = sorted(words, key=lambda w: w["top"])
content_start_y = 0
_ROW_TOLERANCE = img_h * 0.02 # words within 2% of page height = same row
_MIN_ROW_WORDS = 3
_MIN_CONF = 80
i = 0
while i < len(sorted_by_y):
row_y = sorted_by_y[i]["top"]
# Collect words in this row band
row_words = []
j = i
while j < len(sorted_by_y) and sorted_by_y[j]["top"] - row_y < _ROW_TOLERANCE:
row_words.append(sorted_by_y[j])
j += 1
# Count high-confidence words with real text (> 1 char)
high_conf = [
w for w in row_words
if w.get("conf", 0) >= _MIN_CONF
and len((w.get("text") or "").strip()) > 1
]
if len(high_conf) >= _MIN_ROW_WORDS:
content_start_y = row_y
break
i = j if j > i else i + 1
if content_start_y <= 0:
return # no clear content start found
# --- Remove low-conf short junk above content start ---
junk = [
w for w in words
if w["top"] + w.get("height", 0) < content_start_y
and w.get("conf", 0) < 75
and len((w.get("text") or "").strip()) <= 3
]
if not junk:
return
junk_set = set(id(w) for w in junk)
before = len(words)
words[:] = [w for w in words if id(w) not in junk_set]
removed = before - len(words)
if removed:
log.info(
"build-grid session %s: removed %d header junk words above y=%d "
"(content start)",
session_id, removed, content_start_y,
)
# ---------------------------------------------------------------------------
# Core computation (used by build-grid endpoint and regression tests)
# ---------------------------------------------------------------------------
@@ -792,6 +888,10 @@ async def _build_grid_core(session_id: str, session: dict) -> dict:
# page number ("64", "S. 12") and not real content.
_filter_footer_words(all_words, img_h, logger, session_id)
# 2c2. Filter OCR junk from header illustrations.
# Low-confidence short fragments above the first real content row.
_filter_header_junk(all_words, img_h, logger, session_id)
# 2d. Filter words inside user-defined exclude regions (from Structure step).
# These are explicitly marked by the user, so ALL words inside are removed
# regardless of confidence.