feat: box zone artifact filter, spanning headers, parenthesis fix
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 27s
CI / test-python-klausur (push) Failing after 1m59s
CI / test-python-agent-core (push) Successful in 22s
CI / test-nodejs-website (push) Successful in 19s

1. Filter recovered single-char artifacts (!, ?, •) from box zones
   where they are decorative noise, not real text markers

2. Detect spanning header rows (e.g. "Unit4: Bonnie Scotland") that
   stretch across multiple columns with colored text. Merge their
   cells into a single spanning cell in column 0.

3. Fix missing opening parentheses: when cell text has ")" but no
   matching "(", prepend "(" to the text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-17 11:31:55 +01:00
parent 872b47f691
commit b0e1fbc8d6

View File

@@ -415,9 +415,13 @@ def _detect_header_rows(
rows: List[Dict],
zone_words: List[Dict],
zone_y: int,
columns: Optional[List[Dict]] = None,
) -> List[int]:
"""Heuristic: the first row is a header if it has bold/large text or
there's a significant gap after it."""
"""Detect header rows: first-row heuristic + spanning header detection.
A "spanning header" is a row whose words stretch across multiple column
boundaries (e.g. "Unit4: Bonnie Scotland" centred across 4 columns).
"""
if len(rows) < 2:
return []
@@ -425,25 +429,60 @@ def _detect_header_rows(
first_row = rows[0]
second_row = rows[1]
# Gap between first and second row > 1.5x average row height
# Gap between first and second row > 0.5x average row height
avg_h = sum(r["y_max"] - r["y_min"] for r in rows) / len(rows)
gap = second_row["y_min"] - first_row["y_max"]
if gap > avg_h * 0.5:
headers.append(0)
# Also check if first row words are taller than average (bold/header text)
all_heights = [w["height"] for w in zone_words]
median_h = sorted(all_heights)[len(all_heights) // 2] if all_heights else 20
first_row_words = [
w for w in zone_words
if first_row["y_min"] <= w["top"] + w["height"] / 2 <= first_row["y_max"]
]
if first_row_words:
first_h = max(w["height"] for w in first_row_words)
all_heights = [w["height"] for w in zone_words]
median_h = sorted(all_heights)[len(all_heights) // 2] if all_heights else first_h
if first_h > median_h * 1.3:
if 0 not in headers:
headers.append(0)
# Spanning header detection: rows with few words that cross column
# boundaries and don't fit the normal multi-column pattern.
if columns and len(columns) >= 2:
# Typical data row has words in 2+ columns; a spanning header has
# words that sit in the middle columns without matching the pattern.
for row in rows:
ri = row["index"]
if ri in headers:
continue
row_words = [
w for w in zone_words
if row["y_min"] <= w["top"] + w["height"] / 2 <= row["y_max"]
]
if not row_words or len(row_words) > 6:
continue # too many words to be a header
# Check if all row words are colored (common for section headers)
all_colored = all(
w.get("color_name") and w.get("color_name") != "black"
for w in row_words
)
# Check if words span across the middle columns (not in col 0)
word_x_min = min(w["left"] for w in row_words)
word_x_max = max(w["left"] + w["width"] for w in row_words)
first_col_end = columns[0]["x_max"] if columns else 0
# Header if: colored text that starts after the first column
# or spans more than 2 columns
cols_spanned = sum(
1 for c in columns
if word_x_min < c["x_max"] and word_x_max > c["x_min"]
)
if all_colored and cols_spanned >= 2:
headers.append(ri)
elif cols_spanned >= 3 and len(row_words) <= 4:
headers.append(ri)
return headers
@@ -522,8 +561,48 @@ def _build_zone_grid(
cell["cell_id"] = f"Z{zone_index}_{cell['cell_id']}"
cell["zone_index"] = zone_index
# Detect header rows
header_rows = _detect_header_rows(rows, zone_words, zone_y)
# Detect header rows (pass columns for spanning header detection)
header_rows = _detect_header_rows(rows, zone_words, zone_y, columns)
# Merge cells in spanning header rows into a single col-0 cell
if header_rows and len(columns) >= 2:
for hri in header_rows:
header_cells = [c for c in cells if c["row_index"] == hri]
if len(header_cells) <= 1:
continue
# Collect all word_boxes and text from all columns
all_wb = []
all_text_parts = []
for hc in sorted(header_cells, key=lambda c: c["col_index"]):
all_wb.extend(hc.get("word_boxes", []))
if hc.get("text", "").strip():
all_text_parts.append(hc["text"].strip())
# Remove all header cells, replace with one spanning cell
cells = [c for c in cells if c["row_index"] != hri]
if all_wb:
x_min = min(wb["left"] for wb in all_wb)
y_min = min(wb["top"] for wb in all_wb)
x_max = max(wb["left"] + wb["width"] for wb in all_wb)
y_max = max(wb["top"] + wb["height"] for wb in all_wb)
cells.append({
"cell_id": f"R{hri:02d}_C0",
"row_index": hri,
"col_index": 0,
"col_type": "spanning_header",
"text": " ".join(all_text_parts),
"confidence": 0.0,
"bbox_px": {"x": x_min, "y": y_min,
"w": x_max - x_min, "h": y_max - y_min},
"bbox_pct": {
"x": round(x_min / img_w * 100, 2) if img_w else 0,
"y": round(y_min / img_h * 100, 2) if img_h else 0,
"w": round((x_max - x_min) / img_w * 100, 2) if img_w else 0,
"h": round((y_max - y_min) / img_h * 100, 2) if img_h else 0,
},
"word_boxes": all_wb,
"ocr_engine": "words_first",
"is_bold": True,
})
# Convert columns to output format with percentages
out_columns = []
@@ -716,10 +795,29 @@ async def build_grid(session_id: str):
# First pass: build grids per zone independently
zone_grids: List[Dict] = []
_RECOVERED_NOISE = {"!", "?", "", "·"}
for pz in page_zones:
zone_words = _words_in_zone(
all_words, pz.y, pz.height, pz.x, pz.width
)
# In box zones, filter out recovered single-char artifacts
# (decorative elements like !, ?, • from color recovery)
if pz.zone_type == "box":
before = len(zone_words)
zone_words = [
w for w in zone_words
if not (
w.get("recovered")
and w.get("text", "").strip() in _RECOVERED_NOISE
)
]
removed = before - len(zone_words)
if removed:
logger.info(
"build-grid: filtered %d recovered artifacts from box zone %d",
removed, pz.index,
)
grid = _build_zone_grid(
zone_words, pz.x, pz.y, pz.width, pz.height,
pz.index, img_w, img_h,
@@ -863,6 +961,15 @@ async def build_grid(session_id: str):
all_wb.extend(cell.get("word_boxes", []))
detect_word_colors(img_bgr, all_wb)
# 5b. Fix unmatched parentheses in cell text
# OCR often misses opening "(" while detecting closing ")".
# If a cell's text has ")" without a matching "(", prepend "(".
for z in zones_data:
for cell in z.get("cells", []):
text = cell.get("text", "")
if ")" in text and "(" not in text:
cell["text"] = "(" + text
duration = time.time() - t0
# 6. Build result