From d3ff25763cbbe59bcd51fdaf212a28a288785f23 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 18 Aug 2026 13:18:09 +0800 Subject: [PATCH] fix(deepdoc): align table orientation scoring with Python recognition confidence (#18401) --- .../deepdoc/parser/pdf/table/table_orient.go | 61 ++++----- .../pdf/table/table_orient_parity_test.go | 129 ++++++++++++++++++ .../parser/pdf/table/table_orient_test.go | 123 +++++++++++------ .../deepdoc/parser/pdf/table_extract_test.go | 38 +++--- 4 files changed, 258 insertions(+), 93 deletions(-) create mode 100644 internal/deepdoc/parser/pdf/table/table_orient_parity_test.go diff --git a/internal/deepdoc/parser/pdf/table/table_orient.go b/internal/deepdoc/parser/pdf/table/table_orient.go index 9e71e26751..648bea7864 100644 --- a/internal/deepdoc/parser/pdf/table/table_orient.go +++ b/internal/deepdoc/parser/pdf/table/table_orient.go @@ -12,14 +12,21 @@ import ( ) // EvaluateTableOrientation tests 4 rotation angles (0/90/180/270) and picks -// the best orientation based on OCR detect-region count and area coverage. +// the best orientation based on OCR recognition confidence, matching Python's +// pdf_parser.py:367 _evaluate_table_orientation(). +// +// For each angle the table image is rotated and recognized; the combined score +// is avg_conf * (1 + 0.1*min(regions, 50)/50). Recognition legibility is the +// signal, NOT detection geometry: detection box count and axis-aligned area are +// rotation-invariant (a 90°-rotated text line yields the same boxes and area as +// at 0°), so they cannot tell a table's true orientation apart. // // Returns bestAngle (0/90/180/270), the rotated image, and per-angle scores. // // Absolute threshold: non-0° wins only if its combined score exceeds 0° by -// more than 1.4× AND the 0° score is below 6.0. +// more than 0.2 AND the 0° score is below 0.8. // -// Python: pdf_parser.py:314 _evaluate_table_orientation() +// Python: pdf_parser.py:367 _evaluate_table_orientation() func EvaluateTableOrientation(ctx context.Context, tableImg image.Image, doc pdf.DocAnalyzer) (bestAngle int, bestImg image.Image, scores map[int]float64) { rotations := []struct { angle int @@ -46,40 +53,28 @@ func EvaluateTableOrientation(ctx context.Context, tableImg image.Image, doc pdf } } - detectBoxes, err := doc.OCRDetect(ctx, rotated) - if err != nil || len(detectBoxes) == 0 { + // Score by recognition confidence (legibility), matching Python's + // _evaluate_table_orientation: avg_conf * (1 + 0.1*min(regions,50)/50). + texts, err := doc.OCRRecognize(ctx, rotated) + if err != nil || len(texts) == 0 { scores[rot.angle] = 0 continue } - // Score by detect-region count (primary) + area (tiebreaker). - imageArea := float64(rotated.Bounds().Dx() * rotated.Bounds().Dy()) - totalRegions := 0 - var totalArea float64 - for _, box := range detectBoxes { - x0 := math.Min(box.X0, math.Min(box.X1, math.Min(box.X2, box.X3))) - y0 := math.Min(box.Y0, math.Min(box.Y1, math.Min(box.Y2, box.Y3))) - x1 := math.Max(box.X0, math.Max(box.X1, math.Max(box.X2, box.X3))) - y1 := math.Max(box.Y0, math.Max(box.Y1, math.Max(box.Y2, box.Y3))) - if x0 >= x1 || y0 >= y1 { - continue - } - totalRegions++ - totalArea += (x1 - x0) * (y1 - y0) + var confSum float64 + for _, t := range texts { + confSum += t.Confidence } - if totalRegions == 0 { - scores[rot.angle] = 0 - continue - } - areaRatio := totalArea / imageArea - combined := float64(totalRegions) * (1 + 0.06*areaRatio) + avgConf := confSum / float64(len(texts)) + regions := len(texts) + combined := avgConf * (1 + 0.1*math.Min(float64(regions), 50)/50) scores[rot.angle] = combined slog.Debug("table orientation", "angle", rot.angle, - "regions", totalRegions, - "area_ratio", fmt.Sprintf("%.4f", areaRatio), - "combined", fmt.Sprintf("%.2f", combined)) + "regions", regions, + "avg_conf", fmt.Sprintf("%.4f", avgConf), + "combined", fmt.Sprintf("%.4f", combined)) if combined > bestScore { bestScore = combined @@ -88,11 +83,13 @@ func EvaluateTableOrientation(ctx context.Context, tableImg image.Image, doc pdf } } - // Absolute threshold: only accept non-0° if region count is clearly - // higher (≥1.4×) AND 0° has few regions (< 6). + // Absolute threshold: only accept non-0° if its combined score exceeds + // 0° by more than 0.2 AND the 0° score is below 0.8. Mirrors Python's + // `score_0 is not None` (not score_0 > 0): when 0° has no recognized text + // (score_0 == 0) the margin clause still gates acceptance. score0 := scores[0] - if bestAngle != 0 && score0 > 0 { - if !(bestScore > score0*1.4 && score0 < 6.0) { + if bestAngle != 0 { + if !(bestScore-score0 > 0.2 && score0 < 0.8) { bestAngle = 0 bestImg = tableImg bestScore = score0 diff --git a/internal/deepdoc/parser/pdf/table/table_orient_parity_test.go b/internal/deepdoc/parser/pdf/table/table_orient_parity_test.go new file mode 100644 index 0000000000..f202ea4852 --- /dev/null +++ b/internal/deepdoc/parser/pdf/table/table_orient_parity_test.go @@ -0,0 +1,129 @@ +package table + +// ============================================================================= +// Parity: Go's EvaluateTableOrientation must agree with Python's +// _evaluate_table_orientation (deepdoc/parser/pdf_parser.py:367) on which +// rotation angle is chosen. +// +// Both score each candidate rotation (0/90/180/270) by OCR *recognition +// confidence*: +// combined = avg_conf * (1 + 0.1 * min(regions, 50) / 50) +// so the orientation where the text is actually legible wins. +// +// Why recognition confidence (not detection geometry) is the right signal: +// detection box count and axis-aligned bbox area are rotation-invariant — a +// 90°-rotated text line yields the same boxes and area as at 0° (the bbox just +// swaps width/height). Detection therefore carries no orientation signal; only +// recognition legibility does. This is the rationale for scoring by confidence +// rather than by detection geometry. +// +// Run with: +// ./build.sh --test -run TestEvaluateTableOrientation_MatchesPythonRecognitionConfidence ./internal/deepdoc/parser/pdf/table/ +// ============================================================================= + +import ( + "context" + "image" + "math" + "testing" + + pdf "ragflow/internal/deepdoc/parser/pdf/type" +) + +// orientConfMock implements DocAnalyzer with per-angle recognition confidence +// as ground truth — the signal EvaluateTableOrientation consumes to choose the +// best rotation. Detection output is unused by that function (returned empty +// to satisfy the interface). +type orientConfMock struct { + // angle → {regions, avgConf} + angles map[int]struct { + regions int + avgConf float64 + } + seq int +} + +func (m *orientConfMock) DLA(context.Context, image.Image) ([]pdf.DLARegion, error) { + return nil, nil +} +func (m *orientConfMock) TSR(context.Context, image.Image) ([]pdf.TSRCell, error) { + return nil, nil +} +func (m *orientConfMock) OCR(image.Image) (string, error) { return "", nil } +func (m *orientConfMock) Health() bool { return true } + +func (m *orientConfMock) OCRDetect(_ context.Context, _ image.Image) ([]pdf.OCRBox, error) { + // EvaluateTableOrientation scores by OCRRecognize; detection output is + // unused here. Return empty to satisfy the DocAnalyzer interface. + return nil, nil +} + +func (m *orientConfMock) OCRRecognize(_ context.Context, _ image.Image) ([]pdf.OCRText, error) { + angle := rotationOrder[m.seq%len(rotationOrder)] + m.seq++ + cfg := m.angles[angle] + texts := make([]pdf.OCRText, cfg.regions) + for i := range texts { + texts[i] = pdf.OCRText{Text: "X", Confidence: cfg.avgConf} + } + return texts, nil +} + +// TestEvaluateTableOrientation_MatchesPythonRecognitionConfidence verifies that +// Go's EvaluateTableOrientation picks the same rotation angle as Python's +// _evaluate_table_orientation when scoring purely by recognition confidence. +// Detection geometry is rotation-invariant, so only recognition legibility can +// distinguish the correct orientation — Go must agree with Python on which +// angle that is. +func TestEvaluateTableOrientation_MatchesPythonRecognitionConfidence(t *testing.T) { + // Ground truth per angle: detection is identical (8 regions, same area), + // but recognition confidence differs — only 90° is upright/legible. + doc := &orientConfMock{ + angles: map[int]struct { + regions int + avgConf float64 + }{ + 0: {regions: 8, avgConf: 0.15}, // vertical → garbage confidence + 90: {regions: 8, avgConf: 0.90}, // upright → legible confidence + 180: {regions: 8, avgConf: 0.15}, + 270: {regions: 8, avgConf: 0.15}, + }, + } + + // ── Python-equivalent scoring from the SAME ground truth ── + // Mirrors pdf_parser.py:367 _evaluate_table_orientation exactly. + pyBest, pyBestScore, pyScore0 := 0, -1.0, 0.0 + for _, a := range rotationOrder { + cfg := doc.angles[a] + combined := cfg.avgConf * (1 + 0.1*math.Min(float64(cfg.regions), 50)/50) + if a == 0 { + pyScore0 = combined + } + if combined > pyBestScore { + pyBestScore = combined + pyBest = a + } + } + // Python accepts a non-0° orientation only if it beats 0° by > 0.2 and + // 0° itself reads poorly (< 0.8). Here 90° clearly wins. + pyPicksNon0 := pyBest != 0 && (pyBestScore-pyScore0 > 0.2 && pyScore0 < 0.8) + if !pyPicksNon0 { + t.Fatalf("test setup error: Python-equivalent scoring did not pick 90° (best=%d score0=%.3f best=%.3f)", pyBest, pyScore0, pyBestScore) + } + + // ── Go's actual behavior ── + goAngle, _, goScores := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + + t.Logf("Python-expected angle: %d° (score0=%.3f, best=%.3f)", pyBest, pyScore0, pyBestScore) + t.Logf("Go angle: %d° scores=%v", goAngle, goScores) + + // Both implementations score purely by recognition confidence, so they must + // agree on the chosen angle. A mismatch means Go diverged from the Python + // formula or threshold. + if goAngle != pyBest { + t.Errorf("TABLE ORIENTATION PARITY DIVERGENCE: Go returns %d° but Python (recognition-confidence scoring) returns %d°. "+ + "Both should score each angle by avg_conf*(1+0.1*min(regions,50)/50) with threshold "+ + "best-score_0>0.2 && score_0<0.8, yielding angle %d°.", + goAngle, pyBest, pyBest) + } +} diff --git a/internal/deepdoc/parser/pdf/table/table_orient_test.go b/internal/deepdoc/parser/pdf/table/table_orient_test.go index fe4d76beeb..5c229efb2a 100644 --- a/internal/deepdoc/parser/pdf/table/table_orient_test.go +++ b/internal/deepdoc/parser/pdf/table/table_orient_test.go @@ -8,9 +8,9 @@ import ( ) // mockRotationDoc implements DocAnalyzer with deterministic OCR results per angle. -// The mock tracks the call sequence: evaluateTableOrientation tests angles in -// order 0°, 90°, 180°, 270°. Each call to OCRDetect increments an internal -// counter and returns data for the corresponding angle. +// The mock tracks the call sequence: EvaluateTableOrientation calls OCRRecognize +// once per angle in order 0°, 90°, 180°, 270°. Each call to OCRRecognize +// increments an internal counter and returns data for the corresponding angle. type mockRotationDoc struct { // angle → {regions count, average confidence, error} angles map[int]struct { @@ -32,41 +32,18 @@ func (m *mockRotationDoc) TSR(_ context.Context, _ image.Image) ([]pdf.TSRCell, func (m *mockRotationDoc) OCR(_ image.Image) (string, error) { return "", nil } func (m *mockRotationDoc) Health() bool { return true } -func (m *mockRotationDoc) currentAngle() int { - idx := m.callSeq % len(rotationOrder) - return rotationOrder[idx] -} - -func (m *mockRotationDoc) OCRDetect(_ context.Context, img image.Image) ([]pdf.OCRBox, error) { - defer func() { m.callSeq++ }() - angle := m.currentAngle() - cfg, ok := m.angles[angle] - if !ok { - cfg = m.angles[0] // fallback to 0° config - } - if cfg.err != nil { - return nil, cfg.err - } - if cfg.regions == 0 { - return nil, nil - } - w, h := img.Bounds().Dx(), img.Bounds().Dy() - boxes := make([]pdf.OCRBox, cfg.regions) - step := w / (cfg.regions + 1) - for i := 0; i < cfg.regions; i++ { - x := step * (i + 1) - boxes[i] = pdf.OCRBox{ - X0: float64(x), Y0: float64(h / 4), - X1: float64(x + 20), Y1: float64(h / 4), - X2: float64(x + 20), Y2: float64(h * 3 / 4), - X3: float64(x), Y3: float64(h * 3 / 4), - } - } - return boxes, nil +func (m *mockRotationDoc) OCRDetect(_ context.Context, _ image.Image) ([]pdf.OCRBox, error) { + // EvaluateTableOrientation scores by OCRRecognize; detection output is + // unused here. Return empty to satisfy the DocAnalyzer interface. + return nil, nil } func (m *mockRotationDoc) OCRRecognize(_ context.Context, _ image.Image) ([]pdf.OCRText, error) { - angle := rotationOrder[(m.callSeq-1)%len(rotationOrder)] // use angle from last Detect call + // EvaluateTableOrientation calls OCRRecognize once per angle in order + // 0°, 90°, 180°, 270°. Track the call sequence here so each call returns + // the recognition result for the corresponding angle. + angle := rotationOrder[m.callSeq%len(rotationOrder)] + m.callSeq++ cfg, ok := m.angles[angle] if !ok { cfg = m.angles[0] @@ -162,16 +139,16 @@ func TestEvaluateTableOrientation(t *testing.T) { } }) - t.Run("threshold protection — 0° keeps when diff too small", func(t *testing.T) { - // Region-count scoring: 8 vs 9 is too close (< 1.4×) → 0° wins. + t.Run("threshold protection — 0° keeps when confidence diff too small", func(t *testing.T) { + // Recognition scores 0.50 vs 0.55 are too close (< 0.2 margin) → 0° wins. doc := &mockRotationDoc{ angles: map[int]struct { regions int avgConf float64 err error }{ - 0: {regions: 8}, - 90: {regions: 9}, + 0: {regions: 8, avgConf: 0.50}, + 90: {regions: 8, avgConf: 0.55}, }, } angle, _, _ := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) @@ -180,16 +157,16 @@ func TestEvaluateTableOrientation(t *testing.T) { } }) - t.Run("threshold pass — 90° wins when region count is clearly higher", func(t *testing.T) { - // 0° has few regions AND 90° has ≥1.4× more → 90° wins. + t.Run("threshold pass — 90° wins when recognition confidence is clearly higher", func(t *testing.T) { + // 0° reads poorly (0.30) AND 90° reads well (0.90) → 90° wins. doc := &mockRotationDoc{ angles: map[int]struct { regions int avgConf float64 err error }{ - 0: {regions: 4}, - 90: {regions: 10}, + 0: {regions: 4, avgConf: 0.30}, + 90: {regions: 10, avgConf: 0.90}, }, } angle, _, _ := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) @@ -198,6 +175,26 @@ func TestEvaluateTableOrientation(t *testing.T) { } }) + t.Run("threshold guard — score_0 >= 0.8 blocks rotation despite large margin", func(t *testing.T) { + // Isolate the score_0 < 0.8 clause: 0° reads well (0.80) and 90° is + // clearly higher (1.00), so the margin clause (combined diff 0.22 > 0.2) + // passes, but score_0 = 0.88 >= 0.8 must still force keeping 0°. + doc := &mockRotationDoc{ + angles: map[int]struct { + regions int + avgConf float64 + err error + }{ + 0: {regions: 50, avgConf: 0.80}, + 90: {regions: 50, avgConf: 1.00}, + }, + } + angle, _, _ := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + if angle != 0 { + t.Errorf("expected 0° (score_0 >= 0.8 guard), got %d°", angle) + } + }) + t.Run("all angles fail OCR → fallback 0°", func(t *testing.T) { doc := &mockRotationDoc{ angles: map[int]struct { @@ -224,6 +221,46 @@ func TestEvaluateTableOrientation(t *testing.T) { } } }) + + t.Run("zero score_0 with low non-zero score — keep 0°", func(t *testing.T) { + // 0° has no recognized text (score_0 == 0). A non-zero angle with a + // low combined score must NOT be accepted, matching Python's + // `score_0 is not None` threshold (not `score_0 > 0`). + doc := &mockRotationDoc{ + angles: map[int]struct { + regions int + avgConf float64 + err error + }{ + 0: {regions: 0, avgConf: 0}, + 90: {regions: 2, avgConf: 0.05}, + }, + } + angle, _, _ := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + if angle != 0 { + t.Errorf("expected 0° (score_0 == 0, low non-zero score), got %d°", angle) + } + }) + + t.Run("zero score_0 with high non-zero score — accept rotation", func(t *testing.T) { + // 0° has no recognized text (score_0 == 0) but 90° reads clearly + // (combined 1.045 > 0.2). Mirrors Python: score_0 is not None, so the + // margin clause alone decides and 90° is accepted. + doc := &mockRotationDoc{ + angles: map[int]struct { + regions int + avgConf float64 + err error + }{ + 0: {regions: 0, avgConf: 0}, + 90: {regions: 50, avgConf: 0.95}, + }, + } + angle, _, _ := EvaluateTableOrientation(context.Background(), makeTestTableImage(), doc) + if angle != 90 { + t.Errorf("expected 90° (score_0 == 0, high non-zero score), got %d°", angle) + } + }) } var errMockOCR = &mockError{"mock OCR failure"} diff --git a/internal/deepdoc/parser/pdf/table_extract_test.go b/internal/deepdoc/parser/pdf/table_extract_test.go index eb7b551e2c..d51bc2e0ce 100644 --- a/internal/deepdoc/parser/pdf/table_extract_test.go +++ b/internal/deepdoc/parser/pdf/table_extract_test.go @@ -21,26 +21,28 @@ func (d *orientationScoringDoc) TSR(_ context.Context, _ image.Image) ([]pdf.TSR return nil, nil } -func (d *orientationScoringDoc) OCRDetect(_ context.Context, img image.Image) ([]pdf.OCRBox, error) { - regions := 1 - if img.Bounds().Dy() > img.Bounds().Dx() { - regions = 5 - } - boxes := make([]pdf.OCRBox, regions) - for i := range boxes { - x0 := float64((i + 1) * 10) - boxes[i] = pdf.OCRBox{ - X0: x0, Y0: 10, - X1: x0 + 5, Y1: 10, - X2: x0 + 5, Y2: 30, - X3: x0, Y3: 30, - } - } - return boxes, nil +func (d *orientationScoringDoc) OCRDetect(_ context.Context, _ image.Image) ([]pdf.OCRBox, error) { + // EvaluateTableOrientation now scores by OCRRecognize confidence, so + // detection output is unused by this test. Return empty. + return nil, nil } -func (d *orientationScoringDoc) OCRRecognize(_ context.Context, _ image.Image) ([]pdf.OCRText, error) { - return nil, nil +func (d *orientationScoringDoc) OCRRecognize(_ context.Context, img image.Image) ([]pdf.OCRText, error) { + // Encode the orientation signal via recognition confidence: a portrait + // (rotated) crop reads as more legible text, so it should score higher. + // This mirrors the region-count-vs-orientation intent the mock previously + // expressed through OCRDetect. + regions := 1 + conf := 0.1 + if img.Bounds().Dy() > img.Bounds().Dx() { + regions = 5 + conf = 0.9 + } + texts := make([]pdf.OCRText, regions) + for i := range texts { + texts[i] = pdf.OCRText{Text: "cell", Confidence: conf} + } + return texts, nil } func (d *orientationScoringDoc) Health() bool { return true }