Vertical zone split: detect divider lines and create independent sub-zones

Pages with two side-by-side vocabulary columns separated by a vertical
black line are now split into independent sub-zones before row/column
detection. Each sub-zone gets its own rows, preventing misalignment from
different heading rhythms.

- _detect_vertical_dividers(): finds pipe word_boxes at consistent x
  positions spanning >50% of zone height
- _split_zone_at_vertical_dividers(): creates left/right PageZone objects
  with layout_hint and vsplit_group metadata
- Column union skips vsplit zones (independent column sets)
- Frontend renders vsplit zones side by side via flex layout
- PageZone gets layout_hint + vsplit_group fields

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-20 16:38:12 +01:00
parent e4fa634a63
commit 45b83560fd
4 changed files with 215 additions and 19 deletions

View File

@@ -186,25 +186,66 @@ export function GridEditor({ sessionId, onNext }: GridEditorProps) {
<GridImageOverlay sessionId={sessionId} grid={grid} /> <GridImageOverlay sessionId={sessionId} grid={grid} />
)} )}
{/* Zone tables */} {/* Zone tables — group vsplit zones side by side */}
<div className="space-y-4"> <div className="space-y-4">
{grid.zones.map((zone) => ( {(() => {
<div // Group consecutive zones with same vsplit_group
key={zone.zone_index} const groups: typeof grid.zones[][] = []
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden" for (const zone of grid.zones) {
> const prev = groups[groups.length - 1]
<GridTable if (
zone={zone} prev &&
layoutMetrics={grid.layout_metrics} zone.vsplit_group != null &&
selectedCell={selectedCell} prev[0].vsplit_group === zone.vsplit_group
onSelectCell={setSelectedCell} ) {
onCellTextChange={updateCellText} prev.push(zone)
onToggleColumnBold={toggleColumnBold} } else {
onToggleRowHeader={toggleRowHeader} groups.push([zone])
onNavigate={handleNavigate} }
/> }
</div> return groups.map((group) =>
))} group.length === 1 ? (
<div
key={group[0].zone_index}
className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<GridTable
zone={group[0]}
layoutMetrics={grid.layout_metrics}
selectedCell={selectedCell}
onSelectCell={setSelectedCell}
onCellTextChange={updateCellText}
onToggleColumnBold={toggleColumnBold}
onToggleRowHeader={toggleRowHeader}
onNavigate={handleNavigate}
/>
</div>
) : (
<div
key={`vsplit-${group[0].vsplit_group}`}
className="flex gap-2"
>
{group.map((zone) => (
<div
key={zone.zone_index}
className="flex-1 min-w-0 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden"
>
<GridTable
zone={zone}
layoutMetrics={grid.layout_metrics}
selectedCell={selectedCell}
onSelectCell={setSelectedCell}
onCellTextChange={updateCellText}
onToggleColumnBold={toggleColumnBold}
onToggleRowHeader={toggleRowHeader}
onNavigate={handleNavigate}
/>
</div>
))}
</div>
),
)
})()}
</div> </div>
{/* Tip */} {/* Tip */}

View File

