Download tinydoc/geometry.py from harryfrz/tinydoc-5m: direct link, hf CLI and curl.
- Browser
- Download file 4.55 kB
-
https://huggingface.co/harryfrz/tinydoc-5m/resolve/main/tinydoc/geometry.py
- Command line
-
hf download hf://harryfrz/tinydoc-5m/tinydoc/geometry.py
-
curl -L -o geometry.py https://huggingface.co/harryfrz/tinydoc-5m/resolve/main/tinydoc/geometry.py
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 | |