Files
ragflow/tools/render_diff/warp_align.py
Jack 8d20cbd0b3 deepdoc(pdf): WarpCrop de-skew + score-based layer-2 rotation (Go OCR parity with Python) (#18299)
Supersedes / folds in #18305. The score-based layer-2 rotation selection from #18305 now lives here, on top of `WarpCrop` (layer 1), applied to **all three** Go OCR paths, together with the Python score plumbing the Go side depends on. #18305 is closed in favor of this PR.
2026-08-17 15:13:56 +08:00

360 lines
15 KiB
Python
Executable File

#!/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()