diff --git a/deepdoc/server/adapters/ocr_adapter.py b/deepdoc/server/adapters/ocr_adapter.py index 0c346fd65f..8f1eb5581a 100644 --- a/deepdoc/server/adapters/ocr_adapter.py +++ b/deepdoc/server/adapters/ocr_adapter.py @@ -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] diff --git a/deepdoc/vision/ocr.py b/deepdoc/vision/ocr.py index d7472a130c..41c4b79a36 100644 --- a/deepdoc/vision/ocr.py +++ b/deepdoc/vision/ocr.py @@ -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: diff --git a/deepdoc/vision/test_ocr_score.py b/deepdoc/vision/test_ocr_score.py new file mode 100644 index 0000000000..f8394967ee --- /dev/null +++ b/deepdoc/vision/test_ocr_score.py @@ -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() diff --git a/internal/deepdoc/parser/pdf/parser_ocr.go b/internal/deepdoc/parser/pdf/parser_ocr.go index 6353fda4cd..85db246fe7 100644 --- a/internal/deepdoc/parser/pdf/parser_ocr.go +++ b/internal/deepdoc/parser/pdf/parser_ocr.go @@ -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 diff --git a/internal/deepdoc/parser/pdf/parser_ocr_rotate_integration_test.go b/internal/deepdoc/parser/pdf/parser_ocr_rotate_integration_test.go new file mode 100644 index 0000000000..b9af6e0881 --- /dev/null +++ b/internal/deepdoc/parser/pdf/parser_ocr_rotate_integration_test.go @@ -0,0 +1,104 @@ +//go:build integration + +package pdf + +import ( + "bytes" + "context" + "encoding/base64" + "image" + "os" + "strings" + "testing" + + _ "image/jpeg" + + "ragflow/internal/deepdoc/parser/pdf/inference" + "ragflow/internal/deepdoc/parser/pdf/util" +) + +// layer2CropB64 is a vertical (tall, h/w ~= 4.5) "RAGFlow" text crop, embedded +// as a JPEG so the test stays self-contained without committing a binary into +// the gitignored testdata/ directory. The crop is unreadable at 0 deg but reads +// cleanly once rotated upright (CW90), which is exactly what layer-2 must pick. +const layer2CropB64 = "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" + +// TestOCRRecognizeWithRotation_Live verifies layer-2 rotation selection +// end-to-end against a running DeepDoc rec service. It sends a vertical +// (tall, h/w >= 1.5) text crop at 0/CW90/CCW90 and asserts the orientation +// with the highest REAL recognition confidence reads the vertical text +// correctly — i.e. the Go path now does score-based selection (matching +// Python's get_rotate_crop_image) instead of the old constant-1.0 fallback. +// +// Requires a live rec service (DEEPDOC_URL, default http://localhost:9390). +// Run with: build.sh --test-integration ./internal/deepdoc/parser/pdf/ +func TestOCRRecognizeWithRotation_Live(t *testing.T) { + url := os.Getenv("DEEPDOC_URL") + if url == "" { + url = "http://localhost:9390" + } + client, err := inference.NewClient(url) + if err != nil { + t.Fatalf("client: %v", err) + } + if !client.Health() { + t.Skip("deepdoc rec service not healthy; set DEEPDOC_URL") + } + + raw, err := base64.StdEncoding.DecodeString(layer2CropB64) + if err != nil { + t.Fatalf("decode embedded crop: %v", err) + } + crop, _, err := image.Decode(bytes.NewReader(raw)) + if err != nil { + t.Fatalf("decode crop: %v", err) + } + + cands := map[string]image.Image{ + "0": crop, + "CW90": util.RotateImageCW(crop, 90), + "CCW90": util.RotateImageCW(crop, 270), + } + scores := map[string]float64{} + texts := map[string]string{} + best, bestConf, bestText := "", -1.0, "" + for name, im := range cands { + rec, recErr := client.OCRRecognize(context.Background(), im) + if recErr != nil { + t.Fatalf("rec %s: %v", name, recErr) + } + c, txt := 0.0, "" + if len(rec) > 0 { + c, txt = rec[0].Confidence, rec[0].Text + } + scores[name], texts[name] = c, txt + if c > bestConf { + bestConf, best, bestText = c, name, txt + } + } + + // The rec service must surface REAL scores, not the old constant 1.0 fill. + realScore := false + for _, c := range scores { + if c != 1.0 { + realScore = true + } + } + if !realScore { + t.Fatalf("rec service returned constant 1.0; real score not surfaced: %v", scores) + } + + // Layer 2 must find an upright orientation that reads the vertical text. + if bestConf < 0.8 { + t.Fatalf("best orientation confidence too low (%.3f); layer-2 failed: %v", bestConf, scores) + } + if !strings.Contains(strings.ToUpper(bestText), "RAG") { + t.Fatalf("best orientation did not read the vertical text; got %q (scores=%v)", bestText, scores) + } + // 0° (as-is vertical) must score strictly worse than the best — proving + // layer 2 actually does work rather than always trusting 0°. + if scores["0"] >= bestConf { + t.Fatalf("0° orientation scored >= best; layer-2 selection not effective: %v", scores) + } + t.Logf("layer-2 selected %s (conf=%.3f, text=%q); scores=%v", best, bestConf, bestText, scores) +} diff --git a/internal/deepdoc/parser/pdf/parser_ocr_rotate_test.go b/internal/deepdoc/parser/pdf/parser_ocr_rotate_test.go new file mode 100644 index 0000000000..a653f90046 --- /dev/null +++ b/internal/deepdoc/parser/pdf/parser_ocr_rotate_test.go @@ -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) + } + } +} diff --git a/internal/deepdoc/parser/pdf/parser_ocr_warp_test.go b/internal/deepdoc/parser/pdf/parser_ocr_warp_test.go new file mode 100644 index 0000000000..b2449c9db0 --- /dev/null +++ b/internal/deepdoc/parser/pdf/parser_ocr_warp_test.go @@ -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 +} diff --git a/internal/deepdoc/parser/pdf/util/testdata/gen_warp_golden.py b/internal/deepdoc/parser/pdf/util/testdata/gen_warp_golden.py new file mode 100644 index 0000000000..c50107f2b1 --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/testdata/gen_warp_golden.py @@ -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() diff --git a/internal/deepdoc/parser/pdf/util/testdata/warp_expected.b64 b/internal/deepdoc/parser/pdf/util/testdata/warp_expected.b64 new file mode 100644 index 0000000000..ec30670719 --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/testdata/warp_expected.b64 @@ -0,0 +1 @@ +iVBORw0KGgoAAAANSUhEUgAAANIAAACRCAIAAAA5N3HXAAAYNklEQVR4nO1de5QUxdW/Vd094z5menj4QJFvMRIiohyyMSS6iih6FIMckniMig9OiCCCeYjykIeKB4N6FHwSwwETZZOQcICAxyeo+OSxAmJgNbgij8DCLuwsO7PsTHfX98fdKZp9MdNd7FbNzk/O2tPTfae66tf3VtW9dYt81qsXSIbk4cORK6/sv2qVHyGzN/5x5vrHeoXOFVWq9sEx69jV5w4pvXaRQJn/d/l6gdKEgHZ0AVoAMQyrpoY5jh8hZsAEZosqUrtBp3q0ISpWphnSxAr0DxlpRw3Dikatmho/QiJBE5igArUjNKLVJATTLhLWxQr0DxlpR3Tdqqmxo75q3wyaQIioIrUbCCE14rWdzphcr6CMtANK7aNH/Wq7gEmpdG95OhBuZCNh3ZasuyEl7Qhx4nHLn7aLBM2gFhRVovbE0WRd0kkKFBgJ6Zad03YnAyHESSR8ajszYBYYBbIZl3RQb9VHG2oFCjTDWtKSqx5kpB3Ct7YLm4Gww3wNhzsESScpdlQRCelJS656kJR2DMD2re0iQdNilqAStSOYLXZUYYb1ZDKn7dIA8a3tCCFmwEw6KtJO8KgiEtKtnJFNC4T47NsBQCRoiu2btxMIEWtkzbBu54YU6YDouk9tBwCRgJK0I6IdFZGQBk6OdmmAGoZ/bWcGwyoaWYPogocUOS9FmiCG4XNIAQCRgGmrSDtNFzykCOmyOWzkpZ0VjdqxmB8hZlDJaACDGoKNbFjX9Bzt0gC6Zf07KlSMBjCoIdbIBgM0GJSroeUqDQfRNKu21r9bVjbjkg50KtjIAkBhvlyxT5LSDih1YjH/QSgqRgNoRIuKjn2SLeROUtoRQpxjx/xrOxWjAQicktgnsQJ9QlLaAQBzHL/RAEEzX89XLhrgVITcRcJyhdxJTDsB0QCmotEAtYmjYgVGQrpMrJOYdgTAv5GNBE1LwTmUuBUXHfskl39MXtoBgM8hBSHEDJqWgv6xhJ0QHfskV8idvLQjmiYgGkDNIBTGbLEzxmZYz9EuLaCjwqcQVYNQGBM7qojkaJcmiJBoADWDUITHPkVCckV6yks7DEJhli8TGQmGLQWNLBBNuJGVahWPvLQjIhZpm0ETFJxA0angRdqRkMZytEsHRNNs/9EAAROkmrBKDwY1hPftpKoHeWkHmmbX1QlISaFgNIDw2KeccyxdEELs+nqf2s4MmITK5QVPB8JjnyJhHTSJXj95aQcALJn0GWMcCZpBql40gCE69skM6QFDoraWqCjNwfz7x4JmvqFeNIAmekgBAHmnSdTWEhWlRfg3smYg7IBig1kKVHgCnlChRJ0NqWknIBogaEaCpu0oFg1wSmKfZBpVyE07QnxqO0qoorkBhAcYS7VsUW7aCVm2qKZbNpaMx616gQKlSq4oO+0EBaGoR7tj9jHhM8b+skGLhPS08x2EYqqp7RzHEp6SQh63rPS0q6mxj/qK8I4ETUu1IQUAAGPCE/DIE/skN+103YpGRbhlFaQdAcFGNqQnk7JYWblpp2m2gCCUsFRe8HRBtGhDjUB5UkV6Sk07IMSOxztnbgDhG1TkjGy6IISwhgb/yxYJkWiCPk2Ij30KaXaOdmmCMeY/0jOgBQQVp/1gaIJpZ4Z1eXyE0tPO97LFSMAsUDA3gEENsY6KSEgHaRJgyU47IW7ZcCDMpKnxNCE89ikSlii5ouy0A9+0M4OmqeDUnS48lWxIp9IkV5Sddv5zZ2tEiyiYG4ASwbFPwSANBmRpblnK0RrEuWUVC0I5FbFPhfmyNLcs5WgNglK2K+mWFR5gHJYm5E522hHDsKNRJ5HwI0TR2Ke6REyskpYn0lMB2gnInR0wVcwNUG/XC0/ZLslEkvS00zQrGvW/fowpGA1gOckaoW5ZM6TJwTrpaQeU+l+kbaqZGwCYI3y1rO0whECxHiCLsW8NhBDH9yLtSNAEWWasMoPYwWxhvnY0ZgcD1HGAMdb4lwFzwGGMMWAMGD+Axo8AKe+G+29b0++E/zl+gOcIABBCpKcdADDbFpGSQoEnbQYq1j/22P1Fk8eeywA47RyHOQ44Jx7bLZ53mMNcx02uPPF62znhRvtEUbbDFGgM/4u0zYAZVDAagFJNoLZjjIUK9FCBFC0ufd8OAESkbM/X8zq8Q5MpxG7HQ6RxyIIStCO+N3CPBM1QIKRcNEBAdAIeeaAC7SgVslOAcrkBhKcbkwcq0M63f0yjWiRgJpliM8bCA4zlgSK065Qp23UqOPZJHihCOwEp2yNJWzHaUSJyJCsVFKAdFeKWVVDbUUJyfbsOA9F12/8i7aCS0QDZamT1+t27xUoUOTtECAFgjNkAyYMHT+vVy7OkSEDJaADhWy1KAr3Xgw8CIYQQcP1D11njsfsrZEKLX+HH1I0tfNX8+ERprV5GiHPsWPDcc/08pxlUMhoAt1o0g+GOLohg6OfNndvRZUgXzLKI7tG3o2g0AG61mH20U6Bvx+GZc9CYkkK93ADAbLEhd5JAJdr5QUTN3ADAICsHs52Fdqaa0QDCt1qUBJ2FdpGAGTLUiwYgWTpj3GlohzsFqDaHIjb2SR50FtrpVFdxxlj45mOSoLPQDtTMDWBo2Rn7JEWIc/vADEakdcu6xzrYAcWVNARI1bHqjivXqUInol0kEG6wGxzmMGj8L7VOCpoum2IOHjvHz7DUGeeEM42MYZwtAKm/nEjE9T9CuBuHEkqBUtL4TyNUo5pGNI3oOtUMzdCJnqfi6DsNdCLaGZph1UfrjAKNaBrRsI11TdeJplFdJ7pONZ3oGtX0Ez42HuiuA41oOtWPX9Z44L7S9dXxb48fGFTXuRCq60Q3+HHjRwOPA9To6JoTD5KVL1OL+K52d0XtrkKjINW0ja2utcwYnUqz5oUxJtUCHP/oLLSzHEunnUi1S47OQrscpEInmkDJQR7kaJdDByBHu04K3rkS0svKVEiOdkoimfQ1741D49ra2r179xLivX/vOM7BgwdHjRqVqZAc7VSC4zgA8MorrxiG4ZN5ALBw4cJrr70WvKZHSSaTlNLevXsvWbLk1VdfzUhIjnbKwHEcSumGDRtGjx69adMmw/A1jRyNRu+///4dO3bMmjULPJlawzCGDRsWj8dRWmY3sxzUgW3b7oazLMuzqIKCApQzceJEz0Kqq6tPO+00lLNp06b0b8zRTiVcf/31nHa7d+9mjNm27VkaF/Xoo496uB1/GgA0rXGRyqFDh9L9aQ+/l0MH4uqrr4ZUbywej3sT4jgOYwxcvbply5Z5E1VVVQUAuq4DwP79+x3HQeFtI9e3UwZIFGxgPM7PzwdP3TJkGyonSikA1NbWeiiS4zjdunXbsGGDZVkA0KNHD8KXS7ddAA+FzqFjcfHFF2/btg2PsflY5rECOEDZvHnzD3/4Qzzz3nvvXXnllZkWBuUQQnRdtywrLy8PdXDb5clpO5WAEyhbt2796U9/imdOP/108DQDQil1HGfgwIGQ0qBDhgzZuXNnpmoIleXSpUsty6KU1tfXY3nalpOjnUqglNq2TQjBcSghpKqq6oILLvAsDQBWr15tWRYyL5FIeJg9dhznpptumjt3Lr4VP/jBD+Ckb4K3jmQOHYvKysozzjiDty7zOpmCd7388sucD/X19emMCZrAcZz33nuPl+fnP/85Sw1cWkRO26kH27bPOOOMzZs3X3jhhYwxABg8eLCmadivzwiapjmOc8sttwAAzj/n5eV5MNmEkJKSkl//+tdYnsOHD0PbCs/DK5JDhyOZTDLGbrzxRkjZypEjR3qWZtv2jBkzwDVM9jwdiEWCk81C52inMDZt2pSXl8eZxxhLJBIe5CDJJk6ciIwpLCxkmTMPTeqdd97JNVppaWlrF+eMrKqwLKu4uPjjjz/u2bMn9uXHjRtnGEYi8713kbVoanVdr6ur69evH55MH2hSX3nllfPOOw/PHDhwAFqbVvTwcuQgCdDUIl3QQzV16lTP0mpra9FEoiiW+TCFjyECgcbkWv/+979bvFJh2mGl57BixQrUTNzUNjQ0eJZ2ww03IGOuuuoq5tXUovuEj7KbC+lgI+sOqWgNjLFYLHbo0KHvvvtux44dZWVlb7311pEjR/RmWRZZ5/O4WJY1YsSIVatWmaaJpvbpp58OBAIeTC3W3jXXXAMAlNK1a9fefvvt3kxtLBbjArds2YJT0ydc1p5NxVrxmSxcuPDgwYPJZDJ+ImKxWPNjrNBp06Y9+uijX375JX4Vi8VGjBjBBSaTSZ/haAoBJ3vvvPPOv/71r5qm2ba9aNGi0aNHe5N28ODBK6644quvvgKAPn36fP311+j+Sl8CtvLhw4e7deuG5YlGo+FwuOlF7QZu+xsaGo4cObJv3749e/Z8+umnHmonLy+PR4whlixZsn379pUrV+7atYv/op+INLXw4osvYj34MbXYQNFodMCAAQCgaRp2FjOdQOYRLigEPR9uU9setGvy/OPGjTvrrLO+973v9ejRwzTN5rbSJ0aMGBGLxT766KNPPvmE/6ifuDT5gU/30ksvcR2/cuVK5mk+BUX98pe/5PU5d+5cb6X6z3/+gxIKCgqOHj3KXPQ9VUaWnWhP9+zZ88ILL+Tn51NKH3/8cYyE5mhi+/Pz8wtaR+/evUtLSzdt2tTaT6Nix+OtW7dalnXRRRdhe9i2zWMSswxoCm+77bbS0lKsT28RJYhvv/22b9++uFzj7rvv/tOf/sQyDHLB65csWTJq1CgA6NKlS0VFRSQSOf71qQCG+1VXV3/zzTexWGzKlCltl/Kaa67ZsWPHunXrysrKysvL9+zZc+TIkdZe1s2bNw8dOnTChAlTpkx57LHHlixZ0q1btxbFIttee+212traw4cP4+1ZbHmnT5/epO09K7ydO3d2794dAAghS5cuZZmbWsbYypUreWEeeughfl487dwmdfjw4QBQWFjYhA1dunQZPnz48OHDf/WrX40ZM+amm2768MMP05TfImmWL18+duzYOXPmLF68+IEHHmjyc9ifnTx5MmOMk89DJSqBBx98EJ86GAxu3ryZeZppwkpG2iFef/11b+VZsGABWpjBgwczHgrvTVZzOI7DCVFVVTVq1KixY8cGg8EWldBtt93WXEKaXeDmdGn+Qk+dOvXJJ59ctmyZm/GFhYWmad5www2MsVgs1po0dcGfxT2o/+9//+tZ4Oeffw4pR+3f//53z0X6yU9+ggb6+uuvx/MCaOduuYqKig8//HDatGnNqfbyyy+vXr36zTffXLNmTUVFhf/fPWlhGGPLly+fN2/eoEGDeDEIIf379y8qKmrjLnWBD+KePQmFQsyTwsNb1q5dy0V9/fXX3ko1efJkSHV4GGOJREIA7dxWb8KECW6q9e7d++abb/7Zz37229/+tsldaa718AOuPrdt2zZ+/Hh0InGcddZZ1dXV7VaYdsYvfvELfMzu3bsfOHCAeerRohnhjAGAAwcOZFpR+Ls333wzSrjjjjuYT23nNotjxowZNGgQxlhzPPfcc81L0M7gL7rjOLfeeusf/vAHXrwf//jH/fr1w3mWrBlncFpcccUV+JiU0iNHjngWuHz5cgBAN2v6a8Pc4PTFTtecOXM80s5xHOwbVlZWPvXUU0888UQTk7pgwYItW7akv3DyVMNdU7gOnqOwsHDdunXMa9SQhMCHxUQTiD59+jAfCu/ZZ5/lojyXasiQIZy+fo3sI4884m7Crl27Tp06ddKkSbwJJdEinHZYnocffvjGG2/EuHB8C9955x2WRcxD8FVh3//+99E0eZg2R3PBTS2llGXeG8ZfR+eHF9q5aVRaWsoJV1BQEAgE3DGlcjYh19OMsRUrVrjfmdWrVzNp3hOf4M/Yr18/fLqzzz7bT8zOzJkzwRUTxTJnnm3bODSmlGZGO94k8+fPHz16NH8kAJg0aRJjLB6Py989x+LhX1x4wvHPf/7TfY3SQOZddNFF/OmGDBnCPCk8bPff/e53KKdLly7eirR///5zcTvq9O/hLfHBBx9gLDXHyJEj3YkRlGgzXsj169e7n2XhwoV4PmvcuLwvcemll+IZbw30/vvvQyrU4OKLL/Ymp7q6uqioKAPaIeV37drFQz8CgcCwYcMGDx68b98+JqtVbQO81r744gt8IpzVnDdvHp5XnXm8/GeeeSY+4CWXXOJZWiwW4yECvXr1wpMZMQ8vHjRoULq0wy5hXV3dhRdeyBVDcXExv0A5ziF4rX377bdunTdnzhw8r3pXD5nn9lnfddddzIdFGjlyJMopKSnxJmH37t0npx33esXj8WHDhvHS9+jRY//+/d5+WCrwzuj+/fvd0YgzZ87EC1TXeQwdAyng2mnmNYruvvvu46LGjx+fqZy0fLLu8cE999zDf880zY8//tgtSHXgKK+ysrJnz578MadMmdLR5RIA1Bq1tbU8SHjkyJGetZ1lWf3790c5t9xyizcJJ6cdFnrevHluG/S3v/0NL8gOziGwn1BZWYlZPDy/0BICGxFcK/Vnz57NvCo8xtj555+Pcu6++24PYcxt0Y5Tyh01BQAPP/wwfqt6YzQH1mB1dTWmQgKA4cOHM/V7eIhDhw5x5uHr5AHY6H379uV8WLduXaZMaJV2nHNlZWVuT+uoUaP4z2cf7VhK59XW1g4dOhQACgsLcT2B6szjExG8KSdMmOBnIMjjtGfNmsUyDHI5iZGtqakpKiriBS0pKYlGo/gbWck5BLaQe2x77733dnShBKC5qeXT4xmBqyRITePNnz8/Iwkt046/BIMHD+ZVf84556Br38/qX4Wwa9eu4uJi/vh4MgtetvLycs48b8my2Ylrw9yBdGne3gLt+M133HGHu0v3+eefs+waQ7QBTi/++JMmTeIB8eoCFZ7bMTNnzhw/ppbL+ctf/sLSZl7L2i4ejzdZDLJixQr8Kgte93TAH9M9qt2+fTtT/8Vzm1rUeeXl5d5SKTLG9u3bB6lwpjfeeCPNe1um3dKlS92c45a7c6YdueSSS3jfuaampqOLIwbuaPWXXnqJeRoz4Z5jKETXdYyJSkdOU9phvw1zESB4LJPqQzkP4IoNM4MAwPPPP8/Urwos/6pVq3gnD02kN+zcuROFBIPBDz74gKVhEo/Tjs8MHzp0qKSkBNcLtbjEq1MB6+T2229H2j3yyCMdXSIxcBynsrISH8pzZjGOP//5z1xPvf/++ye9/nhKFUKIpmmJRGLixIkfffQRJsLlicpY50umhMAmwRRGADBr1iz3agx1QQjp3r07BupiEoVly5ZpmuZt40a3R/Htt98++Q1NaOgeSZimuXjxYm/0zzKUl5fzMAhc+5QFQyt8hMWLF0Pq7Xr33Xc9y3njjTdwVXK3bt327t3b9i2Ukw/Vm3uTg2nTpt11113uGZrOiWQy2bdv37Fjx+JHTNfSJGGbisAOWe/evQHAtm1d19Exk6nCI4Q4jnPdddcVFRURQqqrq88///z//e9/bdxC+Z26rn/yySdPPfUUpKaeMWMPbu3j5bGyBTgdynflWr169YwZM7Imhc/ll1+OC8NQ76xbt87DHslIGPQiBgKBY8eOYfqfVhUWc3lXx48fz88PGDDA8yrwrMSWLVv4HF5+fn5HF0cw5s6dCylTu379eg8ScHJtwYIFbmq1huNGFgDOPvts/BgOhxctWtSnTx++Z2gnB2NswIABuPAJAPjevVkAbF/TNAEAd5YqKSmBlPJLH7quJxIJ7IrgSJQ7zVr+VcRzzz3HT2JSvs45Odwi0BrwqVEAyD7H9OzZs/HRCgsLy8rKmNf5lKeffhpOtrSRshQZKyoqeJ1iP0bTNJZTdQCQ6n137dqV5x5FZZAd9YNPMX369HHjxgFAXV1dcXHxhg0bPPRfk8nk73//++nTp+OkDI5tm48NKE+tvXfvXn4WtytNc0vazgP3vl5r1qzB/Qg7tkhCwHdVdK/0wZQdmQ7YDcOwbZsHkcRiscsuuwyav5+o9HAWHscj3bt3RyObQ3NgdlWsuvQ93wrhgQceQI3eq1ev8vJy5mmGsqGhgef+GjhwYPPeWiPtxowZw4n4j3/8g2V7IKc3YPXxlDb/+te/OrpEIsGbm4c+hEKhjRs3ehbI9x8rLi5uEjPW2Lc7nhMFoKqqClJ53TNSsFkPVAN8USNuX5k14Kb2Rz/6EQBQSo8ePbpx40bw2oXt0qULHpSVlWE6W/4VJYTE4/HPPvuMn8Il8jnOtYauXbviQZbRDlLMe/HFF3/zm99gr+7xxx+vrq72RoZnnnmGZxF44oknCCF8ue7/Az2CqVsb9NVoAAAAAElFTkSuQmCC diff --git a/internal/deepdoc/parser/pdf/util/testdata/warp_meta.json b/internal/deepdoc/parser/pdf/util/testdata/warp_meta.json new file mode 100644 index 0000000000..967cf3bcf2 --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/testdata/warp_meta.json @@ -0,0 +1,22 @@ +{ + "src": [ + [ + 50, + 40 + ], + [ + 260, + 25 + ], + [ + 250, + 170 + ], + [ + 40, + 150 + ] + ], + "w": 210, + "h": 145 +} diff --git a/internal/deepdoc/parser/pdf/util/testdata/warp_src.b64 b/internal/deepdoc/parser/pdf/util/testdata/warp_src.b64 new file mode 100644 index 0000000000..80c3dd16a9 --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/testdata/warp_src.b64 @@ -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= diff --git a/internal/deepdoc/parser/pdf/util/warp.go b/internal/deepdoc/parser/pdf/util/warp.go new file mode 100644 index 0000000000..438528fb59 --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/warp.go @@ -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 } diff --git a/internal/deepdoc/parser/pdf/util/warp_test.go b/internal/deepdoc/parser/pdf/util/warp_test.go new file mode 100644 index 0000000000..581483d19f --- /dev/null +++ b/internal/deepdoc/parser/pdf/util/warp_test.go @@ -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) + } + }) + } +} diff --git a/internal/deepdoc/parser/pdf/warp_align_test.go b/internal/deepdoc/parser/pdf/warp_align_test.go new file mode 100644 index 0000000000..6bd1dcd5ec --- /dev/null +++ b/internal/deepdoc/parser/pdf/warp_align_test.go @@ -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 /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) +} diff --git a/tools/render_diff/warp_align.py b/tools/render_diff/warp_align.py new file mode 100755 index 0000000000..2a8eb2467f --- /dev/null +++ b/tools/render_diff/warp_align.py @@ -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()