@trillboards/edge-sdk 0.2.113 → 0.2.119
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.
- package/deploy/detector/bench/run_candidate.py +87 -2
- package/deploy/detector/tests/test_class_ownership.py +126 -0
- package/deploy/detector/trillboards_detector/labels.py +60 -3
- package/dist/ChannelProber.d.ts +6 -1
- package/dist/ChannelProber.d.ts.map +1 -1
- package/dist/ChannelProber.js +34 -8
- package/dist/ChannelProber.js.map +1 -1
- package/dist/ConnectorStatusClient.d.ts +20 -0
- package/dist/ConnectorStatusClient.d.ts.map +1 -1
- package/dist/ConnectorStatusClient.js +5 -0
- package/dist/ConnectorStatusClient.js.map +1 -1
- package/dist/EdgeAgent.js +1 -1
- package/dist/LocalDetectorClient.d.ts +40 -0
- package/dist/LocalDetectorClient.d.ts.map +1 -1
- package/dist/LocalDetectorClient.js +68 -0
- package/dist/LocalDetectorClient.js.map +1 -1
- package/dist/autoSense.d.ts +5 -0
- package/dist/autoSense.d.ts.map +1 -1
- package/dist/autoSense.js +67 -0
- package/dist/autoSense.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -0
- package/dist/cli.js.map +1 -1
- package/dist/connectorUpdate.d.ts +30 -0
- package/dist/connectorUpdate.d.ts.map +1 -1
- package/dist/connectorUpdate.js +54 -1
- package/dist/connectorUpdate.js.map +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/dist/setupMode.d.ts.map +1 -1
- package/dist/setupMode.js +5 -0
- package/dist/setupMode.js.map +1 -1
- package/package.json +1 -1
|
@@ -91,6 +91,7 @@ from box_units import ( # noqa: E402 (sibling module, stdlib-only on purpose)
|
|
|
91
91
|
)
|
|
92
92
|
from trillboards_detector.errors import ModelContractError # noqa: E402
|
|
93
93
|
from trillboards_detector.labels import ( # noqa: E402
|
|
94
|
+
BOX_UNITS_NORMALIZED,
|
|
94
95
|
BOX_UNITS_PIXELS,
|
|
95
96
|
BUILTIN_VOCABULARIES,
|
|
96
97
|
ModelVocabulary,
|
|
@@ -554,7 +555,11 @@ class Frame:
|
|
|
554
555
|
|
|
555
556
|
|
|
556
557
|
def synthesise_vocabulary(
|
|
557
|
-
name: str,
|
|
558
|
+
name: str,
|
|
559
|
+
class_names: "dict[int, str]",
|
|
560
|
+
output_layout: str,
|
|
561
|
+
box_units: str = BOX_UNITS_PIXELS,
|
|
562
|
+
box_scale_to_pixels: float = 1.0,
|
|
558
563
|
) -> ModelVocabulary:
|
|
559
564
|
"""A ModelVocabulary over a model's OWN names, canonicalised the service's way.
|
|
560
565
|
|
|
@@ -602,7 +607,7 @@ def synthesise_vocabulary(
|
|
|
602
607
|
# 0..1 boxes as 0..1; what it would lose is per-class detections on
|
|
603
608
|
# padded frames, which is the signal to read the units verdict before
|
|
604
609
|
# trusting that candidate's score.
|
|
605
|
-
onnx_variant=VariantContract(output_layout,
|
|
610
|
+
onnx_variant=VariantContract(output_layout, box_units, box_scale_to_pixels),
|
|
606
611
|
)
|
|
607
612
|
|
|
608
613
|
|
|
@@ -827,6 +832,75 @@ def main() -> int:
|
|
|
827
832
|
if units["refusal"]:
|
|
828
833
|
raise SystemExit(f"REFUSING: {units['refusal']}")
|
|
829
834
|
|
|
835
|
+
# ── MEASURE THE UNITS, THEN DECODE WITH THEM ─────────────────────────────
|
|
836
|
+
#
|
|
837
|
+
# The pass above ran with the identity contract (`pixels`/1.0) ON PURPOSE:
|
|
838
|
+
# it is the only pair that scales nothing, so the graph's own box magnitudes
|
|
839
|
+
# reach `box_abs_max` untouched and the classifier sees what the model
|
|
840
|
+
# actually emitted. What was missing is the second half — USING the answer.
|
|
841
|
+
#
|
|
842
|
+
# For a NORMALISED artifact the identity decode reads 0..1 boxes as pixels,
|
|
843
|
+
# and un-letterboxing then subtracts the pad offset from a sub-pixel box and
|
|
844
|
+
# pushes it out of frame. On a SQUARE source there is no padding and nothing
|
|
845
|
+
# is lost; on any other aspect ratio EVERY detection is dropped, silently,
|
|
846
|
+
# and the run reports a model that fires on nothing.
|
|
847
|
+
#
|
|
848
|
+
# That is not hypothetical. `fall_1cls_11x` is the one normalised ONNX
|
|
849
|
+
# artifact in the registry, and `fall-2026-08-25` scored it on 14 positives
|
|
850
|
+
# that are 1024x1024 SQUARE against 80 negatives that are 640x480. The
|
|
851
|
+
# positives survived, the negatives were all dropped, and the card published
|
|
852
|
+
# `precision 1.0` with `fire_rate_on_labelled_empty: 0` — a measurement of
|
|
853
|
+
# the harness's blind spot wearing the model's name. Measured 2026-08-28:
|
|
854
|
+
# the same 80 frames score up to 0.9400 through the SERVICE's decode, which
|
|
855
|
+
# reads the declared contract, against a published operating threshold of
|
|
856
|
+
# 0.8616873025894165.
|
|
857
|
+
#
|
|
858
|
+
# So a normalised verdict re-runs the scored pass with the measured contract.
|
|
859
|
+
# It costs a second pass on the artifacts that need one and nothing on the
|
|
860
|
+
# ones that do not, and it cannot flatter a candidate: the second pass is the
|
|
861
|
+
# decode the SERVICE would use, and it can only ADD detections the first pass
|
|
862
|
+
# was unable to represent — including on negatives.
|
|
863
|
+
decoded_units = BOX_UNITS_PIXELS
|
|
864
|
+
redecoded = False
|
|
865
|
+
if units["units"] == BOX_UNITS_NORMALIZED and isinstance(runner, ShippedRunner) and declared_names:
|
|
866
|
+
redecoded = True
|
|
867
|
+
runner = ShippedRunner(
|
|
868
|
+
model_path,
|
|
869
|
+
synthesise_vocabulary(
|
|
870
|
+
args.candidate, declared_names, loadability["output_layout"],
|
|
871
|
+
BOX_UNITS_NORMALIZED, float(units["scale_to_pixels"]),
|
|
872
|
+
),
|
|
873
|
+
)
|
|
874
|
+
detections = {}
|
|
875
|
+
infer_ms = []
|
|
876
|
+
wall_ms = []
|
|
877
|
+
for key in keys:
|
|
878
|
+
frame = Frame(frames_dir / os.path.basename(key))
|
|
879
|
+
started = time.perf_counter()
|
|
880
|
+
per_class, ms, _box_max = runner.run(frame, args.conf_floor)
|
|
881
|
+
wall_ms.append((time.perf_counter() - started) * 1000.0)
|
|
882
|
+
infer_ms.append(ms)
|
|
883
|
+
detections[key] = per_class
|
|
884
|
+
frame.release()
|
|
885
|
+
decoded_units = BOX_UNITS_NORMALIZED
|
|
886
|
+
sys.stderr.write(
|
|
887
|
+
f" re-decoded {len(keys)} frame(s) with the MEASURED contract "
|
|
888
|
+
f"({BOX_UNITS_NORMALIZED} x{units['scale_to_pixels']:g}); the first pass used the identity "
|
|
889
|
+
"to measure the units and could not represent this artifact's boxes\n"
|
|
890
|
+
)
|
|
891
|
+
elif units["units"] == BOX_UNITS_NORMALIZED:
|
|
892
|
+
# No path to a correct decode from here: either the graph declares no
|
|
893
|
+
# names to synthesise a vocabulary from, or the runner is not the shipped
|
|
894
|
+
# one. REFUSE rather than emit a run whose detections were decoded with
|
|
895
|
+
# units the same run reports as wrong — that combination is what produced
|
|
896
|
+
# `fall_1cls_11x`'s card.
|
|
897
|
+
raise SystemExit(
|
|
898
|
+
f"REFUSING: {model_path.name} emits NORMALIZED boxes (ratio {units['ratio']:.4f}) and this "
|
|
899
|
+
"runner cannot be rebuilt with that contract, so every detection on a non-square frame was "
|
|
900
|
+
"silently dropped. Score it through the detector service instead: "
|
|
901
|
+
"node trillboard-api/scripts/sensing/score-venue-corpus.js --model <id> --sweep"
|
|
902
|
+
)
|
|
903
|
+
|
|
830
904
|
same_source = bool(rss_before and rss_after_probe and rss_before[1] == rss_after_probe[1])
|
|
831
905
|
rss = {
|
|
832
906
|
"source": rss_before[1] if rss_before else None,
|
|
@@ -855,6 +929,17 @@ def main() -> int:
|
|
|
855
929
|
"sha256": sha256_file(model_path),
|
|
856
930
|
"bytes": model_path.stat().st_size,
|
|
857
931
|
},
|
|
932
|
+
"decode": {
|
|
933
|
+
"units_measured": units["units"],
|
|
934
|
+
"units_decoded_with": decoded_units,
|
|
935
|
+
"rescored_after_measuring_units": redecoded,
|
|
936
|
+
"why": (
|
|
937
|
+
"The scored pass is decoded with the units the run MEASURED, not with the identity it "
|
|
938
|
+
"measured them with. A normalized artifact decoded as pixels loses every detection on a "
|
|
939
|
+
"letterbox-padded frame and keeps them on a square one, which is a property of the eval "
|
|
940
|
+
"set's aspect ratios and not of the model."
|
|
941
|
+
),
|
|
942
|
+
},
|
|
858
943
|
"eval_set_manifest": {
|
|
859
944
|
"path": str(manifest_path),
|
|
860
945
|
"sha256": sha256_file(manifest_path),
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Who answers a class when two models on one box can both emit it.
|
|
2
|
+
|
|
3
|
+
`BUILTIN_VOCABULARIES` states the principle — "a class stays on the model it
|
|
4
|
+
was SCORED on" — and used catalogue position as the mechanism. Position is a
|
|
5
|
+
proxy and it had already failed in the shipped catalogue: `fall detected` is
|
|
6
|
+
named by `ppe_vyra_14cls_v8m` (position 3) and by `fall_1cls_11x` (position 6),
|
|
7
|
+
so order handed falls to vyra, whose score card fitted that class at the 0.05
|
|
8
|
+
FLOOR on 1 hit of a HARDHAT corpus, while the model actually scored on
|
|
9
|
+
`person_fall` — 18/94, fitted threshold 0.8783 — sat idle on the same box.
|
|
10
|
+
|
|
11
|
+
Nothing about that is visible downstream. Both models emit real boxes with
|
|
12
|
+
plausible confidences; the only difference is a threshold nobody fitted, on a
|
|
13
|
+
class nobody scored, and every health surface stays green.
|
|
14
|
+
|
|
15
|
+
So ownership is now DECLARED (`ModelVocabulary.owns`) and position decides only
|
|
16
|
+
what no model declares. These tests pin the three things that can silently
|
|
17
|
+
regress: the declared owner wins, an absent declarer does not black-hole its
|
|
18
|
+
class, and two declarers is an error rather than a coin flip.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import unittest
|
|
24
|
+
|
|
25
|
+
from trillboards_detector.labels import (
|
|
26
|
+
BUILTIN_VOCABULARIES,
|
|
27
|
+
ClassIndex,
|
|
28
|
+
ModelVocabulary,
|
|
29
|
+
VariantContract,
|
|
30
|
+
normalize_class_name,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
_VARIANT = VariantContract(
|
|
34
|
+
output_layout="anchor_4_plus_nc", box_units="normalized", box_scale_to_pixels=640.0
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _vocab(name, classes, owns=frozenset()):
|
|
39
|
+
return ModelVocabulary(
|
|
40
|
+
model_name=name,
|
|
41
|
+
slot_names={i: c for i, c in enumerate(classes)},
|
|
42
|
+
logit_slots=len(classes),
|
|
43
|
+
approx_ram_bytes=1,
|
|
44
|
+
onnx_variant=_VARIANT,
|
|
45
|
+
owns=owns,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DeclaredOwnershipTest(unittest.TestCase):
|
|
50
|
+
def test_declared_owner_beats_catalogue_order(self):
|
|
51
|
+
general = _vocab("general", ["person", "fall detected"])
|
|
52
|
+
specialist = _vocab("specialist", ["fall detected"], owns=frozenset({"fall detected"}))
|
|
53
|
+
# `general` is FIRST, which is what used to decide it.
|
|
54
|
+
index = ClassIndex([general, specialist])
|
|
55
|
+
self.assertEqual(index.owner_of("fall detected"), "specialist")
|
|
56
|
+
self.assertEqual(index.owner_of("person"), "general")
|
|
57
|
+
|
|
58
|
+
def test_position_still_decides_what_nobody_declares(self):
|
|
59
|
+
a = _vocab("a", ["mask", "gloves"])
|
|
60
|
+
b = _vocab("b", ["mask"])
|
|
61
|
+
self.assertEqual(ClassIndex([a, b]).owner_of("mask"), "a")
|
|
62
|
+
self.assertEqual(ClassIndex([b, a]).owner_of("mask"), "b")
|
|
63
|
+
|
|
64
|
+
def test_a_declarer_that_is_not_resident_owns_nothing(self):
|
|
65
|
+
# `ClassIndex` is built over models whose artifact is ON THE BOX. If the
|
|
66
|
+
# specialist has not arrived, its class must fall through to the model
|
|
67
|
+
# that is actually there rather than becoming unanswerable.
|
|
68
|
+
general = _vocab("general", ["person", "fall detected"])
|
|
69
|
+
index = ClassIndex([general])
|
|
70
|
+
self.assertEqual(index.owner_of("fall detected"), "general")
|
|
71
|
+
self.assertIn("fall detected", index.class_names)
|
|
72
|
+
|
|
73
|
+
def test_two_declarers_is_an_error_not_a_coin_flip(self):
|
|
74
|
+
a = _vocab("a", ["fire"], owns=frozenset({"fire"}))
|
|
75
|
+
b = _vocab("b", ["fire"], owns=frozenset({"fire"}))
|
|
76
|
+
with self.assertRaisesRegex(ValueError, "one authority"):
|
|
77
|
+
ClassIndex([a, b])
|
|
78
|
+
|
|
79
|
+
def test_a_model_cannot_own_a_class_it_cannot_emit(self):
|
|
80
|
+
with self.assertRaisesRegex(ValueError, "not one of its own classes"):
|
|
81
|
+
_vocab("a", ["person"], owns=frozenset({"forklift"}))
|
|
82
|
+
|
|
83
|
+
def test_owned_names_must_be_canonical(self):
|
|
84
|
+
# `owner_of` keys on the canonical name, so `fall_detected` here would
|
|
85
|
+
# own nothing at all while looking correct in the source.
|
|
86
|
+
with self.assertRaisesRegex(ValueError, "not canonical"):
|
|
87
|
+
_vocab("a", ["fall detected"], owns=frozenset({"fall_detected"}))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ShippedCatalogueTest(unittest.TestCase):
|
|
91
|
+
"""The catalogue as it ships, so a new model cannot quietly take a class."""
|
|
92
|
+
|
|
93
|
+
def test_every_owned_class_is_canonical_and_emittable(self):
|
|
94
|
+
for vocabulary in BUILTIN_VOCABULARIES:
|
|
95
|
+
for owned in vocabulary.owns:
|
|
96
|
+
self.assertEqual(owned, normalize_class_name(owned), vocabulary.model_name)
|
|
97
|
+
self.assertIn(owned, vocabulary.class_names, vocabulary.model_name)
|
|
98
|
+
|
|
99
|
+
def test_the_scored_model_owns_its_class(self):
|
|
100
|
+
index = ClassIndex(BUILTIN_VOCABULARIES)
|
|
101
|
+
# Each of these is a class whose fitted threshold lives on exactly one
|
|
102
|
+
# model's score card, and which at least one OTHER model also emits.
|
|
103
|
+
self.assertEqual(index.owner_of("fall detected"), "fall_1cls_11x")
|
|
104
|
+
self.assertEqual(index.owner_of("person"), "rfdetr_medium")
|
|
105
|
+
self.assertEqual(index.owner_of("hardhat"), "ppe_vyra_14cls_v8m")
|
|
106
|
+
|
|
107
|
+
def test_declaration_changes_exactly_one_ownership(self):
|
|
108
|
+
# The blast radius, asserted rather than assumed: `owns` is a correction
|
|
109
|
+
# to one class, not a re-shuffle of a 124-class union. If a future model
|
|
110
|
+
# moves a second class, that is a decision someone must make on purpose.
|
|
111
|
+
index = ClassIndex(BUILTIN_VOCABULARIES)
|
|
112
|
+
by_position: dict[str, str] = {}
|
|
113
|
+
for vocabulary in BUILTIN_VOCABULARIES:
|
|
114
|
+
for name in sorted(vocabulary.class_names):
|
|
115
|
+
by_position.setdefault(name, vocabulary.model_name)
|
|
116
|
+
moved = {n: (b, index.owner_of(n)) for n, b in by_position.items() if b != index.owner_of(n)}
|
|
117
|
+
self.assertEqual(moved, {"fall detected": ("ppe_vyra_14cls_v8m", "fall_1cls_11x")})
|
|
118
|
+
|
|
119
|
+
def test_no_class_is_left_without_an_owner(self):
|
|
120
|
+
index = ClassIndex(BUILTIN_VOCABULARIES)
|
|
121
|
+
for name in index.class_names:
|
|
122
|
+
self.assertIsNotNone(index.owner_of(name), name)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
unittest.main()
|
|
@@ -275,6 +275,26 @@ class ModelVocabulary:
|
|
|
275
275
|
#: axis over. A model whose output layout is inferred is a model whose boxes
|
|
276
276
|
#: are plausible and wrong.
|
|
277
277
|
onnx_variant: "VariantContract"
|
|
278
|
+
#: The classes THIS model is the authority for, canonical spelling.
|
|
279
|
+
#:
|
|
280
|
+
#: The box-side copy of a relation the registry already holds: a model earns
|
|
281
|
+
#: a class by declaring `<class>_present` / `<class>_count` in its
|
|
282
|
+
#: `outputs_schema`, which is also the field its FITTED threshold hangs off
|
|
283
|
+
#: in `liveness_evidence.classes`. Same discipline as `slot_names` and
|
|
284
|
+
#: `onnx_variant` — the registry row is the authority, this is the copy the
|
|
285
|
+
#: service reads because there is no PostgreSQL on a shop PC, and check 10
|
|
286
|
+
#: of `check-sensing-model-publish-gate.js` fails the build when they drift.
|
|
287
|
+
#:
|
|
288
|
+
#: WHY THIS EXISTS RATHER THAN CATALOGUE ORDER. `BUILTIN_VOCABULARIES` states
|
|
289
|
+
#: the right principle — a class stays on the model it was SCORED on — and
|
|
290
|
+
#: position is a proxy for it that has already failed once. `fall detected`
|
|
291
|
+
#: is named by `ppe_vyra_14cls_v8m` (position 3) and by `fall_1cls_11x`
|
|
292
|
+
#: (position 6), so order hands falls to vyra, whose score card fitted that
|
|
293
|
+
#: class at the 0.05 FLOOR on 1 hit of a hardhat corpus, while the model
|
|
294
|
+
#: actually scored on `person_fall` — 18/94, fitted 0.8783 — sits idle. A
|
|
295
|
+
#: threshold nobody fitted, on plausible boxes, with every health surface
|
|
296
|
+
#: green. Declaration is the in-band signal position was standing in for.
|
|
297
|
+
owns: "frozenset[str]" = frozenset()
|
|
278
298
|
|
|
279
299
|
def __post_init__(self) -> None:
|
|
280
300
|
# The model name IS A DIRECTORY NAME — `resolve_model_path` puts the
|
|
@@ -310,6 +330,19 @@ class ModelVocabulary:
|
|
|
310
330
|
f"{self.model_name}: class name {name!r} is not canonical "
|
|
311
331
|
f"(expected {normalize_class_name(name)!r})"
|
|
312
332
|
)
|
|
333
|
+
for owned in sorted(self.owns):
|
|
334
|
+
# A model cannot be the authority for a class it cannot emit, and an
|
|
335
|
+
# un-canonical entry here would silently own nothing — `ClassIndex`
|
|
336
|
+
# keys on the canonical name.
|
|
337
|
+
if normalize_class_name(owned) != owned:
|
|
338
|
+
raise ValueError(
|
|
339
|
+
f"{self.model_name}: owned class {owned!r} is not canonical "
|
|
340
|
+
f"(expected {normalize_class_name(owned)!r})"
|
|
341
|
+
)
|
|
342
|
+
if owned not in set(self.slot_names.values()):
|
|
343
|
+
raise ValueError(
|
|
344
|
+
f"{self.model_name}: claims to own {owned!r}, which is not one of its own classes"
|
|
345
|
+
)
|
|
313
346
|
if len(set(self.slot_names.values())) != len(self.slot_names):
|
|
314
347
|
raise ValueError(f"{self.model_name}: two slots share one class name, so a filter would be ambiguous")
|
|
315
348
|
|
|
@@ -330,6 +363,7 @@ RFDETR_MEDIUM = ModelVocabulary(
|
|
|
330
363
|
logit_slots=EXPECTED_LOGIT_SLOTS,
|
|
331
364
|
approx_ram_bytes=632 * 1024 * 1024,
|
|
332
365
|
onnx_variant=RFDETR_MEDIUM_ONNX,
|
|
366
|
+
owns=frozenset({"person"}),
|
|
333
367
|
)
|
|
334
368
|
|
|
335
369
|
#: Every model this build knows how to label, IN PRIORITY ORDER.
|
|
@@ -367,6 +401,7 @@ GH_FORKLIFT_COMBINE = ModelVocabulary(
|
|
|
367
401
|
box_units="pixels",
|
|
368
402
|
box_scale_to_pixels=1.0,
|
|
369
403
|
),
|
|
404
|
+
owns=frozenset({"forklift"}),
|
|
370
405
|
)
|
|
371
406
|
|
|
372
407
|
#: PPE Detector (hard hat) — published 2026-08-25 by
|
|
@@ -401,6 +436,7 @@ PPE_VYRA_14CLS_V8M = ModelVocabulary(
|
|
|
401
436
|
box_units="pixels",
|
|
402
437
|
box_scale_to_pixels=1.0,
|
|
403
438
|
),
|
|
439
|
+
owns=frozenset({"hardhat"}),
|
|
404
440
|
)
|
|
405
441
|
|
|
406
442
|
#: Heavy Machinery Detector — published 2026-08-25 by
|
|
@@ -438,6 +474,7 @@ PPE_CONSTRUCTION_17CLS_V8N = ModelVocabulary(
|
|
|
438
474
|
box_units="pixels",
|
|
439
475
|
box_scale_to_pixels=1.0,
|
|
440
476
|
),
|
|
477
|
+
owns=frozenset({"excavators"}),
|
|
441
478
|
)
|
|
442
479
|
|
|
443
480
|
#: Fire-Safety Detector — published 2026-08-25 by
|
|
@@ -477,6 +514,7 @@ PPE_KILLUMINATI_19CLS_V8S = ModelVocabulary(
|
|
|
477
514
|
box_units="pixels",
|
|
478
515
|
box_scale_to_pixels=1.0,
|
|
479
516
|
),
|
|
517
|
+
owns=frozenset({"fire extinguisher"}),
|
|
480
518
|
)
|
|
481
519
|
|
|
482
520
|
#: FIVE SPECIALISTS WERE SCORED; FOUR ARE HERE. `gh_fall_tonlong` IS ABSENT ON
|
|
@@ -542,6 +580,7 @@ FALL_1CLS_11X = ModelVocabulary(
|
|
|
542
580
|
box_units="normalized",
|
|
543
581
|
box_scale_to_pixels=640.0,
|
|
544
582
|
),
|
|
583
|
+
owns=frozenset({"fall detected"}),
|
|
545
584
|
)
|
|
546
585
|
|
|
547
586
|
#: Heavy Machinery Detector (venue-adjudicated) — published 2026-08-27 by
|
|
@@ -572,6 +611,7 @@ GH_PPE_VINAYAK_L = ModelVocabulary(
|
|
|
572
611
|
box_units="pixels",
|
|
573
612
|
box_scale_to_pixels=1.0,
|
|
574
613
|
),
|
|
614
|
+
owns=frozenset({"machinery"}),
|
|
575
615
|
)
|
|
576
616
|
|
|
577
617
|
#: Fire and Smoke Detector — published 2026-08-27 by
|
|
@@ -594,6 +634,7 @@ FIRE_SMOKE_WESLLEYSKAH_11N = ModelVocabulary(
|
|
|
594
634
|
box_units="pixels",
|
|
595
635
|
box_scale_to_pixels=1.0,
|
|
596
636
|
),
|
|
637
|
+
owns=frozenset({"fire", "smoke"}),
|
|
597
638
|
)
|
|
598
639
|
|
|
599
640
|
BUILTIN_VOCABULARIES: "tuple[ModelVocabulary, ...]" = (RFDETR_MEDIUM, GH_FORKLIFT_COMBINE, PPE_VYRA_14CLS_V8M, PPE_CONSTRUCTION_17CLS_V8N, PPE_KILLUMINATI_19CLS_V8S, FALL_1CLS_11X, GH_PPE_VINAYAK_L, FIRE_SMOKE_WESLLEYSKAH_11N,)
|
|
@@ -611,11 +652,27 @@ class ClassIndex:
|
|
|
611
652
|
def __init__(self, vocabularies: "Sequence[ModelVocabulary]"):
|
|
612
653
|
self._vocabularies = tuple(vocabularies)
|
|
613
654
|
owner: "dict[str, str]" = {}
|
|
655
|
+
# DECLARED ownership first, and it is exclusive. `owns` is the class a
|
|
656
|
+
# model was scored on, so this is the principle the catalogue order was
|
|
657
|
+
# standing in for, applied directly. Two models declaring one class is
|
|
658
|
+
# unresolvable rather than arbitrary, so it raises here instead of
|
|
659
|
+
# letting whichever sorted first answer at whatever threshold it carries.
|
|
660
|
+
for vocabulary in self._vocabularies:
|
|
661
|
+
for name in sorted(vocabulary.owns):
|
|
662
|
+
if name in owner:
|
|
663
|
+
raise ValueError(
|
|
664
|
+
f"{name!r} is declared owned by both {owner[name]} and "
|
|
665
|
+
f"{vocabulary.model_name}; a class has one authority"
|
|
666
|
+
)
|
|
667
|
+
owner[name] = vocabulary.model_name
|
|
614
668
|
for vocabulary in self._vocabularies:
|
|
615
669
|
for name in sorted(vocabulary.class_names):
|
|
616
|
-
#
|
|
617
|
-
#
|
|
618
|
-
#
|
|
670
|
+
# Catalogue order for everything NOBODY declares — a class no
|
|
671
|
+
# model was scored on has no principled owner, and order at
|
|
672
|
+
# least keeps it stable. `setdefault`, not assignment: a later
|
|
673
|
+
# specialist must not capture a class an earlier model already
|
|
674
|
+
# owns. A declarer that is not RESIDENT owns nothing, so its
|
|
675
|
+
# class falls through to the best model actually on the box.
|
|
619
676
|
owner.setdefault(name, vocabulary.model_name)
|
|
620
677
|
if len({v.model_name for v in self._vocabularies}) != len(self._vocabularies):
|
|
621
678
|
raise ValueError("two vocabularies share one model name")
|
package/dist/ChannelProber.d.ts
CHANGED
|
@@ -112,7 +112,12 @@ export declare function brandPathCandidates(brand: string | undefined, ch: numbe
|
|
|
112
112
|
export declare function credentialFreePathTemplate(pathValue: string, brand: string | undefined): string;
|
|
113
113
|
/** Restore a credential-free path recipe while writing the local-only config. */
|
|
114
114
|
export declare function materializeCredentialPathTemplate(pathTemplate: string, username: string, password: string): string;
|
|
115
|
-
/**
|
|
115
|
+
/**
|
|
116
|
+
* Build an RTSP URL without duplicating a vendor's in-path authentication.
|
|
117
|
+
* Ordinary recorders keep RFC userinfo with percent-encoded credentials.
|
|
118
|
+
* XMEye/NETSurveillance paths already carry both values and older firmware
|
|
119
|
+
* rejects the same login when it is also repeated in the URL authority.
|
|
120
|
+
*/
|
|
116
121
|
export declare function buildRtspUrl(host: string, port: number, path: string, username: string, password: string): string;
|
|
117
122
|
/** Inject creds into an ONVIF-returned rtsp URI (they usually come credless). */
|
|
118
123
|
export declare function injectCreds(uri: string, username: string, password: string): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChannelProber.d.ts","sourceRoot":"","sources":["../src/ChannelProber.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"ChannelProber.d.ts","sourceRoot":"","sources":["../src/ChannelProber.ts"],"names":[],"mappings":"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"}
|
package/dist/ChannelProber.js
CHANGED
|
@@ -160,12 +160,10 @@ function brandPathCandidates(brand, ch, credentials) {
|
|
|
160
160
|
`/ch${String(ch).padStart(2, '0')}/0`,
|
|
161
161
|
];
|
|
162
162
|
const uniview = [`/unicast/c${ch}/s1/live`, `/unicast/c${ch}/s0/live`];
|
|
163
|
-
const xmeyeUser = encodeURIComponent(credentials?.username ?? 'admin');
|
|
164
|
-
const xmeyePassword = encodeURIComponent(credentials?.password ?? '');
|
|
165
163
|
const hanwha = [`/LiveChannel/${Math.max(0, ch - 1)}/media.smp`];
|
|
166
164
|
const xmeye = [
|
|
167
|
-
`/user
|
|
168
|
-
`/user
|
|
165
|
+
materializeCredentialPathTemplate(`/user={username}&password={password}&channel=${ch}&stream=1.sdp?real_stream`, credentials?.username ?? 'admin', credentials?.password ?? ''),
|
|
166
|
+
materializeCredentialPathTemplate(`/user={username}&password={password}&channel=${ch}&stream=0.sdp?real_stream`, credentials?.username ?? 'admin', credentials?.password ?? ''),
|
|
169
167
|
];
|
|
170
168
|
const h264Preview = [
|
|
171
169
|
`/h264Preview_${String(ch).padStart(2, '0')}_sub`,
|
|
@@ -286,17 +284,45 @@ function credentialFreePathTemplate(pathValue, brand) {
|
|
|
286
284
|
return pathValue;
|
|
287
285
|
return pathValue.replace(/^\/user=[^&]*&password=[^&]*(&channel=)/i, '/user={username}&password={password}$1');
|
|
288
286
|
}
|
|
287
|
+
function canUseLegacyXmeyePathCredential(value) {
|
|
288
|
+
// Old NETSurveillance/Xiongmai firmware parses these values itself instead
|
|
289
|
+
// of URL-decoding them. In the field, a password containing `@` worked only
|
|
290
|
+
// as a literal path value and was rejected as `%40`. Keep that compatibility
|
|
291
|
+
// fallback deliberately narrow: delimiters/control characters would change
|
|
292
|
+
// the request target and therefore remain percent-encoded.
|
|
293
|
+
return !/[&?#%/=\s\u0000-\u001f\u007f]/.test(value);
|
|
294
|
+
}
|
|
295
|
+
function isXmeyeCredentialPathTemplate(pathTemplate) {
|
|
296
|
+
return /^\/user=\{username\}&password=\{password\}&channel=/i.test(pathTemplate);
|
|
297
|
+
}
|
|
289
298
|
/** Restore a credential-free path recipe while writing the local-only config. */
|
|
290
299
|
function materializeCredentialPathTemplate(pathTemplate, username, password) {
|
|
300
|
+
const legacyRaw = isXmeyeCredentialPathTemplate(pathTemplate) &&
|
|
301
|
+
canUseLegacyXmeyePathCredential(username) &&
|
|
302
|
+
canUseLegacyXmeyePathCredential(password);
|
|
303
|
+
const materialize = (value) => legacyRaw ? value : encodeURIComponent(value);
|
|
291
304
|
return pathTemplate
|
|
292
|
-
.replace(/\{username\}/g,
|
|
293
|
-
.replace(/\{password\}/g,
|
|
305
|
+
.replace(/\{username\}/g, materialize(username))
|
|
306
|
+
.replace(/\{password\}/g, materialize(password));
|
|
294
307
|
}
|
|
295
|
-
|
|
308
|
+
function pathCarriesCredentials(pathValue) {
|
|
309
|
+
const hasUser = /(^|[?&/])(user|username|usr)=[^&#]*/i.test(pathValue);
|
|
310
|
+
const hasPassword = /(^|[?&/])(password|passwd|pwd|pass)=[^&#]*/i.test(pathValue);
|
|
311
|
+
return hasUser && hasPassword;
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Build an RTSP URL without duplicating a vendor's in-path authentication.
|
|
315
|
+
* Ordinary recorders keep RFC userinfo with percent-encoded credentials.
|
|
316
|
+
* XMEye/NETSurveillance paths already carry both values and older firmware
|
|
317
|
+
* rejects the same login when it is also repeated in the URL authority.
|
|
318
|
+
*/
|
|
296
319
|
function buildRtspUrl(host, port, path, username, password) {
|
|
320
|
+
const norm = path.startsWith('/') ? path : `/${path}`;
|
|
321
|
+
if (pathCarriesCredentials(norm)) {
|
|
322
|
+
return `rtsp://${host}:${port}${norm}`;
|
|
323
|
+
}
|
|
297
324
|
const u = encodeURIComponent(username);
|
|
298
325
|
const p = encodeURIComponent(password);
|
|
299
|
-
const norm = path.startsWith('/') ? path : `/${path}`;
|
|
300
326
|
return `rtsp://${u}:${p}@${host}:${port}${norm}`;
|
|
301
327
|
}
|
|
302
328
|
/** Inject creds into an ONVIF-returned rtsp URI (they usually come credless). */
|