mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-20 23:35:06 +08:00
Supersedes / folds in #18305. The score-based layer-2 rotation selection from #18305 now lives here, on top of `WarpCrop` (layer 1), applied to **all three** Go OCR paths, together with the Python score plumbing the Go side depends on. #18305 is closed in favor of this PR.
212 lines
6.5 KiB
Go
212 lines
6.5 KiB
Go
package pdf
|
|
|
|
import (
|
|
"context"
|
|
"image"
|
|
"math"
|
|
"testing"
|
|
|
|
pdf "ragflow/internal/deepdoc/parser/pdf/type"
|
|
util "ragflow/internal/deepdoc/parser/pdf/util"
|
|
)
|
|
|
|
// captureAnalyzer records the image handed to OCRRecognize so a test can
|
|
// assert which crop geometry was fed to recognition.
|
|
type captureAnalyzer struct {
|
|
healthy bool
|
|
boxes []pdf.OCRBox
|
|
texts []pdf.OCRText
|
|
recImage image.Image
|
|
}
|
|
|
|
func (c *captureAnalyzer) Health() bool { return c.healthy }
|
|
func (c *captureAnalyzer) DLA(context.Context, image.Image) ([]pdf.DLARegion, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *captureAnalyzer) TSR(context.Context, image.Image) ([]pdf.TSRCell, error) {
|
|
return nil, nil
|
|
}
|
|
func (c *captureAnalyzer) OCRDetect(context.Context, image.Image) ([]pdf.OCRBox, error) {
|
|
return c.boxes, nil
|
|
}
|
|
func (c *captureAnalyzer) OCRRecognize(_ context.Context, img image.Image) ([]pdf.OCRText, error) {
|
|
c.recImage = img
|
|
return c.texts, nil
|
|
}
|
|
|
|
func sameImage(a, b *image.RGBA) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
if a.Bounds() != b.Bounds() {
|
|
return false
|
|
}
|
|
for y := 0; y < a.Bounds().Dy(); y++ {
|
|
for x := 0; x < a.Bounds().Dx(); x++ {
|
|
if a.RGBAAt(x, y) != b.RGBAAt(x, y) {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// TestOCRDetectAndRecognize_WarpsCrop locks that ocrDetectAndRecognize feeds
|
|
// the perspective-de-skewed (WarpCrop) crop to recognition, not the old
|
|
// axis-aligned FastCrop of the detection bbox. This is the live-path wiring
|
|
// of Step 4 / layer 1.
|
|
func TestOCRDetectAndRecognize_WarpsCrop(t *testing.T) {
|
|
p := newTestParser()
|
|
page := image.NewRGBA(image.Rect(0, 0, 200, 140))
|
|
|
|
// A clearly skewed (perspective) quad: TL, TR, BR, BL. It is intentionally
|
|
// wide (W=120, H=60 after the warp, h/w < 1.5) so layer 2 is a no-op and
|
|
// recognition receives the de-skewed crop unchanged; a tall quad would be
|
|
// rotated by ocrRecognizeWithRotation and break the equality below.
|
|
quad := [4]util.Pt{{X: 50, Y: 40}, {X: 150, Y: 30}, {X: 160, Y: 90}, {X: 40, Y: 100}}
|
|
box := pdf.OCRBox{
|
|
X0: quad[0].X, Y0: quad[0].Y,
|
|
X1: quad[1].X, Y1: quad[1].Y,
|
|
X2: quad[2].X, Y2: quad[2].Y,
|
|
X3: quad[3].X, Y3: quad[3].Y,
|
|
}
|
|
|
|
cap := &captureAnalyzer{
|
|
healthy: true,
|
|
boxes: []pdf.OCRBox{box},
|
|
texts: []pdf.OCRText{{Text: "x", Confidence: 0.9}},
|
|
}
|
|
got := p.ocrDetectAndRecognize(context.Background(), page, cap, 0, "warp")
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 text box, got %d", len(got))
|
|
}
|
|
if cap.recImage == nil {
|
|
t.Fatal("OCRRecognize was not called with a crop")
|
|
}
|
|
|
|
// The crop passed to recognition must be exactly the WarpCrop output.
|
|
want := util.WarpCrop(page, quad)
|
|
rec, ok := cap.recImage.(*image.RGBA)
|
|
if !ok {
|
|
t.Fatalf("rec crop is %T, want *image.RGBA", cap.recImage)
|
|
}
|
|
if !sameImage(rec, want) {
|
|
t.Errorf("rec crop is not the WarpCrop output (size got=%v want=%v)",
|
|
cap.recImage.Bounds(), want.Bounds())
|
|
}
|
|
|
|
// And it must NOT be the axis-aligned FastCrop of the detection bbox,
|
|
// proving de-skew actually happened on the live path.
|
|
minX := int(min4(quad[0].X, quad[1].X, quad[2].X, quad[3].X))
|
|
minY := int(min4(quad[0].Y, quad[1].Y, quad[2].Y, quad[3].Y))
|
|
maxX := int(max4(quad[0].X, quad[1].X, quad[2].X, quad[3].X))
|
|
maxY := int(max4(quad[0].Y, quad[1].Y, quad[2].Y, quad[3].Y))
|
|
bboxCrop := util.FastCrop(page, minX, minY, maxX, maxY)
|
|
if sameImage(rec, bboxCrop) {
|
|
t.Errorf("rec crop equals the axis-aligned bbox crop; warp was not applied")
|
|
}
|
|
|
|
// Safety property: the emitted TextBox must stay the axis-aligned
|
|
// detection bbox — only the crop fed to recognition is de-skewed, the box
|
|
// geometry itself is never transformed. If warp ever leaked into the
|
|
// emitted coordinates this would drift from the original detection bbox.
|
|
got0 := got[0]
|
|
if math.Abs(got0.X0-float64(minX)/pdf.DlaScale) > 1e-6 ||
|
|
math.Abs(got0.Top-float64(minY)/pdf.DlaScale) > 1e-6 ||
|
|
math.Abs(got0.X1-float64(maxX)/pdf.DlaScale) > 1e-6 ||
|
|
math.Abs(got0.Bottom-float64(maxY)/pdf.DlaScale) > 1e-6 {
|
|
t.Errorf("emitted TextBox is not the axis-aligned detection bbox: got=(%.4f,%.4f,%.4f,%.4f) want=(%.4f,%.4f,%.4f,%.4f)",
|
|
got0.X0, got0.Top, got0.X1, got0.Bottom,
|
|
float64(minX)/pdf.DlaScale, float64(minY)/pdf.DlaScale,
|
|
float64(maxX)/pdf.DlaScale, float64(maxY)/pdf.DlaScale)
|
|
}
|
|
}
|
|
|
|
func min4(a, b, c, d float64) float64 {
|
|
m := a
|
|
if b < m {
|
|
m = b
|
|
}
|
|
if c < m {
|
|
m = c
|
|
}
|
|
if d < m {
|
|
m = d
|
|
}
|
|
return m
|
|
}
|
|
|
|
// TestOCRMergeChars_WarpsEmptyBoxCrop locks that the char-merge path
|
|
// (buildTextBoxes) also de-skews the boxes it re-recognizes, matching the
|
|
// ocrDetectAndRecognize path, and that for the axis-aligned boxes this path
|
|
// actually produces (the WarpCrop is behavior-equivalent to the old FastCrop,
|
|
// so this doubles as a no-regression guard for the wiring change).
|
|
func TestOCRMergeChars_WarpsEmptyBoxCrop(t *testing.T) {
|
|
p := newTestParser()
|
|
page := image.NewRGBA(image.Rect(0, 0, 200, 140))
|
|
|
|
// Axis-aligned detection box (pixel space). Char boxes are axis-aligned,
|
|
// so the merge path only ever sees rectangles like this.
|
|
quad := [4]util.Pt{{X: 40, Y: 40}, {X: 140, Y: 40}, {X: 140, Y: 100}, {X: 40, Y: 100}}
|
|
box := pdf.OCRBox{
|
|
X0: quad[0].X, Y0: quad[0].Y,
|
|
X1: quad[1].X, Y1: quad[1].Y,
|
|
X2: quad[2].X, Y2: quad[2].Y,
|
|
X3: quad[3].X, Y3: quad[3].Y,
|
|
}
|
|
|
|
// A single space char inside the box -> matched, but its text trims to
|
|
// empty, so the box is pushed to the need-OCR path.
|
|
chars := []pdf.TextChar{{
|
|
Text: " ",
|
|
X0: 45,
|
|
X1: 135,
|
|
Top: 45,
|
|
Bottom: 95,
|
|
PageNumber: 0,
|
|
}}
|
|
|
|
cap := &captureAnalyzer{
|
|
healthy: true,
|
|
boxes: []pdf.OCRBox{box},
|
|
texts: []pdf.OCRText{{Text: "x", Confidence: 0.9}},
|
|
}
|
|
got := p.ocrMergeChars(context.Background(), page, chars, cap, 0)
|
|
if len(got) == 0 {
|
|
t.Fatal("expected at least one text box from the merge path")
|
|
}
|
|
if cap.recImage == nil {
|
|
t.Fatal("OCRRecognize was not called with a crop on the merge path")
|
|
}
|
|
rec, ok := cap.recImage.(*image.RGBA)
|
|
if !ok {
|
|
t.Fatalf("rec crop is %T, want *image.RGBA", cap.recImage)
|
|
}
|
|
|
|
// Axis-aligned box: WarpCrop must equal FastCrop (no behavioral change),
|
|
// and the crop fed to recognition must be exactly the warped rectangle.
|
|
wantWarp := util.WarpCrop(page, quad)
|
|
wantFast := util.FastCrop(page, 40, 40, 140, 100)
|
|
if !sameImage(rec, wantWarp) {
|
|
t.Errorf("merge-path rec crop is not the WarpCrop output (size got=%v want=%v)",
|
|
cap.recImage.Bounds(), wantWarp.Bounds())
|
|
}
|
|
if !sameImage(rec, wantFast) {
|
|
t.Errorf("merge-path crop diverged from the old FastCrop behavior; regression risk")
|
|
}
|
|
}
|
|
|
|
func max4(a, b, c, d float64) float64 {
|
|
m := a
|
|
if b > m {
|
|
m = b
|
|
}
|
|
if c > m {
|
|
m = c
|
|
}
|
|
if d > m {
|
|
m = d
|
|
}
|
|
return m
|
|
}
|