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