fix(pdf/layout): replace KMeans-only column detection with gap + KMeans hybrid (#18023)

This commit is contained in:
Jack
2026-08-11 22:13:42 +08:00
committed by GitHub
parent d9ed14ce9c
commit b4dd0f7a0c
7 changed files with 874 additions and 439 deletions

View File

@@ -0,0 +1,474 @@
package layout
import (
"math"
"math/rand"
"sort"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
util "ragflow/internal/deepdoc/parser/pdf/util"
)
// AssignColumn groups boxes into columns using the hybrid gap + KMeans
// strategy that beats gap-only column detection on real documents.
//
// Decision per page (mirrors tool-py/diagnose_combined.py):
// 1. Geometric gap (whitespace gutter voting) finds candidate column
// separators. But "gap >= 2" is NOT blindly trusted:
// - If the resulting columns are NARROW (max column width <
// tableMaxColFrac of the page), they are table cells, not text columns:
// the page is a single reading block -> return 1 directly (and do NOT
// fall through to the balance gate, which would re-split the table's
// bimodal x0 into 2).
// - If gap == 2, the separator is unreliable (it is often a fake gutter
// from indentation/line-width variation, not a real column). Defer to
// the balance gate below.
// - If gap >= 3 with WIDE columns, it is a real multi-column layout:
// trust it and partition by KMeans(g).
// 2. When gap reports 1 (single column OR a double column whose gutter is
// bridged by full-width front matter), or gap == 2 was deferred, a forced
// k=2 KMeans on the BODY x0 decides whether the lines form TWO clusters
// each holding >= minModeFrac of body lines, separated by >=
// minSepFrac*width. A balanced split is a real second column; an
// unbalanced split (the usual KMeans false-split on a single page) is
// dropped -> stays 1.
//
// Net effect: tables and fake gutters no longer over-split, while the
// double-column pages that gap alone misses are recovered by the balance gate.
func AssignColumn(boxes []pdf.TextBox) []pdf.TextBox {
if len(boxes) == 0 {
return boxes
}
pageGroups, sortedPages := groupBoxesByPage(boxes)
result := make([]pdf.TextBox, len(boxes))
copy(result, boxes)
for _, pg := range sortedPages {
indices := pageGroups[pg]
k, cents := detectColumnCount(boxes, indices)
assignColIDs(boxes, result, indices, k, cents)
}
return result
}
// tableMaxColFrac: a column narrower than this fraction of the page width is
// treated as a table cell, not a text column. Above this, the columns are
// wide enough to be real reading columns.
const tableMaxColFrac = 0.22
// maxColumnCount caps how many columns the gap detector may report. Gap
// voting can over-split a single page into many spurious gutters (e.g.
// first-line indentation), so we bound the count to the old detector's best-k
// cap of min(4, n). This prevents catastrophic splits (a single page reported
// as 7+ columns) that the old code could never produce.
const maxColumnCount = 4
// minColLineFrac: a column holding fewer than this fraction of the page's
// lines (or zero lines) is not a real reading column — it is a spurious
// gutter sliver (an indented block, a stray caption, an empty kmeans
// centroid). Drop it so the detector does not over-split.
//
// The threshold is set with margin below the smallest genuine column ratio
// observed on the 70-page labeled corpus: the sparsest real double's minority
// column is ~17.7% of lines, and the only real triple's columns are each
// >=22%. 12% prunes genuine outliers (e.g. a 4-line footnote, 7.3%) without
// touching those.
const minColLineFrac = 0.12
// detectColumnCount returns (columnCount, centroids) for one page.
// columnCount is 1, 2, or up to maxColumnCount; centroids are the k cluster
// means in x0 space (snapshot of the gate decision) and are reused for ColID
// assignment.
func detectColumnCount(boxes []pdf.TextBox, indices []int) (int, []float64) {
lines := make([]pdf.TextBox, len(indices))
for i, idx := range indices {
lines[i] = boxes[idx]
}
g := gapColumnCount(lines, 0.04, 0.15, 2.0)
if g >= 2 {
_, width := pageExtent(lines)
if width > 0 {
widths := gapColumnWidths(lines)
maxw := 0.0
for _, w := range widths {
if w > maxw {
maxw = w
}
}
if maxw < tableMaxColFrac*width {
// Narrow columns => table cells, not text columns. The page
// is one reading block; return 1 and skip the balance gate
// (which would otherwise re-split the table's x0).
return 1, nil
}
}
if g > 2 {
// gap >= 3 with wide columns: a real multi-column layout.
// Cap the count (maxColumnCount) so spurious gutters cannot
// split a single page into many columns, then prune empty or
// too-sparse columns so an indentation-created sliver does not
// survive as a spurious column.
k := g
if k > maxColumnCount {
k = maxColumnCount
}
if k > len(lines) {
k = len(lines)
}
_, w := pageExtent(lines)
cents := kmeansCentroids(lines, k, w)
if pk, pc, ok := pruneColumns(lines, cents); ok {
return pk, pc
}
return 1, nil
}
// g == 2: unreliable (fake gutter or real 2-col) -> defer to balance.
}
if ok, cents, body := balancedBodyK2(lines, 0.30, 0.10); ok {
// prune on the SAME body the gate clustered, not all lines: full-width
// titles/abstracts were deliberately excluded from the balance check
// and must not be re-counted here (they would inflate one column and
// let prune wrongly collapse a real two-column page to one).
if pk, pc, ok2 := pruneColumns(body, cents); ok2 {
return pk, pc
}
return 1, nil
}
return 1, nil
}
// pruneColumns drops empty (0-line) or too-sparse (< minColLineFrac) columns
// from a k-centroid partition and returns the surviving (k', cents'). A column
// is "real" only if it captures enough of the page's lines. If fewer than 2
// real columns survive, ok is false and the caller should treat the page as a
// single column.
func pruneColumns(lines []pdf.TextBox, cents []float64) (int, []float64, bool) {
n := len(lines)
if n == 0 || len(cents) < 2 {
return len(cents), cents, len(cents) >= 2
}
counts := make([]int, len(cents))
for _, b := range lines {
best, bestD := 0, math.Abs(b.X0-cents[0])
for c := 1; c < len(cents); c++ {
if d := math.Abs(b.X0 - cents[c]); d < bestD {
bestD, best = d, c
}
}
counts[best]++
}
keep := make([]int, 0, len(cents))
for c := range cents {
if counts[c] > 0 && float64(counts[c]) >= minColLineFrac*float64(n) {
keep = append(keep, c)
}
}
if len(keep) < 2 {
return len(keep), nil, false
}
newCents := make([]float64, len(keep))
for i, c := range keep {
newCents[i] = cents[c]
}
return len(keep), newCents, true
}
// gapColumnWidths returns the width (in page units) of each column found by
// the same gutter voting as gapColumnCount. Used to tell real wide text
// columns apart from narrow table-cell columns.
func gapColumnWidths(lines []pdf.TextBox) []float64 {
n := len(lines)
if n == 0 {
return nil
}
minX0, width := pageExtent(lines)
if width <= 0 {
return nil
}
binPt := 2.0
nb := int(width/binPt) + 1
cov := make([]int, nb)
for _, b := range lines {
i0 := clampInt(int((b.X0-minX0)/binPt), 0, nb-1)
i1 := clampInt(int((b.X1-minX0)/binPt), 0, nb-1)
for i := i0; i <= i1; i++ {
cov[i]++
}
}
thr := 0.15 * float64(n)
var widths []float64
i := 0
for i < nb {
if float64(cov[i]) < thr {
i++
continue
}
j := i
for j < nb && float64(cov[j]) >= thr {
j++
}
widths = append(widths, float64(j-i)*binPt)
i = j
}
return widths
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// gapColumnCount mirrors column_detectors.gap_column_counts: rasterize the
// [minX0, maxX1] text region into x-bins, count how many lines cover each bin,
// and treat a covered-fraction-below-crossTol run wider than gapMinFrac*width
// as a column-separating gutter.
func gapColumnCount(lines []pdf.TextBox, gapMinFrac, crossTol, binPt float64) int {
n := len(lines)
if n == 0 {
return 1
}
minX0, width := pageExtent(lines)
if width <= 0 {
return 1
}
minGap := gapMinFrac * width
nb := int(width/binPt) + 1
cov := make([]int, nb)
for _, b := range lines {
i0 := int((b.X0 - minX0) / binPt)
if i0 < 0 {
i0 = 0
}
i1 := int((b.X1 - minX0) / binPt)
if i1 > nb-1 {
i1 = nb - 1
}
for i := i0; i <= i1; i++ {
cov[i]++
}
}
thr := crossTol * float64(n)
cols := 1
run := 0.0
for _, c := range cov {
if float64(c) < thr {
run += binPt
} else {
if run >= minGap {
cols++
}
run = 0
}
}
if run >= minGap {
cols++
}
return cols
}
// balancedBodyK2 runs a forced k=2 KMeans on the BODY x0 (full-width front
// matter excluded) and reports whether the split is a real two-column: two
// clusters each holding >= minModeFrac of body lines, separated by >=
// minSepFrac*width. Returns the 2 cluster centroids on success, plus the body
// slice it clustered on so the caller's prune step counts the SAME line set
// (otherwise full-width lines re-inflated into one column would let prune
// collapse a balanced two-column page back to one).
func balancedBodyK2(lines []pdf.TextBox, minModeFrac, minSepFrac float64) (bool, []float64, []pdf.TextBox) {
minX0, width := pageExtent(lines)
if width <= 0 {
return false, nil, nil
}
body := dropFullWidth(lines, width)
if len(body) < 4 {
return false, nil, nil
}
x0s := make([]float64, len(body))
for i, b := range body {
x0s[i] = b.X0
}
indentTol := width * 0.12
sx := snapX0s(x0s, minX0, indentTol)
labels, cents := kmeansK2PlusPlus(sx, 42)
if len(uniqueInts(labels)) < 2 {
return false, nil, nil
}
counts := make(map[int]int, 2)
for _, l := range labels {
counts[l]++
}
minCount := math.MaxInt32
for _, c := range counts {
if c < minCount {
minCount = c
}
}
if float64(minCount) < minModeFrac*float64(len(body)) {
return false, nil, nil
}
if math.Abs(cents[0]-cents[1]) < minSepFrac*width {
return false, nil, nil
}
return true, cents, body
}
// dropFullWidth removes lines whose width spans >=90% of the page text width
// (titles / abstracts / headings that legitimately bridge a gutter).
func dropFullWidth(lines []pdf.TextBox, width float64) []pdf.TextBox {
fwThr := 0.9 * width
out := make([]pdf.TextBox, 0, len(lines))
for _, b := range lines {
if b.X1-b.X0 < fwThr {
out = append(out, b)
}
}
if len(out) == 0 {
// Every line is full-width: there is no narrow body to form a second
// column. Return nil (not the original lines) so the caller's
// len(body) < 4 guard treats the page as a single column instead of
// pushing the whole page through the balance gate, which could
// mis-split a full-width single column whose x0 happens to be bimodal.
return nil
}
return out
}
// pageExtent returns minX0 (leftmost x0) and the text width (maxX1 - minX0).
func pageExtent(lines []pdf.TextBox) (minX0, width float64) {
minX0 = math.MaxFloat64
maxX1 := 0.0
for _, b := range lines {
if b.X0 < minX0 {
minX0 = b.X0
}
if b.X1 > maxX1 {
maxX1 = b.X1
}
}
return minX0, maxX1 - minX0
}
// snapX0s pulls x0 values within indentTol of minX0 back to minX0, so slightly
// indented lines still cluster with the left edge (mirrors _assign_column).
func snapX0s(x0s []float64, minX0, indentTol float64) []float64 {
out := make([]float64, len(x0s))
for i, v := range x0s {
if math.Abs(v-minX0) < indentTol {
out[i] = minX0
} else {
out[i] = v
}
}
return out
}
// kmeansK2PlusPlus is a density-aware k=2 clustering (k-means++ init, single
// Lloyd pass). Unlike util.KMeans1D (even-spaced init, a range partition), the
// first center is a random data point and the second is the farthest point, so
// it respects natural x0 density — required for the balance check to reject a
// single column whose x0 merely has a wide range. Deterministic via seed.
func kmeansK2PlusPlus(x0s []float64, seed int64) ([]int, []float64) {
n := len(x0s)
labels := make([]int, n)
if n == 0 {
return labels, nil
}
rng := rand.New(rand.NewSource(seed))
first := rng.Intn(n)
c0 := x0s[first]
bestJ, bestD := 0, -1.0
for j, v := range x0s {
d := (v - c0) * (v - c0)
if d > bestD {
bestD, bestJ = d, j
}
}
c1 := x0s[bestJ]
cents := []float64{c0, c1}
for iter := 0; iter < 100; iter++ {
changed := false
for i, v := range x0s {
bestC := 0
if math.Abs(v-c1) < math.Abs(v-c0) {
bestC = 1
}
if labels[i] != bestC {
changed = true
labels[i] = bestC
}
}
if !changed {
break
}
sum := [2]float64{}
cnt := [2]int{}
for i, v := range x0s {
sum[labels[i]] += v
cnt[labels[i]]++
}
for c := 0; c < 2; c++ {
if cnt[c] > 0 {
cents[c] = sum[c] / float64(cnt[c])
}
}
}
return labels, cents
}
// kmeansCentroids returns the k cluster centroids from util.KMeans1D on the
// snapped x0s of all lines; used to partition a page when gap reports >=2.
func kmeansCentroids(lines []pdf.TextBox, k int, width float64) []float64 {
minX0, _ := pageExtent(lines)
x0s := make([]float64, len(lines))
for i, b := range lines {
x0s[i] = b.X0
}
sx := snapX0s(x0s, minX0, width*0.12)
_, cents := util.KMeans1D(sx, k)
return cents
}
// assignColIDs sets ColID for a page's boxes by nearest centroid, remapped so
// the leftmost centroid becomes column 0.
func assignColIDs(boxes, result []pdf.TextBox, indices []int, k int, cents []float64) {
if k <= 1 || len(cents) == 0 {
for _, idx := range indices {
result[idx].ColID = 0
}
return
}
order := make([]int, len(cents))
idxByVal := make([]int, len(cents))
for i := range cents {
idxByVal[i] = i
}
sort.Slice(idxByVal, func(a, b int) bool { return cents[idxByVal[a]] < cents[idxByVal[b]] })
for newL, oldL := range idxByVal {
order[oldL] = newL
}
for _, idx := range indices {
x := boxes[idx].X0
best, bestD := 0, math.Abs(x-cents[0])
for c := 1; c < len(cents); c++ {
if d := math.Abs(x - cents[c]); d < bestD {
bestD, best = d, c
}
}
result[idx].ColID = order[best]
}
}
func uniqueInts(xs []int) []int {
seen := make(map[int]struct{}, len(xs))
for _, x := range xs {
seen[x] = struct{}{}
}
out := make([]int, 0, len(seen))
for x := range seen {
out = append(out, x)
}
return out
}

View File

@@ -0,0 +1,109 @@
package layout
import (
"testing"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
)
// Synthetic fixtures regression test.
//
// This file is the CI-runnable guardrail for the column detector. Unlike
// TestAssignColumnCombined_Labeled (which needs the gitignored 70-page label
// sheet + 343MB corpus and is therefore skipped in CI), these fixtures are
// hand-built []pdf.TextBox geometries committed to the repo, so they run on
// every `go test ./...` with no external data.
//
// Green assertions pin behavior that must NOT regress. The title-bridged
// double is a skipped TODO that captures the #18079 acceptance target: a 2D
// spatial column detector should split it into 2, but the current x0-based
// balance gate rejects it (sparse minority + x0 overlap).
// columnCount runs AssignColumn on one page of boxes and returns the number of
// distinct columns (max ColID + 1).
func columnCount(boxes []pdf.TextBox) int {
in := make([]pdf.TextBox, len(boxes))
copy(in, boxes)
for i := range in {
in[i].PageNumber = 0
}
res := AssignColumn(in)
k := 1
for _, b := range res {
if b.ColID+1 > k {
k = b.ColID + 1
}
}
return k
}
// stackedColumn builds n text boxes in a vertical column at [x0,x1], starting
// at top0 with line spacing dy.
func stackedColumn(x0, x1, n int, top0, dy float64) []pdf.TextBox {
boxes := make([]pdf.TextBox, n)
for i := 0; i < n; i++ {
top := top0 + float64(i)*dy
boxes[i] = pdf.TextBox{X0: float64(x0), X1: float64(x1), Top: top, Bottom: top + 8}
}
return boxes
}
func concat(dst, src []pdf.TextBox) []pdf.TextBox { return append(dst, src...) }
// A single reading column must stay single (no over-split).
func TestSyntheticSingleColumn(t *testing.T) {
boxes := stackedColumn(50, 240, 10, 10, 10)
if got := columnCount(boxes); got != 1 {
t.Errorf("single column: got %d, want 1", got)
}
}
// A balanced two-column page must be recovered by the balance gate.
func TestSyntheticBalancedDouble(t *testing.T) {
boxes := concat(stackedColumn(50, 240, 10, 10, 10), stackedColumn(270, 460, 10, 10, 10))
if got := columnCount(boxes); got != 2 {
t.Errorf("balanced double: got %d, want 2", got)
}
}
// Narrow side-by-side columns are a table, not text columns: returned as 1.
func TestSyntheticTableNarrowColumns(t *testing.T) {
var boxes []pdf.TextBox
for _, c := range [][2]int{{50, 130}, {200, 280}, {350, 430}} {
boxes = concat(boxes, stackedColumn(c[0], c[1], 5, 10, 10))
}
if got := columnCount(boxes); got != 1 {
t.Errorf("narrow table: got %d, want 1", got)
}
}
// A real left column plus a sparse right column: the balance gate must reject
// the sparse column (minority < 30%), keeping the page single. Guards the
// sparse-column prune (minColLineFrac) against regression.
func TestSyntheticSparseSecondColumn(t *testing.T) {
boxes := concat(stackedColumn(50, 240, 30, 10, 10), stackedColumn(300, 460, 3, 10, 20))
if got := columnCount(boxes); got != 1 {
t.Errorf("sparse second column: got %d, want 1", got)
}
}
// TODO #18079: a 2D spatial column detector should split this into 2.
//
// The page has a full-width title block at the top that bridges the gutter,
// and below it two side-by-side columns where the right column is sparse and
// its x0 overlaps the left column's x0 range. The x0-based balance gate
// therefore rejects it (minority < 30%, x0 overlap) and the page is reported
// as single. Skipped until #18079 lands; then unskip and assert got == 2.
func TestSyntheticTitleBridgedDouble(t *testing.T) {
t.Skip("TODO #18079: title-bridged double should be detected as 2 columns")
boxes := concat(
concat(
stackedColumn(50, 440, 3, 10, 10), // full-width title (bridges the gutter)
stackedColumn(50, 240, 30, 40, 10), // left column
),
stackedColumn(200, 440, 4, 40, 20), // right column: sparse, x0 overlaps left
)
if got := columnCount(boxes); got != 2 {
t.Errorf("title-bridged double: got %d, want 2", got)
}
}

View File

@@ -0,0 +1,283 @@
package layout
import (
"encoding/json"
"math"
"os"
"path/filepath"
"sort"
"testing"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
)
// ---- charspy JSON binding + line reconstruction (mirrors tool-py
// extract_column_divergence._reconstruct_lines so the Go detector is scored on
// the SAME line boxes the Python reference used) ----
type charBox struct {
Text string `json:"text"`
X0 float64 `json:"x0"`
X1 float64 `json:"x1"`
Top float64 `json:"top"`
Bottom float64 `json:"bottom"`
Size float64 `json:"size"`
}
type charPage struct {
Pages [][]charBox `json:"pages"`
}
func reconstructLines(chars []charBox) []pdf.TextBox {
if len(chars) == 0 {
return nil
}
sizes := make([]float64, len(chars))
for i, c := range chars {
sizes[i] = c.Size
}
sort.Float64s(sizes)
medSize := sizes[len(sizes)/2]
vTol := math.Max(medSize*0.8, 4.0)
hGap := math.Max(medSize*3.0, 15.0)
rows := map[int][]charBox{}
for _, c := range chars {
key := int(math.Round(c.Top / vTol))
rows[key] = append(rows[key], c)
}
keys := make([]int, 0, len(rows))
for k := range rows {
keys = append(keys, k)
}
sort.Ints(keys)
var lines []pdf.TextBox
for _, key := range keys {
row := rows[key]
sort.Slice(row, func(a, b int) bool { return row[a].X0 < row[b].X0 })
var cur *pdf.TextBox
for _, c := range row {
if cur == nil {
cur = &pdf.TextBox{X0: c.X0, X1: c.X1, Top: c.Top, Bottom: c.Bottom, Text: c.Text}
} else if c.X0-cur.X1 > hGap {
lines = append(lines, *cur)
cur = &pdf.TextBox{X0: c.X0, X1: c.X1, Top: c.Top, Bottom: c.Bottom, Text: c.Text}
} else if c.X1 > cur.X1 {
cur.X1 = c.X1
}
}
if cur != nil {
lines = append(lines, *cur)
}
}
return lines
}
// labeledPage mirrors the first 18 (locked) entries of
// tool-py/column_labeling_sheet.json — human-confirmed column truth.
type labeledPage struct {
pdf string
page int
truth int
}
// sheetEntry mirrors the JSON shape of tool-py/column_labeling_sheet.json.
type sheetEntry struct {
PDF string `json:"pdf"`
Page int `json:"page"`
TruthK *int `json:"truth_k"`
}
// loadLabeledPages reads every page with a non-null truth_k from the shared
// label sheet (single source of truth, also used by the Python reference).
// Pages still marked null (need the rendered PDF to decide) are skipped.
func loadLabeledPages(t *testing.T) []labeledPage {
sheetPath := "../tool-py/column_labeling_sheet.json"
raw, err := os.ReadFile(sheetPath)
if err != nil {
t.Skipf("label sheet not found at %s: %v", sheetPath, err)
}
var entries []sheetEntry
if err := json.Unmarshal(raw, &entries); err != nil {
t.Fatalf("unmarshal sheet: %v", err)
}
var out []labeledPage
for _, e := range entries {
if e.TruthK == nil {
continue
}
out = append(out, labeledPage{pdf: e.PDF, page: e.Page, truth: *e.TruthK})
}
if len(out) == 0 {
t.Skip("no labeled pages in sheet")
}
return out
}
// TestAssignColumnCombined_Labeled scores the gap+KMeans hybrid against the
// LIVE gap-only baseline on every labeled page in the shared sheet (the sheet
// is the single source of truth, also used by the Python reference). Hard
// pages were labeled from the page coverage profile + strip map in
// tool-py/analyze_hardcases.py, tagged H (high) / L (low) confidence in
// truth_note. The 18-page labeled sample that motivated this detector reported
// KMeans 11.1% / gap 61.1% accuracy, but that sample was hand-picked and must
// not be used as a target. The per-page / single / double / ACC lines below
// are the real signal, and the only asserted contract is "combined must not
// regress gap by >5pt".
func TestAssignColumnCombined_Labeled(t *testing.T) {
charspyDir := "../testdata/charspy"
if _, err := os.Stat(charspyDir); err != nil {
t.Skipf("charspy corpus not found at %s (run from package dir): %v", charspyDir, err)
}
cases := loadLabeledPages(t)
var correct, fs, miss, sCorrect, sTotal, dCorrect, dTotal int
var gCorrect, gFs, gMiss, gsCorrect, gsTotal, gdCorrect, gdTotal int
for _, c := range cases {
raw, err := os.ReadFile(filepath.Join(charspyDir, c.pdf))
if err != nil {
t.Fatalf("read %s: %v", c.pdf, err)
}
var cp charPage
if err := json.Unmarshal(raw, &cp); err != nil {
t.Fatalf("unmarshal %s: %v", c.pdf, err)
}
lines := reconstructLines(cp.Pages[c.page])
boxes := make([]pdf.TextBox, len(lines))
for i, l := range lines {
boxes[i] = l
boxes[i].PageNumber = 0
}
res := AssignColumn(boxes)
k := 1
for _, b := range res {
if b.ColID+1 > k {
k = b.ColID + 1
}
}
// Live gap-only baseline on the SAME lines (combined wraps gap, so
// this is the honest reference — not the old 18-page Python 61.1%).
g := gapColumnCount(lines, 0.04, 0.15, 2.0)
record := func(got, truth int, corr, fsC, missC, sCorr, sTot, dCorr, dTot *int) {
if got == truth {
*corr++
} else if got > truth {
*fsC++
} else {
*missC++
}
if truth == 1 {
*sTot++
if got == truth {
*sCorr++
}
} else {
*dTot++
if got == truth {
*dCorr++
}
}
}
record(k, c.truth, &correct, &fs, &miss, &sCorrect, &sTotal, &dCorrect, &dTotal)
record(g, c.truth, &gCorrect, &gFs, &gMiss, &gsCorrect, &gsTotal, &gdCorrect, &gdTotal)
tag := "OK"
if k != c.truth {
tag = "WRONG"
}
t.Logf("[%s] %s p%d truth=%d got=%d (gap=%d) comb single=%d/%d double=%d/%d gap single=%d/%d double=%d/%d",
tag, c.pdf, c.page, c.truth, k, g, sCorrect, sTotal, dCorrect, dTotal, gsCorrect, gsTotal, gdCorrect, gdTotal)
}
n := len(cases)
acc := 100.0 * float64(correct) / float64(n)
gapAcc := 100.0 * float64(gCorrect) / float64(n)
t.Logf("Go gap+KMeans combined: ACC=%.1f%% false-split=%d miss=%d single=%d/%d double=%d/%d (n=%d)",
acc, fs, miss, sCorrect, sTotal, dCorrect, dTotal, n)
t.Logf("Go gap-only baseline: ACC=%.1f%% false-split=%d miss=%d single=%d/%d double=%d/%d (n=%d)",
gapAcc, gFs, gMiss, gsCorrect, gsTotal, gdCorrect, gdTotal, n)
// The hybrid's contract: gap is the safe base, KMeans only ADDS recoveries
// (double columns gap misses). So combined must not regress gap by more
// than a small tolerance. The 75% / 88.9% figures were tuned targets on
// the comfortable 18 pages and must not be asserted (circular). Watch the
// two ACC lines above as harder pages are labeled and added.
if acc < gapAcc-5.0 {
t.Errorf("combined ACC=%.1f%% regresses gap-only baseline %.1f%% by >5pt (KMeans addition is harmful)", acc, gapAcc)
}
}
// TestKmeansK2PlusPlus_Smoke sanity-checks the density-aware k=2 gate on a
// clean bimodal vs single-mode input.
func TestKmeansK2PlusPlus_Smoke(t *testing.T) {
// Two well-separated modes, balanced -> two real clusters.
bimodal := []float64{10, 11, 12, 13, 90, 91, 92, 93}
labels, cents := kmeansK2PlusPlus(bimodal, 42)
if len(uniqueInts(labels)) != 2 {
t.Errorf("bimodal: expected 2 clusters, got %d", len(uniqueInts(labels)))
}
// kmeansK2PlusPlus does not guarantee centroid ordering, so assert the
// property it DOES guarantee: a bimodal split yields two well-separated
// centroids. (assignColIDs re-sorts them for ColID assignment.)
if math.Abs(cents[0]-cents[1]) < 50 {
t.Errorf("bimodal: centroids should be well separated, got %v", cents)
}
// Single tight mode -> still 2 labels after Lloyd, but minority tiny.
tight := []float64{10, 10, 10, 10, 11, 10, 10, 10}
labels2, _ := kmeansK2PlusPlus(tight, 42)
counts := map[int]int{}
for _, l := range labels2 {
counts[l]++
}
minC := math.MaxInt32
for _, c := range counts {
if c < minC {
minC = c
}
}
if float64(minC) >= 0.30*float64(len(tight)) {
t.Errorf("tight single-mode: minority %.0f%% should be <30%%", 100*float64(minC)/float64(len(tight)))
}
}
// TestAssignColumn_FullWidthBridgeKeepsBalancedTwoColumn is a regression test
// for the balance-gate / prune asymmetry: the balance gate clusters the BODY
// (full-width front matter excluded), but pruneColumns must count the SAME
// body — not all lines. A real but minority-right two-column block dominated
// by full-width title/abstract lines must stay two columns; otherwise the
// full-width lines inflate the left column and prune wrongly collapses the
// page to one.
func TestAssignColumn_FullWidthBridgeKeepsBalancedTwoColumn(t *testing.T) {
const (
minX0 = 100.0
maxX1 = 700.0
)
var boxes []pdf.TextBox
add := func(x0, x1 float64) {
top := float64(len(boxes)) * 12
boxes = append(boxes, pdf.TextBox{
PageNumber: 0, X0: x0, X1: x1, Top: top, Bottom: top + 10, Text: "b",
})
}
// Body: a genuine two-column block with a real gutter. The right column is
// the minority (6 of 20 body lines, i.e. 30% — exactly the gate floor).
for i := 0; i < 14; i++ {
add(minX0, 350) // left column
}
for i := 0; i < 6; i++ {
add(450, maxX1) // right column
}
// 60 full-width front-matter lines (title + abstract) bridge the gutter so
// gap reports a single column. They must not be re-counted by prune.
for i := 0; i < 60; i++ {
add(minX0, maxX1) // full width
}
res := AssignColumn(boxes)
k := 1
for _, b := range res {
if b.ColID+1 > k {
k = b.ColID + 1
}
}
if k != 2 {
t.Fatalf("balanced two-column page with full-width front matter collapsed to %d column(s); want 2", k)
}
}

View File

@@ -14,148 +14,8 @@ import (
)
// ---- Column assignment ----
// AssignColumn groups boxes into columns on each page by KMeans x0 clustering
// with silhouette score selection, matching Python's _assign_column().
//
// Python: pdf_parser.py:739 _assign_column()
func AssignColumn(boxes []pdf.TextBox) []pdf.TextBox {
if len(boxes) == 0 {
return boxes
}
pageGroups, sortedPages := groupBoxesByPage(boxes)
result := make([]pdf.TextBox, len(boxes))
copy(result, boxes)
// Step A: per-page best k using silhouette score.
pageCols := make(map[int]int)
for _, pg := range sortedPages {
indices := pageGroups[pg]
determineBestKForPage(boxes, result, indices, pg, pageCols)
}
// Step B: assign col_id per page using per-page best k.
// Labels are remapped by centroid x-order: leftmost column → 0.
for _, pg := range sortedPages {
indices := pageGroups[pg]
assignColIDsForPage(boxes, result, indices, pg, pageCols)
}
return result
}
// determineBestKForPage finds the best number of clusters (k) for a page using silhouette score
func determineBestKForPage(boxes, result []pdf.TextBox, indices []int, pg int, pageCols map[int]int) {
n := len(indices)
if n < 2 {
pageCols[pg] = 1
for _, idx := range indices {
result[idx].ColID = 0
}
return
}
x0s, minX0, maxX1 := extractX0Values(boxes, indices)
pageWidth := maxX1 - minX0
indentTol := pageWidth * 0.12
applyIndentTolerance(x0s, minX0, indentTol)
bestK, _ := findBestK(x0s, n)
pageCols[pg] = bestK
}
// extractX0Values extracts x0 coordinates from boxes on a page and finds minX0 and maxX1
func extractX0Values(boxes []pdf.TextBox, indices []int) (x0s []float64, minX0 float64, maxX1 float64) {
n := len(indices)
x0s = make([]float64, n)
minX0 = math.MaxFloat64
maxX1 = 0.0
for i, idx := range indices {
x0s[i] = boxes[idx].X0
if x0s[i] < minX0 {
minX0 = x0s[i]
}
if boxes[idx].X1 > maxX1 {
maxX1 = boxes[idx].X1
}
}
return x0s, minX0, maxX1
}
// applyIndentTolerance adjusts x0 values that are close to minX0 to improve clustering
func applyIndentTolerance(x0s []float64, minX0, indentTol float64) {
for i := range x0s {
if math.Abs(x0s[i]-minX0) < indentTol {
x0s[i] = minX0
}
}
}
// findBestK tries k from 1 to min(4, n) and returns the k with the best silhouette score
func findBestK(x0s []float64, n int) (bestK int, bestScore float64) {
maxTry := min(4, n)
if maxTry < 2 {
maxTry = 1
}
bestK, bestScore = 1, -1.0
for k := 1; k <= maxTry; k++ {
labels, _ := util.KMeans1D(x0s, k)
var score float64
if k > 1 {
score = util.Silhouette1D(x0s, labels)
}
// score = 0 for k=1; score = -1 if silhouette undefined.
if score > bestScore {
bestScore = score
bestK = k
}
}
return bestK, bestScore
}
// assignColIDsForPage assigns column IDs to boxes on a page using the best k
func assignColIDsForPage(boxes, result []pdf.TextBox, indices []int, pg int, pageCols map[int]int) {
if len(indices) == 0 {
return
}
k := pageCols[pg]
if len(indices) < k {
k = 1
}
x0s := make([]float64, len(indices))
for i, idx := range indices {
x0s[i] = boxes[idx].X0
}
labels, centroids := util.KMeans1D(x0s, k)
remap := remapLabelsByCentroidOrder(centroids)
for i, idx := range indices {
result[idx].ColID = remap[labels[i]]
}
}
// remapLabelsByCentroidOrder remaps cluster labels so leftmost column = 0
func remapLabelsByCentroidOrder(centroids []float64) map[int]int {
type clPair struct {
center float64
label int
}
var pairs []clPair
for lbl, c := range centroids {
pairs = append(pairs, clPair{c, lbl})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].center < pairs[j].center })
remap := make(map[int]int, len(centroids))
for newL, p := range pairs {
remap[p.label] = newL
}
return remap
}
// AssignColumn is implemented in combined_column.go (gap + KMeans hybrid).
// ---- Text merge (horizontal) ----

