Skip to content

Commit 63fcd58

Browse files
vorojarclaude
andcommitted
feat: add column-aware layout detection for multi-column documents
Layout detection now clusters regions into columns by x-center position, ensuring correct reading order (full-width headers → left column → right column). Merge groups respect column boundaries to prevent cross-column text mixing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3ff116b commit 63fcd58

1 file changed

Lines changed: 111 additions & 2 deletions

File tree

server.py

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,17 +223,122 @@ def detect_layout(img: Image.Image) -> list[dict]:
223223
bbox = [round(c) for c in box.tolist()] # [x1, y1, x2, y2] in pixels
224224
regions.append({"label": label, "bbox": bbox, "score": round(score.item(), 3)})
225225

226-
# Sort by reading order: top-to-bottom, then left-to-right
227-
regions.sort(key=lambda r: (r["bbox"][1], r["bbox"][0]))
226+
# Sort by column-aware reading order
227+
regions = _sort_by_columns(regions, img.size[0])
228228

229229
logger.info(f"[layout] Detected {len(regions)} regions")
230230
return regions
231231

232232

233+
def _detect_columns(regions: list[dict], img_width: int) -> list[list[dict]]:
234+
"""Detect multi-column layout by clustering region x-centers.
235+
Returns list of columns (each a list of regions), left-to-right.
236+
Full-width regions (spanning >60% of image width) are NOT assigned to columns;
237+
they are returned as a special first "column" to be placed before columnar content.
238+
"""
239+
if not regions:
240+
return []
241+
242+
FULL_WIDTH_RATIO = 0.6 # regions wider than this fraction are "full-width"
243+
# Labels that are always treated as full-width (headers, titles)
244+
FULL_WIDTH_LABELS = {"doc_title", "paragraph_title", "title", "section_title"}
245+
full_width = []
246+
narrow = []
247+
248+
for r in regions:
249+
x1, _, x2, _ = r["bbox"]
250+
width = x2 - x1
251+
if width > img_width * FULL_WIDTH_RATIO or r["label"] in FULL_WIDTH_LABELS:
252+
full_width.append(r)
253+
else:
254+
narrow.append(r)
255+
256+
if not narrow:
257+
return [full_width] if full_width else []
258+
259+
# Cluster narrow regions into columns by x-center
260+
centers = [(r["bbox"][0] + r["bbox"][2]) / 2 for r in narrow]
261+
columns = _cluster_columns(narrow, centers, img_width)
262+
263+
# Sort columns left-to-right by average x-center
264+
columns.sort(key=lambda col: sum((r["bbox"][0] + r["bbox"][2]) / 2 for r in col) / len(col))
265+
266+
# Sort regions within each column top-to-bottom
267+
for col in columns:
268+
col.sort(key=lambda r: r["bbox"][1])
269+
270+
# Tag each region with its column index for merge grouping
271+
if full_width:
272+
full_width.sort(key=lambda r: r["bbox"][1])
273+
for r in full_width:
274+
r["_column"] = 0
275+
276+
for ci, col in enumerate(columns):
277+
for r in col:
278+
r["_column"] = ci + (1 if full_width else 0)
279+
280+
result = []
281+
if full_width:
282+
result.append(full_width)
283+
result.extend(columns)
284+
return result
285+
286+
287+
def _cluster_columns(regions: list[dict], centers: list[float], img_width: int) -> list[list[dict]]:
288+
"""Simple 1D clustering of regions into columns by x-center.
289+
Uses a gap threshold: if the gap between sorted x-centers exceeds
290+
a fraction of the image width, start a new column.
291+
"""
292+
GAP_RATIO = 0.15 # minimum gap between columns as fraction of image width
293+
gap_threshold = img_width * GAP_RATIO
294+
295+
indexed = sorted(enumerate(regions), key=lambda t: centers[t[0]])
296+
columns: list[list[dict]] = []
297+
current_col: list[dict] = [indexed[0][1]]
298+
prev_center = centers[indexed[0][0]]
299+
300+
for idx, region in indexed[1:]:
301+
c = centers[idx]
302+
if c - prev_center > gap_threshold:
303+
columns.append(current_col)
304+
current_col = [region]
305+
else:
306+
current_col.append(region)
307+
prev_center = c
308+
309+
columns.append(current_col)
310+
return columns
311+
312+
313+
def _sort_by_columns(regions: list[dict], img_width: int) -> list[dict]:
314+
"""Sort regions by column-aware reading order.
315+
For single-column docs: top-to-bottom.
316+
For multi-column docs: full-width first, then left col top-to-bottom, right col top-to-bottom.
317+
"""
318+
columns = _detect_columns(regions, img_width)
319+
320+
if len(columns) <= 1:
321+
# Single column or all full-width: simple top-to-bottom
322+
regions.sort(key=lambda r: (r["bbox"][1], r["bbox"][0]))
323+
return regions
324+
325+
# Multi-column: interleave full-width regions by y-position among columns
326+
# Full-width regions (columns[0] if wider) come before each column section
327+
# that starts below them
328+
sorted_regions = []
329+
for col in columns:
330+
sorted_regions.extend(col)
331+
332+
logger.info(f"[layout] Detected {len(columns)} columns "
333+
f"({', '.join(str(len(c)) + ' regions' for c in columns)})")
334+
return sorted_regions
335+
336+
233337
def _merge_adjacent_regions(raw_regions: list[dict]) -> list[list[dict]]:
234338
"""Group adjacent non-solo regions into merge groups.
235339
Returns list of groups, where each group is a list of raw regions.
236340
Solo regions (table, figure) always form their own group.
341+
Column boundaries (tagged by _detect_columns) split groups.
237342
"""
238343
groups: list[list[dict]] = []
239344
current: list[dict] = []
@@ -245,6 +350,10 @@ def _merge_adjacent_regions(raw_regions: list[dict]) -> list[list[dict]]:
245350
current = []
246351
groups.append([region])
247352
else:
353+
# Split at column boundaries (different _column tag)
354+
if current and current[-1].get("_column") != region.get("_column"):
355+
groups.append(current)
356+
current = []
248357
current.append(region)
249358

250359
if current:

0 commit comments

Comments
 (0)