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.
This commit is contained in:
Jack
2026-08-17 15:13:56 +08:00
committed by GitHub
parent 272645a27a
commit 8d20cbd0b3
15 changed files with 1938 additions and 19 deletions

View File

@@ -15,9 +15,6 @@ from deepdoc.vision.ocr import OCR
logger = logging.getLogger(__name__)
# Confidence fill value — OSS recognize_batch does not return confidence scores.
_CONFIDENCE_FILL = 1.0
class OCRAdapter:
"""Calls OCR.detect() and OCR.recognize_batch(), converts to wire format."""
@@ -83,10 +80,12 @@ class OCRAdapter:
img = self._decode_bgr(image_data)
# OCR.recognize_batch() returns List[str]; single cropped image → list of 1 image
texts = self._ocr.recognize_batch([img])
# OCR.recognize_batch_with_score() returns List[(text, score)] so the
# client can do score-based layer-2 rotation selection. The score is
# the real recognition confidence (previously a constant 1.0 fill).
scored = self._ocr.recognize_batch_with_score([img])
items = [[text, _CONFIDENCE_FILL] for text in texts]
items = [[text, score] for text, score in scored]
# 4-level nesting matching Go [][][][]any:
# batch → page → items list → pair [text, confidence]

View File

@@ -162,7 +162,6 @@ class TextRecognizer:
return padding_im
def resize_norm_img_vl(self, img, image_shape):
imgC, imgH, imgW = image_shape
img = img[:, :, ::-1] # bgr2rgb
resized_image = cv2.resize(img, (imgW, imgH), interpolation=cv2.INTER_LINEAR)
@@ -197,7 +196,6 @@ class TextRecognizer:
return np.reshape(img_black, (c, row, col)).astype(np.float32)
def srn_other_inputs(self, image_shape, num_heads, max_text_length):
imgC, imgH, imgW = image_shape
feature_dim = int((imgH / 8) * (imgW / 8))
@@ -281,7 +279,6 @@ class TextRecognizer:
return img
def resize_norm_img_svtr(self, img, image_shape):
imgC, imgH, imgW = image_shape
resized_image = cv2.resize(img, (imgW, imgH), interpolation=cv2.INTER_LINEAR)
resized_image = resized_image.astype("float32")
@@ -291,7 +288,6 @@ class TextRecognizer:
return resized_image
def resize_norm_img_abinet(self, img, image_shape):
imgC, imgH, imgW = image_shape
resized_image = cv2.resize(img, (imgW, imgH), interpolation=cv2.INTER_LINEAR)
@@ -307,7 +303,6 @@ class TextRecognizer:
return resized_image
def norm_img_can(self, img, image_shape):
img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # CAN only predict gray scale image
if self.rec_image_shape[0] == 1:
@@ -641,6 +636,27 @@ class OCR:
texts.append(text)
return texts
def recognize_batch_with_score(self, img_list, device_id: int | None = None):
"""Like recognize_batch but keeps the per-item recognition score.
Returns a list of (text, score) tuples. Text below drop_score is
blanked, matching recognize_batch, but the score is preserved so the
caller (the OCR HTTP adapter) can surface it for score-based layer-2
rotation selection. Adding this method instead of changing
recognize_batch keeps the in-process __ocr path (pdf_parser.py) on the
original text-only contract.
"""
if device_id is None:
device_id = 0
rec_res, elapse = self.text_recognizer[device_id](img_list)
out = []
for i in range(len(rec_res)):
text, score = rec_res[i]
if score < self.drop_score:
text = ""
out.append((text, score))
return out
def __call__(self, img, device_id=0, cls=True):
time_dict = {"det": 0, "rec": 0, "cls": 0, "all": 0}
if device_id is None:

View File

@@ -0,0 +1,74 @@
"""Offline unit tests for score-bearing OCR recognition.
These bypass model loading by constructing OCR / OCRAdapter via
object.__new__ and injecting fake recognizers, so they run without the
DeepDoc weights. They verify:
- recognize_batch_with_score returns (text, score) tuples,
- text below drop_score is blanked while the score is preserved,
- the original recognize_batch (text-only) contract is unchanged,
- OCRAdapter.recognize surfaces the real score instead of a 1.0 fill.
"""
import unittest
from deepdoc.server.adapters.ocr_adapter import OCRAdapter
from deepdoc.vision.ocr import OCR
class FakeRecognizer:
"""Callable stand-in for OCR.text_recognizer[device_id].
results is a list of recognizer outputs, one per call; each output is a
list of (text, score) tuples as produced by TextRecognizer.__call__.
"""
def __init__(self, results):
self._results = results
self.calls = 0
def __call__(self, img_list):
res = self._results[self.calls]
self.calls += 1
return res, 0.0
def make_ocr(results, drop_score=0.5):
ocr = object.__new__(OCR)
ocr.text_recognizer = [FakeRecognizer(results)]
ocr.drop_score = drop_score
return ocr
class TestRecognizeBatchWithScore(unittest.TestCase):
def test_returns_score_tuples(self):
ocr = make_ocr([[["hello", 0.92]]])
self.assertEqual(ocr.recognize_batch_with_score(["img"]), [("hello", 0.92)])
def test_blanks_below_drop_score_keeps_score(self):
ocr = make_ocr([[["garble", 0.10]]], drop_score=0.5)
# text is blanked by drop_score, but the score is preserved for
# layer-2 selection.
self.assertEqual(ocr.recognize_batch_with_score(["img"]), [("", 0.10)])
def test_original_recognize_batch_unchanged(self):
# The in-process __ocr path keeps its text-only contract.
ocr = make_ocr([[["hello", 0.92]]])
self.assertEqual(ocr.recognize_batch(["img"]), ["hello"])
class TestOCRAdapterScore(unittest.TestCase):
def test_recognize_surfaces_real_score(self):
adapter = object.__new__(OCRAdapter)
class FakeOCR:
def recognize_batch_with_score(self, imgs):
return [("world", 0.88)]
adapter._ocr = FakeOCR()
adapter._decode_bgr = lambda data: "img"
out = adapter.recognize(b"fake")
self.assertEqual(out, {"output": [[[["world", 0.88]]]]})
if __name__ == "__main__":
unittest.main()

View File

