Download tinydoc/data/datasets.py from harryfrz/tinydoc-5m: direct link, hf CLI and curl.
- Browser
- Download file 34 kB
-
https://huggingface.co/harryfrz/tinydoc-5m/resolve/main/tinydoc/data/datasets.py
- Command line
-
hf download hf://harryfrz/tinydoc-5m/tinydoc/data/datasets.py
-
curl -L -o datasets.py https://huggingface.co/harryfrz/tinydoc-5m/resolve/main/tinydoc/data/datasets.py
34 kB
| """Training datasets: each mixes real pages (data/real, built by tinydoc.data.convert) with synthetic | |
| documents, either pre-rendered (tinydoc.data.prerender) or generated on the fly. Every sample is a pure | |
| function of (seed, index). | |
| Data locations default to data/real, data/synth and fonts, and can be redirected with the environment | |
| variables TINYDOC_REAL_ROOT, TINYDOC_SYNTH_ROOT and TINYDOC_FONT_ROOT (used on Kaggle). | |
| """ | |
| import json | |
| import os | |
| from functools import lru_cache | |
| from pathlib import Path | |
| import cv2 | |
| import numpy as np | |
| import torch | |
| from PIL import Image, ImageDraw | |
| from ..charset import encode, normalize | |
| from ..features import MAX_SEGMENTS, segment_features | |
| from ..geometry import expand_quad, quad_bbox, quad_size, rect_quad, transform_quads | |
| from ..models.detector import BLOCK_SHRINK, KERNEL_END_TRIM, KERNEL_HEIGHT_RATIO | |
| from ..models.orientation import PATCH | |
| from ..models.recognizer import LINE_HEIGHT, WIDTH_STRIDE, X_STRETCH | |
| from ..schema import DOC_ID, LABEL_ID, REL_TYPES | |
| from ..synth import text as T | |
| from ..synth.augment import geometric, photometric | |
| from ..synth.documents import KIND_WEIGHTS, build, render | |
| from ..synth.fonts import FontBank, coverage | |
| MAX_LINE_WIDTH = 4096 # inference cap | |
| MAX_TRAIN_WIDTH = 1536 # training cap: wider samples are resampled (keeps attention cost bounded) | |
| MATH_LINE_P = 0.15 # share of synthetic recognizer lines that carry inline maths | |
| WIDTH_BUCKET = 64 # pad batch widths to multiples of this so compiled graphs get reused | |
| SEGMENT_BUCKET = 32 | |
| def real_root(): | |
| return os.environ.get("TINYDOC_REAL_ROOT", "data/real") | |
| def synth_root(): | |
| return os.environ.get("TINYDOC_SYNTH_ROOT", "data/synth") | |
| def font_root(): | |
| return os.environ.get("TINYDOC_FONT_ROOT", "fonts") | |
| def font_bank(root, split): | |
| return FontBank(root, "val" if split != "train" else "train") | |
| def load_records(path): | |
| p = Path(path) | |
| if not p.exists(): | |
| return [] | |
| return [json.loads(l) for l in open(p)] | |
| def pick_weighted(rng, weights: dict): | |
| names = [k for k, v in weights.items() if v > 0] | |
| p = np.array([weights[k] for k in names], np.float64) | |
| return names[rng.choice(len(names), p=p / p.sum())] | |
| # ---------------------------------------------------------------------------------------- page sources | |
| # geometric mode probabilities (none, rotate, photo) and photometric strength per source | |
| AUG = { | |
| "synth": ((0.35, 0.40, 0.25), 1.0), | |
| "doclaynet": ((0.60, 0.30, 0.10), 0.5), | |
| "wildreceipt": ((0.70, 0.30, 0.0), 0.5), | |
| } | |
| DEFAULT_AUG = ((0.6, 0.3, 0.1), 0.5) | |
| class PagePool: | |
| """Samples un-augmented pages (image, quads, ignore flags, ignore regions) plus how to augment them. | |
| "synth" uses pre-rendered pages from TINYDOC_SYNTH_ROOT when they exist for the split, and renders | |
| on the fly otherwise. | |
| """ | |
| def __init__(self, split, use_key, weights): | |
| self.split = split | |
| self.real = {} | |
| for name in weights: | |
| base = Path(synth_root() if name == "synth" else real_root()) / ("" if name == "synth" else name) / split | |
| recs = [r for r in load_records(str(base / "pages.jsonl")) if r["use"][use_key]] | |
| if recs: | |
| self.real[name] = (base, recs) | |
| self.weights = {k: v for k, v in weights.items() if k == "synth" or k in self.real} | |
| self._cache = {} | |
| def pick(self, rng): | |
| """Choose a page and its augmentation. Returns a dict; the image is loaded separately (load).""" | |
| src = pick_weighted(rng, self.weights) | |
| geo_p, strength = AUG.get(src, DEFAULT_AUG) | |
| geo = str(rng.choice(["none", "rotate", "photo"], p=geo_p)) | |
| if src == "synth" and src not in self.real: | |
| kind = str(rng.choice(list(KIND_WEIGHTS), p=list(KIND_WEIGHTS.values()))) | |
| L = build(rng, font_bank(font_root(), self.split), kind) | |
| img = np.ascontiguousarray(render(L, rng)[:, :, ::-1]) # BGR like cv2.imread | |
| segs = L.segments() | |
| quads = np.array([s["quad"] for s in segs], np.float32).reshape(-1, 4, 2) | |
| info = self._summarise(quads, np.array([detector_ignores(s) for s in segs], bool), [], L.width, L.height, | |
| block_rects(segs) if L.blocks_known else None) | |
| return {"src": src, "img": img, "info": info, "geo": geo, "strength": strength, | |
| "thermal": kind == "receipt" and rng.random() < 0.4} | |
| base, recs = self.real[src] | |
| i = int(rng.integers(len(recs))) | |
| rec = recs[i] | |
| return {"src": src, "path": str(base / rec["image"]), "info": self.info(src, i), "geo": geo, "strength": strength, | |
| "thermal": rec.get("kind") == "receipt" and rng.random() < 0.4} | |
| def _summarise(quads, ignore, regions, W, H, blocks=None): | |
| if len(quads): | |
| v = quads[:, 3] - quads[:, 0] | |
| heights = np.linalg.norm(v, axis=1) | |
| ok = heights[~ignore] if (~ignore).any() else heights | |
| med = float(np.median(ok)) | |
| bboxes = np.concatenate([quads.min(1), quads.max(1)], 1) | |
| else: | |
| med, bboxes = 20.0, np.zeros((0, 4), np.float32) | |
| # blocks: (rects (B, 4) x0 y0 x1 y1, line heights (B,)) when the page has block labels, else None | |
| return {"quads": quads, "ignore": ignore, "regions": regions, "med_h": med, "bboxes": bboxes, "W": W, "H": H, | |
| "blocks": blocks} | |
| def info(self, src, i): | |
| """Per-page box arrays and statistics, cached per worker (pages are revisited many times).""" | |
| key = (src, i) | |
| if key not in self._cache: | |
| rec = self.real[src][1][i] | |
| quads = np.array([s["quad"] for s in rec["segments"]], np.float32).reshape(-1, 4, 2) | |
| ignore = np.array([detector_ignores(s) for s in rec["segments"]], bool) | |
| regions = [np.asarray(q, np.float32) for q in rec["ignore_regions"]] | |
| blocks = block_rects(rec["segments"]) if rec["use"].get("blocks") else None | |
| self._cache[key] = self._summarise(quads, ignore, regions, rec["width"], rec["height"], blocks) | |
| return self._cache[key] | |
| def load(pick, reduce=1): | |
| """BGR page image, decoded at 1/reduce size (JPEG DCT scaling is much cheaper than resizing).""" | |
| if "img" in pick: | |
| return pick["img"] | |
| flag = {1: cv2.IMREAD_COLOR, 2: cv2.IMREAD_REDUCED_COLOR_2, 4: cv2.IMREAD_REDUCED_COLOR_4}[reduce] | |
| return cv2.imread(pick["path"], flag) | |
| def sample(self, rng): | |
| """Full page as RGB (orientation data and evaluation).""" | |
| p = self.pick(rng) | |
| info = p["info"] | |
| img = cv2.cvtColor(self.load(p), cv2.COLOR_BGR2RGB) | |
| return (img, list(info["quads"]), list(info["ignore"]), list(info["regions"]), p["src"], p["geo"], | |
| p["strength"], p["thermal"], info["blocks"]) | |
| def block_rects(segs): | |
| """Layout blocks of a page from its segments' block ids: (rects (B, 4), median line height per block).""" | |
| groups = {} | |
| for s in segs: | |
| if s.get("block") is not None and not s.get("ignore"): | |
| groups.setdefault(s["block"], []).append(np.asarray(s["quad"], np.float32)) | |
| if not groups: | |
| return np.zeros((0, 4), np.float32), np.zeros(0, np.float32) | |
| rects, hs = [], [] | |
| for qs in groups.values(): | |
| q = np.stack(qs) | |
| rects.append([q[:, :, 0].min(), q[:, :, 1].min(), q[:, :, 0].max(), q[:, :, 1].max()]) | |
| hs.append(float(np.median(q[:, :, 1].max(1) - q[:, :, 1].min(1)))) | |
| return np.array(rects, np.float32), np.array(hs, np.float32) | |
| def block_targets(h, w, block_quads, probe_quads, known, regions): | |
| """Block-kernel target and mask. probe_quads: one line-height square per block, transformed with the page, | |
| giving the local line height for the shrink.""" | |
| kernel = np.zeros((h, w), np.uint8) | |
| mask = np.full((h, w), 1 if known else 0, np.uint8) | |
| if known: | |
| for bq, pq in zip(block_quads, probe_quads): | |
| lh = quad_size(pq)[1] | |
| d = BLOCK_SHRINK * lh | |
| bw, bh = quad_size(bq) | |
| if bw > 2 * d + 1 and bh > 2 * d + 1: | |
| cv2.fillPoly(kernel, [np.round(expand_quad(bq, -d, -d, -d, -d)).astype(np.int32)], 1) | |
| for r in regions: | |
| cv2.fillPoly(mask, [np.round(r).astype(np.int32)], 0) | |
| return kernel, mask | |
| def detector_ignores(seg): | |
| """Segments the detector neither learns nor is scored on: unreadable text and display maths.""" | |
| return bool(seg["ignore"]) or seg.get("label") == "formula" | |
| def page_region(img, quads, regions, cx, cy, scale, side, fill): | |
| """Cut a side x side window centred on (cx, cy) of the page scaled by `scale`; move boxes with it. | |
| Cropping before resizing/warping keeps per-sample cost independent of page size. | |
| """ | |
| H, W = img.shape[:2] | |
| src_side = side / scale | |
| x0, y0 = cx - src_side / 2, cy - src_side / 2 | |
| sx0, sy0 = int(max(0, np.floor(x0))), int(max(0, np.floor(y0))) | |
| sx1, sy1 = int(min(W, np.ceil(x0 + src_side))), int(min(H, np.ceil(y0 + src_side))) | |
| out = np.empty((side, side, 3), np.uint8) | |
| out[:] = fill | |
| if sx1 <= sx0 or sy1 <= sy0: | |
| return out, [], [] | |
| patch = img[sy0:sy1, sx0:sx1] | |
| ow, oh = max(1, round((sx1 - sx0) * scale)), max(1, round((sy1 - sy0) * scale)) | |
| patch = cv2.resize(patch, (ow, oh), interpolation=cv2.INTER_AREA if scale < 1 else cv2.INTER_LINEAR) | |
| ox, oy = int(round((sx0 - x0) * scale)), int(round((sy0 - y0) * scale)) | |
| px0, py0 = max(0, ox), max(0, oy) | |
| px1, py1 = min(side, ox + ow), min(side, oy + oh) | |
| out[py0:py1, px0:px1] = patch[py0 - oy:py1 - oy, px0 - ox:px1 - ox] | |
| sx, sy = ow / (sx1 - sx0), oh / (sy1 - sy0) | |
| m = np.array([[sx, 0, ox - sx0 * sx], [0, sy, oy - sy0 * sy], [0, 0, 1]]) | |
| return out, transform_quads(quads, m), transform_quads(regions, m) | |
| # -------------------------------------------------------------------------------------------- detector | |
| def kernel_quad(q): | |
| w, h = quad_size(q) | |
| dy = h * (1 - KERNEL_HEIGHT_RATIO) / 2 | |
| dx = min(KERNEL_END_TRIM * h, w * 0.25) | |
| return expand_quad(q, -dx, -dy, -dx, -dy) | |
| def detector_targets(h, w, quads, ignore, regions, min_h=5): | |
| kernel = np.zeros((h, w), np.uint8) | |
| mask = np.ones((h, w), np.uint8) | |
| for q, ign in zip(quads, ignore): | |
| _, qh = quad_size(q) | |
| if ign or qh < min_h: | |
| cv2.fillPoly(mask, [np.round(q).astype(np.int32)], 0) | |
| continue | |
| cv2.fillPoly(kernel, [np.round(kernel_quad(q)).astype(np.int32)], 1) | |
| for r in regions: | |
| cv2.fillPoly(mask, [np.round(r).astype(np.int32)], 0) | |
| return kernel, mask | |
| class DetectorDataset(torch.utils.data.Dataset): | |
| def __init__(self, split="train", length=100_000, crop=640, seed=0, weights=None): | |
| weights = weights or {"synth": 0.45, "doclaynet": 0.3, "wildreceipt": 0.25} | |
| self.pool = PagePool(split, "detector", weights) | |
| self.length, self.crop, self.seed = length, crop, seed | |
| def __len__(self): | |
| return self.length | |
| def __getitem__(self, idx): | |
| rng = np.random.default_rng([self.seed, idx]) | |
| p = self.pool.pick(rng) | |
| info = p["info"] | |
| W, H = info["W"], info["H"] | |
| scale = np.exp(rng.uniform(np.log(7), np.log(56))) / info["med_h"] # dense newspapers to large titles | |
| scale = float(np.clip(scale, 320 / max(H, W), 3000 / max(H, W))) | |
| reduce = 4 if scale <= 0.22 else 2 if scale <= 0.45 else 1 | |
| img = self.pool.load(p, reduce) | |
| rs = img.shape[1] / W # actual decode scale | |
| c = self.crop | |
| quads, bboxes = info["quads"], info["bboxes"] | |
| if len(quads) and rng.random() < 0.75: | |
| cx, cy = quads[rng.integers(len(quads))].mean(0) | |
| else: | |
| cx, cy = rng.uniform(0, W), rng.uniform(0, H) | |
| cx += rng.uniform(-c / 4, c / 4) / scale | |
| cy += rng.uniform(-c / 4, c / 4) / scale | |
| side = int(c * 1.25) | |
| half = side / scale # generous: rotation/perspective can pull in boxes from just outside the window | |
| near = (bboxes[:, 2] > cx - half) & (bboxes[:, 0] < cx + half) & (bboxes[:, 3] > cy - half) & (bboxes[:, 1] < cy + half) | |
| quads_n, ignore = quads[near] * rs, info["ignore"][near] | |
| regions = [r * rs for r in info["regions"]] | |
| known = info["blocks"] is not None | |
| bq = pq = np.zeros((0, 4, 2), np.float32) | |
| if known: | |
| rects, lhs = info["blocks"] | |
| sel = (rects[:, 2] > cx - half) & (rects[:, 0] < cx + half) & (rects[:, 3] > cy - half) & (rects[:, 1] < cy + half) | |
| bq = np.array([rect_quad(*r) for r in rects[sel]], np.float32).reshape(-1, 4, 2) * rs | |
| pq = np.array([rect_quad(r[0], r[1], r[0] + lh, r[1] + lh) for r, lh in zip(rects[sel], lhs[sel])], | |
| np.float32).reshape(-1, 4, 2) * rs | |
| nq, nb = len(quads_n), len(bq) | |
| allq = np.concatenate([np.asarray(quads_n, np.float32).reshape(-1, 4, 2), bq, pq]) | |
| fill = np.median(img[::16, ::16].reshape(-1, 3), 0).astype(np.uint8) | |
| region, allq, regions = page_region(img, allq, regions, cx * rs, cy * rs, scale / rs, side, fill) | |
| region = np.ascontiguousarray(region[:, :, ::-1]) # BGR -> RGB on the small region only | |
| region, m = geometric(region, rng, p["geo"]) | |
| rh, rw = region.shape[:2] | |
| x0, y0 = (rw - c) // 2, (rh - c) // 2 | |
| m = np.array([[1, 0, -x0], [0, 1, -y0], [0, 0, 1]], np.float64) @ m | |
| allq, regions = transform_quads(allq, m), transform_quads(regions, m) | |
| quads_n, bq, pq = allq[:nq], allq[nq:nq + nb], allq[nq + nb:] | |
| out = region[y0:y0 + c, x0:x0 + c] | |
| kernel, mask = detector_targets(c, c, quads_n, ignore, regions) | |
| bkernel, bmask = block_targets(c, c, bq, pq, known, regions) | |
| out = photometric(np.ascontiguousarray(out), rng, p["strength"], p["thermal"]) | |
| return (torch.from_numpy(out).permute(2, 0, 1).float() / 255, | |
| torch.from_numpy(np.stack([kernel, bkernel])).float(), torch.from_numpy(np.stack([mask, bmask])).float()) | |
| # ------------------------------------------------------------------------------------------ recognizer | |
| def synth_line(rng, bank): | |
| """Render one text line with margins. Returns (gray crop, tight box, text).""" | |
| for _ in range(10): | |
| face, bax = bank.pick(rng, bold=rng.random() < 0.2, italic_ok=True) | |
| text = FontBank.supported(face, normalize(T.line_text(rng))).strip() | |
| if text: | |
| break | |
| else: | |
| text, (face, bax) = "0", bank.pick(rng) | |
| size = int(rng.integers(18, 56)) | |
| font = bank.font(face, size, bax) | |
| # Justified newspaper/magazine columns set words much tighter (or looser) than the font's own space. | |
| words = text.split(" ") | |
| space = font.getlength(" ") * (rng.uniform(0.45, 1.8) if len(words) > 1 and rng.random() < 0.35 else 1.0) | |
| placed, x = [], 0.0 | |
| for wd in words: | |
| if wd: | |
| placed.append((x, wd, font, 0.0)) | |
| x += font.getlength(wd) + space | |
| arr, box = _line_canvas(rng, placed, font) | |
| return arr, box, text | |
| def synth_math_line(rng, bank): | |
| """A line with inline maths: sub/superscripts typeset small and raised or lowered (labelled x², H₂O), Greek and | |
| maths symbols drawn from a maths font when the text font lacks them. Returns (gray crop, tight box, text).""" | |
| for _ in range(10): | |
| runs = T.math_runs(T.math_line(rng)) | |
| face, bax = bank.pick(rng, bold=rng.random() < 0.1, italic_ok=True) | |
| cov = coverage(face.path) | |
| if all(c == " " or c in cov or bank.fallback(c) for t, _ in runs for c in t): | |
| break | |
| else: | |
| return synth_line(rng, bank) | |
| size = int(rng.integers(20, 56)) | |
| font = bank.font(face, size, bax) | |
| small_size = max(6, round(size * rng.uniform(0.58, 0.75))) | |
| rise, drop = size * rng.uniform(0.28, 0.45), size * rng.uniform(0.10, 0.22) | |
| placed, x = [], 0.0 | |
| for text, kind in runs: | |
| spelled = T.scripted(text, kind) if kind else text | |
| if kind and spelled != text and rng.random() < 0.3 and all(c in cov for c in spelled): | |
| text, kind = spelled, "" # typed with the font's own Unicode sub/superscript glyphs | |
| pt, dy = (size, 0.0) if not kind else (small_size, -rise if kind == "^" else drop) | |
| for ch in text: # one glyph at a time so each can fall back to the maths font | |
| if ch == " ": | |
| x += font.getlength(" ") | |
| continue | |
| f = bank.font(face, pt, bax) if ch in cov else bank.font(bank.fallback(ch), pt) | |
| placed.append((x, ch, f, dy)) | |
| x += f.getlength(ch) | |
| if not placed: | |
| return synth_line(rng, bank) | |
| arr, box = _line_canvas(rng, placed, font) | |
| return arr, box, normalize(T.runs_label(runs)) | |
| def _line_canvas(rng, placed, font): | |
| """placed: [(x, text, font, dy)] along one baseline. Draws them on a page-coloured canvas with margins, | |
| sometimes with neighbouring lines, rules and a slight tilt. Returns (gray crop, tight box).""" | |
| boxes = [(x + bb[0], bb[1] + dy, x + bb[2], bb[3] + dy) | |
| for x, s, f, dy in placed for bb in [f.getbbox(s, anchor="ls")]] | |
| l, r = min(b[0] for b in boxes), max(b[2] for b in boxes) | |
| _, rt, _, rb = font.getbbox("Hg", anchor="ls") | |
| top, bot = min(rt, min(b[1] for b in boxes)), max(rb, max(b[3] for b in boxes)) | |
| h, w = bot - top, max(r - l, 1) | |
| mx, my = 0.6 * h, 0.35 * h | |
| W, H = int(w + 2 * mx), int(h + 2 * my) | |
| bg = int(rng.integers(170, 256)) | |
| ink = int(rng.integers(0, max(bg - 90, 1))) | |
| im = Image.new("L", (W, H), bg) | |
| d = ImageDraw.Draw(im) | |
| for x, s, f, dy in placed: | |
| d.text((mx - l + x, my - top + dy), s, font=f, fill=ink, anchor="ls") | |
| if rng.random() < 0.3: # fragments of neighbouring lines peeking into the margins | |
| for sign in (-1, 1): | |
| if rng.random() < 0.6: | |
| d.text((mx - l + rng.uniform(-h, h), my - top + sign * h * rng.uniform(1.05, 1.5)), | |
| T.line_text(rng), font=font, fill=ink, anchor="ls") | |
| if rng.random() < 0.06: | |
| yy = my + h * rng.uniform(0.85, 1.25) | |
| d.line([0, yy, W, yy], fill=ink, width=max(1, int(h / 20))) | |
| if rng.random() < 0.06: | |
| for xx in (mx * rng.uniform(0, 0.7), W - mx * rng.uniform(0, 0.7)): | |
| d.line([xx, 0, xx, H], fill=ink, width=max(1, int(h / 20))) | |
| arr = np.array(im) | |
| if rng.random() < 0.3: | |
| m = cv2.getRotationMatrix2D((W / 2, H / 2), rng.normal(0, 0.8), 1.0) | |
| m[0, 1] += rng.uniform(-0.15, 0.15) | |
| m[0, 2] -= m[0, 1] * H / 2 | |
| arr = cv2.warpAffine(arr, m, (W, H), borderValue=bg) | |
| return arr, (mx, my, mx + w, my + h) | |
| def jitter_crop(gray, box, rng): | |
| x0, y0, x1, y1 = box | |
| h = y1 - y0 | |
| H, W = gray.shape[:2] | |
| cx0 = int(np.clip(x0 - rng.uniform(-0.05, 0.5) * h, 0, W - 1)) | |
| cx1 = int(np.clip(x1 + rng.uniform(-0.05, 0.5) * h, cx0 + 1, W)) | |
| cy0 = int(np.clip(y0 - rng.uniform(-0.08, 0.3) * h, 0, H - 1)) | |
| cy1 = int(np.clip(y1 + rng.uniform(-0.08, 0.3) * h, cy0 + 1, H)) | |
| return gray[cy0:cy1, cx0:cx1] | |
| def line_to_uint8(gray) -> np.ndarray: | |
| """Grayscale crop -> uint8 (32, W), W a multiple of the CNN stride.""" | |
| h, w = gray.shape[:2] | |
| nw = int(round(w * LINE_HEIGHT / max(h, 1) * X_STRETCH)) | |
| nw = int(np.clip(nw, WIDTH_STRIDE * 2, MAX_LINE_WIDTH)) | |
| nw = (nw + WIDTH_STRIDE - 1) // WIDTH_STRIDE * WIDTH_STRIDE | |
| return cv2.resize(gray, (nw, LINE_HEIGHT), interpolation=cv2.INTER_AREA if h > LINE_HEIGHT else cv2.INTER_LINEAR) | |
| def line_to_input(gray) -> np.ndarray: | |
| """Grayscale crop -> float32 (32, W) in [0,1].""" | |
| return line_to_uint8(gray).astype(np.float32) / 255.0 | |
| class LineDataset(torch.utils.data.Dataset): | |
| def __init__(self, split="train", length=1_000_000, seed=0, weights=None): | |
| weights = weights or {"synth": 0.4, "cord-v2": 0.25, "doclaynet": 0.35} | |
| self.split, self.length, self.seed = split, length, seed | |
| self.real = {} | |
| for name in weights: | |
| if name != "synth": | |
| recs = load_records(str(Path(real_root()) / name / split / "lines.jsonl")) | |
| if recs: | |
| self.real[name] = (Path(real_root()) / name / split, recs) | |
| self.weights = {k: v for k, v in weights.items() if k == "synth" or k in self.real} | |
| def __len__(self): | |
| return self.length | |
| def __getitem__(self, idx): | |
| rng = np.random.default_rng([self.seed, idx]) | |
| for _ in range(20): | |
| out = self._sample(rng) | |
| if out[0].shape[-1] <= MAX_TRAIN_WIDTH: | |
| return out | |
| return out | |
| def _sample(self, rng): | |
| src = pick_weighted(rng, self.weights) | |
| tag = src | |
| if src == "synth": | |
| bank = font_bank(font_root(), self.split) | |
| maths = rng.random() < MATH_LINE_P | |
| gray, box, text = (synth_math_line if maths else synth_line)(rng, bank) | |
| strength, tag = 1.0, "synth-math" if maths else "synth" | |
| else: | |
| base, recs = self.real[src] | |
| r = recs[rng.integers(len(recs))] | |
| gray = cv2.imread(str(base / r["path"]), cv2.IMREAD_GRAYSCALE) | |
| box, text, strength = r["box"], r["text"], 0.5 | |
| crop = jitter_crop(gray, box, rng) | |
| if rng.random() < 0.2: | |
| # small print: the text was only 7-13 px tall in the page image (dense newspapers, footnotes) | |
| th = max(box[3] - box[1], 1) | |
| f = min(1.0, rng.uniform(7, 13) / th) | |
| if f < 0.95: | |
| h0, w0 = crop.shape[:2] | |
| small = cv2.resize(crop, (max(1, round(w0 * f)), max(1, round(h0 * f))), interpolation=cv2.INTER_AREA) | |
| crop = cv2.resize(small, (w0, h0), interpolation=cv2.INTER_LINEAR if rng.random() < 0.5 else cv2.INTER_CUBIC) | |
| crop = photometric(crop, rng, strength, thermal=src == "synth" and rng.random() < 0.1) | |
| if rng.random() < 0.08: | |
| crop = 255 - crop | |
| x = line_to_uint8(crop) # uint8 keeps worker->trainer transfer 4x smaller; scaled in collate | |
| return torch.from_numpy(x)[None], torch.tensor(encode(text), dtype=torch.long), tag # tag: metrics source | |
| def collate_lines(batch): | |
| imgs, targets, srcs = zip(*batch) | |
| wmax = max(i.shape[-1] for i in imgs) | |
| wmax = (wmax + WIDTH_BUCKET - 1) // WIDTH_BUCKET * WIDTH_BUCKET | |
| B = len(imgs) | |
| x = torch.ones(B, 1, LINE_HEIGHT, wmax) | |
| steps = torch.tensor([i.shape[-1] // WIDTH_STRIDE for i in imgs]) | |
| for k, im in enumerate(imgs): | |
| x[k, :, :, : im.shape[-1]] = im.float() / 255.0 | |
| step_mask = torch.arange(wmax // WIDTH_STRIDE)[None, :] >= steps[:, None] | |
| return x, step_mask, steps, torch.cat(targets), torch.tensor([len(t) for t in targets]), list(srcs), targets | |
| # ----------------------------------------------------------------------------------------- orientation | |
| def text_patches(gray, n, rng=None): | |
| """Pick up to n PATCH-sized windows dense in ink-like edges. Returns list of (x, y).""" | |
| H, W = gray.shape | |
| if H < PATCH or W < PATCH: | |
| return [(0, 0)] | |
| ink = cv2.adaptiveThreshold(gray, 1, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 25, 15).astype(np.float32) | |
| s = 8 | |
| small = cv2.resize(ink, (W // s, H // s), interpolation=cv2.INTER_AREA) | |
| k = PATCH // s | |
| dens = cv2.boxFilter(small, -1, (k, k), normalize=True, borderType=cv2.BORDER_CONSTANT) | |
| dens[: k // 2, :] = dens[-(k // 2):, :] = 0 | |
| dens[:, : k // 2] = dens[:, -(k // 2):] = 0 | |
| # Text has moderate ink density; solid dark regions (photo backgrounds) are excluded. | |
| score = dens * (dens < 0.35) | |
| out = [] | |
| for _ in range(n): | |
| if rng is not None: | |
| flat = score.ravel() ** 2 | |
| if flat.sum() <= 0: | |
| break | |
| i = rng.choice(len(flat), p=flat / flat.sum()) | |
| else: | |
| i = int(score.argmax()) | |
| if score.ravel()[i] <= 0.01: | |
| break | |
| cy, cx = divmod(i, score.shape[1]) | |
| out.append((int(np.clip(cx * s - PATCH // 2, 0, W - PATCH)), int(np.clip(cy * s - PATCH // 2, 0, H - PATCH)))) | |
| score[max(0, cy - k // 2): cy + k // 2, max(0, cx - k // 2): cx + k // 2] = 0 | |
| return out or [((W - PATCH) // 2, (H - PATCH) // 2)] | |
| ORIENT_LONG_SIDE = 1280 | |
| def orientation_page(img_rgb, long_side=ORIENT_LONG_SIDE): | |
| gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY) if img_rgb.ndim == 3 else img_rgb | |
| s = long_side / max(gray.shape) | |
| return cv2.resize(gray, (max(1, round(gray.shape[1] * s)), max(1, round(gray.shape[0] * s))), interpolation=cv2.INTER_AREA) | |
| class OrientationDataset(torch.utils.data.Dataset): | |
| """Each item is `per_page` patches from one page, each independently rotated by k*90 degrees.""" | |
| def __init__(self, split="train", length=200_000, seed=0, weights=None, per_page=8): | |
| weights = weights or {"synth": 0.5, "doclaynet": 0.25, "wildreceipt": 0.25} | |
| self.pool = PagePool(split, "detector", weights) | |
| self.length, self.seed, self.per_page = length, seed, per_page | |
| def __len__(self): | |
| return self.length | |
| def __getitem__(self, idx): | |
| rng = np.random.default_rng([self.seed, idx]) | |
| img, quads, ignore, _, _, _, strength, thermal, _ = self.pool.sample(rng) | |
| long_side = int(rng.integers(1000, 1600)) | |
| gray = orientation_page(img, long_side) | |
| scale = long_side / max(img.shape[:2]) | |
| centers = [q.mean(0) * scale for q, ig in zip(quads, ignore) if not ig] | |
| pos = text_patches(gray, self.per_page, rng) | |
| big = int(PATCH * 1.3) | |
| padded = cv2.copyMakeBorder(gray, big, big, big, big, cv2.BORDER_REPLICATE) | |
| patches, labels = [], [] | |
| for i in range(self.per_page): | |
| # mostly centred on known text (the heuristic picker can land on wood grain or photos) | |
| if centers and rng.random() < 0.75: | |
| cx, cy = centers[rng.integers(len(centers))] + rng.uniform(-PATCH / 3, PATCH / 3, 2) | |
| else: | |
| x, y = pos[i % len(pos)] | |
| cx, cy = x + PATCH / 2, y + PATCH / 2 | |
| cx, cy = int(np.clip(cx, 0, gray.shape[1])) + big, int(np.clip(cy, 0, gray.shape[0])) + big | |
| win = padded[cy - big // 2: cy - big // 2 + big, cx - big // 2: cx - big // 2 + big] | |
| if rng.random() < 0.6: # residual skew | |
| m = cv2.getRotationMatrix2D((big / 2, big / 2), rng.normal(0, 3), 1.0) | |
| win = cv2.warpAffine(win, m, (big, big), borderMode=cv2.BORDER_REPLICATE) | |
| o = (big - PATCH) // 2 | |
| patch = win[o:o + PATCH, o:o + PATCH] | |
| k = int(rng.integers(4)) | |
| patch = np.ascontiguousarray(np.rot90(patch, -k)) # rotated k*90 degrees clockwise | |
| patches.append(photometric(patch, rng, strength, thermal)) | |
| labels.append(k) | |
| x = torch.from_numpy(np.stack(patches).astype(np.float32) / 255)[:, None] | |
| return x, torch.tensor(labels) | |
| # ------------------------------------------------------------------------------------------- extractor | |
| CONFUSIONS = {"0": "O", "O": "0", "o": "0", "1": "l", "l": "1", "I": "1", "5": "S", "S": "5", "8": "B", "B": "8", | |
| ",": ".", ".": ",", "2": "Z", "6": "G", "g": "9", "9": "g", "e": "c", "c": "e", "u": "v", ":": ";"} | |
| def ocr_noise(text, rng, rate): | |
| out = [] | |
| for ch in text: | |
| r = rng.random() | |
| if r < rate * 0.6: | |
| out.append(CONFUSIONS.get(ch, ch if rng.random() < 0.5 else T.pick(rng, "abcdefghijklmnopqrstuvwxyz0123456789"))) | |
| elif r < rate * 0.8: | |
| continue | |
| elif r < rate: | |
| out.extend([ch, T.pick(rng, ".,'-il")]) | |
| else: | |
| out.append(ch) | |
| return "".join(out) or text | |
| EXTRACTOR_KINDS = {"receipt": 0.3, "invoice": 0.35, "general": 0.1, "newspaper": 0.07, "magazine": 0.06, "academic": 0.12} | |
| class LayoutDataset(torch.utils.data.Dataset): | |
| def __init__(self, split="train", length=200_000, seed=0, weights=None): | |
| weights = weights or {"synth": 0.45, "cord-v2": 0.17, "wildreceipt": 0.13, "doclaynet": 0.25} | |
| self.split, self.length, self.seed = split, length, seed | |
| self.real = {} | |
| for name in weights: | |
| if name != "synth": | |
| recs = [r for r in load_records(str(Path(real_root()) / name / split / "pages.jsonl")) if r["use"]["labels"]] | |
| if recs: | |
| self.real[name] = recs | |
| self.prerendered = load_records(str(Path(synth_root()) / split / "pages.jsonl")) | |
| self.weights = {k: v for k, v in weights.items() if k == "synth" or k in self.real} | |
| def __len__(self): | |
| return self.length | |
| def sample_doc(self, rng): | |
| src = pick_weighted(rng, self.weights) | |
| # Layouts without rendering are cheap, so half the synthetic documents are generated here with a mix that | |
| # favours invoices (v2's field model lost invoice accuracy when invoices were 20% of the mix). | |
| if src == "synth" and (not self.prerendered or rng.random() < 0.5): | |
| kind = str(rng.choice(list(EXTRACTOR_KINDS), p=list(EXTRACTOR_KINDS.values()))) | |
| L = build(rng, font_bank(font_root(), self.split), kind) | |
| segs = [{"bbox": list(s.box), "text": s.text, "label": s.label, "row": s.row, "block": s.block} for s in L.segs] | |
| return segs, L.kv, L.doc_type, L.width, L.height, (True, True, L.blocks_known), f"synth-{L.doc_type}" | |
| recs = self.prerendered if src == "synth" else self.real[src] | |
| rec = recs[rng.integers(len(recs))] | |
| segs = [{"bbox": quad_bbox(s["quad"]), "text": s["text"], "label": s["label"], "row": s["row"], "block": s.get("block")} | |
| for s in rec["segments"] if not s["ignore"]] | |
| keep = [i for i, s in enumerate(rec["segments"]) if not s["ignore"]] | |
| remap = {old: new for new, old in enumerate(keep)} | |
| kv = [(remap[k], remap[v]) for k, v in rec["kv_pairs"] if k in remap and v in remap] | |
| use = rec["use"] | |
| label = f"synth-{rec['doc_type']}" if src == "synth" else src # per-kind scores in evaluation | |
| return segs, kv, rec["doc_type"], rec["width"], rec["height"], (use["relations"], use["relations"], use.get("blocks", False)), label | |
| def __getitem__(self, idx): | |
| rng = np.random.default_rng([self.seed, idx]) | |
| segs, kv, doc_type, W, H, rel_known, src = self.sample_doc(rng) | |
| if self.split == "train": | |
| rate = rng.uniform(0, 0.08) | |
| drop_spaces = rng.random() < 0.08 | |
| for s in segs: | |
| if rng.random() < 0.5: | |
| s["text"] = ocr_noise(s["text"], rng, rate) | |
| if drop_spaces: | |
| s["text"] = s["text"].replace(" ", "") | |
| h = s["bbox"][3] - s["bbox"][1] | |
| s["bbox"] = [v + rng.normal(0, 0.06 * h) for v in s["bbox"]] | |
| s["conf"] = float(np.clip(1 - rate * rng.uniform(2, 8), 0.3, 1)) | |
| if rng.random() < 0.3: # page embedded in a larger photo frame | |
| f = rng.uniform(1.0, 1.8) | |
| ox, oy = rng.uniform(0, W * (f - 1)), rng.uniform(0, H * (f - 1)) | |
| for s in segs: | |
| s["bbox"] = [s["bbox"][0] + ox, s["bbox"][1] + oy, s["bbox"][2] + ox, s["bbox"][3] + oy] | |
| W, H = W * f, H * f | |
| order = sorted(range(len(segs)), key=lambda i: (round(segs[i]["bbox"][1] / 8), segs[i]["bbox"][0])) | |
| if len(order) > MAX_SEGMENTS: | |
| start = int(rng.integers(0, len(order) - MAX_SEGMENTS + 1)) | |
| order = order[start:start + MAX_SEGMENTS] | |
| pos = {old: new for new, old in enumerate(order)} | |
| segs = [segs[i] for i in order] | |
| n = len(segs) | |
| f = segment_features(segs, W, H) | |
| labels = np.array([LABEL_ID[s["label"]] if s["label"] is not None else -100 for s in segs], np.int64) | |
| rel = np.zeros((len(REL_TYPES), n, n), np.float32) | |
| rows = np.array([s["row"] if s["row"] is not None else -1 for s in segs]) | |
| same = (rows[:, None] == rows[None, :]) & (rows[:, None] >= 0) | |
| np.fill_diagonal(same, False) | |
| rel[0] = same | |
| for k, v in kv: | |
| if k in pos and v in pos: | |
| rel[1, pos[k], pos[v]] = 1 | |
| blocks = np.array([s["block"] if s.get("block") is not None else -1 for s in segs]) | |
| same = (blocks[:, None] == blocks[None, :]) & (blocks[:, None] >= 0) | |
| np.fill_diagonal(same, False) | |
| rel[2] = same | |
| rel_mask = np.array(rel_known, np.float32) | |
| return {**{k: torch.from_numpy(v) for k, v in f.items()}, "labels": torch.from_numpy(labels), | |
| "rel": torch.from_numpy(rel), "rel_known": torch.from_numpy(rel_mask), | |
| "doc": DOC_ID[doc_type], "src": src} | |
| def collate_layouts(batch): | |
| B = len(batch) | |
| n = max(max(b["feats"].shape[0] for b in batch), 1) | |
| n = (n + SEGMENT_BUCKET - 1) // SEGMENT_BUCKET * SEGMENT_BUCKET | |
| L = batch[0]["char_ids"].shape[1] | |
| F_ = batch[0]["feats"].shape[1] | |
| R = batch[0]["rel"].shape[0] | |
| out = {"char_ids": torch.zeros(B, n, L, dtype=torch.long), "feats": torch.zeros(B, n, F_), | |
| "rel_boxes": torch.zeros(B, n, 4), "valid": torch.zeros(B, n), "labels": torch.full((B, n), -100, dtype=torch.long), | |
| "rel": torch.zeros(B, R, n, n), "rel_known": torch.stack([b["rel_known"] for b in batch]), | |
| "doc": torch.tensor([b["doc"] for b in batch]), "src": [b["src"] for b in batch]} | |
| for i, b in enumerate(batch): | |
| k = b["feats"].shape[0] | |
| out["char_ids"][i, :k] = b["char_ids"] | |
| out["feats"][i, :k] = b["feats"] | |
| out["rel_boxes"][i, :k] = b["rel_boxes"] | |
| out["valid"][i, :k] = 1 | |
| out["labels"][i, :k] = b["labels"] | |
| out["rel"][i, :, :k, :k] = b["rel"] | |
| return out | |