mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-18 22:38:25 +08:00
fix(deepdoc): align table orientation scoring with Python recognition confidence (#18401)
This commit is contained in:
@@ -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
|
||||
|
||||
129
internal/deepdoc/parser/pdf/table/table_orient_parity_test.go
Normal file
129
internal/deepdoc/parser/pdf/table/table_orient_parity_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user