tinydoc-5m / tinydoc /geometry.py
harryfrz's picture
tinydoc-5m weights (5,035,273 params)
4dce182 verified
Raw History Blame Contribute Delete
4.55 kB
"""Quadrilateral helpers. Quads are (4, 2) float arrays ordered TL, TR, BR, BL in text reading direction."""
import cv2
import numpy as np
def order_quad(pts) -> np.ndarray:
"""Order 4 points TL, TR, BR, BL assuming text runs roughly left-to-right."""
pts = np.asarray(pts, np.float32).reshape(4, 2)
s = pts.sum(1)
d = pts[:, 1] - pts[:, 0]
tl, br = pts[np.argmin(s)], pts[np.argmax(s)]
tr, bl = pts[np.argmin(d)], pts[np.argmax(d)]
q = np.stack([tl, tr, br, bl])
if len({tuple(p) for p in q.tolist()}) < 4: # degenerate (e.g. 45° rotation): fall back to angle sort
c = pts.mean(0)
ang = np.arctan2(pts[:, 1] - c[1], pts[:, 0] - c[0])
q = pts[np.argsort(ang)]
q = np.roll(q, -int(np.argmin(q.sum(1))), axis=0)
return q
def rect_quad(x0, y0, x1, y1) -> np.ndarray:
return np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], np.float32)
def quad_bbox(q) -> list[float]:
q = np.asarray(q)
return [float(q[:, 0].min()), float(q[:, 1].min()), float(q[:, 0].max()), float(q[:, 1].max())]
def quad_size(q):
"""(width along text direction, height across it)."""
q = np.asarray(q, np.float32)
w = (np.linalg.norm(q[1] - q[0]) + np.linalg.norm(q[2] - q[3])) / 2
h = (np.linalg.norm(q[3] - q[0]) + np.linalg.norm(q[2] - q[1])) / 2
return float(w), float(h)
def expand_quad(q, left, top, right, bottom) -> np.ndarray:
"""Move each side of the quad outward by the given amounts (negative shrinks), in pixels."""
q = np.asarray(q, np.float32)
u = (q[1] - q[0]) + (q[2] - q[3])
v = (q[3] - q[0]) + (q[2] - q[1])
u /= max(np.linalg.norm(u), 1e-6)
v /= max(np.linalg.norm(v), 1e-6)
return np.stack([q[0] - u * left - v * top, q[1] + u * right - v * top,
q[2] + u * right + v * bottom, q[3] - u * left + v * bottom]).astype(np.float32)
def crop_quad(image, q, out_h=None) -> np.ndarray:
"""Perspective-crop a quad to an upright rectangle. If out_h is given, scale to that height."""
w, h = quad_size(q)
w, h = max(int(round(w)), 1), max(int(round(h)), 1)
if out_h is not None:
w, h = max(int(round(w * out_h / h)), 1), out_h
dst = rect_quad(0, 0, w, h)
m = cv2.getPerspectiveTransform(np.asarray(q, np.float32), dst)
return cv2.warpPerspective(image, m, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
def transform_quads(quads, m) -> list[np.ndarray]:
"""Apply a 3x3 homography (or 2x3 affine) to each quad."""
if not len(quads):
return []
m = np.asarray(m, np.float64)
if m.shape == (2, 3):
m = np.vstack([m, [0, 0, 1]])
if isinstance(quads, np.ndarray):
pts = quads.reshape(1, -1, 2).astype(np.float64)
else:
pts = np.concatenate([np.asarray(q, np.float64).reshape(4, 2) for q in quads])[None]
out = cv2.perspectiveTransform(pts, m)[0].astype(np.float32)
return [out[i * 4:(i + 1) * 4] for i in range(len(quads))]
def merge_line_cells(cells):
"""cells: list of (x0, y0, x1, y1, text) in one layout box -> merged line segments."""
# Group into lines by vertical overlap first (a glyph run such as "€" can sit slightly off the
# line's centre), then split each line at large horizontal gaps.
rows = []
for c in sorted(cells, key=lambda c: (c[1] + c[3]) / 2):
h = c[3] - c[1]
best, best_ov = None, 0.5
for r in rows:
ov = (min(r["y1"], c[3]) - max(r["y0"], c[1])) / max(min(h, r["y1"] - r["y0"]), 1e-3)
if ov > best_ov:
best, best_ov = r, ov
if best is None:
rows.append({"y0": c[1], "y1": c[3], "cells": [c]})
else:
best["cells"].append(c)
best["y0"], best["y1"] = min(best["y0"], c[1]), max(best["y1"], c[3])
out = []
for r in rows:
ln = sorted(r["cells"], key=lambda c: c[0])
h = float(np.median([c[3] - c[1] for c in ln]))
groups = [[ln[0]]]
for prev, c in zip(ln, ln[1:]):
if c[0] - groups[-1][-1][2] > 0.6 * h:
groups.append([c])
else:
groups[-1].append(c)
for g in groups:
text = g[0][4]
for prev, c in zip(g, g[1:]):
sep = " " if c[0] - prev[2] > 0.15 * h and not text.endswith(" ") and not c[4].startswith(" ") else ""
text += sep + c[4]
out.append((min(c[0] for c in g), min(c[1] for c in g), max(c[2] for c in g), max(c[3] for c in g), text))
return out