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.
Files changed (52) hide show
  1. cardstream/__init__.py +13 -0
  2. cardstream/client/__init__.py +0 -0
  3. cardstream/client/_tflite.py +41 -0
  4. cardstream/client/analyzer.py +406 -0
  5. cardstream/client/banner.py +181 -0
  6. cardstream/client/common.py +622 -0
  7. cardstream/client/embedders.py +208 -0
  8. cardstream/client/identify_target.py +37 -0
  9. cardstream/client/sources.py +299 -0
  10. cardstream/client/stream_client.py +169 -0
  11. cardstream/client/web_camera.py +132 -0
  12. cardstream/client/web_client.py +285 -0
  13. cardstream/client/web_common.py +81 -0
  14. cardstream/client/web_settings.py +243 -0
  15. cardstream/client/web_stream.py +128 -0
  16. cardstream/client/ximilar_api.py +45 -0
  17. cardstream/core/__init__.py +0 -0
  18. cardstream/core/_onnx.py +25 -0
  19. cardstream/core/detection_filters.py +94 -0
  20. cardstream/core/detectors.py +499 -0
  21. cardstream/core/engine.py +377 -0
  22. cardstream/core/id_types.py +210 -0
  23. cardstream/core/identify_client.py +118 -0
  24. cardstream/core/identify_options.py +118 -0
  25. cardstream/core/image_store.py +116 -0
  26. cardstream/core/imaging.py +162 -0
  27. cardstream/core/models.py +152 -0
  28. cardstream/core/motion.py +80 -0
  29. cardstream/core/quad.py +197 -0
  30. cardstream/core/tracking.py +74 -0
  31. cardstream/core/ximilar.py +178 -0
  32. cardstream/log.py +32 -0
  33. cardstream/py.typed +0 -0
  34. cardstream/webui/shared/capture.js +112 -0
  35. cardstream/webui/shared/constants.js +8 -0
  36. cardstream/webui/shared/logo.svg +6 -0
  37. cardstream/webui/shared/overlay.js +358 -0
  38. cardstream/webui/shared/session.js +74 -0
  39. cardstream/webui/shared/style.css +206 -0
  40. cardstream/webui/shared/ws.js +69 -0
  41. cardstream/webui/smart/app.js +183 -0
  42. cardstream/webui/smart/index.html +114 -0
  43. cardstream/webui/smart/settings-fields.js +120 -0
  44. cardstream/webui/smart/settings.js +254 -0
  45. cardstream/webui/smart/smart.css +178 -0
  46. cardstream-0.2.0.dist-info/METADATA +258 -0
  47. cardstream-0.2.0.dist-info/RECORD +52 -0
  48. cardstream-0.2.0.dist-info/WHEEL +5 -0
  49. cardstream-0.2.0.dist-info/entry_points.txt +3 -0
  50. cardstream-0.2.0.dist-info/licenses/LICENSE +201 -0
  51. cardstream-0.2.0.dist-info/licenses/NOTICE +46 -0
  52. 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
+ )