arjuna-ocr 1.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- arjuna_ocr-1.2.1.dist-info/METADATA +83 -0
- arjuna_ocr-1.2.1.dist-info/RECORD +22 -0
- arjuna_ocr-1.2.1.dist-info/WHEEL +5 -0
- arjuna_ocr-1.2.1.dist-info/entry_points.txt +4 -0
- arjuna_ocr-1.2.1.dist-info/licenses/LICENSE +3 -0
- arjuna_ocr-1.2.1.dist-info/top_level.txt +1 -0
- kanen_infer/__init__.py +9 -0
- kanen_infer/api.py +119 -0
- kanen_infer/arjuna_cli.py +111 -0
- kanen_infer/assemble.py +261 -0
- kanen_infer/batch_pipeline.py +230 -0
- kanen_infer/charset.py +72 -0
- kanen_infer/cli.py +71 -0
- kanen_infer/config.py +104 -0
- kanen_infer/formats.py +76 -0
- kanen_infer/geometry.py +19 -0
- kanen_infer/hardware.py +148 -0
- kanen_infer/io.py +68 -0
- kanen_infer/models.py +57 -0
- kanen_infer/pipeline_base.py +229 -0
- kanen_infer/providers.py +229 -0
- kanen_infer/rec_onnx.py +75 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arjuna-ocr
|
|
3
|
+
Version: 1.2.1
|
|
4
|
+
Summary: Arjuna-OCR [Kn&En]: layout-aware Kannada + English document OCR (ONNX Runtime, optional TensorRT)
|
|
5
|
+
Author: Chethan (anandkaman)
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://huggingface.co/anandkaman/arjuna-ocr
|
|
8
|
+
Project-URL: Models, https://huggingface.co/anandkaman/arjuna-ocr
|
|
9
|
+
Project-URL: Releases, https://huggingface.co/anandkaman/arjuna-ocr-kn-en
|
|
10
|
+
Project-URL: Demo, https://huggingface.co/spaces/anandkaman/arjuna_ocr_demo
|
|
11
|
+
Keywords: ocr,kannada,document-ai,layout-analysis,onnx,tensorrt,indic
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
18
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: onnxruntime>=1.20
|
|
23
|
+
Requires-Dist: numpy
|
|
24
|
+
Requires-Dist: opencv-python-headless
|
|
25
|
+
Requires-Dist: pyclipper
|
|
26
|
+
Requires-Dist: shapely
|
|
27
|
+
Requires-Dist: pyyaml
|
|
28
|
+
Requires-Dist: regex
|
|
29
|
+
Requires-Dist: onnx
|
|
30
|
+
Requires-Dist: huggingface_hub>=0.23
|
|
31
|
+
Provides-Extra: gpu
|
|
32
|
+
Requires-Dist: onnxruntime-gpu>=1.20; extra == "gpu"
|
|
33
|
+
Provides-Extra: trt
|
|
34
|
+
Requires-Dist: onnxruntime-gpu>=1.20; extra == "trt"
|
|
35
|
+
Requires-Dist: tensorrt>=10.0; extra == "trt"
|
|
36
|
+
Provides-Extra: server
|
|
37
|
+
Requires-Dist: fastapi>=0.110; extra == "server"
|
|
38
|
+
Requires-Dist: uvicorn>=0.29; extra == "server"
|
|
39
|
+
Requires-Dist: python-multipart; extra == "server"
|
|
40
|
+
Provides-Extra: pdf
|
|
41
|
+
Requires-Dist: pypdfium2>=4.0; extra == "pdf"
|
|
42
|
+
Provides-Extra: cpu
|
|
43
|
+
Requires-Dist: onnxruntime>=1.20; extra == "cpu"
|
|
44
|
+
Provides-Extra: all
|
|
45
|
+
Requires-Dist: onnxruntime-gpu>=1.20; extra == "all"
|
|
46
|
+
Requires-Dist: tensorrt>=10.0; extra == "all"
|
|
47
|
+
Requires-Dist: fastapi>=0.110; extra == "all"
|
|
48
|
+
Requires-Dist: uvicorn>=0.29; extra == "all"
|
|
49
|
+
Requires-Dist: python-multipart; extra == "all"
|
|
50
|
+
Requires-Dist: pypdfium2>=4.0; extra == "all"
|
|
51
|
+
Dynamic: license-file
|
|
52
|
+
|
|
53
|
+
# Arjuna-OCR [Kn&En]
|
|
54
|
+
|
|
55
|
+
Layout-aware **Kannada + English** document OCR: a layout model, a text detector and a CTC recognizer in ONNX, with
|
|
56
|
+
tables, reading order, per-line confidence and review flags. No language model, no GPU required.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install arjuna-ocr # CPU, works anywhere
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
arjuna doctor # what this machine can do and what the pipeline will use
|
|
63
|
+
arjuna ocr page.pdf -o out/ # files, folders, globs or PDFs -> JSON / Markdown / hOCR / ALTO / TSV
|
|
64
|
+
arjuna serve --port 8080 # HTTP server
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from kanen_infer import KanEnOCR
|
|
69
|
+
ocr = KanEnOCR() # detects hardware, configures itself
|
|
70
|
+
doc = ocr.page("page.png") # kanen-ocr/1.0 JSON
|
|
71
|
+
print(ocr.render(doc, "md"))
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The models (~170 MB) download once from [anandkaman/arjuna-ocr](https://huggingface.co/anandkaman/arjuna-ocr) at the
|
|
75
|
+
revision matching this package version, and are cached. Point `ARJUNA_MODELS` at a local copy for air-gapped installs.
|
|
76
|
+
|
|
77
|
+
**Speed and accuracy.** 12.8 pages/s on an RTX 5060 Ti with TensorRT, ~4.6 on the CUDA provider, seconds per page on CPU.
|
|
78
|
+
On a frozen 40-page private-document set: word F1 0.967, page CER 3.1 %; 96.3 % word accuracy on Mozhi, 93.7 % on MILE.
|
|
79
|
+
TensorRT engines are built in the background on first use — the pipeline serves immediately on CUDA and swaps each engine
|
|
80
|
+
in only after it reproduces the output of the session it replaces.
|
|
81
|
+
|
|
82
|
+
Full documentation, hosted-inference instructions and the evaluation record: the model repositories linked above.
|
|
83
|
+
**Licences**: code, recognizer and detector Apache-2.0; the layout model is AGPL-3.0 (see its NOTICE).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
arjuna_ocr-1.2.1.dist-info/licenses/LICENSE,sha256=kSUONfbMTSXdruS_p0-W_SeqHvGH6dVTgENFs2Od-Is,175
|
|
2
|
+
kanen_infer/__init__.py,sha256=ZArJb1qqZlvo_YhX-qqwUonRvhLevMWoiA9aS1fEYiw,800
|
|
3
|
+
kanen_infer/api.py,sha256=FLu6mS8_zgWChuXToeVzuF9pqXJ-UFI1pbKYOn8eHU8,7801
|
|
4
|
+
kanen_infer/arjuna_cli.py,sha256=9zew3ZrazqLH_X1VX_eWLqJ6u8i9cdc-JN8mRBNuIa4,7047
|
|
5
|
+
kanen_infer/assemble.py,sha256=7ka81_ajbx1MQu5oYCifm5aO86NzwBWGGwX56xZiASo,18787
|
|
6
|
+
kanen_infer/batch_pipeline.py,sha256=x3_bJpidJqEPfq39r1dhzfDovfsw-Zjr-o8SoxB3hFU,17855
|
|
7
|
+
kanen_infer/charset.py,sha256=k8cFqDWlYcmrCOH9n5IAR-bAEx_VKy04_ZSRBkbnE-Q,3468
|
|
8
|
+
kanen_infer/cli.py,sha256=m7_OcXUL7rLqiq17dSYd4ocU6QqA8R_DWL38LcoTMqo,5318
|
|
9
|
+
kanen_infer/config.py,sha256=lIYv7RmrOUy77w66zpiOPELW31yzbzoJNLEdyC5NHeI,5906
|
|
10
|
+
kanen_infer/formats.py,sha256=9TK4MrsGM3lRlIaWAOYwXI1_O4-85DrE7kcce4mQ52Q,5493
|
|
11
|
+
kanen_infer/geometry.py,sha256=4_QCbMxlaK0cq2RkJVIWu3vVUFiHmnkG2vd79-6eSX4,833
|
|
12
|
+
kanen_infer/hardware.py,sha256=SjnnivDjY7VLQNZQd4MTZLfHzkDbK7vluEXPKP68mGM,11113
|
|
13
|
+
kanen_infer/io.py,sha256=ozEicuwW3BBxg95oLojmT4YL-SFD7nsOQO-rKoMtdcQ,3777
|
|
14
|
+
kanen_infer/models.py,sha256=CC4B6Fc8EMrauPJ9cyz0-vMPgz7Bm7SOM2zWn8Kfew4,2907
|
|
15
|
+
kanen_infer/pipeline_base.py,sha256=dehRXLg9tyHSwHB8yEo-smLn67DrSb2FtMyd__BBHbo,18089
|
|
16
|
+
kanen_infer/providers.py,sha256=ergf0-l-25qxBsWFwrZzRVYsr6bRcq03jz7GUv2ciAY,15773
|
|
17
|
+
kanen_infer/rec_onnx.py,sha256=XMaKAyPHdDFEKoU9eT9b0Bj6lwy51GauGWyDBiqyc90,5435
|
|
18
|
+
arjuna_ocr-1.2.1.dist-info/METADATA,sha256=UTPRhcvTAD9ZNG0C39RfCXrHNLrHUTUtclTunmBzsJQ,3713
|
|
19
|
+
arjuna_ocr-1.2.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
20
|
+
arjuna_ocr-1.2.1.dist-info/entry_points.txt,sha256=NxHPJ_pJafnXexiIWrjUkLOgrXpxkogV7Ver1hDiajM,122
|
|
21
|
+
arjuna_ocr-1.2.1.dist-info/top_level.txt,sha256=1L1zgtkQPi2eDHA4MRpJIXVvWDYXg3NqhE-b9yh6tRI,12
|
|
22
|
+
arjuna_ocr-1.2.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kanen_infer
|
kanen_infer/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Arjuna-OCR [Kn&En] inference package (Kannada + English document OCR): ONNX Runtime + optional TensorRT, no PyTorch."""
|
|
2
|
+
from pathlib import Path as _P
|
|
3
|
+
try: __version__ = (_P(__file__).resolve().parent.parent / "VERSION").read_text(encoding="utf-8").strip()
|
|
4
|
+
except Exception: __version__ = "1.2.1"
|
|
5
|
+
NAME = "Arjuna-OCR [Kn&En]" # product display name — also `name:` in kanen.yaml (the config value wins at run time)
|
|
6
|
+
from .config import load_config, PKG_ROOT # noqa: E402
|
|
7
|
+
from .api import KanEnOCR # noqa: E402
|
|
8
|
+
from .formats import to_json, to_markdown, to_text, to_hocr, to_alto, to_tsv, render # noqa: E402
|
|
9
|
+
__all__ = ["KanEnOCR", "load_config", "PKG_ROOT", "NAME", "__version__", "to_json", "to_markdown", "to_text", "to_hocr", "to_alto", "to_tsv", "render"]
|
kanen_infer/api.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Arjuna-OCR [Kn&En] Python API.
|
|
2
|
+
|
|
3
|
+
from kanen_infer import KanEnOCR
|
|
4
|
+
ocr = KanEnOCR() # loads kanen.yaml, builds/validates engines (warm-up), see ocr.ready()
|
|
5
|
+
doc = ocr.page("scan.png") # one kanen-ocr/1.0 document (dict)
|
|
6
|
+
docs = ocr.pages(["a.png", "b.pdf"]) # batched; ocr.stream(...) yields documents as they finish
|
|
7
|
+
boxes = ocr.detect(img) # text-line polygons only
|
|
8
|
+
texts = ocr.recognize(crops) # [(text, conf, min_conf)] for line images
|
|
9
|
+
blocks = ocr.layout(img) # layout blocks only
|
|
10
|
+
ocr.render(doc, "hocr") # json | md | txt | hocr | alto | tsv
|
|
11
|
+
|
|
12
|
+
Inputs: file path, bytes, numpy BGR array, PIL image, file object; PDFs through `pages()`/`stream()` (pypdfium2 or pdftoppm,
|
|
13
|
+
optional). The output document never depends on the provider or the batch composition beyond fp16 noise on garbage lines;
|
|
14
|
+
no confidence filtering, no text rewriting, no re-ordering happens here."""
|
|
15
|
+
import logging, time
|
|
16
|
+
import numpy as np, cv2
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from .config import load_config, PKG_ROOT
|
|
19
|
+
from .providers import Providers
|
|
20
|
+
from .rec_onnx import KanEnRecognizer
|
|
21
|
+
from .pipeline_base import Detector, Layout, crop
|
|
22
|
+
from .batch_pipeline import BatchOCR
|
|
23
|
+
from .io import to_bgr, expand_inputs, iter_page_sources
|
|
24
|
+
from . import formats
|
|
25
|
+
|
|
26
|
+
LOG = logging.getLogger("kanen")
|
|
27
|
+
|
|
28
|
+
class KanEnOCR:
|
|
29
|
+
def __init__(self, config=None, providers=None, layout_mode=None, workers=None, warmup=True, **overrides):
|
|
30
|
+
"""config: path to a kanen.yaml/json, a dict, or None (package default). providers: 'trt' | 'cuda' | 'cpu' | 'auto'
|
|
31
|
+
(overrides config provider.policy). Other keyword overrides use dotted section names, e.g. layout={'mode': 'auto'}."""
|
|
32
|
+
ov = dict(overrides)
|
|
33
|
+
if providers: ov.setdefault("provider", {})["policy"] = providers
|
|
34
|
+
if layout_mode: ov.setdefault("layout", {})["mode"] = layout_mode
|
|
35
|
+
if workers: ov.setdefault("runtime", {})["workers"] = workers
|
|
36
|
+
self.cfg = load_config(config, ov); self.name = self.cfg.get("name", "Arjuna-OCR [Kn&En]"); self.version = str(self.cfg.get("version", "1.0.0"))
|
|
37
|
+
t0 = time.time(); self.prov = Providers(self.cfg)
|
|
38
|
+
self.rec = KanEnRecognizer(cfg=self.cfg, providers=self.prov); self.det = Detector(self.cfg, self.prov)
|
|
39
|
+
self.lay = Layout(self.cfg, self.prov) if self.cfg["layout"].get("mode", "always") != "never" else None
|
|
40
|
+
self.batch = BatchOCR(self.rec, cfg=self.cfg, providers=self.prov, det=self.det, lay=self.lay)
|
|
41
|
+
self._load_s = round(time.time() - t0, 1); self._warm = None
|
|
42
|
+
if warmup: self.warmup()
|
|
43
|
+
|
|
44
|
+
# ---- status --------------------------------------------------------------------------------------------------
|
|
45
|
+
def warmup(self):
|
|
46
|
+
"""Run the bundled self-test page through the full pipeline once: builds/validates TensorRT engines for the
|
|
47
|
+
default shapes (layout self-test, detector canvas, recognizer profile) so the first real page is not slow."""
|
|
48
|
+
t0 = time.time(); p = self.cfg.path("models", "layout_selftest")
|
|
49
|
+
if p.exists():
|
|
50
|
+
img = to_bgr(str(p)); self.batch.run([img], page_batch=1, loader=lambda x: x)
|
|
51
|
+
self._warm = round(time.time() - t0, 1); return self.ready()
|
|
52
|
+
|
|
53
|
+
def optimize(self, wait=False, timeout=None):
|
|
54
|
+
"""Build the TensorRT engines for this machine in the background and swap them in once each one is validated
|
|
55
|
+
against the session it replaces. Safe to call at any time; returns immediately unless `wait` is set.
|
|
56
|
+
Long-lived processes (server, endpoint handler, batch jobs) should call this at startup: the pipeline keeps
|
|
57
|
+
serving on the CUDA provider meanwhile, and gets 2-3x faster when the engines are ready."""
|
|
58
|
+
n = self.prov.start_background_builds() if hasattr(self.prov, "start_background_builds") else 0
|
|
59
|
+
if wait and n: return self.prov.wait_for_engines(timeout)
|
|
60
|
+
return {"builds_started": n}
|
|
61
|
+
|
|
62
|
+
def ready(self):
|
|
63
|
+
"""Provider each model ended on, fallbacks that happened, engine-cache state, model hashes."""
|
|
64
|
+
st = self.prov.summary()
|
|
65
|
+
if self.lay is not None: st.setdefault("layout", {})["engine"] = self.lay.stage
|
|
66
|
+
return {"name": self.name, "version": self.version, "config": self.cfg.source, "gpu": self.prov.gpu, "tensorrt": self.prov.trt_ver,
|
|
67
|
+
"providers": st, "events": list(self.prov.events), "engine_cache": str(self.prov.cache_root), "load_s": self._load_s, "warmup_s": self._warm,
|
|
68
|
+
"model": dict(self.batch.model_ids)}
|
|
69
|
+
|
|
70
|
+
# ---- full documents -------------------------------------------------------------------------------------------
|
|
71
|
+
def page(self, image, polygons=None, dpi=None):
|
|
72
|
+
"""Single page (latency path): one kanen-ocr/1.0 document."""
|
|
73
|
+
img = to_bgr(image); docs, _ = self.batch.process([img], dpi=dpi, with_polygons=self._poly(polygons)); d = docs[0]
|
|
74
|
+
if isinstance(image, (str, Path)): d["page"]["file"] = Path(image).name
|
|
75
|
+
return d
|
|
76
|
+
|
|
77
|
+
def pages(self, inputs, page_batch=None, polygons=None, pdf_dpi=200):
|
|
78
|
+
"""Batched: list/dir/glob of image or PDF paths, or a list of arrays / PIL images / bytes -> list of documents."""
|
|
79
|
+
return [d for d in self.stream(inputs, page_batch, polygons, pdf_dpi)]
|
|
80
|
+
|
|
81
|
+
def stream(self, inputs, page_batch=None, polygons=None, pdf_dpi=200):
|
|
82
|
+
"""Generator variant of pages(): yields documents in input order as each page batch completes."""
|
|
83
|
+
sources = self._sources(inputs, pdf_dpi); labels = [s[0] for s in sources]; items = [s[1] for s in sources]
|
|
84
|
+
page_batch = page_batch or self.cfg["runtime"].get("page_batch", 8); k = 0
|
|
85
|
+
for doc, st in self.batch.stream(items, page_batch=page_batch, loader=to_bgr, with_polygons=self._poly(polygons)):
|
|
86
|
+
if labels[k]: doc["page"]["file"] = labels[k]
|
|
87
|
+
self.last_stats = st; k += 1; yield doc
|
|
88
|
+
|
|
89
|
+
def _sources(self, inputs, pdf_dpi):
|
|
90
|
+
if isinstance(inputs, (str, Path)) or (isinstance(inputs, (list, tuple)) and inputs and all(isinstance(x, (str, Path)) for x in inputs)):
|
|
91
|
+
return list(iter_page_sources(expand_inputs(inputs), dpi=pdf_dpi))
|
|
92
|
+
if isinstance(inputs, (list, tuple)): return [(getattr(x, "name", None) or "", x) for x in inputs]
|
|
93
|
+
return [("", inputs)]
|
|
94
|
+
|
|
95
|
+
def _poly(self, polygons): return bool(self.cfg["runtime"].get("polygons", False)) if polygons is None else bool(polygons)
|
|
96
|
+
|
|
97
|
+
# ---- single stages --------------------------------------------------------------------------------------------
|
|
98
|
+
def detect(self, image):
|
|
99
|
+
"""Text-line detection only -> list of (polygon 4x2 float32 in page pixels, score)."""
|
|
100
|
+
return self.det(to_bgr(image))
|
|
101
|
+
|
|
102
|
+
def recognize(self, crops, batch=None):
|
|
103
|
+
"""Recognition only on a list of line images (any size, BGR or gray) -> [(text, confidence, min_conf)]."""
|
|
104
|
+
res = self.rec([to_bgr(c) for c in crops], bs=batch)
|
|
105
|
+
return [(t, float(c), float(getattr(c, "min_char", c))) for t, c in res]
|
|
106
|
+
|
|
107
|
+
def layout(self, image):
|
|
108
|
+
"""Layout blocks only -> [{type, score, bbox, order}] (labels: title, text, table, figure, seal, id_panel, header, footer, handwriting)."""
|
|
109
|
+
if self.lay is None: self.lay = Layout(self.cfg, self.prov); self.batch.lay = self.lay
|
|
110
|
+
return self.lay(to_bgr(image))[0]
|
|
111
|
+
|
|
112
|
+
def crops(self, image, boxes=None):
|
|
113
|
+
"""Perspective line crops for detect() boxes (helper for recognize())."""
|
|
114
|
+
img = to_bgr(image); boxes = self.detect(img) if boxes is None else boxes
|
|
115
|
+
return [crop(img, b) for b, _ in boxes]
|
|
116
|
+
|
|
117
|
+
# ---- serialisation --------------------------------------------------------------------------------------------
|
|
118
|
+
def render(self, doc, fmt="json"):
|
|
119
|
+
return formats.render(doc, fmt)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""`arjuna` command line — the deployment front end.
|
|
2
|
+
|
|
3
|
+
arjuna doctor what this machine can do, what the pipeline will use, and why
|
|
4
|
+
arjuna ocr page.pdf -o out/ OCR files, directories, globs or PDFs (same engine as kanen-ocr)
|
|
5
|
+
arjuna serve --port 8080 HTTP server (FastAPI); builds TensorRT engines in the background
|
|
6
|
+
arjuna bench measured pages/s on the bundled sample pages, before and after optimisation
|
|
7
|
+
arjuna warm build and validate the TensorRT engines for this machine, then exit
|
|
8
|
+
|
|
9
|
+
`ocr` stays on the CUDA/CPU provider for small jobs (a TensorRT build costs more than it saves for a page or two) and
|
|
10
|
+
optimises automatically from `--optimize-after` pages upwards. `serve`, `bench` and `warm` always optimise.
|
|
11
|
+
"""
|
|
12
|
+
import argparse, json, os, sys, time
|
|
13
|
+
|
|
14
|
+
def _cfg(args):
|
|
15
|
+
from .config import load_config
|
|
16
|
+
from . import hardware
|
|
17
|
+
cfg = load_config(args.config)
|
|
18
|
+
if getattr(args, "provider", None): cfg["provider"]["policy"] = args.provider
|
|
19
|
+
changes = [] if getattr(args, "no_autoconfig", False) else hardware.autoconfig(cfg, quality=getattr(args, "quality", None))
|
|
20
|
+
return cfg, changes
|
|
21
|
+
|
|
22
|
+
def cmd_doctor(a):
|
|
23
|
+
from . import hardware
|
|
24
|
+
cfg, changes = _cfg(a)
|
|
25
|
+
hw = hardware.probe(cfg["provider"].get("policy")); print(hardware.report(cfg, changes, hw))
|
|
26
|
+
if a.run:
|
|
27
|
+
from .api import KanEnOCR
|
|
28
|
+
import cv2, glob
|
|
29
|
+
pages = sorted(glob.glob(os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "samples", "pages", "*")))[:1]
|
|
30
|
+
if pages:
|
|
31
|
+
t = time.time(); ocr = KanEnOCR(config=cfg, warmup=False); load = time.time() - t
|
|
32
|
+
t = time.time(); doc = ocr.page(cv2.imread(pages[0])); dt = time.time() - t
|
|
33
|
+
lines = sum(len(b["lines"]) for b in doc["blocks"])
|
|
34
|
+
print(f"\n load {load:.1f}s\n sample page {dt:.1f}s, {lines} lines, {len(doc['blocks'])} blocks ({os.path.basename(pages[0])})")
|
|
35
|
+
print(f" providers {json.dumps({k: v['provider'] for k, v in ocr.prov.status.items() if k in ('det', 'rec', 'layout')})}")
|
|
36
|
+
for e in ocr.prov.events[:6]: print(f" {e}")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
def cmd_warm(a):
|
|
40
|
+
from .api import KanEnOCR
|
|
41
|
+
cfg, changes = _cfg(a)
|
|
42
|
+
print("building TensorRT engines for this machine (this is the one slow step; it is cached afterwards)...", flush=True)
|
|
43
|
+
t = time.time(); ocr = KanEnOCR(config=cfg, warmup=True)
|
|
44
|
+
res = ocr.optimize(wait=True, timeout=a.timeout)
|
|
45
|
+
print(f"done in {time.time() - t:.0f}s: {json.dumps(res)}")
|
|
46
|
+
print("providers:", json.dumps({k: v["provider"] for k, v in ocr.prov.status.items() if k in ("det", "rec", "layout")}))
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
def cmd_bench(a):
|
|
50
|
+
import cv2, glob
|
|
51
|
+
from .api import KanEnOCR
|
|
52
|
+
cfg, changes = _cfg(a)
|
|
53
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
54
|
+
pages = sorted(glob.glob(os.path.join(root, "samples", "pages", "*")))
|
|
55
|
+
if not pages: print("no sample pages bundled"); return 1
|
|
56
|
+
imgs = [cv2.imread(p) for p in pages] * max(1, a.repeat)
|
|
57
|
+
ocr = KanEnOCR(config=cfg, warmup=True)
|
|
58
|
+
def run(tag):
|
|
59
|
+
t = time.time(); docs = ocr.pages(imgs); dt = time.time() - t
|
|
60
|
+
print(f" {tag:<22} {len(imgs)/dt:5.2f} pages/s ({dt:.1f}s for {len(imgs)} pages, {sum(len(b['lines']) for d in docs for b in d['blocks'])} lines)")
|
|
61
|
+
return len(imgs) / dt
|
|
62
|
+
print(f"pages: {len(imgs)} · providers before: {json.dumps({k: v['provider'] for k, v in ocr.prov.status.items() if k in ('det','rec','layout')})}")
|
|
63
|
+
run("as loaded")
|
|
64
|
+
ocr.optimize(wait=True, timeout=a.timeout)
|
|
65
|
+
print(f"providers after: {json.dumps({k: v['provider'] for k, v in ocr.prov.status.items() if k in ('det','rec','layout')})}")
|
|
66
|
+
run("after optimisation"); run("steady state")
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
def cmd_serve(a):
|
|
70
|
+
import importlib.util
|
|
71
|
+
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
72
|
+
spec = importlib.util.spec_from_file_location("kanen_serve", os.path.join(root, "serve.py"))
|
|
73
|
+
mod = importlib.util.module_from_spec(spec); sys.modules["kanen_serve"] = mod; spec.loader.exec_module(mod)
|
|
74
|
+
return mod.serve(a)
|
|
75
|
+
|
|
76
|
+
def cmd_ocr(a):
|
|
77
|
+
from .cli import main as kanen_main
|
|
78
|
+
argv = ["--input"] + a.inputs + ["--out-dir", a.out_dir, "--format", a.format]
|
|
79
|
+
for flag, val in (("--page-batch", a.page_batch), ("--workers", a.workers), ("--provider", a.provider), ("--config", a.config)):
|
|
80
|
+
if val: argv += [flag, str(val)]
|
|
81
|
+
if a.json_lines: argv.append("--json-lines")
|
|
82
|
+
if a.quiet: argv.append("--quiet")
|
|
83
|
+
os.environ["KANEN_OPTIMIZE_AFTER"] = str(a.optimize_after)
|
|
84
|
+
return kanen_main(argv)
|
|
85
|
+
|
|
86
|
+
def build_parser():
|
|
87
|
+
ap = argparse.ArgumentParser(prog="arjuna", description="Arjuna-OCR [Kn&En] — Kannada + English document OCR")
|
|
88
|
+
ap.add_argument("--version", action="store_true")
|
|
89
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
90
|
+
common = lambda p: (p.add_argument("--config", default=None), p.add_argument("--provider", choices=["auto", "trt", "cuda", "cpu"], default=None),
|
|
91
|
+
p.add_argument("--quality", choices=["fast", "balanced", "exact"], default=None), p.add_argument("--no-autoconfig", action="store_true"))
|
|
92
|
+
d = sub.add_parser("doctor", help="hardware, providers, auto-configuration"); common(d); d.add_argument("--run", action="store_true", help="also OCR one bundled page"); d.set_defaults(fn=cmd_doctor)
|
|
93
|
+
w = sub.add_parser("warm", help="build + validate the TensorRT engines, then exit"); common(w); w.add_argument("--timeout", type=int, default=1800); w.set_defaults(fn=cmd_warm)
|
|
94
|
+
b = sub.add_parser("bench", help="measured pages/s on the bundled pages"); common(b); b.add_argument("--repeat", type=int, default=4); b.add_argument("--timeout", type=int, default=1800); b.set_defaults(fn=cmd_bench)
|
|
95
|
+
s = sub.add_parser("serve", help="HTTP server"); common(s); s.add_argument("--host", default="0.0.0.0"); s.add_argument("--port", type=int, default=8080)
|
|
96
|
+
s.add_argument("--workers", type=int, default=None); s.add_argument("--layout-mode", choices=["always", "auto", "never"], default=None); s.set_defaults(fn=cmd_serve)
|
|
97
|
+
o = sub.add_parser("ocr", help="OCR files / directories / globs / PDFs"); common(o)
|
|
98
|
+
o.add_argument("inputs", nargs="+"); o.add_argument("--out-dir", "-o", default="arjuna_out"); o.add_argument("--format", "-f", default="json")
|
|
99
|
+
o.add_argument("--page-batch", type=int, default=None); o.add_argument("--workers", type=int, default=None); o.add_argument("--json-lines", action="store_true")
|
|
100
|
+
o.add_argument("--quiet", "-q", action="store_true"); o.add_argument("--optimize-after", type=int, default=8, help="build TensorRT engines when a job has at least this many pages")
|
|
101
|
+
o.set_defaults(fn=cmd_ocr)
|
|
102
|
+
return ap
|
|
103
|
+
|
|
104
|
+
def main(argv=None):
|
|
105
|
+
a = build_parser().parse_args(argv)
|
|
106
|
+
if a.version:
|
|
107
|
+
from . import __version__; print(__version__); return 0
|
|
108
|
+
if not getattr(a, "fn", None): build_parser().print_help(); return 0
|
|
109
|
+
return a.fn(a)
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__": sys.exit(main())
|
kanen_infer/assemble.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Assembler: layout blocks (Arjuna layout v12) + text-line polygons + recognised text -> kanen-ocr/1.0 JSON.
|
|
2
|
+
Pure geometry, no model. Also: Markdown / plain-text renderers."""
|
|
3
|
+
import numpy as np
|
|
4
|
+
from .charset import script_of
|
|
5
|
+
import re
|
|
6
|
+
WEAK_CHAR = 0.50 # min-char confidence below which a line is flagged weak_char (calibrated on 3,478 dev lines: < 0.5 -> 65 % exact)
|
|
7
|
+
_MIX_RE = re.compile(r"(?=\S*[A-Za-z])(?=\S*[\u0C85-\u0CB9])\S+") # token with both a Latin letter and a Kannada letter
|
|
8
|
+
|
|
9
|
+
TYPE_MAP = {"doc_title": "title", "paragraph_title": "section_title", "text": "text", "abstract": "text", "content": "text", "reference": "text",
|
|
10
|
+
"reference_content": "text", "aside_text": "text", "vertical_text": "text", "footnote": "footnote", "vision_footnote": "footnote",
|
|
11
|
+
"header": "header", "footer": "footer", "number": "page_number", "table": "table", "image": "figure", "chart": "figure",
|
|
12
|
+
"figure_title": "figure_caption", "seal": "seal", "display_formula": "formula", "inline_formula": "formula", "formula_number": "formula",
|
|
13
|
+
"algorithm": "text", "header_image": "figure", "footer_image": "figure",
|
|
14
|
+
"title": "title", "figure": "figure", "id_panel": "id_panel", "handwriting": "handwriting"} # v1.2 layout classes (text/table/seal/header/footer map 1:1 above)
|
|
15
|
+
|
|
16
|
+
def _bbox(poly): p = np.asarray(poly, np.float32); return [float(p[:, 0].min()), float(p[:, 1].min()), float(p[:, 0].max()), float(p[:, 1].max())]
|
|
17
|
+
|
|
18
|
+
def _overlap_frac(line_bb, block_bb):
|
|
19
|
+
ix = max(0, min(line_bb[2], block_bb[2]) - max(line_bb[0], block_bb[0])); iy = max(0, min(line_bb[3], block_bb[3]) - max(line_bb[1], block_bb[1]))
|
|
20
|
+
return ix * iy / max((line_bb[2] - line_bb[0]) * (line_bb[3] - line_bb[1]), 1e-6)
|
|
21
|
+
|
|
22
|
+
def merge_line_fragments(lines, y_tol=0.5, x_gap=1.5):
|
|
23
|
+
"""Merge horizontally adjacent boxes on the same baseline (detector sometimes splits a line at wide gaps).
|
|
24
|
+
lines: list of dict(bbox, polygon, text, conf). Returns merged list."""
|
|
25
|
+
lines = sorted(lines, key=lambda l: (l["bbox"][1], l["bbox"][0])); out = []
|
|
26
|
+
for l in lines:
|
|
27
|
+
merged = False
|
|
28
|
+
for m in out:
|
|
29
|
+
h = min(m["bbox"][3] - m["bbox"][1], l["bbox"][3] - l["bbox"][1])
|
|
30
|
+
same_row = abs((m["bbox"][1] + m["bbox"][3]) / 2 - (l["bbox"][1] + l["bbox"][3]) / 2) < y_tol * h
|
|
31
|
+
gap = l["bbox"][0] - m["bbox"][2]
|
|
32
|
+
if same_row and -0.2 * h < gap < x_gap * h:
|
|
33
|
+
m["bbox"] = [min(m["bbox"][0], l["bbox"][0]), min(m["bbox"][1], l["bbox"][1]), max(m["bbox"][2], l["bbox"][2]), max(m["bbox"][3], l["bbox"][3])]
|
|
34
|
+
m["text"] = (m["text"] + " " + l["text"]).strip(); m["confidence"] = min(m["confidence"], l["confidence"]); m["min_conf"] = min(m.get("min_conf", 1), l.get("min_conf", 1)); m["polygon"] = None; merged = True; break
|
|
35
|
+
if not merged: out.append(dict(l))
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
def xy_cut_order(blocks, page_w):
|
|
39
|
+
"""Fallback reading order: column split then top-to-bottom."""
|
|
40
|
+
if not blocks: return []
|
|
41
|
+
mid = page_w / 2
|
|
42
|
+
left = [b for b in blocks if b["bbox"][2] < mid * 1.05]; right = [b for b in blocks if b["bbox"][0] > mid * 0.95]
|
|
43
|
+
spanning = [b for b in blocks if b not in left and b not in right]
|
|
44
|
+
if len(left) >= 2 and len(right) >= 2 and not spanning:
|
|
45
|
+
return sorted(left, key=lambda b: b["bbox"][1]) + sorted(right, key=lambda b: b["bbox"][1])
|
|
46
|
+
return sorted(blocks, key=lambda b: (round(b["bbox"][1] / 20), b["bbox"][0]))
|
|
47
|
+
|
|
48
|
+
def rows_of(lines, tol=0.5):
|
|
49
|
+
"""Cluster line boxes into visual rows (y-centre within tol x the smaller height), left-to-right inside a row."""
|
|
50
|
+
ls = sorted(lines, key=lambda l: (l["bbox"][1] + l["bbox"][3]) / 2); rows = []
|
|
51
|
+
for l in ls:
|
|
52
|
+
yc = (l["bbox"][1] + l["bbox"][3]) / 2; h = l["bbox"][3] - l["bbox"][1]
|
|
53
|
+
if rows:
|
|
54
|
+
r = rows[-1]; ryc = np.mean([(m["bbox"][1] + m["bbox"][3]) / 2 for m in r]); rh = np.median([m["bbox"][3] - m["bbox"][1] for m in r])
|
|
55
|
+
if abs(yc - ryc) < tol * min(h, rh): r.append(l); continue
|
|
56
|
+
rows.append([l])
|
|
57
|
+
return [sorted(r, key=lambda l: l["bbox"][0]) for r in rows]
|
|
58
|
+
|
|
59
|
+
def split_row_by_gap(r, max_gap_h=2.5):
|
|
60
|
+
"""Split a visual row into runs where the horizontal gap between neighbours exceeds max_gap_h x line height: two
|
|
61
|
+
columns (Kannada | English on ID cards, label | value pairs far apart) must stay separate lines; fragments of one
|
|
62
|
+
justified/spaced line (gaps of a few glyph widths) still join. Geometry only."""
|
|
63
|
+
r = sorted(r, key=lambda l: l["bbox"][0]); runs = [[r[0]]]
|
|
64
|
+
for l in r[1:]:
|
|
65
|
+
prev = runs[-1][-1]; h = max(8.0, min(prev["bbox"][3] - prev["bbox"][1], l["bbox"][3] - l["bbox"][1]))
|
|
66
|
+
if l["bbox"][0] - prev["bbox"][2] > max_gap_h * h: runs.append([l])
|
|
67
|
+
else: runs[-1].append(l)
|
|
68
|
+
return runs
|
|
69
|
+
|
|
70
|
+
def join_row(r):
|
|
71
|
+
"""One text line per visual row: fragments of a justified/spaced line are joined with single spaces."""
|
|
72
|
+
if len(r) == 1: return dict(r[0])
|
|
73
|
+
return {"bbox": [min(l["bbox"][0] for l in r), min(l["bbox"][1] for l in r), max(l["bbox"][2] for l in r), max(l["bbox"][3] for l in r)],
|
|
74
|
+
"polygon": None, "text": " ".join(l["text"] for l in r).strip(), "confidence": min(l["confidence"] for l in r), "min_conf": min(l.get("min_conf", 1) for l in r)}
|
|
75
|
+
|
|
76
|
+
def _runs(mask1d, min_gap=3):
|
|
77
|
+
idx = np.flatnonzero(mask1d)
|
|
78
|
+
if idx.size == 0: return []
|
|
79
|
+
out = []; start = idx[0]; prev = idx[0]
|
|
80
|
+
for i in idx[1:]:
|
|
81
|
+
if i - prev > min_gap: out.append((start + prev) / 2); start = i
|
|
82
|
+
prev = i
|
|
83
|
+
out.append((start + prev) / 2); return out
|
|
84
|
+
|
|
85
|
+
def _rule_positions(mask, axis, min_len):
|
|
86
|
+
"""Centre coordinate of every connected component of a rule mask longer than min_len along `axis` (robust to residual tilt)."""
|
|
87
|
+
import cv2
|
|
88
|
+
n, _, st, cen = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
|
89
|
+
pos = sorted(cen[i][1 - axis] for i in range(1, n) if st[i][cv2.CC_STAT_WIDTH if axis == 0 else cv2.CC_STAT_HEIGHT] >= min_len)
|
|
90
|
+
out = []
|
|
91
|
+
for p in pos:
|
|
92
|
+
if out and p - out[-1] < 6: out[-1] = (out[-1] + p) / 2
|
|
93
|
+
else: out.append(p)
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
def _merge_continuations(rows):
|
|
97
|
+
"""A row whose cells all sit under occupied columns of the previous row and none in its first column is a wrapped cell."""
|
|
98
|
+
out = []
|
|
99
|
+
for r in rows:
|
|
100
|
+
ne = [c for c in r["cells"] if c["text"]]
|
|
101
|
+
if out and ne:
|
|
102
|
+
prev = out[-1]; pne = [c for c in prev["cells"] if c["text"]]
|
|
103
|
+
first = min(c["col"] for c in prev["cells"]) if prev["cells"] else 0
|
|
104
|
+
if all(c["col"] > first for c in ne) and all(any(p["col"] == c["col"] for p in pne) for c in ne):
|
|
105
|
+
for c in ne:
|
|
106
|
+
p = next(p for p in pne if p["col"] == c["col"]); p["text"] = (p["text"] + " " + c["text"]).strip()
|
|
107
|
+
p["bbox"] = [min(p["bbox"][0], c["bbox"][0]), min(p["bbox"][1], c["bbox"][1]), max(p["bbox"][2], c["bbox"][2]), max(p["bbox"][3], c["bbox"][3])]
|
|
108
|
+
continue
|
|
109
|
+
out.append(r)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
def ruled_grid(gray, bbox, lines):
|
|
113
|
+
"""Table structure from printed ruling lines (morphological opening + connected components). Horizontal rules give row
|
|
114
|
+
bands; inside a band, vertical rules give cells - or, when a band has no vertical rules (aligned-column forms), each
|
|
115
|
+
visual row of the band becomes a table row whose cells are the separate line boxes. Returns
|
|
116
|
+
rows=[{cells:[{col,text,bbox,confidence}], ruled}] or None when the region has no horizontal rules. Pure geometry."""
|
|
117
|
+
import cv2
|
|
118
|
+
x0, y0, x1, y1 = [int(round(v)) for v in bbox]; reg = gray[max(0, y0):y1, max(0, x0):x1]
|
|
119
|
+
if reg.size == 0 or reg.shape[0] < 20 or reg.shape[1] < 20: return None
|
|
120
|
+
H, W = reg.shape; bw = cv2.adaptiveThreshold(reg, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 31, 9)
|
|
121
|
+
hor = cv2.morphologyEx(bw, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (max(20, W // 30), 1)))
|
|
122
|
+
hor = cv2.dilate(hor, cv2.getStructuringElement(cv2.MORPH_RECT, (max(20, W // 8), 1))) # bridge faint / dashed rules (text-derived runs are rejected below)
|
|
123
|
+
loc = [(l["bbox"][0] - x0, l["bbox"][1] - y0, l["bbox"][2] - x0, l["bbox"][3] - y0) for l in lines]
|
|
124
|
+
def through_text(y): return any(b + 0.1 * (d - b) < y < d - 0.1 * (d - b) for a, b, c, d in loc) # a rule never crosses the middle of a text line
|
|
125
|
+
def through_text_x(x, ia, ib): return any(a + 0.1 * (c - a) < x < c - 0.1 * (c - a) and b < ib and d > ia for a, b, c, d in loc)
|
|
126
|
+
hr = [y for y in _rule_positions(hor, 0, 0.25 * W) if 0.01 * H + 4 < y < H - 0.01 * H - 4 and not through_text(y)]
|
|
127
|
+
if not hr: return None
|
|
128
|
+
ys = [0.0] + hr + [float(H)]; bands = []
|
|
129
|
+
for a, b in zip(ys[:-1], ys[1:]):
|
|
130
|
+
ia, ib = int(a), int(b)
|
|
131
|
+
if ib - ia < 8: continue
|
|
132
|
+
ver = cv2.morphologyEx(bw[ia:ib], cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(8, int((ib - ia) * 0.4)))))
|
|
133
|
+
ver = cv2.dilate(ver, cv2.getStructuringElement(cv2.MORPH_RECT, (1, max(6, int((ib - ia) * 0.25)))))
|
|
134
|
+
vr = [x for x in _rule_positions(ver, 1, 0.85 * (ib - ia)) if 0.03 * W < x < 0.97 * W and not through_text_x(x, ia, ib)] # cell borders span the band, never cross text; table border excluded
|
|
135
|
+
bands.append((ia, ib, vr))
|
|
136
|
+
allx = sorted(x for _, _, vr in bands for x in vr); G = []
|
|
137
|
+
for x in allx:
|
|
138
|
+
if not G or x - G[-1][-1] > 12: G.append([x])
|
|
139
|
+
else: G[-1].append(x)
|
|
140
|
+
G = [float(np.mean(g)) for g in G]
|
|
141
|
+
def gcol(x): return sum(1 for g in G if g < x + 6)
|
|
142
|
+
rows = []
|
|
143
|
+
for ia, ib, vr in bands:
|
|
144
|
+
inband = [l for l in lines if ia <= (l["bbox"][1] + l["bbox"][3]) / 2 - y0 < ib]
|
|
145
|
+
if not inband: continue
|
|
146
|
+
if vr: # ruled cells: everything between two vertical rules is one cell (a wrapped cell keeps its lines together);
|
|
147
|
+
# the global rule set is used so a band whose faint rule was missed still gets the table's columns
|
|
148
|
+
xs = [0.0] + G + [float(W)]
|
|
149
|
+
percol = [[l for l in inband if ca <= (l["bbox"][0] + l["bbox"][2]) / 2 - x0 < cb] for ca, cb in zip(xs[:-1], xs[1:])]
|
|
150
|
+
multi = sum(1 for inc in percol if len(rows_of(inc)) > 1)
|
|
151
|
+
# several columns stacked -> the band holds several table rows whose separating rule was not printed/found
|
|
152
|
+
groups = rows_of(inband) if multi >= 2 else [inband]
|
|
153
|
+
for g in groups:
|
|
154
|
+
cells = []
|
|
155
|
+
for ca, cb in zip(xs[:-1], xs[1:]):
|
|
156
|
+
inc = [l for l in g if ca <= (l["bbox"][0] + l["bbox"][2]) / 2 - x0 < cb]
|
|
157
|
+
if not inc: cells.append({"col": gcol(ca), "text": "", "bbox": None, "confidence": None}); continue
|
|
158
|
+
cells.append({"col": gcol(ca), "text": " ".join(join_row(r)["text"] for r in rows_of(inc)), "bbox": [min(l["bbox"][0] for l in inc), min(l["bbox"][1] for l in inc), max(l["bbox"][2] for l in inc), max(l["bbox"][3] for l in inc)], "confidence": min(l["confidence"] for l in inc)})
|
|
159
|
+
rows.append({"cells": cells, "ruled": True})
|
|
160
|
+
if len(groups) > 1: rows[-len(groups):] = _merge_continuations(rows[-len(groups):])
|
|
161
|
+
else: # no vertical rules in this band: each visual row is a table row, each line box a cell
|
|
162
|
+
for r in rows_of(inband):
|
|
163
|
+
rows.append({"cells": [{"col": (gcol(l["bbox"][0] - x0) if G else ci), "text": l["text"], "bbox": l["bbox"], "confidence": l["confidence"]} for ci, l in enumerate(r)], "ruled": False})
|
|
164
|
+
return rows if rows else None
|
|
165
|
+
|
|
166
|
+
def group_table(lines):
|
|
167
|
+
"""Unruled tables: rows by y-clustering, columns by clustering cell x-starts; a row that only continues cells of the
|
|
168
|
+
previous row (no cell in the first column, every cell under an occupied column) is a wrapped cell -> merged upward."""
|
|
169
|
+
if not lines: return []
|
|
170
|
+
rows = rows_of(lines)
|
|
171
|
+
spans = sorted(((l["bbox"][0], l["bbox"][2]) for r in rows for l in r)); cols = []
|
|
172
|
+
for a, b in spans: # columns = groups of horizontally overlapping cells (works for left/centre/right alignment)
|
|
173
|
+
if cols and a < cols[-1][1] - 4: cols[-1][1] = max(cols[-1][1], b)
|
|
174
|
+
else: cols.append([a, b])
|
|
175
|
+
def col_of(l):
|
|
176
|
+
xc = (l["bbox"][0] + l["bbox"][2]) / 2
|
|
177
|
+
return next((i for i, (a, b) in enumerate(cols) if a - 4 <= xc <= b + 4), int(np.argmin([abs(xc - (a + b) / 2) for a, b in cols])) if cols else 0)
|
|
178
|
+
return _merge_continuations([{"cells": [{"bbox": l["bbox"], "text": l["text"], "confidence": l["confidence"], "col": col_of(l)} for l in r], "ruled": False} for r in rows])
|
|
179
|
+
|
|
180
|
+
def assemble(page_w, page_h, layout_blocks, det_boxes, texts, model_ids, timing=None, page_index=0, dpi=None, with_polygons=True, img=None):
|
|
181
|
+
"""layout_blocks: [{type, score, bbox, order}], det_boxes: [(poly4x2, score)], texts: [(text, conf)] aligned with det_boxes."""
|
|
182
|
+
lines = []
|
|
183
|
+
for (poly, dscore), (text, conf) in zip(det_boxes, texts):
|
|
184
|
+
if not text.strip(): continue
|
|
185
|
+
lines.append({"bbox": _bbox(poly), "polygon": [[float(x), float(y)] for x, y in poly] if with_polygons else None, "text": text, "confidence": round(float(conf), 4),
|
|
186
|
+
"min_conf": round(float(getattr(conf, "min_char", None) if getattr(conf, "min_char", None) is not None else conf), 4)})
|
|
187
|
+
blocks = []
|
|
188
|
+
for i, b in enumerate(layout_blocks):
|
|
189
|
+
blocks.append({"id": f"b{i}", "type": TYPE_MAP.get(b["type"], "other"), "raw_type": b["type"], "confidence": round(float(b["score"]), 4), "bbox": [round(v, 1) for v in b["bbox"]], "order": b.get("order", i), "lines": []})
|
|
190
|
+
# assign lines to best-overlapping block; unassigned lines become their own text blocks
|
|
191
|
+
orphans = []
|
|
192
|
+
area = lambda bb: max(1.0, (bb[2] - bb[0]) * (bb[3] - bb[1]))
|
|
193
|
+
for l in lines:
|
|
194
|
+
best, bi = 0.0, -1
|
|
195
|
+
for i, b in enumerate(blocks):
|
|
196
|
+
f = _overlap_frac(l["bbox"], b["bbox"])
|
|
197
|
+
# nested blocks (v1.2 layout: a handwriting / seal / id_panel box inside a text region): on a near-tie the
|
|
198
|
+
# smaller, more specific block wins, so signature fragments are not glued to the surrounding text rows
|
|
199
|
+
if f > best + 0.05 or (f >= best - 0.05 and bi >= 0 and f >= 0.5 and area(b["bbox"]) < area(blocks[bi]["bbox"])): best, bi = f, i
|
|
200
|
+
if best >= 0.5: blocks[bi]["lines"].append(l)
|
|
201
|
+
else: orphans.append(l)
|
|
202
|
+
for l in orphans:
|
|
203
|
+
blocks.append({"id": f"b{len(blocks)}", "type": "text", "raw_type": "orphan_line", "confidence": l["confidence"], "bbox": [round(v, 1) for v in l["bbox"]], "order": None, "lines": [l]})
|
|
204
|
+
blocks = [b for b in blocks if b["lines"] or b["type"] in ("figure", "seal", "table", "id_panel", "handwriting")]
|
|
205
|
+
# order: native rank where available, else xy-cut; orphans placed by position among ordered blocks
|
|
206
|
+
ranked = [b for b in blocks if b["order"] is not None]; unranked = [b for b in blocks if b["order"] is None]
|
|
207
|
+
ordered = sorted(ranked, key=lambda b: b["order"])
|
|
208
|
+
for b in unranked:
|
|
209
|
+
pos = next((k for k, o in enumerate(ordered) if o["bbox"][1] > b["bbox"][1] and o["bbox"][0] <= b["bbox"][2]), len(ordered)); ordered.insert(pos, b)
|
|
210
|
+
if not ranked: ordered = xy_cut_order(blocks, page_w)
|
|
211
|
+
full = []
|
|
212
|
+
gray = None
|
|
213
|
+
if img is not None:
|
|
214
|
+
import cv2; gray = img if img.ndim == 2 else cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
215
|
+
heights = [l["bbox"][3] - l["bbox"][1] for b in ordered for l in b["lines"]]; med_h = float(np.median(heights)) if heights else 0.0
|
|
216
|
+
for k, b in enumerate(ordered):
|
|
217
|
+
b["order"] = k
|
|
218
|
+
if b["type"] == "table":
|
|
219
|
+
# table cells stay separate boxes; structure from printed rules when present, else from alignment
|
|
220
|
+
b["lines"] = [l for r in rows_of(b["lines"]) for l in r]
|
|
221
|
+
rows = ruled_grid(gray, b["bbox"], b["lines"]) if gray is not None else None
|
|
222
|
+
b["rows"] = rows or group_table(b["lines"]); b["ruled"] = bool(rows)
|
|
223
|
+
else:
|
|
224
|
+
b["lines"] = [join_row(run) for r in rows_of(b["lines"]) for run in split_row_by_gap(r)] # one text line per visual row run (fragments of a spaced line re-joined; wide-gap columns kept apart)
|
|
225
|
+
for li, l in enumerate(b["lines"]):
|
|
226
|
+
l["id"] = f"{b['id']}.l{li}"; l["script"] = script_of(l["text"]); l["bbox"] = [round(v, 1) for v in l["bbox"]]
|
|
227
|
+
# review flags (never deletion): calibration on 5,478 labelled lines -> conf >= 0.99 is 97.7 % line-exact,
|
|
228
|
+
# 0.95-0.98 only 83.5 %; lines inside seal/figure/header blocks are mostly stamp/handwriting fragments
|
|
229
|
+
fl = []
|
|
230
|
+
if l["confidence"] < 0.90: fl.append("low_conf")
|
|
231
|
+
elif l["confidence"] < 0.98: fl.append("check")
|
|
232
|
+
if b["type"] in ("seal", "figure", "handwriting", "id_panel"): fl.append("in_" + b["type"])
|
|
233
|
+
# review flags (script/character-repertoire checks); flags only, the text is never altered
|
|
234
|
+
if l.get("min_conf", 1.0) < WEAK_CHAR: fl.append("weak_char") # one very uncertain character (garbage lines usually have one)
|
|
235
|
+
if _MIX_RE.search(l["text"]): fl.append("script_mix") # Latin and Kannada letters inside one token (other-script garbage)
|
|
236
|
+
if med_h and (l["bbox"][3] - l["bbox"][1]) > 3.0 * med_h: fl.append("oversize") # stray ornament / drop-cap sized line
|
|
237
|
+
if fl: l["flags"] = fl
|
|
238
|
+
if b["type"] == "table" and b["rows"]: block_text = "\n".join(" | ".join(c["text"] for c in r["cells"]) for r in b["rows"])
|
|
239
|
+
else: block_text = "\n".join(l["text"] for l in b["lines"])
|
|
240
|
+
if block_text.strip(): full.append(block_text)
|
|
241
|
+
return {"schema": "kanen-ocr/1.0", "model": model_ids, "page": {"index": page_index, "width": page_w, "height": page_h, "dpi": dpi, "rotation": 0},
|
|
242
|
+
"blocks": ordered, "text": "\n\n".join(full), "timing_ms": timing or {}}
|
|
243
|
+
|
|
244
|
+
def to_markdown(doc):
|
|
245
|
+
out = []
|
|
246
|
+
for b in doc["blocks"]:
|
|
247
|
+
t = b["type"]; txt = " ".join(l["text"] for l in b["lines"]).strip()
|
|
248
|
+
if t == "title": out.append(f"# {txt}")
|
|
249
|
+
elif t == "section_title": out.append(f"## {txt}")
|
|
250
|
+
elif t == "table" and b.get("rows"):
|
|
251
|
+
ncol = max((c["col"] for r in b["rows"] for c in r["cells"]), default=0) + 1
|
|
252
|
+
rows = []
|
|
253
|
+
for r in b["rows"]:
|
|
254
|
+
cells = [""] * ncol
|
|
255
|
+
for c in r["cells"]: cells[c["col"]] = (cells[c["col"]] + " " + c["text"]).strip()
|
|
256
|
+
rows.append("| " + " | ".join(x.replace("|", "\\|") for x in cells) + " |")
|
|
257
|
+
if rows: out.append(rows[0] + "\n|" + "---|" * ncol + "\n" + "\n".join(rows[1:]))
|
|
258
|
+
elif t in ("figure", "seal"): out.append(f"*[{t}]*")
|
|
259
|
+
elif t in ("header", "footer", "page_number"): out.append(f"<sub>{txt}</sub>")
|
|
260
|
+
elif txt: out.append(txt)
|
|
261
|
+
return "\n\n".join(out) + "\n"
|