@@ -52,6 +52,8 @@ export interface GridZone {
rows: GridRow[] rows: GridRow[]
cells: GridEditorCell[] cells: GridEditorCell[]
header_rows: number[] header_rows: number[]
layout_hint?: 'left_of_vsplit' | 'right_of_vsplit' | 'middle_of_vsplit'
vsplit_group?: number
} }
export interface BBox { export interface BBox {

View File

@@ -179,3 +179,5 @@ class PageZone:
box: Optional[DetectedBox] = None box: Optional[DetectedBox] = None
columns: List[ColumnGeometry] = field(default_factory=list) columns: List[ColumnGeometry] = field(default_factory=list)
image_overlays: List[Dict] = field(default_factory=list) image_overlays: List[Dict] = field(default_factory=list)
layout_hint: Optional[str] = None # 'left_of_vsplit', 'right_of_vsplit'
vsplit_group: Optional[int] = None # group ID for side-by-side rendering

View File

@@ -449,6 +449,108 @@ def _words_in_zone(
return result return result
# ---------------------------------------------------------------------------
# Vertical divider detection and zone splitting
# ---------------------------------------------------------------------------
_PIPE_RE_VSPLIT = re.compile(r"^\|+$")
def _detect_vertical_dividers(
words: List[Dict],
zone_x: int,
zone_w: int,
zone_y: int,
zone_h: int,
) -> List[float]:
"""Detect vertical divider lines from pipe word_boxes at consistent x.
Returns list of divider x-positions (empty if no dividers found).
"""
if not words or zone_w <= 0 or zone_h <= 0:
return []
# Collect pipe word_boxes
pipes = [
w for w in words
if _PIPE_RE_VSPLIT.match((w.get("text") or "").strip())
]
if len(pipes) < 5:
return []
# Cluster pipe x-centers by proximity
tolerance = max(15, int(zone_w * 0.02))
pipe_xs = sorted(w["left"] + w["width"] / 2 for w in pipes)
clusters: List[List[float]] = [[pipe_xs[0]]]
for x in pipe_xs[1:]:
if x - clusters[-1][-1] <= tolerance:
clusters[-1].append(x)
else:
clusters.append([x])
dividers: List[float] = []
for cluster in clusters:
if len(cluster) < 5:
continue
mean_x = sum(cluster) / len(cluster)
# Must be between 15% and 85% of zone width
rel_pos = (mean_x - zone_x) / zone_w
if rel_pos < 0.15 or rel_pos > 0.85:
continue
# Check vertical coverage: pipes must span >= 50% of zone height
cluster_pipes = [
w for w in pipes
if abs(w["left"] + w["width"] / 2 - mean_x) <= tolerance
]
ys = [w["top"] for w in cluster_pipes] + [w["top"] + w["height"] for w in cluster_pipes]
y_span = max(ys) - min(ys) if ys else 0
if y_span < zone_h * 0.5:
continue
dividers.append(mean_x)
return sorted(dividers)
def _split_zone_at_vertical_dividers(
zone: "PageZone",
divider_xs: List[float],
vsplit_group_id: int,
) -> List["PageZone"]:
"""Split a PageZone at vertical divider positions into sub-zones."""
from cv_vocab_types import PageZone
boundaries = [zone.x] + divider_xs + [zone.x + zone.width]
hints = []
for i in range(len(boundaries) - 1):
if i == 0:
hints.append("left_of_vsplit")
elif i == len(boundaries) - 2:
hints.append("right_of_vsplit")
else:
hints.append("middle_of_vsplit")
sub_zones = []
for i in range(len(boundaries) - 1):
x_start = int(boundaries[i])
x_end = int(boundaries[i + 1])
sub = PageZone(
index=0, # re-indexed later
zone_type=zone.zone_type,
y=zone.y,
height=zone.height,
x=x_start,
width=x_end - x_start,
box=zone.box,
image_overlays=zone.image_overlays,
layout_hint=hints[i],
vsplit_group=vsplit_group_id,
)
sub_zones.append(sub)
return sub_zones
def _merge_content_zones_across_boxes( def _merge_content_zones_across_boxes(
zones: List, zones: List,
content_x: int, content_x: int,
@@ -1404,11 +1506,49 @@ async def _build_grid_core(session_id: str, session: dict) -> dict:
page_zones, content_x, content_w page_zones, content_x, content_w
) )
# 3b. Detect vertical dividers and split content zones
vsplit_group_counter = 0
expanded_zones: List = []
for pz in page_zones:
if pz.zone_type != "content":
expanded_zones.append(pz)
continue
zone_words = _words_in_zone(
all_words, pz.y, pz.height, pz.x, pz.width
)
divider_xs = _detect_vertical_dividers(
zone_words, pz.x, pz.width, pz.y, pz.height
)
if divider_xs:
sub_zones = _split_zone_at_vertical_dividers(
pz, divider_xs, vsplit_group_counter
)
expanded_zones.extend(sub_zones)
vsplit_group_counter += 1
# Remove pipe words so they don't appear in sub-zones
pipe_ids = set(
id(w) for w in zone_words
if _PIPE_RE_VSPLIT.match((w.get("text") or "").strip())
)
all_words[:] = [w for w in all_words if id(w) not in pipe_ids]
logger.info(
"build-grid: vertical split zone %d at x=%s%d sub-zones",
pz.index, [int(x) for x in divider_xs], len(sub_zones),
)
else:
expanded_zones.append(pz)
# Re-index zones
for i, pz in enumerate(expanded_zones):
pz.index = i
page_zones = expanded_zones
# --- Union columns from all content zones --- # --- Union columns from all content zones ---
# Each content zone detects columns independently. Narrow # Each content zone detects columns independently. Narrow
# columns (page refs, markers) may appear in only one zone. # columns (page refs, markers) may appear in only one zone.
# Merge column split-points from ALL content zones so every # Merge column split-points from ALL content zones so every
# zone shares the full column set. # zone shares the full column set.
# NOTE: Zones from a vertical split are independent and must
# NOT share columns with each other.
# First pass: build grids per zone independently # First pass: build grids per zone independently
zone_grids: List[Dict] = [] zone_grids: List[Dict] = []
@@ -1459,8 +1599,11 @@ async def _build_grid_core(session_id: str, session: dict) -> dict:
zone_grids.append({"pz": pz, "words": zone_words, "grid": grid}) zone_grids.append({"pz": pz, "words": zone_words, "grid": grid})
# Second pass: merge column boundaries from all content zones # Second pass: merge column boundaries from all content zones
# Exclude zones from vertical splits — they have independent columns.
content_zones = [ content_zones = [
zg for zg in zone_grids if zg["pz"].zone_type == "content" zg for zg in zone_grids
if zg["pz"].zone_type == "content"
and zg["pz"].vsplit_group is None
] ]
if len(content_zones) > 1: if len(content_zones) > 1:
# Collect column split points (x_min of non-first columns) # Collect column split points (x_min of non-first columns)
@@ -1564,6 +1707,11 @@ async def _build_grid_core(session_id: str, session: dict) -> dict:
if pz.image_overlays: if pz.image_overlays:
zone_entry["image_overlays"] = pz.image_overlays zone_entry["image_overlays"] = pz.image_overlays
if pz.layout_hint:
zone_entry["layout_hint"] = pz.layout_hint
if pz.vsplit_group is not None:
zone_entry["vsplit_group"] = pz.vsplit_group
zones_data.append(zone_entry) zones_data.append(zone_entry)
# 4. Fallback: no boxes detected → single zone with all words # 4. Fallback: no boxes detected → single zone with all words
@@ -1719,8 +1867,11 @@ async def _build_grid_core(session_id: str, session: dict) -> dict:
# OCR reads physical vertical divider lines as "|" or "||" characters. # OCR reads physical vertical divider lines as "|" or "||" characters.
# These sit at consistent x positions near column boundaries and pollute # These sit at consistent x positions near column boundaries and pollute
# cell text. Remove them from word_boxes and rebuild cell text. # cell text. Remove them from word_boxes and rebuild cell text.
# NOTE: Zones from a vertical split already had pipes removed in step 3b.
_PIPE_RE = re.compile(r"^\|+$") _PIPE_RE = re.compile(r"^\|+$")
for z in zones_data: for z in zones_data:
if z.get("vsplit_group") is not None:
continue # pipes already removed before split
removed_pipes = 0 removed_pipes = 0
for cell in z.get("cells", []): for cell in z.get("cells", []):
wbs = cell.get("word_boxes") or [] wbs = cell.get("word_boxes") or []