View File

@@ -22,14 +22,15 @@ func newTestTextBox(page int, x0, x1, top, bottom float64, text string) pdf.Text
func TestAssignColumn(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, X0: 50, Text: "col0-left"},
{PageNumber: 0, X0: 55, Text: "col0-mid"},
{PageNumber: 0, X0: 400, Text: "col1"},
{PageNumber: 1, X0: 50, Text: "pg1-col0"},
{PageNumber: 0, X0: 50, X1: 250, Text: "col0-left"},
{PageNumber: 0, X0: 55, X1: 250, Text: "col0-mid"},
{PageNumber: 0, X0: 400, X1: 600, Text: "col1"},
{PageNumber: 0, X0: 410, X1: 610, Text: "col1-b"},
{PageNumber: 1, X0: 50, X1: 250, Text: "pg1-col0"},
}
result := AssignColumn(boxes)
if len(result) != 4 {
t.Fatal("expected 4 boxes")
if len(result) != 5 {
t.Fatal("expected 5 boxes")
}
if result[0].ColID != result[1].ColID {
t.Error("boxes 0 and 1 (close x0) should be same column")
@@ -922,183 +923,3 @@ func TestProcessPageBoxes_NoMerge(t *testing.T) {
t.Errorf("expected 2 boxes, got %d", len(result))
}
}
// ── Column-assignment helper tests ──────────────────────────────────
func TestExtractX0Values(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, X0: 50, X1: 200},
{PageNumber: 0, X0: 30, X1: 100},
{PageNumber: 0, X0: 80, X1: 300},
}
x0s, minX0, maxX1 := extractX0Values(boxes, []int{0, 1, 2})
if len(x0s) != 3 {
t.Fatalf("expected 3 x0s, got %d", len(x0s))
}
if x0s[0] != 50 || x0s[1] != 30 || x0s[2] != 80 {
t.Errorf("x0s mismatch: %v", x0s)
}
if minX0 != 30 {
t.Errorf("minX0 = %v, want 30", minX0)
}
if maxX1 != 300 {
t.Errorf("maxX1 = %v, want 300", maxX1)
}
}
func TestApplyIndentTolerance(t *testing.T) {
values := []float64{100, 105, 200, 210}
applyIndentTolerance(values, 100, 10)
if values[0] != 100 || values[1] != 100 {
t.Errorf("close x0s should be adjusted to minX0: %v", values)
}
if values[2] != 200 || values[3] != 210 {
t.Errorf("distant x0s should remain unchanged: %v", values)
}
}
func TestApplyIndentTolerance_Zero(t *testing.T) {
values := []float64{100, 101, 200}
applyIndentTolerance(values, 100, 0)
if values[1] != 101 {
t.Errorf("zero tolerance: x0s should be unchanged, got %v", values)
}
}
func TestApplyIndentTolerance_Negative(t *testing.T) {
values := []float64{-100, -95, 0, 50}
applyIndentTolerance(values, -100, 10)
if values[0] != -100 || values[1] != -100 {
t.Errorf("negative x0s close to minX0 should be adjusted: %v", values)
}
}
func TestFindBestK_SingleCluster(t *testing.T) {
// Note: KMeans1D uses random initialization, so non-identical values
// may occasionally produce k>1. This test verifies the function runs
// without error and returns k>=1 (not a correctness check).
x0s := []float64{100, 99, 101}
bestK, _ := findBestK(x0s, len(x0s))
if bestK < 1 {
t.Errorf("expected bestK>=1, got %d", bestK)
}
}
func TestFindBestK_TwoColumns(t *testing.T) {
x0s := []float64{50, 55, 60, 200, 210, 220}
bestK, _ := findBestK(x0s, len(x0s))
if bestK != 2 {
t.Errorf("two columns: expected bestK=2, got %d", bestK)
}
}
func TestFindBestK_OneValue(t *testing.T) {
x0s := []float64{100}
bestK, _ := findBestK(x0s, len(x0s))
if bestK != 1 {
t.Errorf("single value: expected bestK=1, got %d", bestK)
}
}
func TestFindBestK_Identical(t *testing.T) {
x0s := []float64{100, 100, 100, 100, 100}
bestK, _ := findBestK(x0s, len(x0s))
if bestK != 1 {
t.Errorf("identical values: expected bestK=1, got %d", bestK)
}
}
func TestRemapLabelsByCentroidOrder_Ordered(t *testing.T) {
centroids := []float64{50, 200, 400}
remap := remapLabelsByCentroidOrder(centroids)
if remap[0] != 0 || remap[1] != 1 || remap[2] != 2 {
t.Errorf("ordered centroids: expected 0->0,1->1,2->2, got %v", remap)
}
}
func TestRemapLabelsByCentroidOrder_Unordered(t *testing.T) {
centroids := []float64{200, 50, 400}
remap := remapLabelsByCentroidOrder(centroids)
if remap[0] != 1 || remap[1] != 0 || remap[2] != 2 {
t.Errorf("unordered centroids: expected {0:1,1:0,2:2}, got %v", remap)
}
}
func TestRemapLabelsByCentroidOrder_Nil(t *testing.T) {
remap := remapLabelsByCentroidOrder(nil)
if len(remap) != 0 {
t.Errorf("nil centroids: expected empty map, got %v", remap)
}
}
func TestDetermineBestKForPage_SingleBox(t *testing.T) {
boxes := []pdf.TextBox{{PageNumber: 0, X0: 100, X1: 200}}
result := make([]pdf.TextBox, len(boxes))
copy(result, boxes)
pageCols := make(map[int]int)
determineBestKForPage(boxes, result, []int{0}, 0, pageCols)
if pageCols[0] != 1 {
t.Errorf("single box: expected pageCols[0]=1, got %d", pageCols[0])
}
if result[0].ColID != 0 {
t.Errorf("single box: expected ColID=0, got %d", result[0].ColID)
}
}
func TestDetermineBestKForPage_TwoColumns(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, X0: 50, X1: 100},
{PageNumber: 0, X0: 55, X1: 100},
{PageNumber: 0, X0: 300, X1: 400},
{PageNumber: 0, X0: 310, X1: 400},
}
result := make([]pdf.TextBox, len(boxes))
copy(result, boxes)
pageCols := make(map[int]int)
determineBestKForPage(boxes, result, []int{0, 1, 2, 3}, 0, pageCols)
if pageCols[0] != 2 {
t.Errorf("two distinct columns: expected pageCols[0]=2, got %d", pageCols[0])
}
}
func TestAssignmentHelpers_IndentTolerance(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, X0: 50, X1: 150, Top: 10, Bottom: 30},
{PageNumber: 0, X0: 205, X1: 350, Top: 10, Bottom: 30},
{PageNumber: 0, X0: 58, X1: 150, Top: 40, Bottom: 60},
}
result := make([]pdf.TextBox, len(boxes))
copy(result, boxes)
pageCols := make(map[int]int)
determineBestKForPage(boxes, result, []int{0, 1, 2}, 0, pageCols)
if pageCols[0] != 2 {
t.Errorf("expected 2 columns after indent tolerance, got %d", pageCols[0])
}
}
func TestAssignColIDsForPage_Normal(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, X0: 50, X1: 100},
{PageNumber: 0, X0: 200, X1: 300},
}
result := make([]pdf.TextBox, len(boxes))
copy(result, boxes)
pageCols := map[int]int{0: 2}
assignColIDsForPage(boxes, result, []int{0, 1}, 0, pageCols)
if result[0].ColID != 0 || result[1].ColID != 1 {
t.Errorf("expected ColIDs 0,1 but got %d,%d", result[0].ColID, result[1].ColID)
}
}
func TestAssignColIDsForPage_KTooLarge(t *testing.T) {
boxes := []pdf.TextBox{
{PageNumber: 0, X0: 100, X1: 200},
}
result := make([]pdf.TextBox, len(boxes))
copy(result, boxes)
pageCols := map[int]int{0: 3}
assignColIDsForPage(boxes, result, []int{0}, 0, pageCols)
if result[0].ColID != 0 {
t.Errorf("expected ColID=0 (k clamped to 1), got %d", result[0].ColID)
}
}

