cardstream 0.2.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.
- cardstream/__init__.py +13 -0
- cardstream/client/__init__.py +0 -0
- cardstream/client/_tflite.py +41 -0
- cardstream/client/analyzer.py +406 -0
- cardstream/client/banner.py +181 -0
- cardstream/client/common.py +622 -0
- cardstream/client/embedders.py +208 -0
- cardstream/client/identify_target.py +37 -0
- cardstream/client/sources.py +299 -0
- cardstream/client/stream_client.py +169 -0
- cardstream/client/web_camera.py +132 -0
- cardstream/client/web_client.py +285 -0
- cardstream/client/web_common.py +81 -0
- cardstream/client/web_settings.py +243 -0
- cardstream/client/web_stream.py +128 -0
- cardstream/client/ximilar_api.py +45 -0
- cardstream/core/__init__.py +0 -0
- cardstream/core/_onnx.py +25 -0
- cardstream/core/detection_filters.py +94 -0
- cardstream/core/detectors.py +499 -0
- cardstream/core/engine.py +377 -0
- cardstream/core/id_types.py +210 -0
- cardstream/core/identify_client.py +118 -0
- cardstream/core/identify_options.py +118 -0
- cardstream/core/image_store.py +116 -0
- cardstream/core/imaging.py +162 -0
- cardstream/core/models.py +152 -0
- cardstream/core/motion.py +80 -0
- cardstream/core/quad.py +197 -0
- cardstream/core/tracking.py +74 -0
- cardstream/core/ximilar.py +178 -0
- cardstream/log.py +32 -0
- cardstream/py.typed +0 -0
- cardstream/webui/shared/capture.js +112 -0
- cardstream/webui/shared/constants.js +8 -0
- cardstream/webui/shared/logo.svg +6 -0
- cardstream/webui/shared/overlay.js +358 -0
- cardstream/webui/shared/session.js +74 -0
- cardstream/webui/shared/style.css +206 -0
- cardstream/webui/shared/ws.js +69 -0
- cardstream/webui/smart/app.js +183 -0
- cardstream/webui/smart/index.html +114 -0
- cardstream/webui/smart/settings-fields.js +120 -0
- cardstream/webui/smart/settings.js +254 -0
- cardstream/webui/smart/smart.css +178 -0
- cardstream-0.2.0.dist-info/METADATA +258 -0
- cardstream-0.2.0.dist-info/RECORD +52 -0
- cardstream-0.2.0.dist-info/WHEEL +5 -0
- cardstream-0.2.0.dist-info/entry_points.txt +3 -0
- cardstream-0.2.0.dist-info/licenses/LICENSE +201 -0
- cardstream-0.2.0.dist-info/licenses/NOTICE +46 -0
- cardstream-0.2.0.dist-info/top_level.txt +1 -0
cardstream/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""cardstream — real-time trading-card identification.
|
|
2
|
+
|
|
3
|
+
The version is authored once, in pyproject.toml; everything else reads it
|
|
4
|
+
from the installed package metadata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib.metadata import PackageNotFoundError
|
|
8
|
+
from importlib.metadata import version as _pkg_version
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
__version__ = _pkg_version("cardstream")
|
|
12
|
+
except PackageNotFoundError: # bare source tree on PYTHONPATH, no install
|
|
13
|
+
__version__ = "0.0.0+uninstalled"
|
|
File without changes
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Shared TFLite/LiteRT interpreter bootstrap (embedders, future detectors)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def load_tflite_interpreter(model_path: str):
|
|
7
|
+
"""Open a ``.tflite`` model; return ``(interpreter, input_detail, output_detail)``.
|
|
8
|
+
|
|
9
|
+
Tries the runtimes lightest-first: ``ai-edge-litert`` (the current LiteRT
|
|
10
|
+
package), the legacy ``tflite-runtime``, then full ``tensorflow``.
|
|
11
|
+
"""
|
|
12
|
+
interpreter_cls = None
|
|
13
|
+
for importer in (
|
|
14
|
+
lambda: (
|
|
15
|
+
__import__(
|
|
16
|
+
"ai_edge_litert.interpreter", fromlist=["Interpreter"]
|
|
17
|
+
).Interpreter
|
|
18
|
+
),
|
|
19
|
+
lambda: (
|
|
20
|
+
__import__(
|
|
21
|
+
"tflite_runtime.interpreter", fromlist=["Interpreter"]
|
|
22
|
+
).Interpreter
|
|
23
|
+
),
|
|
24
|
+
lambda: __import__("tensorflow").lite.Interpreter,
|
|
25
|
+
):
|
|
26
|
+
try:
|
|
27
|
+
interpreter_cls = importer()
|
|
28
|
+
break
|
|
29
|
+
except ImportError:
|
|
30
|
+
continue
|
|
31
|
+
if interpreter_cls is None:
|
|
32
|
+
raise RuntimeError(
|
|
33
|
+
"no TFLite runtime is installed — pip install 'cardstream[tflite]'"
|
|
34
|
+
)
|
|
35
|
+
interpreter = interpreter_cls(model_path=model_path)
|
|
36
|
+
interpreter.allocate_tensors()
|
|
37
|
+
return (
|
|
38
|
+
interpreter,
|
|
39
|
+
interpreter.get_input_details()[0],
|
|
40
|
+
interpreter.get_output_details()[0],
|
|
41
|
+
)
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""SmartAnalyzer — the client-side driver over the shared DecisionCore.
|
|
2
|
+
|
|
3
|
+
Motion gate, card detection and the same-card identity gate all run locally
|
|
4
|
+
(free); the only network call is one ``/identify`` per distinct card. The
|
|
5
|
+
state machine (three-tier detect throttle, identity-gate policy, cooldown /
|
|
6
|
+
watchdog / no-retry) lives in
|
|
7
|
+
:mod:`cardstream.core.engine`; this class owns the client's scheduling:
|
|
8
|
+
detection is inline-synchronous, the identify request runs in a small
|
|
9
|
+
background thread so the capture loop never blocks.
|
|
10
|
+
|
|
11
|
+
Card removed: the gate signature + identification are kept, so the SAME card
|
|
12
|
+
returning is shown instantly without another paid call.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import dataclasses
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
|
|
25
|
+
from cardstream.client.embedders import Embedder, EmbeddingGate
|
|
26
|
+
from cardstream.client.identify_target import IdentifyTarget
|
|
27
|
+
from cardstream.core.detection_filters import make_detection_filters
|
|
28
|
+
from cardstream.core.detectors import CardDetector
|
|
29
|
+
from cardstream.core.engine import (
|
|
30
|
+
DEFAULT_CALL_TIMEOUT_SECONDS,
|
|
31
|
+
DEFAULT_PHASH_DISTANCE,
|
|
32
|
+
DecisionCore,
|
|
33
|
+
DetectIntervals,
|
|
34
|
+
PhashGate,
|
|
35
|
+
)
|
|
36
|
+
from cardstream.core.image_store import ImageStore
|
|
37
|
+
from cardstream.core.imaging import FramePair
|
|
38
|
+
from cardstream.core.models import AnalysisResult, BoundingBox, DetectionResult
|
|
39
|
+
from cardstream.core.motion import MotionGate
|
|
40
|
+
from cardstream.core.quad import expand_quad, paid_quad
|
|
41
|
+
from cardstream.core.tracking import ObjectTracker, make_tracker
|
|
42
|
+
|
|
43
|
+
# The analysis downscale, named so the web layer's own signatures can default
|
|
44
|
+
# to it instead of repeating the number in three more places.
|
|
45
|
+
DEFAULT_ANALYSIS_WIDTH = 960
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class AnalyzerConfig:
|
|
50
|
+
"""The tuning knobs, and the single source of their defaults — argparse in
|
|
51
|
+
client/common.py reads every one of them off this dataclass."""
|
|
52
|
+
|
|
53
|
+
gate: str = "embedding" # "embedding" | "phash"
|
|
54
|
+
similarity_threshold: float = 0.85 # cosine sim below this = new card
|
|
55
|
+
phash_threshold: int = DEFAULT_PHASH_DISTANCE
|
|
56
|
+
result_threshold: float = (
|
|
57
|
+
0.9 # drop matches with distance above this (1.0 = keep all)
|
|
58
|
+
)
|
|
59
|
+
analysis_width: int = (
|
|
60
|
+
DEFAULT_ANALYSIS_WIDTH # local analysis downscale; 0 = full res
|
|
61
|
+
)
|
|
62
|
+
# A detection narrower than this FRACTION of the analysed frame — in either
|
|
63
|
+
# dimension — is ignored, as if nothing had been detected. 0 = accept any
|
|
64
|
+
# box. A fraction rather than pixels so it means the same thing whatever
|
|
65
|
+
# --width and the camera resolution are.
|
|
66
|
+
min_card_fraction: float = 0.1
|
|
67
|
+
# Shortest side over longest, so it reads the same whether the card is held
|
|
68
|
+
# portrait or landscape. A card is ~0.71; a fragment (a corner clipped by
|
|
69
|
+
# the frame edge, a sleeve lip) is well under. 0 = accept any shape.
|
|
70
|
+
min_card_aspect: float = 0.4
|
|
71
|
+
# Grow the located card by this fraction on every side before cutting the
|
|
72
|
+
# crop that is PAID FOR — nothing else sees it (not the gate, not the
|
|
73
|
+
# overlay, not the filters). 0 = send exactly what was located.
|
|
74
|
+
detection_expansion: float = 0.0
|
|
75
|
+
cooldown_seconds: float = 2.0
|
|
76
|
+
forget_after_seconds: float = 2.0 # card away this long = analyse fresh (0 = never)
|
|
77
|
+
# A card whose identify came back with nothing is asked about again after
|
|
78
|
+
# this long, rather than being left alone until it changes. Short on
|
|
79
|
+
# purpose: an unnamed card in frame is a card the show still needs a name
|
|
80
|
+
# for, and the miss is usually a bad look (glare, a hand across the art)
|
|
81
|
+
# that the next frame already fixes. 0 = never retry.
|
|
82
|
+
retry_unmatched_seconds: float = 0.5
|
|
83
|
+
motion_threshold: float = 8.0
|
|
84
|
+
still_frames_required: int = 2
|
|
85
|
+
detect_interval_seconds: float = 0.1
|
|
86
|
+
idle_detect_interval_seconds: float = 0.2
|
|
87
|
+
empty_detect_interval_seconds: float = 0.2
|
|
88
|
+
tracker_model: str | None = None # path to vitTracker .onnx; set = enable tracking
|
|
89
|
+
tracker_score_threshold: float = 0.3 # below this the track counts as lost
|
|
90
|
+
tracking_detect_interval_seconds: float = 2.0 # re-sync detect while tracking
|
|
91
|
+
# No flag on purpose: the watchdog only force-clears a call that hung past
|
|
92
|
+
# this, which is a fault path, not a tuning knob. Settable here so a test
|
|
93
|
+
# (or an embedder of this package) can shorten it.
|
|
94
|
+
call_timeout_seconds: float = DEFAULT_CALL_TIMEOUT_SECONDS
|
|
95
|
+
debug: bool = False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# AnalyzerConfig fields the settings dialog may change on a RUNNING analyzer.
|
|
99
|
+
# Everything else is read once at construction, so retuning it would be a lie.
|
|
100
|
+
LIVE_FIELDS = frozenset({"result_threshold"})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class SmartAnalyzer:
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
detector: CardDetector,
|
|
107
|
+
embedder: Embedder | None,
|
|
108
|
+
identify_client: IdentifyTarget,
|
|
109
|
+
config: AnalyzerConfig | None = None,
|
|
110
|
+
on_result: Callable[[dict], None] | None = None,
|
|
111
|
+
on_log: Callable[[str], None] | None = None,
|
|
112
|
+
run_async: bool = True,
|
|
113
|
+
tracker: ObjectTracker | None = None,
|
|
114
|
+
store: ImageStore | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
cfg = config or AnalyzerConfig()
|
|
117
|
+
if cfg.gate == "embedding" and embedder is None:
|
|
118
|
+
raise ValueError("gate='embedding' requires an embedder")
|
|
119
|
+
self._detector = detector
|
|
120
|
+
# Trackers are stateful per card, so each analyzer owns its own
|
|
121
|
+
# instance (web_client builds one analyzer per connection). The
|
|
122
|
+
# parameter is an override for tests.
|
|
123
|
+
self._tracker = (
|
|
124
|
+
tracker
|
|
125
|
+
if tracker is not None
|
|
126
|
+
else make_tracker(cfg.tracker_model, cfg.tracker_score_threshold)
|
|
127
|
+
)
|
|
128
|
+
self._identify_client = identify_client
|
|
129
|
+
# --store-images in `frame` mode: the identify path only ever sees the
|
|
130
|
+
# crop, so the whole picture has to be kept from here. In `object` mode
|
|
131
|
+
# save_frame is a no-op and the identify client does the keeping.
|
|
132
|
+
self._store = store
|
|
133
|
+
self._cfg = cfg
|
|
134
|
+
# Paid identify calls this analyzer has fired — the point of the whole
|
|
135
|
+
# state machine, so the UI shows it. Counted on fire, not on success:
|
|
136
|
+
# a call that returns no match was still spent.
|
|
137
|
+
self.identify_calls = 0
|
|
138
|
+
# (w, h) of the frame the last detection ran on — the space bboxes are
|
|
139
|
+
# in, so the browser needs it to draw them. Dimensions only: holding
|
|
140
|
+
# the frame itself would pin a full-resolution copy per connection.
|
|
141
|
+
self.analysis_size: tuple[int, int] | None = None
|
|
142
|
+
self._on_result = on_result
|
|
143
|
+
# Log sink: the CLI leaves it None (plain stdout); the browser UI
|
|
144
|
+
# injects a callback that also pushes lines to the page.
|
|
145
|
+
self._on_log = on_log
|
|
146
|
+
# run_async=False runs /identify inline — for tests and determinism.
|
|
147
|
+
self._run_async = run_async
|
|
148
|
+
|
|
149
|
+
# Built once, like the gate's thresholds: baked into the objects, not
|
|
150
|
+
# re-read per frame (and LIVE_FIELDS says as much).
|
|
151
|
+
self._filters = make_detection_filters(
|
|
152
|
+
cfg.min_card_fraction, cfg.min_card_aspect
|
|
153
|
+
)
|
|
154
|
+
self._motion = MotionGate(cfg.motion_threshold, cfg.still_frames_required)
|
|
155
|
+
self._prev_settled: bool | None = None # for motion-transition debug logs
|
|
156
|
+
# The detection whose crop the gate is currently deciding on — lets the
|
|
157
|
+
# gate debug line carry the detector's confidence too.
|
|
158
|
+
self._gate_det = None
|
|
159
|
+
on_debug = self._gate_debug if cfg.debug else None
|
|
160
|
+
gate = (
|
|
161
|
+
EmbeddingGate(embedder, cfg.similarity_threshold, on_debug=on_debug)
|
|
162
|
+
if cfg.gate == "embedding"
|
|
163
|
+
else PhashGate(cfg.phash_threshold, on_debug=on_debug)
|
|
164
|
+
)
|
|
165
|
+
self._core = DecisionCore(
|
|
166
|
+
intervals=DetectIntervals(
|
|
167
|
+
moving=cfg.detect_interval_seconds,
|
|
168
|
+
idle_with_card=cfg.idle_detect_interval_seconds,
|
|
169
|
+
empty=cfg.empty_detect_interval_seconds,
|
|
170
|
+
tracking=cfg.tracking_detect_interval_seconds,
|
|
171
|
+
),
|
|
172
|
+
motion_threshold=cfg.motion_threshold,
|
|
173
|
+
gate=gate,
|
|
174
|
+
cooldown_seconds=cfg.cooldown_seconds,
|
|
175
|
+
call_timeout_seconds=cfg.call_timeout_seconds,
|
|
176
|
+
log=self._log,
|
|
177
|
+
use_tracker=self._tracker is not None,
|
|
178
|
+
forget_after_seconds=cfg.forget_after_seconds,
|
|
179
|
+
retry_unmatched_seconds=cfg.retry_unmatched_seconds,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def _log(self, msg: str) -> None:
|
|
183
|
+
if self._on_log is not None:
|
|
184
|
+
self._on_log(msg)
|
|
185
|
+
else:
|
|
186
|
+
print(msg)
|
|
187
|
+
|
|
188
|
+
def _gate_debug(self, msg: str) -> None:
|
|
189
|
+
"""Gate debug lines, augmented with the detector's confidence."""
|
|
190
|
+
det = self._gate_det
|
|
191
|
+
if det is not None and det.prob is not None:
|
|
192
|
+
msg = f"{msg} det_prob={det.prob:.2f}"
|
|
193
|
+
self._log(msg)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def result_threshold(self) -> float:
|
|
197
|
+
"""Read-only — retune via :meth:`tune` so the config stays the truth."""
|
|
198
|
+
return self._cfg.result_threshold
|
|
199
|
+
|
|
200
|
+
def tune(self, **fields: object) -> None:
|
|
201
|
+
"""Swap live-tunable config values on a running analyzer.
|
|
202
|
+
|
|
203
|
+
The config stays frozen — it IS the startup default — so this replaces
|
|
204
|
+
it wholesale rather than shadowing individual fields with instance
|
|
205
|
+
attributes. LIVE_FIELDS is also the honest list of what CANNOT be
|
|
206
|
+
retuned: gate thresholds are baked into the gate object and detect
|
|
207
|
+
intervals into DecisionCore, so changing them here would silently do
|
|
208
|
+
nothing.
|
|
209
|
+
"""
|
|
210
|
+
if unknown := set(fields) - LIVE_FIELDS:
|
|
211
|
+
raise ValueError(f"not live-tunable: {', '.join(sorted(unknown))}")
|
|
212
|
+
self._cfg = dataclasses.replace(self._cfg, **fields)
|
|
213
|
+
|
|
214
|
+
def process(self, frame_bgr: np.ndarray) -> AnalysisResult:
|
|
215
|
+
# Everything local runs on the downscaled frame; only the crop handed to
|
|
216
|
+
# the paid id endpoint is cut from the original (pair.crop below).
|
|
217
|
+
pair = FramePair.from_frame(frame_bgr, self._cfg.analysis_width)
|
|
218
|
+
frame_bgr = pair.analysis
|
|
219
|
+
if self._cfg.debug and pair.size != self.analysis_size:
|
|
220
|
+
fw, fh = pair.full_size
|
|
221
|
+
aw, ah = pair.size
|
|
222
|
+
self._log(
|
|
223
|
+
f"[frame] {fw}x{fh} -> analysis {aw}x{ah} (scale {1 / pair.scale:.2f})"
|
|
224
|
+
)
|
|
225
|
+
self.analysis_size = pair.size
|
|
226
|
+
|
|
227
|
+
settled, score = self._motion.update(frame_bgr)
|
|
228
|
+
now = time.monotonic()
|
|
229
|
+
|
|
230
|
+
# Log motion-gate TRANSITIONS only — per-frame lines would flood at 30 fps.
|
|
231
|
+
if self._cfg.debug and settled != self._prev_settled:
|
|
232
|
+
state = "settled" if settled else "moving"
|
|
233
|
+
self._log(
|
|
234
|
+
f"[motion] {state} (score={score:.1f}, threshold "
|
|
235
|
+
f"{self._cfg.motion_threshold})"
|
|
236
|
+
)
|
|
237
|
+
self._prev_settled = settled
|
|
238
|
+
|
|
239
|
+
if self._tracker is not None and self._core.tracking:
|
|
240
|
+
ok, bbox = self._tracker.update(frame_bgr)
|
|
241
|
+
self._core.on_track(ok, bbox)
|
|
242
|
+
if self._cfg.debug and not ok:
|
|
243
|
+
self._log("[track] lost — forcing re-detect")
|
|
244
|
+
|
|
245
|
+
if self._core.on_frame(settled, score, now):
|
|
246
|
+
self._detect_tick(pair, settled, now)
|
|
247
|
+
|
|
248
|
+
return self._snapshot()
|
|
249
|
+
|
|
250
|
+
def _detect_tick(self, pair: FramePair, settled: bool, now: float) -> None:
|
|
251
|
+
"""One detection: run it, judge it, and pay for it if the core says so."""
|
|
252
|
+
was_present = self._core.present
|
|
253
|
+
try:
|
|
254
|
+
det = self._detector.detect(pair.analysis)
|
|
255
|
+
except BaseException:
|
|
256
|
+
self._core.abort_detection() # a raising detector must not jam the throttle
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
if det is not None and (reason := self._rejected(det.bbox, pair.size)):
|
|
260
|
+
# Dropped to None rather than flagged: a sliver of card is not a
|
|
261
|
+
# card, and the rest of the pipeline already knows what "nothing
|
|
262
|
+
# detected" means.
|
|
263
|
+
det = None
|
|
264
|
+
if self._cfg.debug:
|
|
265
|
+
self._log(reason)
|
|
266
|
+
|
|
267
|
+
self._gate_det = det
|
|
268
|
+
fire = self._core.on_detection(det, settled, now)
|
|
269
|
+
if det is not None:
|
|
270
|
+
self._init_tracker(pair.analysis, det)
|
|
271
|
+
if self._cfg.debug:
|
|
272
|
+
if det is None and was_present:
|
|
273
|
+
self._log("[detect] card lost")
|
|
274
|
+
elif det is not None and not was_present:
|
|
275
|
+
conf = f" det_prob={det.prob:.2f}" if det.prob is not None else ""
|
|
276
|
+
self._log(f"[detect] card found bbox={det.bbox.as_list()}{conf}")
|
|
277
|
+
if fire:
|
|
278
|
+
self._identify_detection(pair, det)
|
|
279
|
+
|
|
280
|
+
def _rejected(self, bbox: BoundingBox, frame_size: tuple[int, int]) -> str | None:
|
|
281
|
+
"""The first rule that says this box is not a card, or None."""
|
|
282
|
+
for rule in self._filters:
|
|
283
|
+
if reason := rule.reject(bbox, frame_size):
|
|
284
|
+
return reason
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
def _identify_detection(self, pair: FramePair, det: DetectionResult) -> None:
|
|
288
|
+
"""Send the crop for this detection — the one step that costs money."""
|
|
289
|
+
# Cut from the ORIGINAL frame: the gate already had its (cheap,
|
|
290
|
+
# analysis-space) look via det.crop, and both pair.crop and pair.warp
|
|
291
|
+
# return an owned array, so the identify thread can outlive this frame.
|
|
292
|
+
# A locator that found the card's CORNERS (a segmentor) gets a deskewed
|
|
293
|
+
# crop, tight at the card edge; a box locator gets the square cut.
|
|
294
|
+
crop = self._identify_crop(pair, det)
|
|
295
|
+
if self._store is not None:
|
|
296
|
+
# Inline, not on the identify thread: pair.full belongs to the
|
|
297
|
+
# caller and may be reused for the next frame, while pair.crop()
|
|
298
|
+
# hands the thread an owned copy.
|
|
299
|
+
self._store.save_frame(pair.full)
|
|
300
|
+
if crop is None:
|
|
301
|
+
self._log("[identify] degenerate crop — skipping")
|
|
302
|
+
self._core.on_identify_done(None)
|
|
303
|
+
return
|
|
304
|
+
if self._cfg.debug:
|
|
305
|
+
self._log(self._identify_log(crop, deskewed=det.quad is not None))
|
|
306
|
+
if self._run_async:
|
|
307
|
+
threading.Thread(target=self._identify, args=(crop,), daemon=True).start()
|
|
308
|
+
else:
|
|
309
|
+
self._identify(crop)
|
|
310
|
+
|
|
311
|
+
def _identify_crop(
|
|
312
|
+
self, pair: FramePair, det: DetectionResult
|
|
313
|
+
) -> np.ndarray | None:
|
|
314
|
+
"""The pixels that go on the wire, cut from the ORIGINAL frame.
|
|
315
|
+
|
|
316
|
+
--detection-expansion is applied HERE and nowhere else, so it changes
|
|
317
|
+
what is IDENTIFIED without disturbing what was LOCATED: the identity
|
|
318
|
+
gate keeps comparing the tight crop (a padded one would dilute the
|
|
319
|
+
SAME-vs-NEW decision with background) and the overlay keeps drawing the
|
|
320
|
+
box the model actually returned.
|
|
321
|
+
|
|
322
|
+
Deskew or square cut is the only branch — the growth itself follows
|
|
323
|
+
``core.quad.paid_quad``, which is what _crop_outline draws.
|
|
324
|
+
"""
|
|
325
|
+
grow = self._cfg.detection_expansion
|
|
326
|
+
if det.quad is not None:
|
|
327
|
+
return pair.warp(expand_quad(det.quad, grow))
|
|
328
|
+
return pair.crop(det.bbox.expanded(grow))
|
|
329
|
+
|
|
330
|
+
def _identify_log(self, crop_bgr: np.ndarray, deskewed: bool = False) -> str:
|
|
331
|
+
"""The debug line that proves which resolution and shape was actually sent."""
|
|
332
|
+
h, w = crop_bgr.shape[:2]
|
|
333
|
+
how = " deskewed" if deskewed else ""
|
|
334
|
+
if self._cfg.detection_expansion:
|
|
335
|
+
how += f" +{self._cfg.detection_expansion:.0%}"
|
|
336
|
+
return f"[identify] new card — calling identify crop={w}x{h}{how}"
|
|
337
|
+
|
|
338
|
+
def _init_tracker(self, frame_bgr: np.ndarray, det: DetectionResult) -> None:
|
|
339
|
+
"""Re-seed the tracker from a fresh detection (detector = ground truth)."""
|
|
340
|
+
if self._tracker is None:
|
|
341
|
+
return
|
|
342
|
+
try:
|
|
343
|
+
self._tracker.init(frame_bgr, det.bbox)
|
|
344
|
+
if self._cfg.debug:
|
|
345
|
+
self._log(f"[track] init bbox={det.bbox.as_list()}")
|
|
346
|
+
except Exception as exc:
|
|
347
|
+
# A broken tracker must not kill the capture loop — fall back to
|
|
348
|
+
# plain motion-tiered detection for this card.
|
|
349
|
+
self._core.on_tracker_failed()
|
|
350
|
+
self._log(f"[track] init failed ({type(exc).__name__}: {exc})")
|
|
351
|
+
|
|
352
|
+
def _identify(self, crop_bgr: np.ndarray) -> None:
|
|
353
|
+
ident = None
|
|
354
|
+
self.identify_calls += 1
|
|
355
|
+
started = time.monotonic()
|
|
356
|
+
try:
|
|
357
|
+
ident = self._identify_client.identify(crop_bgr)
|
|
358
|
+
if ident is not None:
|
|
359
|
+
dist = ident.get("distance")
|
|
360
|
+
dist = float(dist) if isinstance(dist, (int, float)) else 1.0
|
|
361
|
+
if dist > self._cfg.result_threshold:
|
|
362
|
+
self._log(
|
|
363
|
+
f"[identify] dist {dist:.3f} > result threshold "
|
|
364
|
+
f"{self._cfg.result_threshold} — dropping match"
|
|
365
|
+
)
|
|
366
|
+
ident = None
|
|
367
|
+
except Exception as exc:
|
|
368
|
+
# Surface client failures — a raising client must not kill the
|
|
369
|
+
# daemon thread silently and leave the state reverting unexplained.
|
|
370
|
+
self._log(f"[identify] {type(exc).__name__}: {exc}")
|
|
371
|
+
finally:
|
|
372
|
+
self._core.on_identify_done(ident)
|
|
373
|
+
if ident is None:
|
|
374
|
+
# The gate signature was committed on fire: don't hammer a card the
|
|
375
|
+
# upstream can't identify — it retries when the card changes.
|
|
376
|
+
self._log("[identify] no match / failed; not retrying this card")
|
|
377
|
+
return
|
|
378
|
+
# Wall time of the whole identify call (network included) — the UI
|
|
379
|
+
# shows it next to the distance.
|
|
380
|
+
ident["elapsed_ms"] = int((time.monotonic() - started) * 1000)
|
|
381
|
+
if self._on_result is not None:
|
|
382
|
+
self._on_result(ident)
|
|
383
|
+
|
|
384
|
+
def _snapshot(self) -> AnalysisResult:
|
|
385
|
+
snap = self._core.snapshot()
|
|
386
|
+
return AnalysisResult(
|
|
387
|
+
state=snap.state,
|
|
388
|
+
bbox=snap.bbox,
|
|
389
|
+
identification=snap.identification,
|
|
390
|
+
quad=snap.quad,
|
|
391
|
+
crop_quad=self._crop_outline(snap.bbox, snap.quad),
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
def _crop_outline(
|
|
395
|
+
self, bbox: BoundingBox | None, quad: np.ndarray | None
|
|
396
|
+
) -> np.ndarray | None:
|
|
397
|
+
"""Where the paid crop WILL be cut, when that differs from what was
|
|
398
|
+
located — i.e. only under --detection-expansion.
|
|
399
|
+
|
|
400
|
+
Always four corners, so the page draws one kind of shape whichever
|
|
401
|
+
locator is running. ``core.quad.paid_quad`` is the shared statement of
|
|
402
|
+
how the expansion applies, so this cannot promise a region
|
|
403
|
+
_identify_crop would not cut.
|
|
404
|
+
"""
|
|
405
|
+
grow = self._cfg.detection_expansion
|
|
406
|
+
return paid_quad(bbox, quad, grow) if grow else None
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""The startup banner: the wordmark, then every knob and what it is set to.
|
|
2
|
+
|
|
3
|
+
Both entrypoints print this before any model loads, so a run's whole
|
|
4
|
+
configuration is on screen — and in the log someone pastes into an issue —
|
|
5
|
+
instead of having to be reconstructed from the command line. Values that came
|
|
6
|
+
from a flag are MARKED and carry the default they replaced: the interesting
|
|
7
|
+
lines are the ones you changed, and a threshold that silently stayed at its
|
|
8
|
+
default (a dropped ``\\`` in a pasted multi-line command, say) is visible
|
|
9
|
+
rather than something you find out about mid-show.
|
|
10
|
+
|
|
11
|
+
Pure formatting. It takes the parser and the parsed namespace and returns a
|
|
12
|
+
string, so it is testable without running either CLI.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from collections.abc import Iterator
|
|
21
|
+
from typing import IO, Any
|
|
22
|
+
|
|
23
|
+
# CARDSTREAM at 59 columns — inside 80 with the two-space indent below.
|
|
24
|
+
WORDMARK = r"""
|
|
25
|
+
███ ███ ████ ████ ████ █████ ████ █████ ███ █ █
|
|
26
|
+
█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ ██ ██
|
|
27
|
+
█ █████ ████ █ █ ███ █ ████ ████ █████ █ █ █
|
|
28
|
+
█ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █ █
|
|
29
|
+
███ █ █ █ █ ████ ████ █ █ █ █████ █ █ █ █
|
|
30
|
+
""".strip("\n")
|
|
31
|
+
|
|
32
|
+
MARK = "●" # flag-supplied value; a default gets a blank of the same width
|
|
33
|
+
|
|
34
|
+
# Never print a credential. The value is confirmed, not shown.
|
|
35
|
+
SECRET_DESTS = frozenset({"api_key"})
|
|
36
|
+
|
|
37
|
+
_BOLD = "\033[1m"
|
|
38
|
+
_DIM = "\033[2m"
|
|
39
|
+
_BLUE = "\033[34m"
|
|
40
|
+
_RESET = "\033[0m"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _wants_color(stream: IO[str]) -> bool:
|
|
44
|
+
"""Colour only for a real terminal that has not opted out."""
|
|
45
|
+
if os.environ.get("NO_COLOR"):
|
|
46
|
+
return False
|
|
47
|
+
if os.environ.get("TERM") == "dumb":
|
|
48
|
+
return False
|
|
49
|
+
try:
|
|
50
|
+
return bool(stream.isatty())
|
|
51
|
+
except Exception: # a stream that doesn't implement isatty
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_value(dest: str, value: Any) -> str:
|
|
56
|
+
"""One knob's value as a human reads it — never the raw repr."""
|
|
57
|
+
if dest in SECRET_DESTS:
|
|
58
|
+
if value:
|
|
59
|
+
return "set on the command line"
|
|
60
|
+
return (
|
|
61
|
+
"from $XIMILAR_API_KEY" if os.environ.get("XIMILAR_API_KEY") else "not set"
|
|
62
|
+
)
|
|
63
|
+
if value is None:
|
|
64
|
+
return "not set"
|
|
65
|
+
if isinstance(value, bool):
|
|
66
|
+
return "on" if value else "off"
|
|
67
|
+
if value == "":
|
|
68
|
+
return "not set"
|
|
69
|
+
return str(value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _groups(
|
|
73
|
+
parser: argparse.ArgumentParser,
|
|
74
|
+
) -> Iterator[tuple[str, list[argparse.Action]]]:
|
|
75
|
+
"""(title, actions) per argparse group, in declaration order.
|
|
76
|
+
|
|
77
|
+
Reads ``_action_groups`` — private, but the only way to recover the
|
|
78
|
+
grouping the parser already has, and the alternative (a second list of
|
|
79
|
+
dests kept in this module) is exactly the duplication that goes stale when
|
|
80
|
+
someone adds a flag. Groups the entrypoints never populate are skipped, so
|
|
81
|
+
a parser that uses no groups at all still renders as one flat list.
|
|
82
|
+
"""
|
|
83
|
+
for group in getattr(parser, "_action_groups", []):
|
|
84
|
+
actions = [
|
|
85
|
+
a
|
|
86
|
+
for a in group._group_actions
|
|
87
|
+
if a.dest not in (argparse.SUPPRESS, "help", "version")
|
|
88
|
+
]
|
|
89
|
+
if actions:
|
|
90
|
+
yield group.title or "options", actions
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def render_banner(
|
|
94
|
+
parser: argparse.ArgumentParser,
|
|
95
|
+
args: argparse.Namespace,
|
|
96
|
+
*,
|
|
97
|
+
version: str,
|
|
98
|
+
subtitle: str = "",
|
|
99
|
+
color: bool = False,
|
|
100
|
+
) -> str:
|
|
101
|
+
"""The full banner for one run, as a string."""
|
|
102
|
+
bold, dim, blue, reset = (_BOLD, _DIM, _BLUE, _RESET) if color else ("", "", "", "")
|
|
103
|
+
values = vars(args)
|
|
104
|
+
|
|
105
|
+
rows: list[tuple[str, list[tuple[bool, str, str, str]]]] = []
|
|
106
|
+
width = 0
|
|
107
|
+
for title, actions in _groups(parser):
|
|
108
|
+
entries = []
|
|
109
|
+
for action in actions:
|
|
110
|
+
if action.dest not in values:
|
|
111
|
+
continue
|
|
112
|
+
# The FIRST spelling, which is the canonical one everywhere here:
|
|
113
|
+
# --store-images-type before its --store_images_type alias,
|
|
114
|
+
# --known-attrs before the --no-known-attrs half of
|
|
115
|
+
# BooleanOptionalAction.
|
|
116
|
+
flag = (
|
|
117
|
+
action.option_strings[0]
|
|
118
|
+
if action.option_strings
|
|
119
|
+
else f"<{action.dest}>"
|
|
120
|
+
)
|
|
121
|
+
value = values[action.dest]
|
|
122
|
+
overridden = value != parser.get_default(action.dest)
|
|
123
|
+
was = (
|
|
124
|
+
f"default: {_format_value(action.dest, parser.get_default(action.dest))}"
|
|
125
|
+
if overridden
|
|
126
|
+
else ""
|
|
127
|
+
)
|
|
128
|
+
entries.append((overridden, flag, _format_value(action.dest, value), was))
|
|
129
|
+
width = max(width, len(flag))
|
|
130
|
+
if entries:
|
|
131
|
+
rows.append((title, entries))
|
|
132
|
+
|
|
133
|
+
out: list[str] = [
|
|
134
|
+
"",
|
|
135
|
+
*(f" {blue}{bold}{line}{reset}" for line in WORDMARK.splitlines()),
|
|
136
|
+
"",
|
|
137
|
+
]
|
|
138
|
+
head = f"cardstream {version}"
|
|
139
|
+
out.append(
|
|
140
|
+
f" {bold}{head}{reset}" + (f" {dim}—{reset} {subtitle}" if subtitle else "")
|
|
141
|
+
)
|
|
142
|
+
changed = sum(1 for _, entries in rows for over, *_ in entries if over)
|
|
143
|
+
out.append(
|
|
144
|
+
f" {dim}{MARK} = set by you; everything else is the built-in default"
|
|
145
|
+
f" ({changed} of {sum(len(e) for _, e in rows)} overridden){reset}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
for title, entries in rows:
|
|
149
|
+
out.append("")
|
|
150
|
+
out.append(f" {bold}{title}{reset}")
|
|
151
|
+
for overridden, flag, value, was in entries:
|
|
152
|
+
mark = f"{blue}{MARK}{reset}" if overridden else " "
|
|
153
|
+
shown = f"{bold}{value}{reset}" if overridden else f"{dim}{value}{reset}"
|
|
154
|
+
line = f" {mark} {flag.ljust(width)} {shown}"
|
|
155
|
+
if was:
|
|
156
|
+
line += f" {dim}({was}){reset}"
|
|
157
|
+
out.append(line)
|
|
158
|
+
out.append("")
|
|
159
|
+
return "\n".join(out)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def print_banner(
|
|
163
|
+
parser: argparse.ArgumentParser,
|
|
164
|
+
args: argparse.Namespace,
|
|
165
|
+
*,
|
|
166
|
+
version: str,
|
|
167
|
+
subtitle: str = "",
|
|
168
|
+
stream: IO[str] | None = None,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Render and write the banner, colouring it only for a terminal."""
|
|
171
|
+
stream = stream if stream is not None else sys.stdout
|
|
172
|
+
print(
|
|
173
|
+
render_banner(
|
|
174
|
+
parser, args, version=version, subtitle=subtitle, color=_wants_color(stream)
|
|
175
|
+
),
|
|
176
|
+
file=stream,
|
|
177
|
+
# Piped stdout is block-buffered: without this the banner appears only
|
|
178
|
+
# once something else fills the buffer, which for `… | tee show.log`
|
|
179
|
+
# means minutes after the run started (or never, if it crashes first).
|
|
180
|
+
flush=True,
|
|
181
|
+
)
|