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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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]