@@ -37,8 +37,20 @@ func (p *Parser) ocrDetectAndRecognize(ctx context.Context, pageImg image.Image,
if x0 >= x1 || y0 >= y1 {
continue
}
cropped := util.FastCrop(pageImg, x0, y0, x1, y1)
texts, recErr := p.inferOCRRecognize(ctx, doc, cropped)
// De-skew the quad with a perspective transform (WarpCrop, layer 1),
// then recognize via ocrRecognizeWithRotation which applies layer-2
// score-based 0/CW90/CCW90 selection for tall crops (get_rotate_crop_image
// parity), so the recognizer receives a rectangular, horizontal crop
// instead of the slanted detection region. The emitted box bounds below
// still use the axis-aligned detection bbox (x0..y1); only the crop fed
// to recognition is transformed.
cropped := util.WarpCrop(pageImg, [4]util.Pt{
{X: b.X0, Y: b.Y0},
{X: b.X1, Y: b.Y1},
{X: b.X2, Y: b.Y2},
{X: b.X3, Y: b.Y3},
})
texts, recErr := p.ocrRecognizeWithRotation(ctx, doc, cropped)
if recErr != nil {
slog.Warn(logLabel+" OCR recognize failed", "page", pageNum, "err", recErr)
continue
@@ -79,6 +91,58 @@ func (p *Parser) ocrDetectAndRecognize(ctx context.Context, pageImg image.Image,
return result
}
// ocrRecognizeWithRotation recognizes a single cropped text region, applying
// layer-2 rotation selection for tall, narrow crops.
//
// When a crop's height is at least 1.5x its width, the text is most likely a
// vertical line and the recognizer — trained on horizontal text — only reads
// it cleanly after a 90 deg rotation. The crop is recognized at 0, CW90, and
// CCW90 and the orientation with the highest recognition confidence is kept.
// The emitted box bounds are unchanged; only the recognized text is
// effectively rotated. Layer 1 (the perspective de-skew) is applied at crop
// time by WarpCrop before this runs.
//
// The DeepDoc rec service surfaces the real recognition confidence, so the
// orientation is picked by score rather than by a fixed rotation.
func (p *Parser) ocrRecognizeWithRotation(ctx context.Context, doc pdf.DocAnalyzer, cropped image.Image) ([]pdf.OCRText, error) {
b := cropped.Bounds()
// Short / wide crops are already horizontal — recognize once at 0 deg.
if float64(b.Dy()) < 1.5*float64(b.Dx()) {
return p.inferOCRRecognize(ctx, doc, cropped)
}
candidates := []image.Image{
cropped,
util.RotateImageCW(cropped, 90), // CW90
util.RotateImageCW(cropped, 270), // CCW90
}
var best []pdf.OCRText
bestScore := -1.0
for _, c := range candidates {
texts, err := p.inferOCRRecognize(ctx, doc, c)
if err != nil {
return nil, err
}
if s := ocrBestScore(texts); s > bestScore {
bestScore = s
best = texts
}
}
return best, nil
}
// ocrBestScore is the layer-2 orientation score: the highest recognition
// confidence among the recognized items. A correctly oriented line reads with
// high confidence; a mis-rotated line reads with low confidence.
func ocrBestScore(texts []pdf.OCRText) float64 {
best := 0.0
for _, t := range texts {
if t.Confidence > best {
best = t.Confidence
}
}
return best
}
// ocrMergeChars runs full-page detect on a page that has embedded chars,
// merges the chars into detect regions, and OCRs any regions without chars.
// Matches Python's __ocr: detect → match chars to boxes → use char text
@@ -231,8 +295,17 @@ func (p *Parser) ocrTableCells(ctx context.Context, cells []pdf.TSRCell, tableIm
if x0 >= x1 || y0 >= y1 {
continue
}
cropped := util.FastCrop(tableImg, x0, y0, x1, y1)
texts, err := p.inferOCRRecognize(ctx, doc, cropped)
// De-skew via WarpCrop and recognize via ocrRecognizeWithRotation like
// the other OCR paths. Table cells are axis-aligned, so WarpCrop
// early-exits to FastCrop and ocrRecognizeWithRotation recognizes once
// at 0 deg; the bounds-clamp / non-finite guard is still inherited.
cropped := util.WarpCrop(tableImg, [4]util.Pt{
{X: float64(x0), Y: float64(y0)},
{X: float64(x1), Y: float64(y0)},
{X: float64(x1), Y: float64(y1)},
{X: float64(x0), Y: float64(y1)},
})
texts, err := p.ocrRecognizeWithRotation(ctx, doc, cropped)
if err != nil {
slog.Warn("table cell OCR failed", "err", err)
continue
@@ -290,10 +363,20 @@ func (p *Parser) buildTextBoxes(ctx context.Context, pageImg image.Image,
}
if len(needOCR) > 0 && doc != nil && doc.Health() {
for _, idx := range needOCR {
cropped := util.FastCrop(pageImg,
int(boxes[idx].x0*scale), int(boxes[idx].y0*scale),
int(boxes[idx].x1*scale), int(boxes[idx].y1*scale))
texts, err := p.inferOCRRecognize(ctx, doc, cropped)
// De-skew via WarpCrop and recognize via ocrRecognizeWithRotation
// the same way ocrDetectAndRecognize does, so all Go OCR paths feed
// the recognizer an identical geometry. Char/table-derived boxes are
// axis-aligned, so WarpCrop early-exits to FastCrop and
// ocrRecognizeWithRotation recognizes once at 0 deg here, while still
// inheriting WarpCrop's bounds-clamp / non-finite guard on the
// untrusted detector box.
cropped := util.WarpCrop(pageImg, [4]util.Pt{
{X: boxes[idx].x0 * scale, Y: boxes[idx].y0 * scale},
{X: boxes[idx].x1 * scale, Y: boxes[idx].y0 * scale},
{X: boxes[idx].x1 * scale, Y: boxes[idx].y1 * scale},
{X: boxes[idx].x0 * scale, Y: boxes[idx].y1 * scale},
})
texts, err := p.ocrRecognizeWithRotation(ctx, doc, cropped)
if err != nil {
slog.Warn("ocr merge: recognize failed", "page", pageNum, "err", err)
continue

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,134 @@
package pdf
import (
"context"
"image"
"sync"
"testing"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
)
// fakeRotateDoc is a DocAnalyzer whose OCRRecognize returns text and a
// recognition score keyed on the image orientation: a "wide" image (w >= h)
// reads as a clean coherent line with wideScore, a "tall" image (h > w) reads
// as a low-score / garbled result with tallScore. This mirrors reality: a
// correctly oriented horizontal line reads with high confidence, while a
// 90°-rotated line reads with low confidence. It also counts calls so the
// tests can assert the layer-2 fan-out (1 call for short crops, 3 for tall
// crops) and the score-based selection outcome.
type fakeRotateDoc struct {
mu sync.Mutex
calls int
wideText string
tallText string
wideScore float64
tallScore float64
}
func (f *fakeRotateDoc) OCRRecognize(_ context.Context, img image.Image) ([]pdf.OCRText, error) {
f.mu.Lock()
f.calls++
f.mu.Unlock()
b := img.Bounds()
txt := f.tallText
score := f.tallScore
if b.Dx() >= b.Dy() {
txt = f.wideText
score = f.wideScore
}
if txt == "" {
return nil, nil
}
return []pdf.OCRText{{Text: txt, Confidence: score}}, nil
}
func (f *fakeRotateDoc) Health() bool { return true }
func (f *fakeRotateDoc) DLA(context.Context, image.Image) ([]pdf.DLARegion, error) {
return nil, nil
}
func (f *fakeRotateDoc) TSR(context.Context, image.Image) ([]pdf.TSRCell, error) {
return nil, nil
}
func (f *fakeRotateDoc) OCRDetect(context.Context, image.Image) ([]pdf.OCRBox, error) {
return nil, nil
}
// TestOCRRecognizeWithRotation_ShortCrop_NoRotation verifies that crops whose
// height is below 1.5x their width are recognized once at 0° with no rotation
// fan-out — i.e. layer 2 is a no-op for the common horizontal case.
func TestOCRRecognizeWithRotation_ShortCrop_NoRotation(t *testing.T) {
crop := image.NewRGBA(image.Rect(0, 0, 60, 20)) // w=60, h=20 -> ratio 0.33
doc := &fakeRotateDoc{wideText: "Hello", tallText: "x"}
p := &Parser{}
texts, err := p.ocrRecognizeWithRotation(context.Background(), doc, crop)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if doc.calls != 1 {
t.Fatalf("expected 1 rec call for short crop, got %d", doc.calls)
}
if len(texts) != 1 || texts[0].Text != "Hello" {
t.Fatalf("expected [Hello], got %+v", texts)
}
}
// TestOCRRecognizeWithRotation_TallCrop_PicksBestScore verifies that a tall
// narrow crop (h/w >= 1.5) is tried at 0°, CW90°, CCW90° and the orientation
// with the highest recognition score wins (score-based, matching Python).
func TestOCRRecognizeWithRotation_TallCrop_PicksBestScore(t *testing.T) {
crop := image.NewRGBA(image.Rect(0, 0, 20, 60)) // w=20, h=60 -> ratio 3.0
doc := &fakeRotateDoc{wideText: "Hello world", tallText: "x", wideScore: 0.95, tallScore: 0.30}
p := &Parser{}
texts, err := p.ocrRecognizeWithRotation(context.Background(), doc, crop)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if doc.calls != 3 {
t.Fatalf("expected 3 rec calls for tall crop, got %d", doc.calls)
}
if len(texts) != 1 || texts[0].Text != "Hello world" {
t.Fatalf("expected best-score text [Hello world], got %+v", texts)
}
}
// TestOCRRecognizeWithRotation_TallCrop_TieKeepsZero verifies the stable
// tie-break: when all three orientations yield equal score, the 0° result is
// kept (matches Python's first-wins selection order).
func TestOCRRecognizeWithRotation_TallCrop_TieKeepsZero(t *testing.T) {
crop := image.NewRGBA(image.Rect(0, 0, 20, 60)) // w=20, h=60 -> ratio 3.0
doc := &fakeRotateDoc{wideText: "AB", tallText: "CD", wideScore: 0.5, tallScore: 0.5}
p := &Parser{}
texts, err := p.ocrRecognizeWithRotation(context.Background(), doc, crop)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if doc.calls != 3 {
t.Fatalf("expected 3 rec calls, got %d", doc.calls)
}
if len(texts) != 1 || texts[0].Text != "CD" {
t.Fatalf("expected tie to keep 0° text [CD], got %+v", texts)
}
}
// TestOCRBestScore verifies the orientation score is the max recognition
// confidence across items.
func TestOCRBestScore(t *testing.T) {
cases := []struct {
name string
texts []pdf.OCRText
want float64
}{
{"empty", nil, 0},
{"single", []pdf.OCRText{{Text: "ab", Confidence: 0.9}}, 0.9},
{"multi", []pdf.OCRText{{Text: "ab", Confidence: 0.7}, {Text: "c", Confidence: 0.95}}, 0.95},
{"keeps max", []pdf.OCRText{{Text: "a", Confidence: 0.2}, {Text: "b", Confidence: 0.8}}, 0.8},
}
for _, c := range cases {
if got := ocrBestScore(c.texts); got != c.want {
t.Fatalf("%s: got %v, want %v", c.name, got, c.want)
}
}
}

View File

@@ -0,0 +1,211 @@
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
}

View File

@@ -0,0 +1,151 @@
"""Generate golden data for the Go util.WarpCrop unit test.
Produces, under this directory:
* warp_src.png - a synthetic source image with high-frequency content
* warp_expected.png - the perspective-de-skewed crop, computed with PIL's
PERSPECTIVE transform (BICUBIC)
* warp_meta.json - the 4 source corners (TL,TR,BR,BL) and the expected
output size (w,h) consumed by warp_test.go.
The reference perspective transform and the Go WarpCrop implementation compute
the same homogeneous mapping (destination -> source for the backward sampler);
any minor resampling-kernel difference (PIL-bicubic vs the Go Catmull-Rom
sampler) is absorbed by the MSE tolerance in the test.
"""
import base64
import io
import json
import math
import os
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
# A general quadrilateral (true perspective, not a parallelogram) inside the
# source image. Order: top-left, top-right, bottom-right, bottom-left.
SRC = [(50, 40), (260, 25), (250, 170), (40, 150)]
def dist(a, b):
return math.hypot(a[0] - b[0], a[1] - b[1])
def out_size(src):
w = int(max(dist(src[0], src[1]), dist(src[2], src[3])))
h = int(max(dist(src[0], src[3]), dist(src[1], src[2])))
return w, h
def solve_homography(src, dst):
"""Solve the 8-DOF homography mapping src->dst with bottom-right fixed to 1.
Returns coeffs [a,b,c,d,e,f,g,h] for PIL's PERSPECTIVE:
x' = (a*x + b*y + c) / (g*x + h*y + 1)
y' = (d*x + e*y + f) / (g*x + h*y + 1)
"""
A = [[0.0] * 9 for _ in range(8)]
b = [0.0] * 8
for i in range(4):
sx, sy = src[i]
dx, dy = dst[i]
# x' equation.
A[2 * i][0] = sx
A[2 * i][1] = sy
A[2 * i][2] = 1.0
A[2 * i][6] = -sx * dx
A[2 * i][7] = -sy * dx
b[2 * i] = dx
# y' equation.
A[2 * i + 1][3] = sx
A[2 * i + 1][4] = sy
A[2 * i + 1][5] = 1.0
A[2 * i + 1][6] = -sx * dy
A[2 * i + 1][7] = -sy * dy
b[2 * i + 1] = dy
# Gaussian elimination with partial pivoting.
for col in range(8):
pivot = max(range(col, 8), key=lambda r: abs(A[r][col]))
A[col], A[pivot] = A[pivot], A[col]
b[col], b[pivot] = b[pivot], b[col]
piv = A[col][col]
for r in range(col + 1, 8):
f = A[r][col] / piv
for c in range(col, 9):
A[r][c] -= f * A[col][c]
b[r] -= f * b[col]
x = [0.0] * 8
for r in range(7, -1, -1):
s = b[r]
for c in range(r + 1, 8):
s -= A[r][c] * x[c]
x[r] = s / A[r][r]
return x # [a,b,c,d,e,f,g,h]
def make_source(path):
img = Image.new("RGB", (320, 210), (255, 255, 255))
d = ImageDraw.Draw(img)
# Border.
d.rectangle([4, 4, 315, 205], outline=(0, 0, 0), width=2)
# Solid color blocks (smooth edges -> small resampling-kernel differences).
d.rectangle([20, 20, 90, 90], fill=(200, 30, 30))
d.rectangle([110, 30, 170, 100], fill=(30, 160, 40))
d.rectangle([200, 20, 300, 80], fill=(30, 60, 200))
# Circle outline (interpolation signal, smooth curvature).
d.ellipse([40, 120, 130, 200], outline=(0, 0, 0), width=3)
# A few thick diagonal bars (width 3) to exercise bicubic sampling without
# pushing content to the Nyquist limit.
for k in range(0, 160, 28):
d.line([(175 + k, 110), (175 + k + 60, 200)], fill=(0, 0, 0), width=3)
img.save(path)
def png_b64(img):
"""Encode a PIL image as a single-line base64 PNG string.
Golden fixtures are committed as base64 TEXT rather than binary PNG so the
repo's pre-commit text filters (mixed-line-ending / end-of-file-fixer) can
never corrupt the binary signature. A trailing newline added to the .b64
file is harmless: base64 decode ignores surrounding whitespace.
"""
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def main():
src_path = os.path.join(HERE, "warp_src.png")
exp_path = os.path.join(HERE, "warp_expected.png")
src_b64 = os.path.join(HERE, "warp_src.b64")
exp_b64 = os.path.join(HERE, "warp_expected.b64")
meta_path = os.path.join(HERE, "warp_meta.json")
make_source(src_path)
w, h = out_size(SRC)
dst = [(0, 0), (w, 0), (w, h), (0, h)]
# PIL's PERSPECTIVE coeffs map DESTINATION -> SOURCE directly. So solve the
# homography dst->src, matching the Go WarpCrop implementation (which
# computes src->dst, then uses its inverse for backward mapping).
coeffs = solve_homography(dst, SRC)
img = Image.open(src_path).convert("RGB")
warped = img.transform((w, h), Image.PERSPECTIVE, coeffs, resample=Image.BICUBIC)
warped.save(exp_path)
# Committed (text) golden fixtures.
with open(src_b64, "w") as f:
f.write(png_b64(img))
with open(exp_b64, "w") as f:
f.write(png_b64(warped))
with open(meta_path, "w") as f:
json.dump({"src": SRC, "w": w, "h": h}, f, indent=2)
print(f"wrote {src_path} ({img.size}), {exp_path} ({warped.size}), {src_b64}, {exp_b64}, {meta_path}")
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,22 @@
{
"src": [
[
50,
40
],
[
260,
25
],
[
250,
170
],
[
40,
150
]
],
"w": 210,
"h": 145
}

View File

@@ -0,0 +1 @@
iVBORw0KGgoAAAANSUhEUgAAAUAAAADSCAIAAAC0K44BAAAHK0lEQVR4nO3dwXXcRhBFUcCHeXDtCLhyLI7NsWilCLyeSOBFiyQ0DcIg0I3+v+rdneSjEjDmU9dAI2lelmUC4OmP0RcA4DwCBowRMGDsZf2NeZ5HXQeAg9bPrTiBAWMv9XfxXBrQVO/InMCAMQIGjBEwYIyAAWMEDBgjYMAYAQPGCBgwRsCAMQIGjBEwYIyAAWMEDBjb+NNIx/18fW11HTreHo/RlxDB618/R1+Ck8ePt3M/kBMYMEbAgDECBowRMGCMgAFjBAwYI2DAGAEDxggYMHbpk1hYe/3nz9GXcMbj739HXwLO4wQGjBEwYIyAAWMEDBgjYMAYAQPGCBgwRsCAMQIGjBEwYIyAAWMEDBgjYMAYAQPGCBgwRsCAMQIGjBEwYIyAAWMEDBgjYMAYAQPGCBgwRsCAMQIGjBEwYIyAAWMEDBgjYMAYAQPGCBgwRsCAMQIGjBEwYIyAAWMEDBgjYMAYAQPGCBgwRsCAMQIGjBEwYIyAAWMEDBgjYMAYAQPGCBgwRsCAMQIGjBEwYOxl9AUgpsePt9GXkAInMGCMgAFjBAwYI2DAGAEDxggYMEbAgDECBowRMGDs0iex3h6PVtcB4AROYMAYAQPGCBgwRsCAMQIGjBEwYIyAAWMEDBgjYMDYvCzL5zfmeZqm9fcA0FEXygkMGCNgwBgBA8YIGDBGwIAxAgaMETBgjIDxm/I7jcx0mUnA+FS+Mtp+zTGza8MEjF8cz59sM2sEjF/qD9UyU23mWplJwNjmcigln0nA+NTjz7EwsysCxm9clklmFgSMPUbLZM6ZBIxnLsskMycCxibHZTLnTALG/7NYJnPOvPSPm3n51qvG3yu0LEvzr11mtp8Z+O/Eavh6hXlNvmv9GrZ6EZh5emaKvxNrfic+0474MplzZpwV+sjLcfDXvJ1RH/8pyZnstEymnBlhhd55RZrcS+/5+gSXyZwz60K9T+DNtJpHtfN7AKa/5F0xz3Pz+2Xm6R/u+h64fke6vOv6827+LOHfHtt9vCHPTL+Av0r35svIlrHXxxvyzDQLWCHd/QsI3PCa7FPZbDNtAn4634anu/Z0MVGPYvFlMudMj4Drg3fUlezIcBQrL5M5ZxoELHvw1uqjeODF3EBqmcw5Uz3gHr8L11vshmWXyZwzpQN2rLfI07DOMplzpm7AvvUWsRteE1kmc84UDdi93iJww4LLZM6ZigHHqLdI0rDCMplzplzAkeotAje8NnyZzDlTK+B49RZRG5ZaJnPO1Ar4Q6R6i3h3VOgskzlnCgUc/s/Kf9xXpEP4SbAFVX+mSsCBv6Y3RbpfkWUy50yVgD9EPX6LqHensEzmnCkRcKTj6LjAdx1mQdWfKRHwh6gH1FrUexy+TOacOT7g8M+ualGfZsVbUPVnjg8YUQVYUPVnDg442BF0QrBXINiCqj9T5QTOsz8Xge830oKqNrOmEjCisl5QBWc+GRlwsO3xtHivQ5gFVX+mxAkceJ/cEfuuHRdUx5kSASM8lwXVbiYBo5cAC6r+TAJGR+4LqtrMtTJzWMAJP4BVi/qRrK/YLaj6MzmB0Zf1gio48wkBozuXBdVxJgHjbkYLqv5MAsYdXBZUu5kEjJs4Lqj6MwkYY1gsqPozCRj3sVtQ9WcSMG7ltaDqzyRgjCS+oOrPJGDczWhB1Z85LOBsnyLclPbzpC4Lqv5MTmCMJ7ug6s8kYIxhsaDqzyRgDKO/oOrPlAg459vgnHe9Q3BB1Z85MuBsT26+kvl1EF9Q9WdKnMDITHlB1Z+pEnC2fTLb/R4ntaDqzxwccObtseAVmIQXVP2ZKicwktNcUPVnjg844Uey0n4A6ziRBVV/5viA1zI0nOEezxFcUPVnSgSc8yDKedf71BZU/ZkSAa/FPqBi311zwxdU/ZkqAWc7jrLd73FSC6r+TJWApwRPs3h2dZDOgqo/UyjgtXgNx7uj2wRbetvO1Aq4978HNcr6Xjh+jxBZUPVnagU8RWyYes9RWFD1Z8oFPMVqmHpbCbP0tp2pGPAUpWHqvWj4gqo/UzTgyb9h6m0i3tLbdqZuwJNzw9TbSYClt+1M6YAnz4apt61gS2/bmeoBT1XDyhk/XR71thJp6b0ys2YQ8FTdg2bDT1dFvf1YL71teQQ8TdOyLLJHcX3wUm9zYZbetjNtAi7qo3hsxvUFkG4/jktv75lmAU9b59uQjDfTpd47uSy9XWf6BVx8lXHvkjd/FtK9TYClt+3MuT6a7b4Wd6Jtci+95+O7ejzqt5hZnxwv14cOV16azczOvUE9coyTroh5npv/vzCaGSHg4sjfB3B9waZbBcuyNH+v5DLziet74B3LO/GZuML96XGrmXFO4NqVj3/QqhHrp1AXRXiIhbSM3qw2fIj18dBnWZaAKzTycDkn+80kYMDYxntgnc8YA9jHCQwYa/9+HcBtOIEBYwQMGCNgwNh/xBMe6SmdX9EAAAAASUVORK5CYII=

View File

@@ -0,0 +1,353 @@
package util
import (
"image"
"image/color"
"image/draw"
"math"
)
// Pt is a 2D float point used for warp corners.
type Pt struct {
X, Y float64
}
// WarpCrop de-skews a quadrilateral region from src using a perspective
// transform, producing the rectangular crop fed to text recognition.
//
// points must be the 4 corners in order: top-left, top-right, bottom-right,
// bottom-left (the DBNet quad order emitted by the OCR detector). The output
// size is (W, H) where
//
// W = int(max(|p0-p1|, |p2-p3|))
// H = int(max(|p0-p3|, |p1-p2|))
//
// Each destination pixel is mapped back to the source via the inverse
// homography and sampled with Catmull-Rom (bicubic) interpolation. Out-of-
// bounds source coordinates use BORDER_REPLICATE semantics (edge pixels
// repeated).
//
// WarpCrop performs NO rotation selection (the h/w >= 1.5 branch) — that
// belongs to the caller / layer 2.
//
// If the quad is degenerate (collinear / non-invertible homography), WarpCrop
// falls back to an axis-aligned crop of the quad's bounding box so callers
// stay safe.
// maxWarpDim bounds the allocated crop so a (clamped) quad can never drive an
// unbounded image.NewRGBA. Detector boxes arrive from a remote DocAnalyzer /
// DEEPDOC_URL and are treated as untrusted; this ceiling is a last line of
// defence against an unexpectedly large source image even after clamping.
const maxWarpDim = 1 << 16
func WarpCrop(src image.Image, points [4]Pt) *image.RGBA {
// Detection boxes come from a remote DocAnalyzer / DEEPDOC_URL and are
// effectively untrusted. FastCrop clamps its rectangle to the source
// bounds before allocating; this path must do the same on its four
// corners and must reject non-finite coordinates, so a malformed or
// out-of-range response cannot drive an unbounded image.NewRGBA (panic /
// OOM). On a normal in-bounds quad the clamp is a no-op, so detection
// accuracy is unchanged.
if !pointsFinite(points) {
return image.NewRGBA(image.Rect(0, 0, 1, 1))
}
rgba := toRGBA(src)
b := rgba.Bounds()
pts := clampQuad(points, b)
// Axis-aligned fast path: an axis-parallel quad is just a sub-rectangle,
// so the perspective warp degenerates to a copy. FastCrop does exactly
// that with a direct Pix slice copy (no per-pixel bicubic resampling),
// which is far cheaper. Table cells and char-derived boxes are always
// axis-aligned, so this short-circuits the common OCR paths to the cheap
// copy — the de-skew is only paid for genuinely slanted detection quads.
if axisAligned(pts) {
minX := int(math.Min(pts[0].X, math.Min(pts[1].X, math.Min(pts[2].X, pts[3].X))))
minY := int(math.Min(pts[0].Y, math.Min(pts[1].Y, math.Min(pts[2].Y, pts[3].Y))))
maxX := int(math.Max(pts[0].X, math.Max(pts[1].X, math.Max(pts[2].X, pts[3].X))))
maxY := int(math.Max(pts[0].Y, math.Max(pts[1].Y, math.Max(pts[2].Y, pts[3].Y))))
return FastCrop(rgba, minX, minY, maxX, maxY)
}
w := int(math.Max(dist(pts[0], pts[1]), dist(pts[2], pts[3])))
h := int(math.Max(dist(pts[0], pts[3]), dist(pts[1], pts[2])))
if w <= 0 || h <= 0 || w > maxWarpDim || h > maxWarpDim {
return axisFallback(src, pts)
}
dst := [4]Pt{{0, 0}, {float64(w), 0}, {float64(w), float64(h)}, {0, float64(h)}}
hMat, ok := perspectiveTransform(pts, dst)
if !ok {
return axisFallback(src, pts)
}
inv, ok := invert3x3(hMat)
if !ok {
return axisFallback(src, pts)
}
out := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// Backward map: src = inv * [x, y, 1].
den := inv[6]*float64(x) + inv[7]*float64(y) + inv[8]
if den == 0 {
continue
}
sx := (inv[0]*float64(x) + inv[1]*float64(y) + inv[2]) / den
sy := (inv[3]*float64(x) + inv[4]*float64(y) + inv[5]) / den
out.SetRGBA(x, y, sampleBicubic(rgba, sx, sy, b))
}
}
return out
}
// perspectiveTransform solves the 8-DOF homography H (row-major 3x3 with
// H[8]=1) such that dst_i = H * src_i in homogeneous coordinates. It fixes the
// bottom-right homography element to 1 (the 8-DOF normalization). Returns
// ok=false if the linear system is singular.
func perspectiveTransform(src, dst [4]Pt) ([9]float64, bool) {
var A [8][9]float64
for i := 0; i < 4; i++ {
sx, sy := src[i].X, src[i].Y
dx, dy := dst[i].X, dst[i].Y
// x' equation.
A[2*i][0] = sx
A[2*i][1] = sy
A[2*i][2] = 1
A[2*i][6] = -sx * dx
A[2*i][7] = -sy * dx
A[2*i][8] = dx
// y' equation.
A[2*i+1][3] = sx
A[2*i+1][4] = sy
A[2*i+1][5] = 1
A[2*i+1][6] = -sx * dy
A[2*i+1][7] = -sy * dy
A[2*i+1][8] = dy
}
x, ok := solveLinear8(A)
if !ok {
return [9]float64{}, false
}
return [9]float64{x[0], x[1], x[2], x[3], x[4], x[5], x[6], x[7], 1}, true
}
// solveLinear8 solves A * x = b for an 8x8 system via Gaussian elimination
// with partial pivoting. b is stored in the last column of A.
func solveLinear8(A [8][9]float64) ([8]float64, bool) {
for col := 0; col < 8; col++ {
// Partial pivot.
pivot := col
maxAbs := math.Abs(A[col][col])
for r := col + 1; r < 8; r++ {
if v := math.Abs(A[r][col]); v > maxAbs {
maxAbs = v
pivot = r
}
}
if maxAbs < 1e-12 {
return [8]float64{}, false
}
A[col], A[pivot] = A[pivot], A[col]
// Eliminate below.
for r := col + 1; r < 8; r++ {
f := A[r][col] / A[col][col]
for c := col; c < 9; c++ {
A[r][c] -= f * A[col][c]
}
}
}
// Back-substitution.
var x [8]float64
for r := 7; r >= 0; r-- {
sum := A[r][8]
for c := r + 1; c < 8; c++ {
sum -= A[r][c] * x[c]
}
x[r] = sum / A[r][r]
}
return x, true
}
// invert3x3 returns the inverse of the row-major 3x3 matrix m. Returns
// ok=false if singular.
func invert3x3(m [9]float64) ([9]float64, bool) {
det := m[0]*(m[4]*m[8]-m[5]*m[7]) -
m[1]*(m[3]*m[8]-m[5]*m[6]) +
m[2]*(m[3]*m[7]-m[4]*m[6])
if math.Abs(det) < 1e-12 {
return [9]float64{}, false
}
invDet := 1.0 / det
return [9]float64{
(m[4]*m[8] - m[5]*m[7]) * invDet,
(m[2]*m[7] - m[1]*m[8]) * invDet,
(m[1]*m[5] - m[2]*m[4]) * invDet,
(m[5]*m[6] - m[3]*m[8]) * invDet,
(m[0]*m[8] - m[2]*m[6]) * invDet,
(m[2]*m[3] - m[0]*m[5]) * invDet,
(m[3]*m[7] - m[4]*m[6]) * invDet,
(m[1]*m[6] - m[0]*m[7]) * invDet,
(m[0]*m[4] - m[1]*m[3]) * invDet,
}, true
}
// sampleBicubic returns the bicubic-interpolated (Catmull-Rom) color at the
// (possibly sub-pixel, out-of-bounds) location (x, y). Out-of-bounds
// coordinates use BORDER_REPLICATE semantics (edge pixels repeated). b is the
// source image bounds; sampling indices are offset by b.Min so a non-zero
// origin image samples correctly.
func sampleBicubic(img *image.RGBA, x, y float64, b image.Rectangle) color.RGBA {
ox, oy := float64(b.Min.X), float64(b.Min.Y)
x0 := int(math.Floor(x - ox))
y0 := int(math.Floor(y - oy))
tx := x - ox - float64(x0)
ty := y - oy - float64(y0)
maxX, maxY := b.Dx()-1, b.Dy()-1
// Interpolate each of the 4 source rows horizontally, then combine
// the 4 results vertically.
colX := func(cy int) (uint8, uint8, uint8, uint8) {
r0, g0, b0, a0 := pxAt(img, b.Min.X+clampIdx(x0-1, maxX), b.Min.Y+clampIdx(cy, maxY))
r1, g1, b1, a1 := pxAt(img, b.Min.X+clampIdx(x0, maxX), b.Min.Y+clampIdx(cy, maxY))
r2, g2, b2, a2 := pxAt(img, b.Min.X+clampIdx(x0+1, maxX), b.Min.Y+clampIdx(cy, maxY))
r3, g3, b3, a3 := pxAt(img, b.Min.X+clampIdx(x0+2, maxX), b.Min.Y+clampIdx(cy, maxY))
return uint8(clampByte(cubic(tx, [4]float64{float64(r0), float64(r1), float64(r2), float64(r3)}))),
uint8(clampByte(cubic(tx, [4]float64{float64(g0), float64(g1), float64(g2), float64(g3)}))),
uint8(clampByte(cubic(tx, [4]float64{float64(b0), float64(b1), float64(b2), float64(b3)}))),
uint8(clampByte(cubic(tx, [4]float64{float64(a0), float64(a1), float64(a2), float64(a3)})))
}
rA, gA, bA, aA := colX(y0 - 1)
rB, gB, bB, aB := colX(y0)
rC, gC, bC, aC := colX(y0 + 1)
rD, gD, bD, aD := colX(y0 + 2)
return color.RGBA{
R: uint8(clampByte(cubic(ty, [4]float64{float64(rA), float64(rB), float64(rC), float64(rD)}))),
G: uint8(clampByte(cubic(ty, [4]float64{float64(gA), float64(gB), float64(gC), float64(gD)}))),
B: uint8(clampByte(cubic(ty, [4]float64{float64(bA), float64(bB), float64(bC), float64(bD)}))),
A: uint8(clampByte(cubic(ty, [4]float64{float64(aA), float64(aB), float64(aC), float64(aD)}))),
}
}
// pxAt returns the RGBA bytes at (x, y), with coordinates already clamped by
// the caller (BORDER_REPLICATE).
func pxAt(img *image.RGBA, x, y int) (r, g, b, a uint8) {
c := img.RGBAAt(x, y)
return c.R, c.G, c.B, c.A
}
func clampIdx(i, max int) int {
if i < 0 {
return 0
}
if i > max {
return max
}
return i
}
func clampByte(v float64) float64 {
if v < 0 {
return 0
}
if v > 255 {
return 255
}
return v
}
// cubic is the Catmull-Rom cubic basis for parameter t in [0,1] over the four
// control samples p0..p3.
func cubic(t float64, p [4]float64) float64 {
t2 := t * t
t3 := t2 * t
return 0.5 * ((2 * p[1]) +
(-p[0]+p[2])*t +
(2*p[0]-5*p[1]+4*p[2]-p[3])*t2 +
(-p[0]+3*p[1]-3*p[2]+p[3])*t3)
}
// toRGBA returns src as *image.RGBA, converting when necessary.
func toRGBA(src image.Image) *image.RGBA {
if r, ok := src.(*image.RGBA); ok {
return r
}
b := src.Bounds()
out := image.NewRGBA(b)
draw.Draw(out, b, src, b.Min, draw.Src)
return out
}
// axisFallback crops the bounding box of the quad with FastCrop.
func axisFallback(src image.Image, points [4]Pt) *image.RGBA {
minX, minY := math.MaxFloat64, math.MaxFloat64
maxX, maxY := -math.MaxFloat64, -math.MaxFloat64
for _, p := range points {
minX = math.Min(minX, p.X)
minY = math.Min(minY, p.Y)
maxX = math.Max(maxX, p.X)
maxY = math.Max(maxY, p.Y)
}
return FastCrop(src, int(minX), int(minY), int(maxX), int(maxY))
}
func dist(a, b Pt) float64 {
return math.Hypot(a.X-b.X, a.Y-b.Y)
}
// pointsFinite reports whether all four corner coordinates are finite. A
// non-finite value from a malformed detector response must be rejected before
// any dimension derivation or allocation.
func pointsFinite(p [4]Pt) bool {
for _, q := range p {
if math.IsNaN(q.X) || math.IsNaN(q.Y) || math.IsInf(q.X, 0) || math.IsInf(q.Y, 0) {
return false
}
}
return true
}
// clampQuad clamps every corner to the source image bounds. FastCrop performs
// the equivalent clamp on its axis-aligned rectangle; WarpCrop must do the same
// on its four corners so an out-of-range detector box cannot produce an
// out-of-bounds or unbounded crop. Corners already inside the bounds are
// returned unchanged, so a well-formed detection box is unaffected.
func clampQuad(p [4]Pt, b image.Rectangle) [4]Pt {
out := p
minX, minY := float64(b.Min.X), float64(b.Min.Y)
maxX, maxY := float64(b.Max.X), float64(b.Max.Y)
for i := range out {
if out[i].X < minX {
out[i].X = minX
} else if out[i].X > maxX {
out[i].X = maxX
}
if out[i].Y < minY {
out[i].Y = minY
} else if out[i].Y > maxY {
out[i].Y = maxY
}
}
return out
}
// axisAligned reports whether the quad is axis-parallel: its left/right edges
// are vertical and its top/bottom edges are horizontal, within a small epsilon.
// The OCR detector can emit sub-pixel jitter on an otherwise upright box; that
// jitter is negligible for recognition, so the cheap FastCrop path is still
// correct for it. A genuinely slanted detection quad fails this test and pays
// the full perspective warp instead.
func axisAligned(p [4]Pt) bool {
const eps = 1e-3
// Quad order is TL, TR, BR, BL.
// Left edge TL-BL vertical: p0.X == p3.X
// Right edge TR-BR vertical: p1.X == p2.X
// Top edge TL-TR horizontal: p0.Y == p1.Y
// Bottom edge BL-BR horizonal: p3.Y == p2.Y
return approxEq(p[0].X, p[3].X, eps) &&
approxEq(p[1].X, p[2].X, eps) &&
approxEq(p[0].Y, p[1].Y, eps) &&
approxEq(p[3].Y, p[2].Y, eps)
}
func approxEq(a, b, eps float64) bool { return math.Abs(a-b) <= eps }

View File

@@ -0,0 +1,278 @@
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)
}
})
}
}

