fix(deepdoc): align table orientation scoring with Python recognition confidence (#18401)

This commit is contained in:
Jack
2026-08-18 13:18:09 +08:00
committed by GitHub
parent 63a454084e
commit d3ff25763c
4 changed files with 258 additions and 93 deletions

View File

@@ -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

View File

@@ -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)
}
}

View File

@@ -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"}

View File

@@ -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 }