File size: 20,719 Bytes
4dce182 9d054d4 4dce182 e24bf15 4dce182 d28e3c4 e24bf15 4dce182 d28e3c4 9d054d4 8941e3a 4dce182 8941e3a 4dce182 e24bf15 4dce182 e24bf15 4dce182 e24bf15 4dce182 e24bf15 4dce182 8941e3a 4dce182 8941e3a d28e3c4 9d054d4 d28e3c4 e24bf15 4dce182 d28e3c4 e24bf15 d28e3c4 4dce182 e24bf15 4dce182 8941e3a 4dce182 9d054d4 4dce182 e24bf15 4dce182 9d054d4 4dce182 d28e3c4 4dce182 d28e3c4 4dce182 6a08b03 4dce182 6a08b03 4dce182 e24bf15 d28e3c4 4dce182 d28e3c4 4dce182 9d054d4 d28e3c4 9d054d4 d28e3c4 9d054d4 d28e3c4 9d054d4 4dce182 9d054d4 4dce182 9d054d4 4dce182 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 | """End-to-end document processing: file -> pages -> text segments -> labelled fields -> JSON."""
import json
import time
from pathlib import Path
import cv2
import numpy as np
from ..charset import NUM_CLASSES, ctc_greedy_decode
from ..data.datasets import line_to_input, orientation_page, text_patches
from ..features import MAX_SEGMENTS, segment_features
from ..geometry import crop_quad, expand_quad, quad_bbox, quad_size, transform_quads
from ..models.orientation import ANGLES, PATCH
from ..models.recognizer import WIDTH_STRIDE
from ..schema import DOC_TYPES, FIELD_LABELS, REL_TYPES
from .assemble import (assemble_fields, assemble_page_structure, block_groups, block_order, groups_from_block_map,
structure_from_groups, xy_cut)
from .detect import DET_MAX_SIDE, detector_input, find_rules, kernels_to_quads, split_gutter_crossers
from .inputs import estimate_skew, load_pages, rot90_matrix, rotate_image
COMPONENTS = ("detector", "recognizer", "orientation", "extractor")
# Frequent words used to check a proposed page rotation by reading a few lines both ways (upside-down text
# still yields plausible-looking letters, so OCR confidence alone does not separate the two).
COMMON_WORDS = frozenset("""
the of and to in is for on that with as by this be are from at or an it was will not have all can has
but which their more one were been other its into also than about after these such may when only over
new they we you he she his her our your there two first time most some would what so if no up out who
total tax subtotal cash change qty amount price date invoice item items paid due balance receipt number
page table figure section chapter results data used use between each both during through under where
""".split())
SCHEMA_VERSION = "1.1" # 1.1: "formula" blocks; layout blocks from the model's same_block relation
# White columns after every line the recognizer reads. Training pads each line of a batch with white to the right
# (to a multiple of 64 px), so the model reads a line's end against white; without this strip a line read on its
# own, or the widest line of a batch, would end at the image edge instead. 32 px covers the CNN's receptive field
# (64 px gives identical output), and with it the output no longer depends on how lines are batched.
RECOGNIZER_PAD = 32
def _softmax(x, axis=-1):
e = np.exp(x - x.max(axis, keepdims=True))
return e / e.sum(axis, keepdims=True)
def line_batch(lines):
"""Recognizer input for line arrays (32, W) in [0, 1]: (batch padded with white, step mask, steps per line)."""
wmax = max(x.shape[1] for x in lines) + RECOGNIZER_PAD
batch = np.ones((len(lines), 1, lines[0].shape[0], wmax), np.float32)
steps = np.array([x.shape[1] // WIDTH_STRIDE for x in lines])
for k, x in enumerate(lines):
batch[k, 0, :, : x.shape[1]] = x
mask = np.arange(wmax // WIDTH_STRIDE)[None, :] >= steps[:, None]
return batch, mask, steps
def _sigmoid(x):
return 1 / (1 + np.exp(-np.clip(x, -60, 60)))
class TorchBackend:
def __init__(self, weights_dir, device="cpu"):
import torch
from ..models import build, count_params
self.torch, self.device, self.models, self.params = torch, torch.device(device), {}, {}
for name in COMPONENTS:
p = Path(weights_dir) / name / "best.pt"
if not p.exists():
raise FileNotFoundError(f"missing {p}; train it with `python -m tinydoc.train {name}`")
ck = torch.load(p, map_location="cpu", weights_only=False)
m = build(name, **(ck.get("arch") or {}))
m.load_state_dict(ck["model"])
self.models[name] = m.to(self.device).eval()
self.params[name] = count_params(m)
self.vocab = NUM_CLASSES
def __call__(self, name, *inputs):
with self.torch.no_grad():
t = [self.torch.from_numpy(np.ascontiguousarray(x)).to(self.device) for x in inputs]
out = self.models[name](*t)
out = out if isinstance(out, tuple) else (out,)
return [o.float().cpu().numpy() for o in out]
class OnnxBackend:
def __init__(self, onnx_dir, threads=None):
import onnx
import onnxruntime as ort
opts = ort.SessionOptions()
if threads:
opts.intra_op_num_threads = threads
self.sessions, self.params = {}, {}
for name in COMPONENTS:
p = Path(onnx_dir) / f"{name}.onnx"
if not p.exists():
raise FileNotFoundError(f"missing {p}; export with `python -m tinydoc.export`")
self.sessions[name] = ort.InferenceSession(str(p), opts, providers=["CPUExecutionProvider"])
meta = {m.key: m.value for m in onnx.load(str(p), load_external_data=False).metadata_props}
self.params[name] = int(meta.get("trained_parameters", 0))
# Character vocabulary the models were trained with: releases before the inline-maths characters were
# appended have fewer classes, and their extractor must not see the newer ids.
width = self.sessions["recognizer"].get_outputs()[0].shape[-1]
self.vocab = width if isinstance(width, int) else NUM_CLASSES
def __call__(self, name, *inputs):
s = self.sessions[name]
feed = {i.name: np.ascontiguousarray(x) for i, x in zip(s.get_inputs(), inputs)}
return s.run(None, feed)
class Engine:
def __init__(self, weights="runs", backend="torch", device="cpu", recognizer_batch=None, model_blocks=None, blocks=None):
self.backend = TorchBackend(weights, device) if backend == "torch" else OnnxBackend(weights)
# Lines per recognizer call. On CPU (ONNX Runtime) one line per call is fastest: nothing is padded to the
# widest line of a batch and the activations stay in cache. On 40 OmniDocBench pages (Apple M5 Pro) it cut
# recognition from 421 to 248 ms per page at 4 threads and from 1190 to 440 ms at 1 thread, against
# batches of 32, with the same output (see RECOGNIZER_PAD). The torch backend keeps batches, which pay
# off on a GPU.
self.recognizer_batch = recognizer_batch or (1 if backend == "onnx" else 32)
# How lines are grouped into paragraphs/blocks on ordinary pages: "geometric" rules, the extractor's
# same_block relation ("model"), or the detector's block regions ("detector"). The default comes from
# config.json next to the weights, chosen by measuring each on validation pages.
cfg = Path(weights) / "config.json"
cfg = json.loads(cfg.read_text()) if cfg.exists() else {}
if blocks is None and model_blocks is not None:
blocks = "model" if model_blocks else "geometric"
self.blocks = blocks or cfg.get("blocks") or ("model" if cfg.get("model_blocks") else "geometric")
# Splitting lines merged across a column gutter: off by default (on OmniDocBench such merges were rare,
# 5 in 380 pages, and the split hurt as often as it helped).
self.split_gutters = cfg.get("split_gutters", False)
# Detector input size: one pass with the long side at det_max_side. Optionally (det_fast_side) a page is
# first detected at a smaller size and again at det_max_side only when its median line is under
# det_min_line_px there. Off by default: on v3 it was ~15% faster and helped academic and exam pages, but
# the official OmniDocBench evaluator showed it losing small print on newspapers (+0.017 to +0.038 text
# edit) and magazines (+0.02), which the median-line test does not catch.
self.det_max_side = cfg.get("det_max_side", DET_MAX_SIDE)
self.det_fast_side = cfg.get("det_fast_side")
self.det_min_line_px = cfg.get("det_min_line_px", 14)
# ------------------------------------------------------------------------------------ stages
def orientation(self, img):
gray = orientation_page(img)
pos = text_patches(gray, 8)
patches = np.stack([gray[y:y + PATCH, x:x + PATCH] for x, y in pos]).astype(np.float32)[:, None] / 255
if patches.shape[-1] != PATCH or patches.shape[-2] != PATCH:
return 0, 1.0
probs = _softmax(self.backend("orientation", patches)[0]).mean(0)
k = int(probs.argmax())
return k, float(probs[k])
def _reading_evidence(self, img, n=16):
"""(common-word hits, mean OCR confidence) over the n widest detected lines of an image."""
dets = sorted(self.detect(img), key=lambda d: -quad_size(d[0])[0])[:n]
if not dets:
return 0, 0.0
texts = self.recognize(img, [q for q, _ in dets])
words = [w.strip(".,:;!?()'\"").lower() for t, _ in texts for w in t.split()]
return sum(w in COMMON_WORDS for w in words), float(np.mean([c for _, c in texts]))
def verify_rotation(self, img, k):
"""Keep a proposed 90/180/270 correction only if the rotated page reads better than the original."""
hits0, conf0 = self._reading_evidence(img)
hitsk, confk = self._reading_evidence(np.ascontiguousarray(np.rot90(img, k)))
if abs(hitsk - hits0) >= 2:
return hitsk > hits0
if abs(confk - conf0) > 0.05:
return confk > conf0
return True # no clear evidence either way: trust the orientation classifier
def detect_full(self, img, blocks=True):
"""Line detections plus the block-region map (None for detectors without a block channel, or when
blocks=False because the caller does not use it)."""
for side in ([self.det_fast_side] if self.det_fast_side else []) + [self.det_max_side]:
x, scale = detector_input(img, max_side=side)
out = self.backend("detector", x[None])[0][0]
prob = _sigmoid(out[0])
quads = kernels_to_quads(prob)
if side == self.det_max_side or not quads or np.median([quad_size(q)[1] for q, _ in quads]) >= self.det_min_line_px:
break
block = _sigmoid(out[1]) if blocks and out.shape[0] > 1 else None
return [(q / scale, s) for q, s in quads], block, scale
def detect(self, img):
return self.detect_full(img)[0]
def recognize(self, img, quads):
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
crops = []
for q in quads:
_, h = quad_size(q)
crop = crop_quad(gray, expand_quad(q, 0.25 * h, 0.1 * h, 0.25 * h, 0.1 * h))
if np.median(crop[::2, ::2]) < 100: # light text on a dark band
crop = 255 - crop
crops.append(line_to_input(crop))
results = [None] * len(crops)
order = sorted(range(len(crops)), key=lambda i: crops[i].shape[1])
for start in range(0, len(order), self.recognizer_batch):
idx = order[start:start + self.recognizer_batch]
batch, mask, steps = line_batch([crops[i] for i in idx])
probs = _softmax(self.backend("recognizer", batch, mask)[0])
for k, i in enumerate(idx):
text, confs = ctc_greedy_decode(probs[k, : steps[k]])
results[i] = (text, float(np.mean(confs)) if confs else 0.0)
return results
def extract(self, segs, W, H):
if not segs:
return np.zeros((0, len(FIELD_LABELS))), np.zeros((len(REL_TYPES), 0, 0)), np.full(len(DOC_TYPES), 1 / len(DOC_TYPES))
n = len(segs)
labels = np.zeros((n, len(FIELD_LABELS)), np.float32)
rel = None # sized from the model: older exports predict fewer relations
docs = []
# windows of MAX_SEGMENTS in reading order, overlapping by half, for very dense pages
step = MAX_SEGMENTS // 2
starts = list(range(0, max(n - MAX_SEGMENTS, 0) + 1, step)) or [0]
if starts[-1] + MAX_SEGMENTS < n:
starts.append(n - MAX_SEGMENTS)
for s0 in starts:
idx = list(range(s0, min(n, s0 + MAX_SEGMENTS)))
f = segment_features([segs[i] for i in idx], W, H)
f["char_ids"][f["char_ids"] >= getattr(self.backend, "vocab", NUM_CLASSES)] = 0
out = self.backend("extractor", f["char_ids"][None], f["feats"][None], f["rel_boxes"][None],
np.ones((1, len(idx)), np.float32))
lp = _softmax(out[0][0])
labels[idx, :lp.shape[1]] = np.maximum(labels[idx, :lp.shape[1]], lp)
if rel is None:
rel = np.zeros((out[1].shape[1], n, n), np.float32)
rel[:, idx[0]:idx[-1] + 1, idx[0]:idx[-1] + 1] = np.maximum(rel[:, idx[0]:idx[-1] + 1, idx[0]:idx[-1] + 1], _sigmoid(out[1][0]))
docs.append(_softmax(out[2][0]))
return labels, rel, np.mean(docs, 0)
# -------------------------------------------------------------------------------------- run
def process(self, path, force_ocr=False):
t_start = time.time()
timings = {"load": 0.0, "orientation": 0.0, "deskew": 0.0, "detect": 0.0, "recognize": 0.0, "extract": 0.0, "assemble": 0.0}
pages_out, all_segs, page_info = [], [], []
t = time.time()
pages = list(load_pages(path, force_ocr=force_ocr))
timings["load"] += time.time() - t
for page in pages:
info = {"page": page.number, "width": page.width, "height": page.height, "text_source": page.text_source}
if page.text_source == "pdf_text":
segs = [{"bbox": [x0, y0, x1, y1], "quad": [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], "text": t_, "conf": 1.0}
for x0, y0, x1, y1, t_ in page.pdf_segments]
frame_w, frame_h = page.width, page.height
rules, block_map, det_scale = [], None, 1.0
else:
img = page.image
t = time.time()
k, kconf = self.orientation(img)
if k != 0 and kconf > 0.6 and not self.verify_rotation(img, k):
k, kconf = 0, 1.0 - kconf
info["rotation_rejected"] = True
timings["orientation"] += time.time() - t
m_total = np.eye(3)
if k != 0 and kconf > 0.6:
m_total = np.vstack([rot90_matrix(k, img.shape[1], img.shape[0]), [0, 0, 1]]) @ m_total
img = np.ascontiguousarray(np.rot90(img, k))
info["rotation_corrected_deg"] = ANGLES[k] if (k != 0 and kconf > 0.6) else 0
info["orientation_confidence"] = round(kconf, 3)
t = time.time()
skew = estimate_skew(img) # text is rotated `skew` degrees counter-clockwise
if abs(skew) >= 0.5:
img, m = rotate_image(img, -skew) # rotate it back
m_total = np.vstack([m, [0, 0, 1]]) @ m_total
info["skew_corrected_deg"] = round(skew, 2) if abs(skew) >= 0.5 else 0.0
timings["deskew"] += time.time() - t
t = time.time()
dets, block_map, det_scale = self.detect_full(img, blocks=self.blocks == "detector")
if self.split_gutters:
dets = split_gutter_crossers(cv2.cvtColor(img, cv2.COLOR_RGB2GRAY), dets)
timings["detect"] += time.time() - t
t = time.time()
texts = self.recognize(img, [q for q, _ in dets])
timings["recognize"] += time.time() - t
inv = np.linalg.inv(m_total)
segs = []
for (q, dscore), (text, conf) in zip(dets, texts):
if not text.strip():
continue
orig_q = transform_quads([q], inv)[0] # report positions in the source image
segs.append({"bbox": quad_bbox(q), "quad_src": orig_q, "text": text.strip(),
"conf": conf * min(1.0, dscore / 0.7)})
frame_w, frame_h = img.shape[1], img.shape[0]
hs = [s["bbox"][3] - s["bbox"][1] for s in segs]
rules = find_rules(img, float(np.median(hs))) if hs else []
for i, s in enumerate(segs):
s["page"] = page.number
s["id"] = f"p{page.number}-s{i}"
t = time.time()
labels, rel, doc = self.extract(segs, frame_w, frame_h)
timings["extract"] += time.time() - t
boxes = [s["bbox"] for s in segs]
groups = None
general = bool(segs) and DOC_TYPES[int(doc.argmax())] == "general"
if general and self.blocks == "detector" and block_map is not None:
groups = block_order(boxes, groups_from_block_map(boxes, block_map, det_scale), rules)
elif general and self.blocks == "model" and rel.shape[0] > REL_TYPES.index("same_block"):
names = [FIELD_LABELS[int(r.argmax())] for r in labels]
groups = block_order(boxes, block_groups(boxes, names, rel[REL_TYPES.index("same_block")]), rules)
if groups is not None:
order = [i for g in groups for i in g]
else:
order = xy_cut(boxes, rules=rules) if segs else []
page_info.append((info, segs, labels, rel, doc, order, groups))
t = time.time()
doc_probs = np.mean([p[4] for p in page_info if len(p[1])], 0) if any(len(p[1]) for p in page_info) else np.full(len(DOC_TYPES), 1 / len(DOC_TYPES))
doc_type = DOC_TYPES[int(doc_probs.argmax())]
# global segment list across pages for field assembly
offset = 0
glob_segs, glob_labels, glob_conf, glob_order = [], [], [], []
n_total = sum(len(p[1]) for p in page_info)
same_row = np.zeros((n_total, n_total), np.float32)
key_value = np.zeros((n_total, n_total), np.float32)
for info, segs, labels, rel, _, order, groups in page_info:
n = len(segs)
glob_segs += segs
glob_labels += [FIELD_LABELS[int(r.argmax())] for r in labels]
glob_conf += [float(r.max()) for r in labels]
glob_order += [offset + i for i in order]
if n:
same_row[offset:offset + n, offset:offset + n] = rel[0]
key_value[offset:offset + n, offset:offset + n] = rel[1]
label_names = [FIELD_LABELS[int(r.argmax())] for r in labels]
if groups is not None:
blocks, tables = structure_from_groups(segs, groups, label_names, rel[0])
else:
blocks, tables = assemble_page_structure(segs, order, label_names, rel[0] if n else np.zeros((0, 0)))
pages_out.append({**info, "blocks": blocks, "tables": tables, "segments": [
{"id": s["id"], "text": s["text"], "bbox": [round(v, 1) for v in (quad_bbox(s["quad_src"]) if "quad_src" in s else s["bbox"])],
"quad": np.round(np.asarray(s.get("quad_src", s.get("quad"))), 1).tolist(),
"ocr_confidence": round(s["conf"], 3), "label": label_names[i], "label_confidence": round(float(labels[i].max()), 3)}
for i, s in enumerate(segs)]})
offset += n
result = {
"schema_version": SCHEMA_VERSION,
"source": {"file": str(Path(path).name), "pages": len(pages)},
"document_type": {"value": doc_type, "confidence": round(float(doc_probs.max()), 3),
"scores": {d: round(float(p), 3) for d, p in zip(DOC_TYPES, doc_probs)}},
}
if n_total == 0:
result["document_type"] = {"value": None, "confidence": 0.0, "scores": None}
result["fields"] = None
result["validation"] = {"checks": [], "issues": ["no text found"], "needs_review": True}
elif doc_type in ("receipt", "invoice"):
fields, validation = assemble_fields(glob_segs, glob_order, glob_labels, glob_conf, same_row, key_value)
result["fields"] = fields
result["validation"] = validation
else:
result["fields"] = None
result["validation"] = {"checks": [], "issues": [], "needs_review": float(doc_probs.max()) < 0.6}
result["text"] = "\n\n".join("\n".join(b.get("text", "") for b in p["blocks"] if b.get("text")) for p in pages_out)
result["pages"] = pages_out
timings["assemble"] += time.time() - t
result["timing_ms"] = {k: round(v * 1000, 1) for k, v in timings.items()}
result["timing_ms"]["total"] = round((time.time() - t_start) * 1000, 1)
result["model"] = {"parameters": dict(self.backend.params), "total_parameters": int(sum(self.backend.params.values()))}
return result
|