View File

@@ -2,7 +2,6 @@ package util
import (
"math"
"sort"
)
// KMeans1D performs 1-dimensional KMeans clustering.
@@ -89,86 +88,3 @@ func KMeans1D(data []float64, k int) (labels []int, centroids []float64) {
return
}
// Silhouette1D computes the silhouette score for 1D data.
// Returns a score in [-1, 1]. Higher is better.
// Returns -1 if the score cannot be computed (fewer than 2 unique labels).
// Samples alone in their cluster contribute 0, matching sklearn behavior.
//
// Python: sklearn.metrics.silhouette_score with Euclidean distance.
func Silhouette1D(data []float64, labels []int) float64 {
n := len(data)
if n <= 1 {
return 0
}
clusterCounts := make(map[int]int)
for _, l := range labels {
clusterCounts[l]++
}
uniqueClusters := make([]int, 0, len(clusterCounts))
for cl := range clusterCounts {
uniqueClusters = append(uniqueClusters, cl)
}
// Need at least 2 distinct labels for silhouette.
if len(uniqueClusters) < 2 {
return -1
}
sort.Ints(uniqueClusters)
var totalScore float64
for i := 0; i < n; i++ {
// sklearn convention: silhouette = 0 for samples alone in their cluster.
if clusterCounts[labels[i]] <= 1 {
continue
}
// a_i: mean distance to other points in same cluster
var aSum float64
aCount := 0
for j := 0; j < n; j++ {
if i != j && labels[j] == labels[i] {
aSum += math.Abs(data[i] - data[j])
aCount++
}
}
a := 0.0
if aCount > 0 {
a = aSum / float64(aCount)
}
// b_i: min mean distance to points in other clusters
b := math.MaxFloat64
for _, cl := range uniqueClusters {
if cl == labels[i] {
continue
}
var bSum float64
bCount := 0
for j := 0; j < n; j++ {
if labels[j] == cl {
bSum += math.Abs(data[i] - data[j])
bCount++
}
}
if bCount > 0 {
meanDist := bSum / float64(bCount)
if meanDist < b {
b = meanDist
}
}
}
if b == math.MaxFloat64 {
b = 0
}
maxAB := math.Max(a, b)
if maxAB > 0 {
totalScore += (b - a) / maxAB
}
}
return totalScore / float64(n)
}

View File

@@ -61,31 +61,3 @@ func TestKMeans1D(t *testing.T) {
}
})
}
func TestSilhouette1D(t *testing.T) {
t.Run("well-separated clusters", func(t *testing.T) {
data := []float64{0, 1, 2, 100, 101, 102}
labels := []int{0, 0, 0, 1, 1, 1}
score := Silhouette1D(data, labels)
if score < 0.8 {
t.Errorf("well-separated score should be high, got %.3f", score)
}
})
t.Run("overlapping clusters", func(t *testing.T) {
data := []float64{0, 1, 0, 1, 0, 1}
labels := []int{0, 0, 0, 1, 1, 1}
score := Silhouette1D(data, labels)
if score > 0.5 {
t.Errorf("overlapping score should be low, got %.3f", score)
}
})
t.Run("single cluster returns -1", func(t *testing.T) {
data := []float64{1, 2, 3}
labels := []int{0, 0, 0}
if score := Silhouette1D(data, labels); score != -1 {
t.Errorf("single cluster should return -1, got %.3f", score)
}
})
}