anaksor-ocr 0.1.0__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.
- anaksor/__init__.py +54 -0
- anaksor/__main__.py +3 -0
- anaksor/cli.py +151 -0
- anaksor/data/det_repvit_db_v2.yml +33 -0
- anaksor/data/det_repvit_db_v3.yml +22 -0
- anaksor/data/glyphless.ttf +0 -0
- anaksor/data/khmer_dict_v3.txt +211 -0
- anaksor/data/rec_svtrv2_v3.yml +51 -0
- anaksor/data/rec_svtrv2_v4.yml +57 -0
- anaksor/db_boxes.py +105 -0
- anaksor/decoding.py +107 -0
- anaksor/detector.py +136 -0
- anaksor/export/__init__.py +33 -0
- anaksor/export/searchable_pdf.py +147 -0
- anaksor/export/word.py +189 -0
- anaksor/geometry.py +109 -0
- anaksor/inputs.py +138 -0
- anaksor/layout.py +209 -0
- anaksor/longline.py +101 -0
- anaksor/models.py +236 -0
- anaksor/nets/__init__.py +66 -0
- anaksor/nets/cppd.py +103 -0
- anaksor/nets/dbnet.py +108 -0
- anaksor/nets/fuse.py +109 -0
- anaksor/nets/layers.py +106 -0
- anaksor/nets/rctc.py +40 -0
- anaksor/nets/repvit.py +123 -0
- anaksor/nets/smtr.py +132 -0
- anaksor/nets/svtrv2.py +221 -0
- anaksor/nets/textline.py +62 -0
- anaksor/ocr.py +214 -0
- anaksor/onnx_export.py +109 -0
- anaksor/py.typed +0 -0
- anaksor/recognizer.py +215 -0
- anaksor/runtime.py +101 -0
- anaksor/text.py +190 -0
- anaksor_ocr-0.1.0.dist-info/METADATA +265 -0
- anaksor_ocr-0.1.0.dist-info/RECORD +42 -0
- anaksor_ocr-0.1.0.dist-info/WHEEL +4 -0
- anaksor_ocr-0.1.0.dist-info/entry_points.txt +2 -0
- anaksor_ocr-0.1.0.dist-info/licenses/LICENSE +202 -0
- anaksor_ocr-0.1.0.dist-info/licenses/NOTICE +7 -0
anaksor/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""anaksor: Khmer OCR (អានអក្សរ, "read the letters").
|
|
2
|
+
|
|
3
|
+
import anaksor
|
|
4
|
+
|
|
5
|
+
ocr = anaksor.OCR() # weights download on first use
|
|
6
|
+
result = ocr("page.jpg")
|
|
7
|
+
print(result["text"])
|
|
8
|
+
|
|
9
|
+
pages = ocr.read("scan.pdf", keep_images=True) # needs anaksor-ocr[pdf]
|
|
10
|
+
open("scan.pdf", "wb").write(anaksor.export.to_pdf(pages)) # searchable PDF
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import threading
|
|
16
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
17
|
+
|
|
18
|
+
from . import export
|
|
19
|
+
from .detector import Detector
|
|
20
|
+
from .inputs import InputError, load_image
|
|
21
|
+
from .models import WeightsError
|
|
22
|
+
from .ocr import OCR
|
|
23
|
+
from .recognizer import Recognizer
|
|
24
|
+
from .text import normalize
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
__version__ = version("anaksor-ocr")
|
|
28
|
+
except PackageNotFoundError: # running from a source tree
|
|
29
|
+
__version__ = "0.0.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"OCR",
|
|
33
|
+
"Detector",
|
|
34
|
+
"InputError",
|
|
35
|
+
"Recognizer",
|
|
36
|
+
"WeightsError",
|
|
37
|
+
"__version__",
|
|
38
|
+
"export",
|
|
39
|
+
"load_image",
|
|
40
|
+
"normalize",
|
|
41
|
+
"read",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_default: OCR | None = None
|
|
45
|
+
_default_lock = threading.Lock()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read(src, **kwargs) -> list[dict]:
|
|
49
|
+
"""OCR an image or PDF with a shared default ``OCR()``; returns one result per page."""
|
|
50
|
+
global _default
|
|
51
|
+
with _default_lock:
|
|
52
|
+
if _default is None:
|
|
53
|
+
_default = OCR()
|
|
54
|
+
return _default.read(src, **kwargs)
|
anaksor/__main__.py
ADDED
anaksor/cli.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Command line: ``anaksor scan.pdf -o scan.docx``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
FORMATS = ("text", "json", "pdf", "docx")
|
|
11
|
+
_EXT = {"text": ".txt", "json": ".json", "pdf": ".pdf", "docx": ".docx"}
|
|
12
|
+
_BY_EXT = {".txt": "text", ".md": "text", ".json": "json", ".pdf": "pdf", ".docx": "docx"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_pages(spec: str | None) -> list[int] | None:
|
|
16
|
+
"""'1-3,5' to [1, 2, 3, 5]."""
|
|
17
|
+
if not spec:
|
|
18
|
+
return None
|
|
19
|
+
pages: list[int] = []
|
|
20
|
+
for part in spec.split(","):
|
|
21
|
+
part = part.strip()
|
|
22
|
+
if not part:
|
|
23
|
+
continue
|
|
24
|
+
a, sep, b = part.partition("-")
|
|
25
|
+
try:
|
|
26
|
+
lo, hi = int(a), int(b) if sep else int(a)
|
|
27
|
+
except ValueError:
|
|
28
|
+
raise argparse.ArgumentTypeError(f"bad page range {part!r} (use e.g. 1-3,5)") from None
|
|
29
|
+
if lo < 1 or hi < lo:
|
|
30
|
+
raise argparse.ArgumentTypeError(f"bad page range {part!r}")
|
|
31
|
+
pages.extend(range(lo, hi + 1))
|
|
32
|
+
return pages
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
from . import __version__
|
|
37
|
+
|
|
38
|
+
p = argparse.ArgumentParser(
|
|
39
|
+
prog="anaksor",
|
|
40
|
+
description="Khmer OCR: images and PDFs to text, JSON, searchable PDF or Word.",
|
|
41
|
+
epilog="Examples:\n anaksor photo.jpg\n anaksor scan.pdf -o scan.docx\n anaksor a.jpg b.png -o out/ -f json",
|
|
42
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
43
|
+
)
|
|
44
|
+
p.add_argument("inputs", nargs="*", type=Path, help="images (JPEG, PNG, WEBP, BMP, TIFF) or PDFs")
|
|
45
|
+
p.add_argument(
|
|
46
|
+
"-o", "--output", type=Path, help="output file, or a directory for several inputs (default: stdout for text/json)"
|
|
47
|
+
)
|
|
48
|
+
p.add_argument("-f", "--format", choices=FORMATS, help="default: from the output's extension, else text")
|
|
49
|
+
p.add_argument("--pages", type=parse_pages, help="PDF pages to read, e.g. 1-3,5 (default: all)")
|
|
50
|
+
p.add_argument("--dpi", type=int, default=200, help="PDF render resolution (default: 200)")
|
|
51
|
+
p.add_argument("--device", default="auto", help="auto, cpu, cuda, cuda:1, mps (default: auto)")
|
|
52
|
+
p.add_argument(
|
|
53
|
+
"--backend", choices=("auto", "torch", "onnx"), default="auto", help="torch or onnx runtime (default: torch if installed)"
|
|
54
|
+
)
|
|
55
|
+
p.add_argument("--det-weights", type=Path, help="detection weights file (default: cached download)")
|
|
56
|
+
p.add_argument("--rec-weights", type=Path, help="recognition weights file (default: cached download)")
|
|
57
|
+
p.add_argument("--pad-y", type=float, help="line crop margin above and below, fraction of line height")
|
|
58
|
+
p.add_argument("--pad-x", type=float, help="line crop margin left and right")
|
|
59
|
+
p.add_argument("--min-score", type=float, default=0.5, help="drop lines read below this confidence")
|
|
60
|
+
p.add_argument("--highlight-low", action="store_true", help="Word: highlight low-confidence lines")
|
|
61
|
+
p.add_argument("--fetch-models", action="store_true", help="download the model weights and exit")
|
|
62
|
+
p.add_argument("--version", action="version", version=f"anaksor {__version__}")
|
|
63
|
+
return p
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _out_path(args, src: Path, fmt: str, many: bool) -> Path | None:
|
|
67
|
+
if args.output is None:
|
|
68
|
+
return None
|
|
69
|
+
if many or args.output.is_dir():
|
|
70
|
+
args.output.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
return args.output / (src.stem + _EXT[fmt])
|
|
72
|
+
return args.output
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> int:
|
|
76
|
+
parser = build_parser()
|
|
77
|
+
args = parser.parse_args(argv)
|
|
78
|
+
|
|
79
|
+
from .models import DET_DEFAULT, REC_DEFAULT, WeightsError, load_spec, resolve_weights
|
|
80
|
+
|
|
81
|
+
if args.fetch_models:
|
|
82
|
+
try:
|
|
83
|
+
from .runtime import choose_backend
|
|
84
|
+
|
|
85
|
+
for name, w in ((DET_DEFAULT, args.det_weights), (REC_DEFAULT, args.rec_weights)):
|
|
86
|
+
fmt = choose_backend(args.backend, w)
|
|
87
|
+
print(f"{name}: {resolve_weights(load_spec(name), w, fmt=fmt)}")
|
|
88
|
+
except WeightsError as e:
|
|
89
|
+
print(f"anaksor: {e}", file=sys.stderr)
|
|
90
|
+
return 1
|
|
91
|
+
return 0
|
|
92
|
+
if not args.inputs:
|
|
93
|
+
parser.error("give at least one image or PDF")
|
|
94
|
+
fmt = args.format or _BY_EXT.get(args.output.suffix.lower() if args.output else "", "text")
|
|
95
|
+
many = len(args.inputs) > 1
|
|
96
|
+
if fmt in ("pdf", "docx") and args.output is None:
|
|
97
|
+
parser.error(f"{fmt} output needs -o FILE")
|
|
98
|
+
if many and args.output is not None and args.output.suffix and not args.output.is_dir():
|
|
99
|
+
parser.error("several inputs: -o must be a directory")
|
|
100
|
+
for src in args.inputs:
|
|
101
|
+
if not src.is_file():
|
|
102
|
+
parser.error(f"no such file: {src}")
|
|
103
|
+
|
|
104
|
+
from . import export
|
|
105
|
+
from .inputs import InputError
|
|
106
|
+
from .ocr import OCR
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
ocr = OCR(
|
|
110
|
+
device=args.device,
|
|
111
|
+
backend=args.backend,
|
|
112
|
+
det_weights=args.det_weights,
|
|
113
|
+
rec_weights=args.rec_weights,
|
|
114
|
+
pad_y=args.pad_y,
|
|
115
|
+
pad_x=args.pad_x,
|
|
116
|
+
min_rec_score=args.min_score,
|
|
117
|
+
)
|
|
118
|
+
except (WeightsError, ValueError, ImportError) as e:
|
|
119
|
+
print(f"anaksor: {e}", file=sys.stderr)
|
|
120
|
+
return 1
|
|
121
|
+
|
|
122
|
+
status = 0
|
|
123
|
+
for src in args.inputs:
|
|
124
|
+
try:
|
|
125
|
+
pages = ocr.read(src, pages=args.pages, dpi=args.dpi, keep_images=fmt == "pdf")
|
|
126
|
+
except (InputError, ImportError) as e:
|
|
127
|
+
print(f"anaksor: {src}: {e}", file=sys.stderr)
|
|
128
|
+
status = 1
|
|
129
|
+
continue
|
|
130
|
+
out = _out_path(args, src, fmt, many)
|
|
131
|
+
if fmt == "text":
|
|
132
|
+
data: str | bytes = export.to_text(pages) + "\n"
|
|
133
|
+
elif fmt == "json":
|
|
134
|
+
data = json.dumps({"file": str(src), "pages": pages}, ensure_ascii=False, indent=1) + "\n"
|
|
135
|
+
elif fmt == "pdf":
|
|
136
|
+
data = export.to_pdf(pages, title=src.stem)
|
|
137
|
+
else:
|
|
138
|
+
data = export.to_docx(pages, title=src.stem, highlight_low=args.highlight_low)
|
|
139
|
+
if out is None:
|
|
140
|
+
sys.stdout.write(data if isinstance(data, str) else data.decode())
|
|
141
|
+
elif isinstance(data, bytes):
|
|
142
|
+
out.write_bytes(data)
|
|
143
|
+
else:
|
|
144
|
+
out.write_text(data, encoding="utf-8")
|
|
145
|
+
if out is not None:
|
|
146
|
+
print(f"{src} -> {out}", file=sys.stderr)
|
|
147
|
+
return status
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Khmer text detection v2: RepViT backbone + DBNet (KhmerST + synthetic pages).
|
|
2
|
+
# Architecture block as in Alpha-OCR configs/repvit_db_khmer_v2.yml (training-only keys are ignored).
|
|
3
|
+
name: det-repvit-db-v2
|
|
4
|
+
kind: det
|
|
5
|
+
weights:
|
|
6
|
+
file: khmer_det_repvit_db_v2.safetensors
|
|
7
|
+
sha256: null # set when the release weights are published
|
|
8
|
+
onnx: khmer_det_repvit_db_v2.onnx # tools/export_onnx.py; the default (torch-free) install runs this
|
|
9
|
+
onnx_sha256: null
|
|
10
|
+
input:
|
|
11
|
+
limit_side_len: 1280 # long side resized down to this (small document text needs 1280+)
|
|
12
|
+
min_side_len: 736 # smaller images are upscaled
|
|
13
|
+
mean: [0.485, 0.456, 0.406]
|
|
14
|
+
std: [0.229, 0.224, 0.225]
|
|
15
|
+
architecture:
|
|
16
|
+
algorithm: DB_mobile
|
|
17
|
+
Backbone:
|
|
18
|
+
name: RepSVTR_det
|
|
19
|
+
Neck:
|
|
20
|
+
name: RSEFPN
|
|
21
|
+
out_channels: 96
|
|
22
|
+
shortcut: true
|
|
23
|
+
Head:
|
|
24
|
+
name: DBHead
|
|
25
|
+
k: 50
|
|
26
|
+
postprocess:
|
|
27
|
+
name: DBPostProcess
|
|
28
|
+
thresh: 0.3
|
|
29
|
+
box_thresh: 0.5
|
|
30
|
+
max_candidates: 1000
|
|
31
|
+
unclip_ratio: 1.5
|
|
32
|
+
score_mode: slow
|
|
33
|
+
use_dilation: false # dilating at inference glues tightly spaced Khmer lines together
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Khmer text detection v3: RepViT backbone + DBNet (KhmerST, synthetic pages and signs,
|
|
2
|
+
# HierText English on photos). Made with tools/spec_from_config.py from the training config
|
|
3
|
+
# published as det_latest.yml at Sokheng/anaksor-ocr, tag rec-v4-det-v3.
|
|
4
|
+
name: det-repvit-db-v3
|
|
5
|
+
kind: det
|
|
6
|
+
weights:
|
|
7
|
+
file: khmer_det_repvit_db_latest.safetensors # as published (Hub commit 5c9f76c)
|
|
8
|
+
sha256: 94d633392ec4be36b679a272bcb741524e003649bcdcbaf3b81ab77a504dca2c
|
|
9
|
+
onnx: khmer_det_repvit_db_latest.onnx # the default (torch-free) install runs this
|
|
10
|
+
onnx_sha256: 5d5fdff595e644a4cc95f8c23e8b455a7a003293b35476212c0b09820c304440
|
|
11
|
+
input:
|
|
12
|
+
limit_side_len: 1280
|
|
13
|
+
min_side_len: 736
|
|
14
|
+
mean: [0.485, 0.456, 0.406]
|
|
15
|
+
std: [0.229, 0.224, 0.225]
|
|
16
|
+
architecture:
|
|
17
|
+
algorithm: DB_mobile
|
|
18
|
+
Backbone: {name: RepSVTR_det}
|
|
19
|
+
Neck: {name: RSEFPN, out_channels: 96, shortcut: true}
|
|
20
|
+
Head: {name: DBHead, k: 50}
|
|
21
|
+
postprocess: {name: DBPostProcess, thresh: 0.3, box_thresh: 0.5, max_candidates: 1000,
|
|
22
|
+
unclip_ratio: 1.5, score_mode: slow, use_dilation: false}
|
|
Binary file
|
|
@@ -0,0 +1,211 @@
|
|
|
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
|
+
0
|
|
116
|
+
1
|
|
117
|
+
2
|
|
118
|
+
3
|
|
119
|
+
4
|
|
120
|
+
5
|
|
121
|
+
6
|
|
122
|
+
7
|
|
123
|
+
8
|
|
124
|
+
9
|
|
125
|
+
:
|
|
126
|
+
;
|
|
127
|
+
<
|
|
128
|
+
=
|
|
129
|
+
>
|
|
130
|
+
?
|
|
131
|
+
@
|
|
132
|
+
A
|
|
133
|
+
B
|
|
134
|
+
C
|
|
135
|
+
D
|
|
136
|
+
E
|
|
137
|
+
F
|
|
138
|
+
G
|
|
139
|
+
H
|
|
140
|
+
I
|
|
141
|
+
J
|
|
142
|
+
K
|
|
143
|
+
L
|
|
144
|
+
M
|
|
145
|
+
N
|
|
146
|
+
O
|
|
147
|
+
P
|
|
148
|
+
Q
|
|
149
|
+
R
|
|
150
|
+
S
|
|
151
|
+
T
|
|
152
|
+
U
|
|
153
|
+
V
|
|
154
|
+
W
|
|
155
|
+
X
|
|
156
|
+
Y
|
|
157
|
+
Z
|
|
158
|
+
[
|
|
159
|
+
\
|
|
160
|
+
]
|
|
161
|
+
^
|
|
162
|
+
_
|
|
163
|
+
`
|
|
164
|
+
a
|
|
165
|
+
b
|
|
166
|
+
c
|
|
167
|
+
d
|
|
168
|
+
e
|
|
169
|
+
f
|
|
170
|
+
g
|
|
171
|
+
h
|
|
172
|
+
i
|
|
173
|
+
j
|
|
174
|
+
k
|
|
175
|
+
l
|
|
176
|
+
m
|
|
177
|
+
n
|
|
178
|
+
o
|
|
179
|
+
p
|
|
180
|
+
q
|
|
181
|
+
r
|
|
182
|
+
s
|
|
183
|
+
t
|
|
184
|
+
u
|
|
185
|
+
v
|
|
186
|
+
w
|
|
187
|
+
x
|
|
188
|
+
y
|
|
189
|
+
z
|
|
190
|
+
{
|
|
191
|
+
|
|
|
192
|
+
}
|
|
193
|
+
~
|
|
194
|
+
«
|
|
195
|
+
»
|
|
196
|
+
°
|
|
197
|
+
×
|
|
198
|
+
÷
|
|
199
|
+
–
|
|
200
|
+
—
|
|
201
|
+
•
|
|
202
|
+
€
|
|
203
|
+
£
|
|
204
|
+
¥
|
|
205
|
+
©
|
|
206
|
+
®
|
|
207
|
+
±
|
|
208
|
+
§
|
|
209
|
+
№
|
|
210
|
+
→
|
|
211
|
+
✓
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Khmer text-line recognition v3: SVTRv2 encoder, CTC head (+ SMTR head, unused by default).
|
|
2
|
+
# Architecture block as in Alpha-OCR configs/svtrv2_khmer_v3.yml (training-only keys are ignored).
|
|
3
|
+
# The SMTR vocabulary is </s> + the dict + <s>, <INF>, <INB>, <pad>; the CTC one is blank + the dict.
|
|
4
|
+
name: rec-svtrv2-v3
|
|
5
|
+
kind: rec
|
|
6
|
+
weights:
|
|
7
|
+
file: khmer_rec_svtrv2_v3.safetensors
|
|
8
|
+
sha256: null # set when the release weights are published
|
|
9
|
+
onnx: khmer_rec_svtrv2_v3.onnx # tools/export_onnx.py; the default (torch-free) install runs this
|
|
10
|
+
onnx_sha256: null
|
|
11
|
+
charset:
|
|
12
|
+
dict: khmer_dict_v3.txt
|
|
13
|
+
use_space_char: true
|
|
14
|
+
max_text_length: 140
|
|
15
|
+
input: # same shape buckets as training (RatioDataSetTVResize, no padding)
|
|
16
|
+
base_h: 48
|
|
17
|
+
base_shape: [[64, 64], [96, 48], [144, 48], [192, 48]]
|
|
18
|
+
max_ratio: 20
|
|
19
|
+
crop: # margin around each detected line, fraction of its height
|
|
20
|
+
pad_y: 0.22 # live sweep, 12 pages, CER: 0.04 -> 3.9%, 0.22/0.20 -> 0.34%
|
|
21
|
+
pad_x: 0.20
|
|
22
|
+
architecture:
|
|
23
|
+
model_type: rec
|
|
24
|
+
algorithm: BGPD
|
|
25
|
+
in_channels: 3
|
|
26
|
+
Transform:
|
|
27
|
+
Encoder:
|
|
28
|
+
name: SVTRv2LNConvTwo33
|
|
29
|
+
use_pos_embed: false
|
|
30
|
+
out_channels: 256
|
|
31
|
+
dims: [96, 192, 384]
|
|
32
|
+
depths: [3, 6, 3]
|
|
33
|
+
num_heads: [3, 6, 12]
|
|
34
|
+
mixer: [[Conv, Conv, Conv], [Conv, Conv, Conv, FGlobal, Global, Global], [Global, Global, Global]]
|
|
35
|
+
local_k: [[5, 5], [5, 5], [-1, -1]]
|
|
36
|
+
sub_k: [[1, 1], [2, 1], [-1, -1]]
|
|
37
|
+
last_stage: false
|
|
38
|
+
feat2d: true
|
|
39
|
+
Decoder:
|
|
40
|
+
name: GTCDecoder
|
|
41
|
+
infer_gtc: true
|
|
42
|
+
detach: false
|
|
43
|
+
gtc_decoder:
|
|
44
|
+
name: SMTRDecoder
|
|
45
|
+
num_layer: 1
|
|
46
|
+
ds: true
|
|
47
|
+
max_len: 140
|
|
48
|
+
next_mode: true
|
|
49
|
+
sub_str_len: 5
|
|
50
|
+
ctc_decoder:
|
|
51
|
+
name: RCTCDecoder
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Khmer text-line recognition v4: SVTRv2-B encoder, CTC head (+ SMTR head, unused by default;
|
|
2
|
+
# the counting head only trained the encoder). Made with tools/spec_from_config.py from the
|
|
3
|
+
# training config published as rec_latest.yml at Sokheng/anaksor-ocr, tag rec-v4-det-v3.
|
|
4
|
+
name: rec-svtrv2-v4
|
|
5
|
+
kind: rec
|
|
6
|
+
weights:
|
|
7
|
+
file: khmer_rec_svtrv2_latest.safetensors # as published (Hub commit 5c9f76c)
|
|
8
|
+
sha256: 2aead8175ed67ce8492363ae8664963643f1dc2108b74c663ed224b2b648f41d
|
|
9
|
+
onnx: khmer_rec_svtrv2_latest.onnx # the default (torch-free) install runs this
|
|
10
|
+
onnx_sha256: afe130ebd442b35cff553efc91213219d33afc5f1c1883a0ddad402a8d1c96ec
|
|
11
|
+
charset: {dict: khmer_dict_v3.txt, use_space_char: true, max_text_length: 140}
|
|
12
|
+
input:
|
|
13
|
+
base_h: 48
|
|
14
|
+
base_shape:
|
|
15
|
+
- [64, 64]
|
|
16
|
+
- [96, 48]
|
|
17
|
+
- [144, 48]
|
|
18
|
+
- [192, 48]
|
|
19
|
+
max_ratio: 20
|
|
20
|
+
crop: {pad_y: 0.16, pad_x: 0.1}
|
|
21
|
+
architecture:
|
|
22
|
+
model_type: rec
|
|
23
|
+
algorithm: BGPD
|
|
24
|
+
in_channels: 3
|
|
25
|
+
Encoder:
|
|
26
|
+
name: SVTRv2LNConvTwo33
|
|
27
|
+
use_pos_embed: false
|
|
28
|
+
dims: [128, 256, 384]
|
|
29
|
+
depths: [6, 6, 6]
|
|
30
|
+
num_heads: [4, 8, 12]
|
|
31
|
+
mixer:
|
|
32
|
+
- [Conv, Conv, Conv, Conv, Conv, Conv]
|
|
33
|
+
- [Conv, Conv, FGlobal, Global, Global, Global]
|
|
34
|
+
- [Global, Global, Global, Global, Global, Global]
|
|
35
|
+
kernel_sizes:
|
|
36
|
+
- [5, 5, 5, 5, 5, 5]
|
|
37
|
+
- [5, 5, 5, 5, 5, 5]
|
|
38
|
+
- [-1]
|
|
39
|
+
num_convs:
|
|
40
|
+
- [1, 1, 1, 1, 1, 1]
|
|
41
|
+
- [1, 1, 1, 1, 1, 1]
|
|
42
|
+
- [-1]
|
|
43
|
+
pope_bias: true
|
|
44
|
+
sub_k:
|
|
45
|
+
- [1, 1]
|
|
46
|
+
- [2, 1]
|
|
47
|
+
- [-1, -1]
|
|
48
|
+
last_stage: false
|
|
49
|
+
feat2d: true
|
|
50
|
+
Decoder:
|
|
51
|
+
name: GTCCountDecoder
|
|
52
|
+
infer_gtc: true
|
|
53
|
+
detach: false
|
|
54
|
+
count_head: {max_len: 140, count_bins: 32, num_layer: 2}
|
|
55
|
+
gtc_decoder: {name: SMTRDecoder, num_layer: 1, ds: true, max_len: 140, next_mode: true,
|
|
56
|
+
sub_str_len: 5}
|
|
57
|
+
ctc_decoder: {name: RCTCDecoder}
|
anaksor/db_boxes.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Text boxes from a DBNet probability map.
|
|
2
|
+
|
|
3
|
+
DBNet's inference (Liao et al., AAAI 2020, sec. 3.4): binarise the shrunk-text probability
|
|
4
|
+
map with a fixed threshold, take each connected region's contour, discard regions whose
|
|
5
|
+
mean probability is low, then *unclip*: grow the region by D = A * r / L (A area, L
|
|
6
|
+
perimeter, r the unclip ratio) to undo the shrinking applied to the training labels.
|
|
7
|
+
Each grown region becomes the minimum-area rotated rectangle around it, scaled back to
|
|
8
|
+
the source image.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pyclipper
|
|
16
|
+
from shapely.geometry import Polygon
|
|
17
|
+
|
|
18
|
+
MIN_SIDE = 3 # px on the probability map: smaller regions are noise
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def boxes_from_map(
|
|
22
|
+
prob: np.ndarray,
|
|
23
|
+
src_hw: tuple[int, int],
|
|
24
|
+
thresh: float = 0.3,
|
|
25
|
+
box_thresh: float = 0.5,
|
|
26
|
+
unclip_ratio: float = 1.5,
|
|
27
|
+
max_candidates: int = 1000,
|
|
28
|
+
score_mode: str = "slow",
|
|
29
|
+
dilate: bool = False,
|
|
30
|
+
) -> tuple[list[np.ndarray], list[float]]:
|
|
31
|
+
"""Quads (4 x 2 int32, clockwise from top-left) and scores of the text regions in
|
|
32
|
+
``prob`` (H x W, float), in the coordinates of a ``src_hw`` image.
|
|
33
|
+
|
|
34
|
+
score_mode "slow" averages the probability inside the region's contour, "fast"
|
|
35
|
+
inside its rotated rectangle."""
|
|
36
|
+
if score_mode not in ("slow", "fast"):
|
|
37
|
+
raise ValueError(f"score_mode must be slow or fast, not {score_mode!r}")
|
|
38
|
+
mask = (prob > thresh).astype(np.uint8)
|
|
39
|
+
if dilate:
|
|
40
|
+
mask = cv2.dilate(mask, np.ones((2, 2), np.uint8))
|
|
41
|
+
height, width = mask.shape
|
|
42
|
+
src_h, src_w = np.float64(src_hw[0]), np.float64(src_hw[1])
|
|
43
|
+
contours, _ = cv2.findContours(mask * 255, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
|
44
|
+
|
|
45
|
+
boxes, scores = [], []
|
|
46
|
+
for contour in contours[:max_candidates]:
|
|
47
|
+
quad, short = _min_area_quad(contour)
|
|
48
|
+
if short < MIN_SIDE:
|
|
49
|
+
continue
|
|
50
|
+
score = _mean_in_polygon(prob, contour.reshape(-1, 2)) if score_mode == "slow" else _mean_in_box(prob, quad)
|
|
51
|
+
if score < box_thresh:
|
|
52
|
+
continue
|
|
53
|
+
grown = _unclip(quad, unclip_ratio)
|
|
54
|
+
if len(grown) != 1: # vanished or split: not a text line
|
|
55
|
+
continue
|
|
56
|
+
quad, short = _min_area_quad(np.array(grown).reshape(-1, 1, 2))
|
|
57
|
+
if short < MIN_SIDE + 2:
|
|
58
|
+
continue
|
|
59
|
+
quad[:, 0] = np.clip(np.round(quad[:, 0] / width * src_w), 0, src_w)
|
|
60
|
+
quad[:, 1] = np.clip(np.round(quad[:, 1] / height * src_h), 0, src_h)
|
|
61
|
+
boxes.append(quad.astype(np.int32))
|
|
62
|
+
scores.append(score)
|
|
63
|
+
return boxes, scores
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _min_area_quad(points) -> tuple[np.ndarray, float]:
|
|
67
|
+
"""Corners of the minimum-area rectangle around ``points`` in the order top-left,
|
|
68
|
+
top-right, bottom-right, bottom-left, and its shorter side."""
|
|
69
|
+
rect = cv2.minAreaRect(points)
|
|
70
|
+
pts = sorted(cv2.boxPoints(rect), key=lambda p: p[0]) # two left corners, then two right
|
|
71
|
+
tl, bl = (pts[0], pts[1]) if pts[1][1] > pts[0][1] else (pts[1], pts[0])
|
|
72
|
+
tr, br = (pts[2], pts[3]) if pts[3][1] > pts[2][1] else (pts[3], pts[2])
|
|
73
|
+
return np.array([tl, tr, br, bl]), min(rect[1])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _unclip(quad: np.ndarray, ratio: float) -> list:
|
|
77
|
+
"""Offset the polygon outwards by area * ratio / perimeter (round joins)."""
|
|
78
|
+
poly = Polygon(quad)
|
|
79
|
+
offset = pyclipper.PyclipperOffset()
|
|
80
|
+
offset.AddPath(quad, pyclipper.JT_ROUND, pyclipper.ET_CLOSEDPOLYGON)
|
|
81
|
+
return offset.Execute(poly.area * ratio / poly.length)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _mean_in_polygon(prob: np.ndarray, pts: np.ndarray) -> float:
|
|
85
|
+
h, w = prob.shape
|
|
86
|
+
x0, x1 = np.clip(pts[:, 0].min(), 0, w - 1), np.clip(pts[:, 0].max(), 0, w - 1)
|
|
87
|
+
y0, y1 = np.clip(pts[:, 1].min(), 0, h - 1), np.clip(pts[:, 1].max(), 0, h - 1)
|
|
88
|
+
return _masked_mean(prob, pts, x0, x1, y0, y1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _mean_in_box(prob: np.ndarray, quad: np.ndarray) -> float:
|
|
92
|
+
h, w = prob.shape
|
|
93
|
+
x0 = np.clip(np.floor(quad[:, 0].min()).astype(np.int32), 0, w - 1)
|
|
94
|
+
x1 = np.clip(np.ceil(quad[:, 0].max()).astype(np.int32), 0, w - 1)
|
|
95
|
+
y0 = np.clip(np.floor(quad[:, 1].min()).astype(np.int32), 0, h - 1)
|
|
96
|
+
y1 = np.clip(np.ceil(quad[:, 1].max()).astype(np.int32), 0, h - 1)
|
|
97
|
+
return _masked_mean(prob, quad, x0, x1, y0, y1)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _masked_mean(prob, pts, x0, x1, y0, y1) -> float:
|
|
101
|
+
"""Mean of ``prob`` inside polygon ``pts``, over the window [y0..y1, x0..x1]."""
|
|
102
|
+
mask = np.zeros((y1 - y0 + 1, x1 - x0 + 1), np.uint8)
|
|
103
|
+
local = (pts - np.array([x0, y0], dtype=pts.dtype)).astype(np.int32)
|
|
104
|
+
cv2.fillPoly(mask, local.reshape(1, -1, 2), 1)
|
|
105
|
+
return cv2.mean(prob[y0 : y1 + 1, x0 : x1 + 1], mask)[0]
|