Files
ragflow/internal/deepdoc/parser/pdf/parser_ocr_warp_test.go
Jack 8d20cbd0b3 deepdoc(pdf): WarpCrop de-skew + score-based layer-2 rotation (Go OCR parity with Python) (#18299)
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.
2026-08-17 15:13:56 +08:00

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
}