tinydoc-5m / tinydoc /data /datasets.py
harryfrz's picture
tinydoc-5m v4: OmniDocBench EN text edit 0.117 (v3 0.127), inline maths as text, invoice summary tables
e24bf15 verified
Raw History Blame Contribute Delete
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")
@lru_cache(maxsize=4)
def font_bank(root, split):
return FontBank(root, "val" if split != "train" else "train")
@lru_cache(maxsize=32)
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}
@staticmethod
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]
@staticmethod
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