Files
ragflow/internal/deepdoc/parser/pdf/util/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

279 lines
9.8 KiB
Go

package util
import (
"bytes"
"encoding/base64"
"encoding/json"
"image"
"image/color"
"image/png"
"math"
"os"
"path/filepath"
"strings"
"testing"
)
// TestWarpCropMatchesGolden locks the perspective de-skew behaviour of
// WarpCrop against a reference warp (perspective transform with bicubic
// resampling) generated offline by testdata/gen_warp_golden.py. The reference
// uses the same homogeneous mapping as WarpCrop, so this test pins the
// geometry (output size + de-skew) of the implementation. Minor
// resampling-kernel differences between the reference sampler and the Go
// Catmull-Rom sampler are absorbed by the MSE tolerance.
//
// This is the unit-tier (model-free) lock for the warp step: the perspective
// de-skew applied to OCR detection quads before recognition.
func TestWarpCropMatchesGolden(t *testing.T) {
metaPath := filepath.Join("testdata", "warp_meta.json")
metaBytes, err := os.ReadFile(metaPath)
if err != nil {
t.Fatalf("read meta: %v", err)
}
var meta struct {
Src [4][2]float64 `json:"src"`
W int `json:"w"`
H int `json:"h"`
}
if err := json.Unmarshal(metaBytes, &meta); err != nil {
t.Fatalf("parse meta: %v", err)
}
var pts [4]Pt
for i := range meta.Src {
pts[i] = Pt{X: meta.Src[i][0], Y: meta.Src[i][1]}
}
src := loadGolden(t, filepath.Join("testdata", "warp_src.b64"))
expected := loadGolden(t, filepath.Join("testdata", "warp_expected.b64"))
got := WarpCrop(src, pts)
// Output size must match the reference contract exactly:
// W = int(max(|p0-p1|,|p2-p3|)), H = int(max(|p0-p3|,|p1-p2|)).
if got.Bounds().Dx() != meta.W || got.Bounds().Dy() != meta.H {
t.Fatalf("output size = %dx%d, want %dx%d",
got.Bounds().Dx(), got.Bounds().Dy(), meta.W, meta.H)
}
if expected.Bounds().Dx() != meta.W || expected.Bounds().Dy() != meta.H {
t.Fatalf("golden size = %dx%d, want %dx%d",
expected.Bounds().Dx(), expected.Bounds().Dy(), meta.W, meta.H)
}
mse := imageMSE(got, expected)
t.Logf("WarpCrop vs golden MSE = %.4f (RMSE/channel = %.4f)", mse, math.Sqrt(mse))
// Generous enough to absorb resampling-kernel differences, tight enough
// to catch a grossly wrong implementation (e.g. an axis-aligned crop of
// the same quad would diverge by orders of magnitude on this skewed input).
const maxMSE = 30.0
if mse > maxMSE {
t.Errorf("WarpCrop de-skew diverges from golden: MSE=%.4f > %.4f", mse, maxMSE)
}
// Sanity: WarpCrop must actually de-skew, not just return an axis-aligned
// bbox crop of the quad. On this perspective (non-parallelogram) input the
// output dimensions differ from the axis-aligned bbox, so the two are
// trivially unequal — confirm that rather than asserting a number.
bbox := axisFallback(src, pts)
if bbox.Bounds().Dx() == meta.W && bbox.Bounds().Dy() == meta.H {
t.Errorf("WarpCrop output size %dx%d equals the axis-aligned fallback size; warp may not be de-skewing",
meta.W, meta.H)
}
}
// TestWarpCropDegenerateQuadIsSafe checks that a collinear (degenerate) quad
// does not panic and returns a non-nil crop (falls back to axis-aligned).
func TestWarpCropDegenerateQuadIsSafe(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 50, 50))
// All four corners on a single line -> singular homography.
pts := [4]Pt{{10, 10}, {20, 10}, {30, 10}, {40, 10}}
got := WarpCrop(src, pts)
if got == nil {
t.Fatal("WarpCrop returned nil for degenerate quad")
}
if got.Bounds().Dx() <= 0 || got.Bounds().Dy() <= 0 {
t.Errorf("WarpCrop returned empty crop for degenerate quad: %dx%d",
got.Bounds().Dx(), got.Bounds().Dy())
}
}
// TestWarpCropAxisAlignedQuadIsStable checks that an already axis-aligned,
// axis-parallel quad is reproduced (up to bicubic resampling) without
// distortion — i.e. the output matches the source sub-rect.
func TestWarpCropAxisAlignedQuadIsStable(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 100, 100))
// Fill with a checkerboard so resampling has signal.
for y := 0; y < 100; y++ {
for x := 0; x < 100; x++ {
if ((x/10)+(y/10))%2 == 0 {
src.SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
} else {
src.SetRGBA(x, y, color.RGBA{255, 255, 255, 255})
}
}
}
// Exact axis-aligned rectangle -> output should match the source sub-rect.
pts := [4]Pt{{20, 20}, {80, 20}, {80, 70}, {20, 70}}
got := WarpCrop(src, pts)
if got.Bounds().Dx() != 60 || got.Bounds().Dy() != 50 {
t.Fatalf("axis-aligned output size = %dx%d, want 60x50",
got.Bounds().Dx(), got.Bounds().Dy())
}
// For an axis-parallel quad the warp is identity (just a sub-rect copy),
// so it must match FastCrop of the same bbox up to resampling error.
want := FastCrop(src, 20, 20, 80, 70)
mse := imageMSE(got, want)
t.Logf("axis-aligned WarpCrop vs FastCrop MSE = %.4f", mse)
if mse > 5.0 {
t.Errorf("axis-aligned warp diverged from the source sub-rect: MSE=%.4f > 5.0", mse)
}
}
// loadGolden reads a single-line base64-encoded PNG fixture (committed as
// text so pre-commit text filters cannot corrupt the binary signature).
func loadGolden(t *testing.T, path string) *image.RGBA {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
dec, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
if err != nil {
t.Fatalf("base64 decode %s: %v", path, err)
}
img, err := png.Decode(bytes.NewReader(dec))
if err != nil {
t.Fatalf("decode %s: %v", path, err)
}
return toRGBA(img)
}
// imageMSE returns the mean squared error across all RGBA channels between a
// and b (both must have identical dimensions).
func imageMSE(a, b *image.RGBA) float64 {
ba, bb := a.Bounds(), b.Bounds()
if ba.Dx() != bb.Dx() || ba.Dy() != bb.Dy() {
return math.MaxFloat64
}
var acc float64
n := ba.Dx() * ba.Dy()
for y := 0; y < ba.Dy(); y++ {
for x := 0; x < ba.Dx(); x++ {
ca := a.RGBAAt(x, y)
cb := b.RGBAAt(x, y)
acc += sqDiff(ca.R, cb.R) + sqDiff(ca.G, cb.G) + sqDiff(ca.B, cb.B) + sqDiff(ca.A, cb.A)
}
}
return acc / float64(n*4)
}
func sqDiff(x, y uint8) float64 {
d := float64(x) - float64(y)
return d * d
}
// TestWarpCropRespectsNonZeroOrigin guards the source-image bounds handling in
// sampleBicubic: a source with a non-zero origin (Min != (0,0)) must be sampled
// at its absolute coordinates, not relative to (0,0). WarpCrop on such an image
// must produce the same crop as WarpCrop on an equivalent (0,0)-origin image
// holding identical pixels at the same absolute coordinates.
//
// The quad is interior to both images so the sampler never reaches either
// image's edge; this isolates the origin handling from edge-replication
// differences and exercises the far-edge clamp where a zero-origin assumption
// would clamp too early.
func TestWarpCropRespectsNonZeroOrigin(t *testing.T) {
gradient := func(x, y int) color.RGBA {
return color.RGBA{uint8(x % 256), uint8(y % 256), uint8((x + y) % 256), 255}
}
// Non-zero-origin source with its own pixel buffer.
origin := image.Pt(50, 50)
sub := image.NewRGBA(image.Rect(origin.X, origin.Y, origin.X+200, origin.Y+200))
for y := origin.Y; y < origin.Y+200; y++ {
for x := origin.X; x < origin.X+200; x++ {
sub.SetRGBA(x, y, gradient(x, y))
}
}
// Equivalent (0,0)-origin image holding the same pixels at the same
// absolute coordinates.
flat := image.NewRGBA(image.Rect(0, 0, 300, 300))
for y := 0; y < 300; y++ {
for x := 0; x < 300; x++ {
flat.SetRGBA(x, y, gradient(x, y))
}
}
// Interior quad in absolute coordinates (so sampling stays away from both
// images' edges).
pts := [4]Pt{
{X: 60, Y: 60},
{X: 240, Y: 60},
{X: 240, Y: 240},
{X: 60, Y: 240},
}
gotSub := WarpCrop(sub, pts)
gotFlat := WarpCrop(flat, pts)
if gotSub.Bounds() != gotFlat.Bounds() {
t.Fatalf("output size mismatch: sub=%v flat=%v", gotSub.Bounds(), gotFlat.Bounds())
}
// With correct origin handling the two are pixel-identical; a zero-origin
// assumption clamps ~20% of the crop too early and diverges by orders of
// magnitude.
if mse := imageMSE(gotSub, gotFlat); mse > 1e-3 {
t.Errorf("WarpCrop ignored the source image origin: MSE between sub- and flat-frame warps = %v", mse)
}
}
// TestWarpCropRejectsMalformedQuad guards against a process-crashing panic /
// OOM on an out-of-range or non-finite detector quad. The old FastCrop path
// clamped coordinates to the source bounds before allocating; WarpCrop must be
// equally safe. A finite but absurd coordinate (e.g. 3e18) would otherwise
// reach image.NewRGBA and panic with "huge or negative dimensions", and a
// non-finite coordinate would drive an undefined-size allocation.
//
// This is a regression guard for the untrusted-boundary contract: OCRDetect
// accepts coordinates from a configured DocAnalyzer / DEEPDOC_URL, and the
// first-party Python detector clips its points, but that invariant is not
// enforced at this Go boundary.
func TestWarpCropRejectsMalformedQuad(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 10, 10))
cases := []struct {
name string
pts [4]Pt
}{
{"huge x", [4]Pt{{0, 0}, {3e18, 0}, {3e18, 2}, {0, 2}}},
{"huge negative", [4]Pt{{-3e18, 0}, {0, 0}, {0, 2}, {-3e18, 2}}},
{"nan", [4]Pt{{0, 0}, {math.NaN(), 0}, {10, 10}, {0, 10}}},
{"inf", [4]Pt{{0, 0}, {math.Inf(1), 0}, {10, 10}, {0, 10}}},
{"outside bounds", [4]Pt{{-100, -100}, {200, -100}, {200, 200}, {-100, 200}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var got *image.RGBA
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("WarpCrop panicked on %q: %v", tc.name, r)
}
}()
got = WarpCrop(src, tc.pts)
}()
if got == nil {
t.Fatalf("WarpCrop returned nil on %q", tc.name)
}
w, h := got.Bounds().Dx(), got.Bounds().Dy()
if w <= 0 || h <= 0 {
t.Errorf("WarpCrop returned an empty crop on %q: %dx%d", tc.name, w, h)
}
if w > maxWarpDim || h > maxWarpDim {
t.Errorf("WarpCrop returned an unbounded crop on %q: %dx%d", tc.name, w, h)
}
})
}
}