pharmacode-toolkit 0.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.
- pharmacode/__init__.py +71 -0
- pharmacode/__main__.py +10 -0
- pharmacode/benchmark.py +248 -0
- pharmacode/cli.py +214 -0
- pharmacode/decoding.py +33 -0
- pharmacode/detection.py +318 -0
- pharmacode/encoding.py +59 -0
- pharmacode/imageops.py +59 -0
- pharmacode/io.py +41 -0
- pharmacode/models.py +280 -0
- pharmacode/pipeline.py +77 -0
- pharmacode/rendering.py +233 -0
- pharmacode/segmentation.py +377 -0
- pharmacode/visualization.py +68 -0
- pharmacode_toolkit-0.2.1.dist-info/METADATA +129 -0
- pharmacode_toolkit-0.2.1.dist-info/RECORD +19 -0
- pharmacode_toolkit-0.2.1.dist-info/WHEEL +4 -0
- pharmacode_toolkit-0.2.1.dist-info/entry_points.txt +2 -0
- pharmacode_toolkit-0.2.1.dist-info/licenses/LICENSE +21 -0
pharmacode/__init__.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Toolkit for generating, detecting and decoding one-track Pharmacode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pharmacode.decoding import decode_bars
|
|
6
|
+
from pharmacode.detection import find_candidates
|
|
7
|
+
from pharmacode.encoding import MAX_BARS, MAX_VALUE, MIN_BARS, MIN_VALUE, bars_to_value, encode
|
|
8
|
+
from pharmacode.io import InputError, load_image, save_image
|
|
9
|
+
from pharmacode.models import (
|
|
10
|
+
BarKind,
|
|
11
|
+
BarSequence,
|
|
12
|
+
BoundingBox,
|
|
13
|
+
DecodedPharmacode,
|
|
14
|
+
DecodeError,
|
|
15
|
+
DecoderConfig,
|
|
16
|
+
DecodeResult,
|
|
17
|
+
DetectionCandidate,
|
|
18
|
+
ErrorCode,
|
|
19
|
+
ImageInfo,
|
|
20
|
+
)
|
|
21
|
+
from pharmacode.pipeline import decode_image
|
|
22
|
+
from pharmacode.rendering import (
|
|
23
|
+
NEGATIVE_KINDS,
|
|
24
|
+
Distortion,
|
|
25
|
+
RenderSpec,
|
|
26
|
+
compose_scene,
|
|
27
|
+
distort,
|
|
28
|
+
render_bars,
|
|
29
|
+
render_negative,
|
|
30
|
+
render_value,
|
|
31
|
+
)
|
|
32
|
+
from pharmacode.segmentation import extract_bars_from_upright
|
|
33
|
+
from pharmacode.visualization import annotate
|
|
34
|
+
|
|
35
|
+
__version__ = "0.2.1"
|
|
36
|
+
|
|
37
|
+
__all__ = [
|
|
38
|
+
"MAX_BARS",
|
|
39
|
+
"MAX_VALUE",
|
|
40
|
+
"MIN_BARS",
|
|
41
|
+
"MIN_VALUE",
|
|
42
|
+
"BarKind",
|
|
43
|
+
"BarSequence",
|
|
44
|
+
"BoundingBox",
|
|
45
|
+
"DecodeError",
|
|
46
|
+
"DecodedPharmacode",
|
|
47
|
+
"DecodeResult",
|
|
48
|
+
"DecoderConfig",
|
|
49
|
+
"DetectionCandidate",
|
|
50
|
+
"Distortion",
|
|
51
|
+
"ErrorCode",
|
|
52
|
+
"ImageInfo",
|
|
53
|
+
"InputError",
|
|
54
|
+
"NEGATIVE_KINDS",
|
|
55
|
+
"RenderSpec",
|
|
56
|
+
"__version__",
|
|
57
|
+
"annotate",
|
|
58
|
+
"bars_to_value",
|
|
59
|
+
"compose_scene",
|
|
60
|
+
"decode_bars",
|
|
61
|
+
"decode_image",
|
|
62
|
+
"distort",
|
|
63
|
+
"encode",
|
|
64
|
+
"extract_bars_from_upright",
|
|
65
|
+
"find_candidates",
|
|
66
|
+
"load_image",
|
|
67
|
+
"render_bars",
|
|
68
|
+
"render_negative",
|
|
69
|
+
"render_value",
|
|
70
|
+
"save_image",
|
|
71
|
+
]
|
pharmacode/__main__.py
ADDED
pharmacode/benchmark.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Synthetic benchmark: a matrix of conditions, never a single headline number."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import platform
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import asdict, dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import cv2
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from pharmacode import __version__
|
|
17
|
+
from pharmacode.encoding import MAX_VALUE, MIN_VALUE
|
|
18
|
+
from pharmacode.io import save_image
|
|
19
|
+
from pharmacode.models import DecoderConfig
|
|
20
|
+
from pharmacode.pipeline import decode_image
|
|
21
|
+
from pharmacode.rendering import (
|
|
22
|
+
NEGATIVE_KINDS,
|
|
23
|
+
Distortion,
|
|
24
|
+
RenderSpec,
|
|
25
|
+
compose_scene,
|
|
26
|
+
distort,
|
|
27
|
+
render_negative,
|
|
28
|
+
render_value,
|
|
29
|
+
)
|
|
30
|
+
from pharmacode.visualization import annotate
|
|
31
|
+
|
|
32
|
+
BOUNDARY_VALUES = [3, 4, 5, 6, 7, 8, 13, 25, 91, 100, 1234, 12345, 65535, 123456, 131070]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Condition:
|
|
37
|
+
name: str
|
|
38
|
+
group: str
|
|
39
|
+
dpi: float
|
|
40
|
+
distortion: Distortion = Distortion()
|
|
41
|
+
spec: RenderSpec | None = None
|
|
42
|
+
multi: bool = False
|
|
43
|
+
edge: bool = False
|
|
44
|
+
use_dpi: bool = True
|
|
45
|
+
|
|
46
|
+
def render_spec(self) -> RenderSpec:
|
|
47
|
+
return self.spec if self.spec is not None else RenderSpec(dpi=self.dpi)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def build_conditions(quick: bool) -> list[Condition]:
|
|
51
|
+
conditions = [
|
|
52
|
+
Condition("clean-300", "clean", 300.0),
|
|
53
|
+
Condition("rotation-90", "rotation", 300.0, Distortion(rotation_deg=90)),
|
|
54
|
+
Condition("rotation-180", "rotation", 300.0, Distortion(rotation_deg=180)),
|
|
55
|
+
Condition("rotation-270", "rotation", 300.0, Distortion(rotation_deg=270)),
|
|
56
|
+
Condition("tilt-plus-3", "rotation", 300.0, Distortion(rotation_deg=3)),
|
|
57
|
+
Condition("tilt-minus-3", "rotation", 300.0, Distortion(rotation_deg=-3)),
|
|
58
|
+
Condition("dpi-150", "dpi", 150.0),
|
|
59
|
+
Condition("dpi-600", "dpi", 600.0),
|
|
60
|
+
Condition("blur-1", "blur", 300.0, Distortion(blur_sigma=1.0)),
|
|
61
|
+
Condition("blur-2", "blur", 300.0, Distortion(blur_sigma=2.0)),
|
|
62
|
+
Condition("noise-10", "noise", 300.0, Distortion(noise_sigma=10.0)),
|
|
63
|
+
Condition("noise-25", "noise", 300.0, Distortion(noise_sigma=25.0)),
|
|
64
|
+
Condition("jpeg-50", "jpeg", 300.0, Distortion(jpeg_quality=50)),
|
|
65
|
+
Condition("jpeg-30", "jpeg", 300.0, Distortion(jpeg_quality=30)),
|
|
66
|
+
Condition("contrast-0.4", "contrast", 300.0, Distortion(contrast=0.4)),
|
|
67
|
+
Condition("illumination-0.5", "illumination", 300.0, Distortion(illumination_gradient=0.5)),
|
|
68
|
+
Condition("perspective-3", "perspective", 300.0, Distortion(perspective_deg=3.0)),
|
|
69
|
+
Condition(
|
|
70
|
+
"scale-0.7x1.3", "scale", 300.0, Distortion(scale_x=0.7, scale_y=1.3), use_dpi=False
|
|
71
|
+
),
|
|
72
|
+
Condition("edge-corner", "edge", 300.0, edge=True),
|
|
73
|
+
Condition("multi-3", "multi", 300.0, multi=True),
|
|
74
|
+
Condition("miniature-600", "tolerance", 600.0, spec=RenderSpec.miniature(dpi=600.0)),
|
|
75
|
+
Condition(
|
|
76
|
+
"tolerance-max-gap",
|
|
77
|
+
"tolerance",
|
|
78
|
+
300.0,
|
|
79
|
+
spec=RenderSpec(narrow_mm=0.7, wide_mm=2.5, gap_mm=2.5),
|
|
80
|
+
),
|
|
81
|
+
Condition("dpi-150-blur-1", "blur", 150.0, Distortion(blur_sigma=1.0)),
|
|
82
|
+
]
|
|
83
|
+
if quick:
|
|
84
|
+
# One condition per group (the first that appears), so a slice can't silently drop
|
|
85
|
+
# a whole group from CI coverage; rotation-180 is added on top for a second angle.
|
|
86
|
+
keep = {"clean", "rotation", "blur", "noise", "multi", "scale", "tolerance"}
|
|
87
|
+
selected: list[Condition] = []
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
for c in conditions:
|
|
90
|
+
if c.group in keep and c.group not in seen:
|
|
91
|
+
selected.append(c)
|
|
92
|
+
seen.add(c.group)
|
|
93
|
+
if c.group == "rotation" and c.name == "rotation-180":
|
|
94
|
+
selected.append(c)
|
|
95
|
+
conditions = selected
|
|
96
|
+
return conditions
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_values(seed: int, quick: bool) -> list[int]:
|
|
100
|
+
rng = np.random.default_rng(seed)
|
|
101
|
+
count = 8 if quick else 40
|
|
102
|
+
random_values = sorted(int(v) for v in rng.integers(MIN_VALUE, MAX_VALUE + 1, count))
|
|
103
|
+
return (BOUNDARY_VALUES[:6] if quick else BOUNDARY_VALUES) + random_values
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _make_image(condition: Condition, value: int, rng: np.random.Generator) -> np.ndarray:
|
|
107
|
+
spec = condition.render_spec()
|
|
108
|
+
code = render_value(value, spec)
|
|
109
|
+
if condition.multi:
|
|
110
|
+
others = [render_value(int(v), spec) for v in rng.integers(MIN_VALUE, MAX_VALUE + 1, 2)]
|
|
111
|
+
turned = distort(others[0], Distortion(rotation_deg=90))
|
|
112
|
+
canvas = (
|
|
113
|
+
code.shape[0] * 2 + turned.shape[0] + 200,
|
|
114
|
+
code.shape[1] + turned.shape[1] + others[1].shape[1] + 300,
|
|
115
|
+
)
|
|
116
|
+
code = compose_scene(
|
|
117
|
+
canvas,
|
|
118
|
+
[
|
|
119
|
+
(code, 50, 50),
|
|
120
|
+
(turned, code.shape[1] + 150, 60),
|
|
121
|
+
(others[1], 80, code.shape[0] + turned.shape[0] + 120),
|
|
122
|
+
],
|
|
123
|
+
)
|
|
124
|
+
if condition.edge:
|
|
125
|
+
code = compose_scene((code.shape[0] + 150, code.shape[1] + 150), [(code, 0, 0)])
|
|
126
|
+
return (
|
|
127
|
+
distort(code, condition.distortion, rng) if condition.distortion != Distortion() else code
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _environment() -> dict[str, str]:
|
|
132
|
+
return {
|
|
133
|
+
"toolkit": __version__,
|
|
134
|
+
"python": sys.version.split()[0],
|
|
135
|
+
"platform": platform.platform(),
|
|
136
|
+
"numpy": np.__version__,
|
|
137
|
+
"opencv": cv2.__version__,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def run_benchmark(seed: int, output_dir: str | Path, quick: bool = False) -> dict[str, Any]:
|
|
142
|
+
"""Render, decode and score every condition; write reports and failure images."""
|
|
143
|
+
output = Path(output_dir)
|
|
144
|
+
failures = output / "failures"
|
|
145
|
+
failures.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
rng = np.random.default_rng(seed)
|
|
147
|
+
values = build_values(seed, quick)
|
|
148
|
+
report: dict[str, Any] = {
|
|
149
|
+
"seed": seed,
|
|
150
|
+
"quick": quick,
|
|
151
|
+
"environment": _environment(),
|
|
152
|
+
"config": asdict(DecoderConfig()),
|
|
153
|
+
"values": values,
|
|
154
|
+
"conditions": {},
|
|
155
|
+
"negatives": {},
|
|
156
|
+
}
|
|
157
|
+
for condition in build_conditions(quick):
|
|
158
|
+
detected = correct = 0
|
|
159
|
+
elapsed: list[float] = []
|
|
160
|
+
failed: list[dict[str, Any]] = []
|
|
161
|
+
config = DecoderConfig(dpi=condition.dpi if condition.use_dpi else None)
|
|
162
|
+
for value in values:
|
|
163
|
+
image = _make_image(condition, value, rng)
|
|
164
|
+
start = time.perf_counter()
|
|
165
|
+
result = decode_image(image, config)
|
|
166
|
+
elapsed.append((time.perf_counter() - start) * 1000.0)
|
|
167
|
+
found = bool(result.detections)
|
|
168
|
+
hit = any(value in (d.value, d.mirror_value) for d in result.detections)
|
|
169
|
+
detected += found
|
|
170
|
+
correct += hit
|
|
171
|
+
if not hit:
|
|
172
|
+
target = failures / condition.name
|
|
173
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
save_image(target / f"{value}.png", annotate(image, result))
|
|
175
|
+
failed.append({"value": value, "errors": [e.to_dict() for e in result.errors]})
|
|
176
|
+
report["conditions"][condition.name] = {
|
|
177
|
+
"group": condition.group,
|
|
178
|
+
"images": len(values),
|
|
179
|
+
"detected": detected,
|
|
180
|
+
"detected_rate": detected / len(values),
|
|
181
|
+
"correct": correct,
|
|
182
|
+
"correct_rate": correct / len(values),
|
|
183
|
+
"mean_ms": float(np.mean(elapsed)),
|
|
184
|
+
"failures": failed,
|
|
185
|
+
}
|
|
186
|
+
negative_count = 40 if quick else 200
|
|
187
|
+
false_positives: list[dict[str, Any]] = []
|
|
188
|
+
for index in range(negative_count):
|
|
189
|
+
kind = NEGATIVE_KINDS[index % len(NEGATIVE_KINDS)]
|
|
190
|
+
image = render_negative(kind, rng)
|
|
191
|
+
result = decode_image(image, DecoderConfig())
|
|
192
|
+
if result.detections:
|
|
193
|
+
target = failures / "negatives"
|
|
194
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
195
|
+
save_image(target / f"{kind}-{index}.png", annotate(image, result))
|
|
196
|
+
false_positives.append(
|
|
197
|
+
{"kind": kind, "index": index, "values": [d.value for d in result.detections]}
|
|
198
|
+
)
|
|
199
|
+
report["negatives"] = {
|
|
200
|
+
"images": negative_count,
|
|
201
|
+
"false_positives": len(false_positives),
|
|
202
|
+
"false_positive_rate": len(false_positives) / negative_count,
|
|
203
|
+
"cases": false_positives,
|
|
204
|
+
}
|
|
205
|
+
(output / "results.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
206
|
+
(output / "results.md").write_text(render_markdown(report), encoding="utf-8")
|
|
207
|
+
return report
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def gate(report: dict[str, Any], min_correct: float, max_false_positives: int) -> list[str]:
|
|
211
|
+
"""Return one message per violated threshold; empty means the benchmark passed."""
|
|
212
|
+
violations: list[str] = []
|
|
213
|
+
for name, row in report["conditions"].items():
|
|
214
|
+
if row["correct_rate"] < min_correct:
|
|
215
|
+
violations.append(
|
|
216
|
+
f"{name}: correct rate {row['correct_rate']:.1%} below {min_correct:.1%}"
|
|
217
|
+
)
|
|
218
|
+
false_positives = report["negatives"]["false_positives"]
|
|
219
|
+
if false_positives > max_false_positives:
|
|
220
|
+
violations.append(
|
|
221
|
+
f"negatives: {false_positives} false positives, maximum {max_false_positives}"
|
|
222
|
+
)
|
|
223
|
+
return violations
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def render_markdown(report: dict[str, Any]) -> str:
|
|
227
|
+
env = report["environment"]
|
|
228
|
+
lines = [
|
|
229
|
+
f"# Benchmark (seed {report['seed']}, {'quick' if report['quick'] else 'full'})",
|
|
230
|
+
"",
|
|
231
|
+
f"Python {env['python']}, numpy {env['numpy']}, OpenCV {env['opencv']}, {env['platform']}",
|
|
232
|
+
"",
|
|
233
|
+
"| condition | group | images | detected | correct | mean ms |",
|
|
234
|
+
"|---|---|---|---|---|---|",
|
|
235
|
+
]
|
|
236
|
+
for name, row in report["conditions"].items():
|
|
237
|
+
lines.append(
|
|
238
|
+
f"| {name} | {row['group']} | {row['images']} | {row['detected_rate']:.1%} | "
|
|
239
|
+
f"{row['correct_rate']:.1%} | {row['mean_ms']:.1f} |"
|
|
240
|
+
)
|
|
241
|
+
negatives = report["negatives"]
|
|
242
|
+
lines += [
|
|
243
|
+
"",
|
|
244
|
+
f"Negatives: {negatives['images']} images, {negatives['false_positives']} false positives "
|
|
245
|
+
f"({negatives['false_positive_rate']:.1%}).",
|
|
246
|
+
"",
|
|
247
|
+
]
|
|
248
|
+
return "\n".join(lines)
|
pharmacode/cli.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""Command line interface: ``pharmacode generate | decode | benchmark``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from pharmacode import __version__
|
|
14
|
+
from pharmacode.encoding import encode
|
|
15
|
+
from pharmacode.io import InputError, load_image, save_image
|
|
16
|
+
from pharmacode.models import DecoderConfig, DecodeResult, ErrorCode
|
|
17
|
+
from pharmacode.pipeline import decode_image
|
|
18
|
+
from pharmacode.rendering import Distortion, RenderSpec, distort, render_bars
|
|
19
|
+
from pharmacode.visualization import annotate
|
|
20
|
+
|
|
21
|
+
EXIT_OK = 0
|
|
22
|
+
EXIT_USAGE = 2
|
|
23
|
+
EXIT_INPUT = 3
|
|
24
|
+
EXIT_NO_CANDIDATES = 4
|
|
25
|
+
EXIT_VALIDATION_FAILED = 5
|
|
26
|
+
EXIT_PARTIAL = 6
|
|
27
|
+
EXIT_BENCHMARK_FAILED = 7
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _fail(message: str, code: int) -> int:
|
|
31
|
+
print(f"error: {message}", file=sys.stderr)
|
|
32
|
+
return code
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="pharmacode",
|
|
38
|
+
description="Generate, detect and decode one-track Pharmacode barcodes in images.",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
41
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
42
|
+
|
|
43
|
+
generate = commands.add_parser("generate", help="render a synthetic Pharmacode image")
|
|
44
|
+
generate.add_argument("--value", type=int, required=True, help="integer 3..131070")
|
|
45
|
+
generate.add_argument("--output", required=True, help="PNG, JPEG or TIFF path")
|
|
46
|
+
generate.add_argument("--dpi", type=float, default=300.0)
|
|
47
|
+
generate.add_argument("--miniature", action="store_true", help="Laetus miniature dimensions")
|
|
48
|
+
generate.add_argument("--rotation", type=float, default=0.0, help="degrees counter-clockwise")
|
|
49
|
+
generate.add_argument("--scale-x", type=float, default=1.0)
|
|
50
|
+
generate.add_argument("--scale-y", type=float, default=1.0)
|
|
51
|
+
generate.add_argument("--perspective", type=float, default=0.0, help="tilt in degrees")
|
|
52
|
+
generate.add_argument("--blur", type=float, default=0.0, help="Gaussian sigma in pixels")
|
|
53
|
+
generate.add_argument("--noise", type=float, default=0.0, help="Gaussian noise sigma")
|
|
54
|
+
generate.add_argument("--contrast", type=float, default=1.0, help="1.0 = full contrast")
|
|
55
|
+
generate.add_argument("--illumination", type=float, default=0.0, help="0..1 brightness loss")
|
|
56
|
+
generate.add_argument("--jpeg", type=int, default=None, help="JPEG quality round trip")
|
|
57
|
+
generate.add_argument("--seed", type=int, default=0)
|
|
58
|
+
generate.set_defaults(handler=run_generate)
|
|
59
|
+
|
|
60
|
+
decode = commands.add_parser("decode", help="find and decode Pharmacode in an image")
|
|
61
|
+
decode.add_argument("input", help="PNG, JPEG or TIFF file")
|
|
62
|
+
decode.add_argument("--dpi", type=float, default=None, help="resolution for physical checks")
|
|
63
|
+
decode.add_argument("--json", default=None, help="write the result here instead of stdout")
|
|
64
|
+
decode.add_argument("--annotated", default=None, help="write an annotated image here")
|
|
65
|
+
decode.add_argument("--min-bars", type=int, default=2)
|
|
66
|
+
decode.add_argument("--max-bars", type=int, default=16)
|
|
67
|
+
decode.add_argument(
|
|
68
|
+
"--allow-cropped-quiet-zone",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="treat a quiet zone cut by the image edge as a warning instead of "
|
|
71
|
+
"QUIET_ZONE_VIOLATION",
|
|
72
|
+
)
|
|
73
|
+
decode.add_argument(
|
|
74
|
+
"--min-confidence",
|
|
75
|
+
type=float,
|
|
76
|
+
default=0.0,
|
|
77
|
+
help="reject detections below this confidence as LOW_CONFIDENCE errors (0.0..1.0)",
|
|
78
|
+
)
|
|
79
|
+
decode.set_defaults(handler=run_decode)
|
|
80
|
+
|
|
81
|
+
benchmark = commands.add_parser("benchmark", help="run the synthetic benchmark matrix")
|
|
82
|
+
benchmark.add_argument("--seed", type=int, default=20260919)
|
|
83
|
+
benchmark.add_argument("--output", default="benchmark-output")
|
|
84
|
+
benchmark.add_argument("--quick", action="store_true", help="small subset for CI")
|
|
85
|
+
benchmark.add_argument(
|
|
86
|
+
"--min-correct",
|
|
87
|
+
type=float,
|
|
88
|
+
default=1.0,
|
|
89
|
+
help="fail unless every condition reaches this correct rate",
|
|
90
|
+
)
|
|
91
|
+
benchmark.add_argument("--max-false-positives", type=int, default=0)
|
|
92
|
+
benchmark.set_defaults(handler=run_benchmark_command)
|
|
93
|
+
return parser
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def run_generate(args: argparse.Namespace) -> int:
|
|
97
|
+
try:
|
|
98
|
+
bars = encode(args.value)
|
|
99
|
+
except (TypeError, ValueError) as exc:
|
|
100
|
+
return _fail(str(exc), EXIT_USAGE)
|
|
101
|
+
if args.dpi <= 0:
|
|
102
|
+
return _fail("--dpi must be positive", EXIT_USAGE)
|
|
103
|
+
spec = RenderSpec.miniature(dpi=args.dpi) if args.miniature else RenderSpec(dpi=args.dpi)
|
|
104
|
+
image = render_bars(bars, spec)
|
|
105
|
+
try:
|
|
106
|
+
distortion = Distortion(
|
|
107
|
+
rotation_deg=args.rotation,
|
|
108
|
+
scale_x=args.scale_x,
|
|
109
|
+
scale_y=args.scale_y,
|
|
110
|
+
perspective_deg=args.perspective,
|
|
111
|
+
blur_sigma=args.blur,
|
|
112
|
+
noise_sigma=args.noise,
|
|
113
|
+
contrast=args.contrast,
|
|
114
|
+
illumination_gradient=args.illumination,
|
|
115
|
+
jpeg_quality=args.jpeg,
|
|
116
|
+
)
|
|
117
|
+
except ValueError as exc:
|
|
118
|
+
return _fail(str(exc), EXIT_USAGE)
|
|
119
|
+
if distortion != Distortion():
|
|
120
|
+
image = distort(image, distortion, np.random.default_rng(args.seed))
|
|
121
|
+
try:
|
|
122
|
+
save_image(args.output, image)
|
|
123
|
+
except InputError as exc:
|
|
124
|
+
return _fail(str(exc), EXIT_INPUT)
|
|
125
|
+
print(
|
|
126
|
+
json.dumps(
|
|
127
|
+
{
|
|
128
|
+
"value": args.value,
|
|
129
|
+
"bars": [bar.value for bar in bars],
|
|
130
|
+
"output": str(args.output),
|
|
131
|
+
"width": int(image.shape[1]),
|
|
132
|
+
"height": int(image.shape[0]),
|
|
133
|
+
"dpi": args.dpi,
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
return EXIT_OK
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def exit_code_for(result: DecodeResult) -> int:
|
|
141
|
+
"""Map a result to the documented exit codes."""
|
|
142
|
+
if result.detections and not result.errors:
|
|
143
|
+
return EXIT_OK
|
|
144
|
+
if not result.detections:
|
|
145
|
+
if any(error.code is ErrorCode.NO_CANDIDATES for error in result.errors):
|
|
146
|
+
return EXIT_NO_CANDIDATES
|
|
147
|
+
return EXIT_VALIDATION_FAILED
|
|
148
|
+
return EXIT_PARTIAL
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def run_decode(args: argparse.Namespace) -> int:
|
|
152
|
+
if args.dpi is not None and args.dpi <= 0:
|
|
153
|
+
return _fail("--dpi must be positive", EXIT_USAGE)
|
|
154
|
+
if not 2 <= args.min_bars <= args.max_bars <= 16:
|
|
155
|
+
return _fail("--min-bars and --max-bars must satisfy 2 <= min <= max <= 16", EXIT_USAGE)
|
|
156
|
+
if not 0.0 <= args.min_confidence <= 1.0:
|
|
157
|
+
return _fail("--min-confidence must be between 0.0 and 1.0", EXIT_USAGE)
|
|
158
|
+
try:
|
|
159
|
+
image = load_image(args.input)
|
|
160
|
+
except InputError as exc:
|
|
161
|
+
return _fail(str(exc), EXIT_INPUT)
|
|
162
|
+
config = DecoderConfig(
|
|
163
|
+
dpi=args.dpi,
|
|
164
|
+
min_bars=args.min_bars,
|
|
165
|
+
max_bars=args.max_bars,
|
|
166
|
+
allow_truncated_quiet_zone=args.allow_cropped_quiet_zone,
|
|
167
|
+
min_confidence=args.min_confidence,
|
|
168
|
+
)
|
|
169
|
+
result = decode_image(image, config, path=str(args.input))
|
|
170
|
+
payload = json.dumps(result.to_dict(), indent=2)
|
|
171
|
+
if args.json:
|
|
172
|
+
try:
|
|
173
|
+
Path(args.json).write_text(payload + "\n", encoding="utf-8")
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
return _fail(f"could not write JSON to {args.json}: {exc}", EXIT_INPUT)
|
|
176
|
+
else:
|
|
177
|
+
print(payload)
|
|
178
|
+
if args.annotated:
|
|
179
|
+
try:
|
|
180
|
+
save_image(args.annotated, annotate(image, result))
|
|
181
|
+
except InputError as exc:
|
|
182
|
+
return _fail(str(exc), EXIT_INPUT)
|
|
183
|
+
return exit_code_for(result)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_benchmark_command(args: argparse.Namespace) -> int:
|
|
187
|
+
from pharmacode.benchmark import gate, render_markdown, run_benchmark
|
|
188
|
+
|
|
189
|
+
if not 0.0 <= args.min_correct <= 1.0:
|
|
190
|
+
return _fail("--min-correct must be between 0.0 and 1.0", EXIT_USAGE)
|
|
191
|
+
if args.max_false_positives < 0:
|
|
192
|
+
return _fail("--max-false-positives must be >= 0", EXIT_USAGE)
|
|
193
|
+
report = run_benchmark(seed=args.seed, output_dir=args.output, quick=args.quick)
|
|
194
|
+
print(render_markdown(report))
|
|
195
|
+
violations = gate(report, args.min_correct, args.max_false_positives)
|
|
196
|
+
if violations:
|
|
197
|
+
for violation in violations:
|
|
198
|
+
print(f"gate: {violation}", file=sys.stderr)
|
|
199
|
+
return EXIT_BENCHMARK_FAILED
|
|
200
|
+
print(
|
|
201
|
+
f"gate: passed (min correct {args.min_correct:.0%}, "
|
|
202
|
+
f"max false positives {args.max_false_positives})"
|
|
203
|
+
)
|
|
204
|
+
return EXIT_OK
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
208
|
+
parser = build_parser()
|
|
209
|
+
args = parser.parse_args(argv)
|
|
210
|
+
return int(args.handler(args))
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
if __name__ == "__main__":
|
|
214
|
+
sys.exit(main())
|
pharmacode/decoding.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Turn a classified bar sequence into both reading directions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from pharmacode.encoding import bars_to_value
|
|
8
|
+
from pharmacode.models import BarKind, DecodeError, DecoderConfig, ErrorCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def decode_bars(kinds: Sequence[BarKind]) -> tuple[int, int]:
|
|
12
|
+
"""Return ``(value, mirror_value)``.
|
|
13
|
+
|
|
14
|
+
``value`` reads the sequence as given (most significant bar first);
|
|
15
|
+
``mirror_value`` reads it reversed. Both are computed independently and no
|
|
16
|
+
heuristic prefers one over the other: the format itself does not encode
|
|
17
|
+
the reading direction.
|
|
18
|
+
"""
|
|
19
|
+
sequence = tuple(kinds)
|
|
20
|
+
return bars_to_value(sequence), bars_to_value(tuple(reversed(sequence)))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def check_bar_count(count: int, config: DecoderConfig) -> DecodeError | None:
|
|
24
|
+
"""Return a ``DecodeError`` if ``count`` is outside the configured range."""
|
|
25
|
+
if count < config.min_bars:
|
|
26
|
+
return DecodeError(
|
|
27
|
+
ErrorCode.TOO_FEW_BARS, f"found {count} bars, minimum is {config.min_bars}"
|
|
28
|
+
)
|
|
29
|
+
if count > config.max_bars:
|
|
30
|
+
return DecodeError(
|
|
31
|
+
ErrorCode.TOO_MANY_BARS, f"found {count} bars, maximum is {config.max_bars}"
|
|
32
|
+
)
|
|
33
|
+
return None
|