View File

@@ -0,0 +1,133 @@
//go:build cgo && manual
package pdf
import (
"encoding/json"
"fmt"
"image"
"math"
"os"
"path/filepath"
"testing"
util "ragflow/internal/deepdoc/parser/pdf/util"
)
// TestWarpAlignGo renders a real PDF page with the production pdfium render
// path (the exact image the OCR detector receives) and, once a quads.json has
// been produced by tools/render_diff/warp_align.py's detect phase, also writes
// the Go WarpCrop and FastCrop crops for every detected quad.
//
// The crops land under WARP_OUT (default /tmp/render_diff/align) so nothing is
// committed and the worktree stays isolated from the rest of the repo.
//
// Run (two passes):
//
// # pass 1: render the page (WARP_PDF is required by the Go test)
// WARP_PDF=test/benchmark/test_docs/Doc1.pdf \
// bash build.sh --test-manual ./internal/deepdoc/parser/pdf/ \
// -run TestWarpAlignGo
// # derive quads + reference crops from the rendered page
// .venv/bin/python tools/render_diff/warp_align.py genquads \
// --pdf test/benchmark/test_docs/Doc1.pdf --go-page-png /tmp/render_diff/align/page0.png
// # pass 2: now quads.json exists -> write the Go crops
// WARP_PDF=test/benchmark/test_docs/Doc1.pdf \
// bash build.sh --test-manual ./internal/deepdoc/parser/pdf/ \
// -run TestWarpAlignGo
// .venv/bin/python tools/render_diff/warp_align.py compare
//
// Env:
//
// WARP_PDF (required on pass 1) input PDF path
// WARP_PAGE (default 0) 0-based page index
// WARP_OUT (default /tmp/render_diff/align) output directory
// WARP_QUADS (default <WARP_OUT>/quads.json) detect-phase output
type warpAlignBox struct {
Quad []float64 `json:"quad"` // [x0,y0,x1,y1,x2,y2,x3,y3]
}
type warpAlignQuads struct {
Boxes []warpAlignBox `json:"boxes"`
}
func TestWarpAlignGo(t *testing.T) {
pdfPath := os.Getenv("WARP_PDF")
if pdfPath == "" {
t.Skip("WARP_PDF not set; nothing to render")
}
page := 0
if v := os.Getenv("WARP_PAGE"); v != "" {
fmt.Sscanf(v, "%d", &page)
}
out := os.Getenv("WARP_OUT")
if out == "" {
out = filepath.Join("/tmp", "render_diff", "align")
}
if err := os.MkdirAll(out, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
data, err := os.ReadFile(pdfPath)
if err != nil {
t.Fatalf("read pdf: %v", err)
}
engine, err := NewEngine(data)
if err != nil {
t.Fatalf("new engine: %v", err)
}
defer engine.Close()
img, err := RenderPageToImage(engine, page)
if err != nil {
t.Fatalf("render page %d: %v", page, err)
}
if err := writeAlignPNG(filepath.Join(out, fmt.Sprintf("page%d.png", page)), img); err != nil {
t.Fatalf("write page: %v", err)
}
t.Logf("wrote page%d.png (%dx%d)", page, img.Bounds().Dx(), img.Bounds().Dy())
quadsPath := os.Getenv("WARP_QUADS")
if quadsPath == "" {
quadsPath = filepath.Join(out, "quads.json")
}
raw, err := os.ReadFile(quadsPath)
if err != nil {
t.Skipf("quads not found at %s; run warp_align.py detect first", quadsPath)
}
var q warpAlignQuads
if err := json.Unmarshal(raw, &q); err != nil {
t.Fatalf("parse quads: %v", err)
}
for i, b := range q.Boxes {
if len(b.Quad) != 8 {
t.Fatalf("box %d: bad quad len %d", i, len(b.Quad))
}
pts := [4]util.Pt{
{X: b.Quad[0], Y: b.Quad[1]},
{X: b.Quad[2], Y: b.Quad[3]},
{X: b.Quad[4], Y: b.Quad[5]},
{X: b.Quad[6], Y: b.Quad[7]},
}
warp := util.WarpCrop(img, pts)
if err := writeAlignPNG(filepath.Join(out, fmt.Sprintf("go_warp_%d.png", i)), warp); err != nil {
t.Fatalf("write warp %d: %v", i, err)
}
x0 := int(math.Min(b.Quad[0], math.Min(b.Quad[2], math.Min(b.Quad[4], b.Quad[6]))))
y0 := int(math.Min(b.Quad[1], math.Min(b.Quad[3], math.Min(b.Quad[5], b.Quad[7]))))
x1 := int(math.Max(b.Quad[0], math.Max(b.Quad[2], math.Max(b.Quad[4], b.Quad[6]))))
y1 := int(math.Max(b.Quad[1], math.Max(b.Quad[3], math.Max(b.Quad[5], b.Quad[7]))))
fc := util.FastCrop(img, x0, y0, x1, y1)
if err := writeAlignPNG(filepath.Join(out, fmt.Sprintf("go_fastcrop_%d.png", i)), fc); err != nil {
t.Fatalf("write fastcrop %d: %v", i, err)
}
}
t.Logf("wrote %d go crops (warp + fastcrop)", len(q.Boxes))
}
func writeAlignPNG(path string, img image.Image) error {
b, err := util.EncodePNG(img)
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}

359
tools/render_diff/warp_align.py Executable file
View File

@@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""WarpCrop (Go) vs an independent perspective-crop reference (cv2) pixel
alignment on REAL document text, with no model weights required.
This exercises the perspective de-skew step (layer 1 of the OCR warp work). It
answers the geometric part of the regression question: for real text regions on
a real PDF page, does Go's util.WarpCrop reproduce the same crop geometry as an
independent perspective transform?
Detection and recognition need the deepdoc ONNX weights (downloaded from
HuggingFace, unavailable offline). They are intentionally NOT used here:
- quads come from pdfplumber word boxes (rotated to simulate skew) -- no model
- the reference crop is pure geometry (getPerspectiveTransform +
warpPerspective with BORDER_REPLICATE + INTER_CUBIC) -- no model
So this runs fully offline and isolates the warp geometry, which is the part
WarpCrop actually implements.
Pipeline
--------
1. genquads : from the Go-rendered page, derive real word boxes (pdfplumber),
rotate each by a sweep of angles to build skewed quads, write
quads.json and the reference crops (py_warp_*.png).
2. (Go) : TestWarpAlignGo pass 2 reads quads.json and writes go_warp_*.png
(WarpCrop, de-skewed) and go_fastcrop_*.png (FastCrop, axis-aligned bbox).
3. compare : pixel-MSE Go-WarpCrop vs reference and Go-FastCrop vs reference,
aggregated (layer-1 geometry; no model weights needed).
4. rec : run recognize_batch (the actual Go->service path, NO layer-2
rotation) on all three crops and compare the recognized text
(layer-1 + recognizer; needs the deepdoc ONNX weights).
All outputs go under --out-dir (default /tmp/render_diff/align); the worktree
is never touched and nothing is committed.
Run
---
WARP_PDF=test/benchmark/test_docs/Doc1.pdf \
bash build.sh --test-manual ./internal/deepdoc/parser/pdf/ -run TestWarpAlignGo
.venv/bin/python tools/render_diff/warp_align.py genquads \
--pdf test/benchmark/test_docs/Doc1.pdf --go-page-png /tmp/render_diff/align/page0.png
WARP_PDF=test/benchmark/test_docs/Doc1.pdf \
bash build.sh --test-manual ./internal/deepdoc/parser/pdf/ -run TestWarpAlignGo
.venv/bin/python tools/render_diff/warp_align.py compare
# (needs HF weights in rag/res/deepdoc)
.venv/bin/python tools/render_diff/warp_align.py rec
"""
import argparse
import difflib
import json
import logging
import math
import os
import sys
import cv2
import numpy as np
import pdfplumber
logging.basicConfig(level=logging.INFO, format="%(message)s", stream=sys.stdout)
# Go renders the page at exactly 3x (216 DPI); pdfplumber coords are in points.
SCALE = 3.0
def _sim(a, b):
"""Normalized similarity in [0,1] between two recognized strings."""
return difflib.SequenceMatcher(None, a or "", b or "").ratio()
def _quad_from_word(w, angle_deg, W, H, margin, perturb=4.0):
"""Return [x0,y0,x1,y1,x2,y2,x3,y3] (TL,TR,BR,BL) rotated by angle_deg, or
None if the rotated quad leaves the page.
A small deterministic perturbation is applied to the bottom-left corner so
the quad is a genuine trapezoid rather than a parallelogram. A plain
rotated rectangle maps to a parallelogram, whose getPerspectiveTransform has
no projective term -- the 8-DOF (true perspective) path in WarpCrop would
then go untested against cv2. The perturbation makes the cv2 reference
exercise the full homography the Go code implements.
"""
x0, top, x1, bottom = w["x0"], w["top"], w["x1"], w["bottom"]
pts = [
(x0 * SCALE, top * SCALE),
(x1 * SCALE, top * SCALE),
(x1 * SCALE, bottom * SCALE),
(x0 * SCALE, bottom * SCALE),
]
cx = (x0 + x1) / 2 * SCALE
cy = (top + bottom) / 2 * SCALE
a = math.radians(angle_deg)
ca, sa = math.cos(a), math.sin(a)
rot = []
for x, y in pts:
dx, dy = x - cx, y - cy
rot.append((cx + dx * ca - dy * sa, cy + dx * sa + dy * ca))
# Break the parallelogram so cv2 exercises the non-zero projective terms.
bx, by = rot[3]
rot[3] = (bx + perturb, by - perturb)
# in-bounds check
for x, y in rot:
if not (margin <= x <= W - margin and margin <= y <= H - margin):
return None
return [v for p in rot for v in p]
def _cv2_warp(page, quad):
p = np.array([[quad[0], quad[1]], [quad[2], quad[3]], [quad[4], quad[5]], [quad[6], quad[7]]], dtype=np.float32)
w = int(max(np.linalg.norm(p[0] - p[1]), np.linalg.norm(p[2] - p[3])))
h = int(max(np.linalg.norm(p[0] - p[3]), np.linalg.norm(p[1] - p[2])))
std = np.array([[0, 0], [w, 0], [w, h], [0, h]], dtype=np.float32)
M = cv2.getPerspectiveTransform(p, std)
return cv2.warpPerspective(page, M, (w, h), borderMode=cv2.BORDER_REPLICATE, flags=cv2.INTER_CUBIC)
def cmd_genquads(args):
page = cv2.imread(args.go_page_png)
if page is None:
raise SystemExit(f"cannot read {args.go_page_png}")
H, W = page.shape[:2]
with pdfplumber.open(args.pdf) as pdf:
pg = pdf.pages[args.page]
words = pg.extract_words()
angles = [float(x) for x in args.angles.split(",")]
boxes = []
for w in words:
if w["bottom"] - w["top"] < 6: # skip tiny fragments
continue
for ang in angles:
if len(boxes) >= args.max_quads:
break
q = _quad_from_word(w, ang, W, H, args.margin)
if q is None:
continue
idx = len(boxes)
boxes.append({"quad": q, "text": w.get("text", ""), "angle": ang})
cv2.imwrite(os.path.join(args.out_dir, f"py_warp_{idx}.png"), _cv2_warp(page, q))
if len(boxes) >= args.max_quads:
break
with open(os.path.join(args.out_dir, "quads.json"), "w") as f:
json.dump({"boxes": [{"quad": b["quad"]} for b in boxes]}, f, indent=2)
with open(os.path.join(args.out_dir, "quads_meta.json"), "w") as f:
json.dump(boxes, f, indent=2)
logging.info("[genquads] %d quads (from %d words, angles=%s) -> quads.json + py_warp_*.png", len(boxes), len(words), angles)
def pixel_mse(a_path, b_path):
"""Pixel MSE between two crops that MUST share identical dimensions.
Width/height are part of the WarpCrop <-> cv2 parity contract: a 1px
dimension difference (e.g. float64 vs float32 truncation in the norm) is a
real divergence and must surface, not be hidden by resizing. Callers that
compare the de-skewed WarpCrop crop against the cv2 reference use this and
treat a shape mismatch as a contract failure.
"""
A = np.asarray(cv2.imread(a_path), dtype=np.float64)
B = np.asarray(cv2.imread(b_path), dtype=np.float64)
if A.shape != B.shape:
raise ValueError(f"shape mismatch for {os.path.basename(a_path)} vs {os.path.basename(b_path)}: {A.shape} != {B.shape} (dimensions are part of the parity contract)")
diff = A - B
return float((diff**2).mean())
def pixel_mse_baseline(a_path, b_path):
"""Pixel MSE for the FastCrop baseline vs cv2.
FastCrop is axis-aligned while cv2 de-skews, so the two crops legitimately
differ in size. Resize to a common size to obtain a scalar divergence
estimate (the baseline is known-wrong; only its magnitude matters).
"""
A = np.asarray(cv2.imread(a_path), dtype=np.float64)
B = np.asarray(cv2.imread(b_path), dtype=np.float64)
if A.shape != B.shape:
B = cv2.resize(B, (A.shape[1], A.shape[0]))
diff = A - B
return float((diff**2).mean())
def cmd_compare(args):
with open(os.path.join(args.out_dir, "quads.json")) as f:
boxes = json.load(f)["boxes"]
try:
with open(os.path.join(args.out_dir, "quads_meta.json")) as f:
meta = json.load(f)
except FileNotFoundError:
meta = [{}] * len(boxes)
limit = args.limit if args.limit and args.limit > 0 else len(boxes)
mse_warp, mse_fc = [], []
warp_results = [] # (box_index, mw) so the per-angle summary stays aligned
better = 0 # cases where WarpCrop is closer to cv2 than FastCrop
warp_dim_mismatch = 0 # WarpCrop vs cv2 dimension contract failures
for i in range(min(limit, len(boxes))):
gw = os.path.join(args.out_dir, f"go_warp_{i}.png")
gf = os.path.join(args.out_dir, f"go_fastcrop_{i}.png")
pw = os.path.join(args.out_dir, f"py_warp_{i}.png")
if not (os.path.exists(gw) and os.path.exists(gf) and os.path.exists(pw)):
continue
# WarpCrop must match cv2 exactly in size; a mismatch is a contract
# failure (previously hidden by resizing) and is not counted as "closer".
try:
mw = pixel_mse(gw, pw)
except ValueError as e:
warp_dim_mismatch += 1
logging.warning("[compare] box %d: %s", i, e)
continue
mf = pixel_mse_baseline(gf, pw)
mse_warp.append(mw)
mse_fc.append(mf)
warp_results.append((i, mw))
if mw < mf:
better += 1
n = len(mse_warp)
if n == 0:
logging.info("[compare] no go crops found; run TestWarpAlignGo pass 2 first")
return
mean_w = sum(mse_warp) / n
mean_f = sum(mse_fc) / n
logging.info("\n[compare] %d boxes (limit=%s)", n, limit)
logging.info(" pixel MSE WarpCrop vs cv2 : mean=%.2f max=%.2f", mean_w, max(mse_warp))
logging.info(" pixel MSE FastCrop vs cv2 : mean=%.2f max=%.2f", mean_f, max(mse_fc))
logging.info(" WarpCrop closer to cv2 than FastCrop : %d/%d", better, n)
if warp_dim_mismatch:
logging.info(" WarpCrop vs cv2 DIMENSION MISMATCH (contract fail) : %d", warp_dim_mismatch)
# show per-angle summary if meta present; pair each MSE with its box index
# so a skipped/missing crop cannot misalign the angle buckets.
by_angle = {}
for idx, mw in warp_results:
ang = meta[idx].get("angle", 0) if idx < len(meta) else 0
by_angle.setdefault(ang, []).append(mw)
if by_angle:
logging.info("\n -- mean MSE(WarpCrop vs cv2) by rotation angle --")
for ang in sorted(by_angle):
vals = by_angle[ang]
logging.info(" angle %+6.1fdeg : n=%3d meanMSE=%.2f", ang, len(vals), sum(vals) / len(vals))
def cmd_rec(args):
"""Recognition-level alignment (the decisive "no regression" evidence).
The Go->Python OCR service feeds cropped image BYTES (no box) to the
recognizer via recognize_batch(), which does NOT perform layer-2 rotation
selection. So the apples-to-apples comparison is recognize_batch() on each
of the three crops already on disk:
go_warp_*.png : Go util.WarpCrop (de-skewed, layer-1 only)
go_fastcrop_*.png : Go util.FastCrop (axis-aligned bounding box)
py_warp_*.png : cv2.getPerspectiveTransform + warpPerspective
(the de-skewed crop Python produces before recognition)
If go_warp matches py_warp at the text level and both beat go_fastcrop,
WarpCrop reproduces the de-skewed crop geometry that FastCrop misses.
"""
from deepdoc.vision.ocr import OCR
ocr = OCR()
limit = args.limit if args.limit and args.limit > 0 else 10**9
n = 0
warp_eq_py = 0 # WarpCrop text == cv2 text (exact)
fc_eq_py = 0 # FastCrop text == cv2 text (exact)
warp_sim_total = 0.0
fc_sim_total = 0.0
warp_closer = 0 # WarpCrop text closer to cv2 than FastCrop text
by_angle = {}
worst = [] # (sim_warp, i, text_warp, text_py, text_fc)
# Per-box rotation angle, if the genquads metadata is present.
meta = []
meta_path = os.path.join(args.out_dir, "quads_meta.json")
if os.path.exists(meta_path):
with open(meta_path) as f:
meta = json.load(f)
for i in range(limit):
gw = os.path.join(args.out_dir, f"go_warp_{i}.png")
gf = os.path.join(args.out_dir, f"go_fastcrop_{i}.png")
pw = os.path.join(args.out_dir, f"py_warp_{i}.png")
if not (os.path.exists(gw) and os.path.exists(gf) and os.path.exists(pw)):
break
img_w = cv2.imread(gw)
img_f = cv2.imread(gf)
img_p = cv2.imread(pw)
tw = ocr.recognize_batch([img_w])[0]
tf = ocr.recognize_batch([img_f])[0]
tp = ocr.recognize_batch([img_p])[0]
sw = _sim(tw, tp)
sf = _sim(tf, tp)
warp_sim_total += sw
fc_sim_total += sf
if tw == tp:
warp_eq_py += 1
if tf == tp:
fc_eq_py += 1
if sw > sf:
warp_closer += 1
# per-angle bucket
ang = meta[i].get("angle", 0.0) if i < len(meta) else 0.0
by_angle.setdefault(ang, []).append(sw)
worst.append((sw, i, tw, tp, tf))
n += 1
if n == 0:
logging.info("[rec] no crops found; run genquads + TestWarpAlignGo pass 2 first")
return
logging.info("\n[rec] %d boxes (recognize_batch, no layer-2 rotation)", n)
logging.info(" exact text match WarpCrop vs cv2 : %d/%d (%.1f%%)", warp_eq_py, n, 100 * warp_eq_py / n)
logging.info(" exact text match FastCrop vs cv2 : %d/%d (%.1f%%)", fc_eq_py, n, 100 * fc_eq_py / n)
logging.info(" mean text similarity WarpCrop vs cv2 : %.3f", warp_sim_total / n)
logging.info(" mean text similarity FastCrop vs cv2 : %.3f", fc_sim_total / n)
logging.info(" WarpCrop text strictly closer to cv2 than FastCrop : %d/%d (%.1f%%)", warp_closer, n, 100 * warp_closer / n)
if by_angle:
logging.info("\n -- mean text similarity(WarpCrop vs cv2) by rotation angle --")
for ang in sorted(by_angle):
vals = by_angle[ang]
logging.info(" angle %+6.1fdeg : n=%3d sim=%.3f", ang, len(vals), sum(vals) / len(vals))
logging.info("\n -- 8 worst WarpCrop-vs-cv2 cases --")
worst.sort(key=lambda x: x[0])
for sw, i, tw, tp, tf in worst[:8]:
logging.info(" #%3d sim=%.2f | warp=%r py=%r fastcrop=%r", i, sw, tw, tp, tf)
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
g = sub.add_parser("genquads")
g.add_argument("--pdf", required=True)
g.add_argument("--page", type=int, default=0)
g.add_argument("--go-page-png", required=True)
g.add_argument("--out-dir", default="/tmp/render_diff/align")
g.add_argument("--angles", default="0,15,-15,30,-30")
g.add_argument("--max-quads", type=int, default=120)
g.add_argument("--margin", type=int, default=24)
c = sub.add_parser("compare")
c.add_argument("--out-dir", default="/tmp/render_diff/align")
c.add_argument("--limit", type=int, default=0, help="max boxes to compare (0 = all)")
r = sub.add_parser("rec", help="recognition-level alignment (needs deepdoc weights)")
r.add_argument("--out-dir", default="/tmp/render_diff/align")
r.add_argument("--limit", type=int, default=0, help="max boxes to recognize (0 = all)")
args = ap.parse_args()
os.makedirs(args.out_dir, exist_ok=True)
if args.cmd == "genquads":
cmd_genquads(args)
elif args.cmd == "rec":
cmd_rec(args)
else:
cmd_compare(args)
if __name__ == "__main__":
main()