@trillboards/edge-sdk 0.2.110 → 0.2.111
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/litert-twin-parity.json +25 -0
- package/deploy/detector/bench/twin_parity_contract.py +82 -13
- package/deploy/detector/bench/venue_adjudication.py +88 -6
- package/deploy/detector/bench/verify_litert_twin_parity.py +22 -18
- package/deploy/detector/tests/test_box_units_are_measured.py +137 -0
- package/deploy/detector/trillboards_detector/labels.py +24 -1
- package/deploy/macos/TrillboardsSetupApp.m +135 -0
- package/deploy/windows/trillboards-setup.ps1 +166 -0
- package/dist/EdgeAgent.js +1 -1
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.js +1 -1
- package/package.json +1 -1
|
@@ -334,6 +334,31 @@
|
|
|
334
334
|
"litert_input_layout": "nhwc",
|
|
335
335
|
"measured_at": "2026-08-26",
|
|
336
336
|
"note": "The 22nd twin, added 2026-08-26, and the FIRST whose LiteRT input is NHWC. gh_fall_tonlong had no parity measurement at all until now: upstream ships `Violence` at slot 0 and `violence` at slot 6, normalize_class_name folds them to one name and ModelVocabulary refuses at construction, so neither format could be probed. Both were re-exported from ok.pt (sha256 1e12d89198e28d7c650ba86886c511fecd9f70b9d7f8bf985e76704690e58dd9) with the slots disambiguated by INDEX -- violence_slot0 / violence_slot6 -- which preserves upstream truth and asserts no semantics we have not measured."
|
|
337
|
+
},
|
|
338
|
+
{
|
|
339
|
+
"layout": "anchor_4_plus_nc",
|
|
340
|
+
"imgsz": 640,
|
|
341
|
+
"box_abs_max": 1.052759051322937,
|
|
342
|
+
"units": "normalized",
|
|
343
|
+
"box_scale_applied": 640.0,
|
|
344
|
+
"box_residual_px": 0.001220703125,
|
|
345
|
+
"box_residual_unscaled": 672.7130490541458,
|
|
346
|
+
"cls_residual": 1.3779020591186963e-07,
|
|
347
|
+
"pass": true,
|
|
348
|
+
"reason": "ok",
|
|
349
|
+
"onnx": "fire_smoke_weslleyskah_11n.onnx",
|
|
350
|
+
"litert": "fire_smoke_weslleyskah_11n.tflite",
|
|
351
|
+
"model_id": "fire_smoke_weslleyskah_11n",
|
|
352
|
+
"box_units": "normalized",
|
|
353
|
+
"box_scale_to_pixels": 640.0,
|
|
354
|
+
"litert_input_layout": "nhwc",
|
|
355
|
+
"litert_sha256": "1511903fece4e163348105cfddb89c657ccc43f1482cde7b090e3f1f7b003c84",
|
|
356
|
+
"litert_bytes": 9809490,
|
|
357
|
+
"onnx_sha256": "af8b648caf175edb928eeb3dc6ef73dffc55feaca4097f8133809d8d0ac16419",
|
|
358
|
+
"onnx_bytes": 10216160,
|
|
359
|
+
"measured_at": "2026-08-27",
|
|
360
|
+
"evidence": "twin parity: box residual 0.001221 px after x640, UNSCALED 672.7 px \u2014 the scale is load-bearing, not cosmetic. Class residual 1.38e-07 with no scaling at all. The ONNX half emits PIXELS (max|box| 945.30 over 1,670 real frames, ratio 1.4770) and the LiteRT half NORMALIZED (box_abs_max 1.0528), which is the pair this contract exists for.",
|
|
361
|
+
"note": "Exported from weslleyskah/fire_smoke_detection_box weights/best.pt (sha256 d2b0be21...) with ultralytics 8.3.201, imgsz 640, opset 19, nms=False. `units` and `box_abs_max` are DERIVED from the LiteRT artifact's own forward pass, not assumed from the layout. Verified twice \u2014 once under the contract at production HEAD and once under PR #13224's derived-units contract \u2014 byte-identical numbers and `pass` under both, because this twin IS normalised, the case the hardcoded x640 already handled. The row does not depend on that PR."
|
|
337
362
|
}
|
|
338
363
|
]
|
|
339
364
|
}
|
|
@@ -74,17 +74,51 @@ def classify_layout(shape, output_names) -> str:
|
|
|
74
74
|
raise ParityError(f"shape {shape} matches no known detector layout")
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def box_scale_for(layout: str, imgsz: int) -> float:
|
|
77
|
+
def box_scale_for(layout: str, imgsz: int, box_abs_max: "float | None" = None) -> float:
|
|
78
78
|
"""The multiplier that puts LiteRT boxes into the ONNX pixel frame.
|
|
79
79
|
|
|
80
|
-
|
|
81
|
-
model in `_NormalizeCoords`, which divides the four BOX channels by (W, H)
|
|
82
|
-
and leaves the class channels alone. An end2end head is `end2end=True`, so
|
|
83
|
-
the wrapper never runs and both runtimes emit pixels.
|
|
80
|
+
UNITS ARE A PROPERTY OF THE CHECKPOINT, NOT OF THE LAYOUT.
|
|
84
81
|
|
|
85
|
-
|
|
86
|
-
|
|
82
|
+
This returned `imgsz` for every ANCHOR-layout twin, on the reasoning that
|
|
83
|
+
ultralytics wraps a `task=detect` / `end2end=False` LiteRT export in
|
|
84
|
+
`_NormalizeCoords`. That reasoning is disproved by a control: two models
|
|
85
|
+
exported MINUTES APART in one venv, same tool (8.3.201), same flags
|
|
86
|
+
(opset 19, imgsz 640, `nms=False`), both classified ANCHOR, measured
|
|
87
|
+
|
|
88
|
+
0.0024 (normalized) and 2.8526 (pixels)
|
|
89
|
+
|
|
90
|
+
Same exporter, same settings, same run, opposite units. So no function of
|
|
91
|
+
(layout, imgsz) can be right in general, and the previous signature could
|
|
92
|
+
not express the truth even when it knew it.
|
|
93
|
+
|
|
94
|
+
`box_abs_max` — the largest absolute box value the LiteRT artifact actually
|
|
95
|
+
produced on a forward pass — is what settles it. Pass it and the units are
|
|
96
|
+
DERIVED via `classify_box_units`; the layout is then only a sanity check.
|
|
97
|
+
|
|
98
|
+
Omitting it is still allowed for the historical rows in
|
|
99
|
+
`litert-twin-parity.json`, which were recorded before anything measured
|
|
100
|
+
box_abs_max — those keep the old layout-derived answer, and the ANCHOR case
|
|
101
|
+
is exactly where that answer may be wrong. It is deliberately NOT the path
|
|
102
|
+
a new measurement takes: `verify_litert_twin_parity.py` always passes it,
|
|
103
|
+
and `verdict()` refuses a new ANCHOR row that arrives without one.
|
|
104
|
+
|
|
105
|
+
No layout and no measurement gets a scale by default. A fall-through to 1.0
|
|
106
|
+
would be a guess about an artifact nobody measured, and the wrong guess is
|
|
107
|
+
silent.
|
|
87
108
|
"""
|
|
109
|
+
if box_abs_max is not None:
|
|
110
|
+
# Deferred import: the contract module is stdlib-only so the bare
|
|
111
|
+
# `detector-service` CI lane can import it, and box_units is too.
|
|
112
|
+
from box_units import classify_box_units # noqa: PLC0415
|
|
113
|
+
|
|
114
|
+
measured = classify_box_units(box_abs_max, imgsz)
|
|
115
|
+
if measured["scale_to_pixels"] is None:
|
|
116
|
+
raise ParityError(
|
|
117
|
+
f"box units are unmeasured for this artifact: {measured['refusal']}. "
|
|
118
|
+
"Refusing to assume a scale from the layout — that assumption is "
|
|
119
|
+
"what the 0.0024-vs-2.8526 control disproved."
|
|
120
|
+
)
|
|
121
|
+
return float(measured["scale_to_pixels"])
|
|
88
122
|
if layout == ANCHOR:
|
|
89
123
|
return float(imgsz)
|
|
90
124
|
if layout in (END2END, DETS_LABELS):
|
|
@@ -95,14 +129,49 @@ def box_scale_for(layout: str, imgsz: int) -> float:
|
|
|
95
129
|
def verdict(result) -> "tuple[bool, str]":
|
|
96
130
|
"""Does this recorded measurement pass, and if not, which assertion failed."""
|
|
97
131
|
layout = result["layout"]
|
|
98
|
-
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
|
|
132
|
+
scale = result.get("box_scale_applied")
|
|
133
|
+
# THE LOAD-BEARING CHECK APPLIES TO A NORMALISED ARTIFACT, NOT TO A LAYOUT.
|
|
134
|
+
#
|
|
135
|
+
# This keyed on `layout == ANCHOR` and read an unscaled match as "the layout
|
|
136
|
+
# is misread". The control disproved the premise: an ANCHOR twin can emit
|
|
137
|
+
# PIXELS, and for that artifact an unscaled match is the CORRECT result, not
|
|
138
|
+
# a misread. Keying it on the layout refused a good artifact and is why the
|
|
139
|
+
# two models published 2026-08-27 shipped ONNX-only.
|
|
140
|
+
#
|
|
141
|
+
# Keyed on the applied scale, the assertion says what it always meant: if we
|
|
142
|
+
# scaled, the scaling has to matter. A scale of 1.0 is the measured answer
|
|
143
|
+
# for a pixel artifact and asserts nothing about normalisation.
|
|
144
|
+
if scale is not None and scale > 1.0 and result["box_residual_unscaled"] <= BOX_TOL_PX:
|
|
102
145
|
return False, (
|
|
103
|
-
"the
|
|
104
|
-
"
|
|
146
|
+
f"the x{scale:g} scale changed nothing — a twin whose RAW boxes already match "
|
|
147
|
+
"its ONNX source is not normalised, so the measured units are wrong"
|
|
148
|
+
)
|
|
149
|
+
# THE ROW MUST ESTABLISH ITS UNITS, BY EITHER ROUTE.
|
|
150
|
+
#
|
|
151
|
+
# `box_abs_max` settles it directly. But a row recorded before anything
|
|
152
|
+
# measured that field can still be decisive: if the scaled residual passes
|
|
153
|
+
# and the UNSCALED one misses by orders of magnitude, the artifact is
|
|
154
|
+
# demonstrably normalised — the 22 committed rows carry exactly that shape
|
|
155
|
+
# (e.g. 0.00087 px scaled against 641.03 unscaled). Demanding the field when
|
|
156
|
+
# the evidence is already present in another form would refuse good rows to
|
|
157
|
+
# satisfy a schema, so the check asks whether the units are ESTABLISHED, not
|
|
158
|
+
# whether a particular key exists.
|
|
159
|
+
#
|
|
160
|
+
# What it still refuses is the ambiguous row: no measurement, and two
|
|
161
|
+
# residuals that do not discriminate. That is a twin whose units nobody knows.
|
|
162
|
+
if result.get("box_abs_max") is None and result.get("units") is None:
|
|
163
|
+
scaled = result.get("box_residual_px")
|
|
164
|
+
unscaled = result.get("box_residual_unscaled")
|
|
165
|
+
decisive = (
|
|
166
|
+
scaled is not None and unscaled is not None
|
|
167
|
+
and scaled <= BOX_TOL_PX and unscaled > BOX_TOL_PX * 100
|
|
105
168
|
)
|
|
169
|
+
if not decisive:
|
|
170
|
+
return False, (
|
|
171
|
+
"twin recorded without box_abs_max, and its scaled/unscaled residuals do "
|
|
172
|
+
"not discriminate — units were assumed from the layout, and layout does "
|
|
173
|
+
"not determine units (0.0024 vs 2.8526 control, one venv, one exporter)"
|
|
174
|
+
)
|
|
106
175
|
if result["box_residual_px"] > BOX_TOL_PX:
|
|
107
176
|
return False, (
|
|
108
177
|
f"box residual {result['box_residual_px']:.4g} px exceeds {BOX_TOL_PX} "
|
|
@@ -130,7 +130,13 @@ CLASS_CONTROL = {
|
|
|
130
130
|
"gloves": ["ppe_full", "wc_glove"], "glove": ["ppe_full", "wc_glove"], "glove_hand": ["wc_glove", "ppe_full"],
|
|
131
131
|
"apron": ["wc_apron"], "Apron": ["wc_apron"], "hairnet": ["n_hairnet"], "Hairnet": ["n_hairnet"],
|
|
132
132
|
"safety cone": ["n_cone"], "safety_cone": ["n_cone"],
|
|
133
|
-
"fire_extinguisher": ["n_extinguisher"],
|
|
133
|
+
"fire_extinguisher": ["n_extinguisher"],
|
|
134
|
+
# `ladder_openimages` (22 frames / 22 scenes, `good`) leads because it is the set that
|
|
135
|
+
# can rate the class: human-verified Open Images boxes, hand-graded on the pixels.
|
|
136
|
+
# `n_ladder` is KEPT and stays second so its POOR number remains visible in
|
|
137
|
+
# `per_control` -- the same reason `fire_generic` was kept after it was demoted. A
|
|
138
|
+
# control that was refused is evidence about the search, and deleting it hides that.
|
|
139
|
+
"ladder": ["ladder_openimages", "n_ladder"],
|
|
134
140
|
"person": ["helmet", "ppe_full", "n_crowd"], "worker": ["ppe_full", "helmet", "n_crowd"],
|
|
135
141
|
"human": ["helmet", "ppe_full", "n_crowd"], "head": ["helmet", "n_crowd"],
|
|
136
142
|
"forklift": ["forklift"], "forklift-partial": ["forklift"], "Forklift-Partial": ["forklift"], "yoto": ["forklift"],
|
|
@@ -209,6 +215,33 @@ def assert_every_control_is_graded(pos_sets, ctlq) -> None:
|
|
|
209
215
|
"would be treated as able to rate a class." % ", ".join(missing))
|
|
210
216
|
|
|
211
217
|
|
|
218
|
+
#: The sellable-capability SHAPE contract, read by this tool and by
|
|
219
|
+
#: `.github/scripts/check-sensing-capability-shape.js`. ONE definition, two consumers --
|
|
220
|
+
#: the same posture as `LIVENESS_ONLY_GRADES` above and for the same reason: a tool and a
|
|
221
|
+
#: guard that each carried their own idea of which capabilities a frame can answer would
|
|
222
|
+
#: let a file through that the other would never emit.
|
|
223
|
+
CAPABILITY_SHAPE_PATH = os.path.join(
|
|
224
|
+
os.path.dirname(os.path.abspath(__file__)),
|
|
225
|
+
*([os.pardir] * 6), ".github", "contracts", "sensing-capability-shape.json")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def load_capability_shape(path: str = CAPABILITY_SHAPE_PATH) -> dict:
|
|
229
|
+
"""`capability -> row` from the shape contract.
|
|
230
|
+
|
|
231
|
+
REFUSES rather than degrading when the contract is missing. An adjudication that could
|
|
232
|
+
not read it would silently fall back to grading every capability as if a frame answered
|
|
233
|
+
it, which is exactly the state this file is being changed to end: on 2026-08-26 five
|
|
234
|
+
capabilities came back UNINFORMATIVE under one sentence -- "no usable positive control
|
|
235
|
+
exists for this class" -- and for four of them that was a statement about a set somebody
|
|
236
|
+
sourced and refused, while for `shoplifting / theft` it was a statement about a dict that
|
|
237
|
+
never had an entry. The reader could not tell, and `cctv-detector-tier.md` s10.7 then
|
|
238
|
+
told them all five needed "an afternoon of labelling".
|
|
239
|
+
"""
|
|
240
|
+
with open(path) as fh:
|
|
241
|
+
doc = json.load(fh)
|
|
242
|
+
return doc.get("capabilities") or {}
|
|
243
|
+
|
|
244
|
+
|
|
212
245
|
def normalise_class(name: str) -> str:
|
|
213
246
|
"""`labels.py::normalize_class_name`: lower, and `-`/`_` folded to space.
|
|
214
247
|
|
|
@@ -403,6 +436,10 @@ def main() -> None:
|
|
|
403
436
|
"`assert_every_control_is_graded` refuses the run rather than "
|
|
404
437
|
"adjudicating on a set nobody looked at."
|
|
405
438
|
)
|
|
439
|
+
ap.add_argument("--capability-shape", default=CAPABILITY_SHAPE_PATH,
|
|
440
|
+
help="the sellable-capability SHAPE contract. Defaults to "
|
|
441
|
+
".github/contracts/sensing-capability-shape.json; an override "
|
|
442
|
+
"exists for replaying an old run, not for the normal path.")
|
|
406
443
|
ap.add_argument("--out", required=True)
|
|
407
444
|
ap.add_argument("--markdown", help="also write the runbook table here")
|
|
408
445
|
args = ap.parse_args()
|
|
@@ -420,6 +457,15 @@ def main() -> None:
|
|
|
420
457
|
for _k, _v in json.load(open(_q)).items():
|
|
421
458
|
if isinstance(_v, dict) and isinstance(_v.get("grade"), str):
|
|
422
459
|
ctlq[_k] = _v
|
|
460
|
+
shape_rows = load_capability_shape(args.capability_shape)
|
|
461
|
+
missing_shape = sorted(set(CAPS) - set(shape_rows))
|
|
462
|
+
if missing_shape:
|
|
463
|
+
raise SystemExit(
|
|
464
|
+
"capability shape undeclared for: %s. Every sellable capability must say "
|
|
465
|
+
"whether a FRAME can answer it before a firing rate on it means anything; "
|
|
466
|
+
"add a row to .github/contracts/sensing-capability-shape.json. The "
|
|
467
|
+
"`sensing-capability-shape` guard refuses the same state statically."
|
|
468
|
+
% ", ".join(missing_shape))
|
|
423
469
|
index = json.load(open(os.path.join(args.out_dir, "_index.json")))
|
|
424
470
|
|
|
425
471
|
meta = combo["meta"]
|
|
@@ -779,7 +825,7 @@ def main() -> None:
|
|
|
779
825
|
c["per_control"] = {}
|
|
780
826
|
c["gate"] = None
|
|
781
827
|
|
|
782
|
-
now = decide(cap, cands)
|
|
828
|
+
now = decide(cap, cands, shape_rows)
|
|
783
829
|
was, was_model = PRIOR_VERDICT.get(cap, (None, None))
|
|
784
830
|
rows.append({"capability": cap, "n_candidates": len(cands),
|
|
785
831
|
"prior_decision_taxi_negatives": was,
|
|
@@ -790,6 +836,10 @@ def main() -> None:
|
|
|
790
836
|
json.dump({
|
|
791
837
|
"schema": "trillboards.venue-adjudication/1",
|
|
792
838
|
"negative_set": "venue-phase3-source-2026-08-26",
|
|
839
|
+
"capability_shape_contract": os.path.relpath(args.capability_shape, os.path.join(
|
|
840
|
+
os.path.dirname(os.path.abspath(__file__)), *([os.pardir] * 6))),
|
|
841
|
+
"inexpressible_capabilities": sorted(
|
|
842
|
+
k for k, v in shape_rows.items() if v.get("expressible_today") is False),
|
|
793
843
|
"supersedes": "the 1,500-frame `production` negative set of 2026-08-25, "
|
|
794
844
|
"1,494 of which were taxi_rideshare_tv cabin interiors",
|
|
795
845
|
"bar": {
|
|
@@ -892,11 +942,32 @@ def _other_controls_clause(win: dict) -> str:
|
|
|
892
942
|
return "; the same artifact at that threshold keeps " + ", ".join(parts)
|
|
893
943
|
|
|
894
944
|
|
|
895
|
-
def decide(cap: str, cands: list) -> dict:
|
|
896
|
-
"""PUBLISH / NO MODEL CLEARS BAR / NOT SCREENABLE, with the reason.
|
|
945
|
+
def decide(cap: str, cands: list, shape_rows: dict = None) -> dict:
|
|
946
|
+
"""NOT A FRAME QUESTION / PUBLISH / NO MODEL CLEARS BAR / NOT SCREENABLE, with the reason.
|
|
947
|
+
|
|
948
|
+
Same ladder as the 2026-08-25 screen below the first rung. Only the negative set differs.
|
|
897
949
|
|
|
898
|
-
|
|
950
|
+
THE FIRST RUNG IS NEW, and it runs before candidates are considered at all, because
|
|
951
|
+
whether a frame can answer the question is not a fact about which artifacts were sourced.
|
|
952
|
+
A capability the shape contract declares inexpressible gets `NOT A FRAME QUESTION` and
|
|
953
|
+
names the substrate it is blocked on -- never `UNINFORMATIVE`, which in this file means
|
|
954
|
+
"a control was sourced for this class and its contact-sheet grade refuses it" and is a
|
|
955
|
+
corpus problem an afternoon of labelling fixes.
|
|
899
956
|
"""
|
|
957
|
+
row = (shape_rows or {}).get(cap) or {}
|
|
958
|
+
if row.get("expressible_today") is False:
|
|
959
|
+
return {"decision": "NOT A FRAME QUESTION",
|
|
960
|
+
"shape": row.get("shape"),
|
|
961
|
+
"frame_observable": row.get("frame_observable"),
|
|
962
|
+
"substrate_required": row.get("substrate_required"),
|
|
963
|
+
"n_candidates_screened": len(cands),
|
|
964
|
+
"why": "this product cannot express the question, so no positive control "
|
|
965
|
+
"would make a firing rate here mean anything. Shape is `%s`; the "
|
|
966
|
+
"per-frame observable is %s; blocked on `%s`. See "
|
|
967
|
+
".github/contracts/sensing-capability-shape.json for the evidence and "
|
|
968
|
+
"docs/runbooks/sensing-model-library.md s1a for the working."
|
|
969
|
+
% (row.get("shape"), row.get("frame_observable"),
|
|
970
|
+
row.get("substrate_required"))}
|
|
900
971
|
if not cands:
|
|
901
972
|
return {"decision": "NO CANDIDATE",
|
|
902
973
|
"why": "no sourced artifact declares a class for this capability"}
|
|
@@ -909,9 +980,20 @@ def decide(cap: str, cands: list) -> dict:
|
|
|
909
980
|
live = [c for c in cands if (c["control_rate_025"] or 0) > 0]
|
|
910
981
|
if not live:
|
|
911
982
|
if all(c["best_control"] is None for c in cands):
|
|
983
|
+
# NAME the sets, because "no usable positive control exists" reads as a claim
|
|
984
|
+
# about the world and is a claim about two dicts. A class with a control that was
|
|
985
|
+
# sourced, rendered on a contact sheet and REFUSED is one afternoon of labelling
|
|
986
|
+
# from a verdict; a class that was never mapped may be a class no frame answers,
|
|
987
|
+
# and `NOT A FRAME QUESTION` above is where that ends up. Both used to print here.
|
|
988
|
+
mapped = sorted({n for c in cands
|
|
989
|
+
for n in CLASS_CONTROL_N.get(normalise_class(c["class"]), [])})
|
|
990
|
+
detail = ("the mapped control set(s) %s were all refused by their contact-sheet "
|
|
991
|
+
"grade" % ", ".join("`%s`" % m for m in mapped)) if mapped else (
|
|
992
|
+
"no control set is mapped to this class in CLASS_CONTROL at all")
|
|
912
993
|
return {"decision": "UNINFORMATIVE",
|
|
994
|
+
"controls_mapped": mapped,
|
|
913
995
|
"why": "no usable positive control exists for this class, so no firing "
|
|
914
|
-
"rate here is a finding"}
|
|
996
|
+
"rate here is a finding -- %s" % detail}
|
|
915
997
|
return {"decision": "NO MODEL CLEARS BAR",
|
|
916
998
|
"why": "no candidate fires on its positive control"}
|
|
917
999
|
retained = [c for c in live if (c["control_rate_at_quiet_t"] or 0) >= RECALL_RETENTION_BAR]
|
|
@@ -53,6 +53,7 @@ import numpy as np
|
|
|
53
53
|
|
|
54
54
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
55
55
|
|
|
56
|
+
from box_units import classify_box_units
|
|
56
57
|
from twin_parity_contract import ( # noqa: E402 (same directory)
|
|
57
58
|
ANCHOR,
|
|
58
59
|
BOX_TOL_PX,
|
|
@@ -93,12 +94,19 @@ def compare(onnx_out: np.ndarray, litert_out: np.ndarray, layout: str, imgsz: in
|
|
|
93
94
|
raise ParityError(f"shape mismatch onnx={onnx_out.shape} litert={litert_out.shape}")
|
|
94
95
|
a = litert_out.astype(np.float64)
|
|
95
96
|
b = onnx_out.astype(np.float64)
|
|
96
|
-
scale = box_scale_for(layout, imgsz)
|
|
97
97
|
box_l, cls_l = split_channels(a, layout)
|
|
98
98
|
box_o, cls_o = split_channels(b, layout)
|
|
99
|
+
# MEASURE the units off this artifact's own boxes rather than inferring them
|
|
100
|
+
# from the layout. Two anchor-layout twins exported minutes apart in one venv
|
|
101
|
+
# measured 0.0024 and 2.8526 — same tool, same flags, opposite units.
|
|
102
|
+
box_abs_max = float(np.abs(box_l).max()) if box_l.size else 0.0
|
|
103
|
+
units = classify_box_units(box_abs_max, imgsz)
|
|
104
|
+
scale = box_scale_for(layout, imgsz, box_abs_max=box_abs_max)
|
|
99
105
|
return {
|
|
100
106
|
"layout": layout,
|
|
101
107
|
"imgsz": imgsz,
|
|
108
|
+
"box_abs_max": box_abs_max,
|
|
109
|
+
"units": units["units"],
|
|
102
110
|
"box_scale_applied": scale,
|
|
103
111
|
"box_residual_px": float(np.abs(box_l * scale - box_o).max()),
|
|
104
112
|
"box_residual_unscaled": float(np.abs(box_l - box_o).max()),
|
|
@@ -106,23 +114,19 @@ def compare(onnx_out: np.ndarray, litert_out: np.ndarray, layout: str, imgsz: in
|
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
)
|
|
123
|
-
if result["cls_residual"] > CLS_TOL:
|
|
124
|
-
return False, f"class residual {result['cls_residual']:.4g} exceeds {CLS_TOL}"
|
|
125
|
-
return True, "ok"
|
|
117
|
+
# `verdict` is NOT redefined here. It lives in `twin_parity_contract` and is
|
|
118
|
+
# imported above.
|
|
119
|
+
#
|
|
120
|
+
# A second copy stood at this exact spot and SHADOWED the import. It was a
|
|
121
|
+
# faithful duplicate when written, so nothing looked wrong — and then the
|
|
122
|
+
# contract's copy was fixed to derive box units from the artifact while this one
|
|
123
|
+
# kept keying on the layout. The verifier went on refusing the pixel-space
|
|
124
|
+
# anchor twin the fix was written to admit, and the contract's own tests passed
|
|
125
|
+
# the whole time, because they exercise the module that is not the one running.
|
|
126
|
+
#
|
|
127
|
+
# Caught by codex on the very PR that introduced the divergence. One rule, one
|
|
128
|
+
# home: everything that decides pass/fail for a twin is in the contract module,
|
|
129
|
+
# which is also the module the stdlib-only CI lane can import without numpy.
|
|
126
130
|
|
|
127
131
|
|
|
128
132
|
def main() -> int:
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Box units are a property of the CHECKPOINT, not of the layout.
|
|
2
|
+
|
|
3
|
+
`box_scale_for` returned `imgsz` for every anchor-layout twin, reasoning that
|
|
4
|
+
ultralytics wraps a `task=detect` / `end2end=False` LiteRT export in
|
|
5
|
+
`_NormalizeCoords`. A control disproves the premise: two models exported MINUTES
|
|
6
|
+
APART in one venv — same tool 8.3.201, same flags (opset 19, imgsz 640,
|
|
7
|
+
nms=False), both classified anchor — measured
|
|
8
|
+
|
|
9
|
+
box residual 0.0024 under the normalized reading, and 2.8526 under the
|
|
10
|
+
pixel reading — one artifact matches its ONNX twin only when scaled, the
|
|
11
|
+
other only when not
|
|
12
|
+
|
|
13
|
+
so no function of (layout, imgsz) can be right in general. (Those two figures are
|
|
14
|
+
RESIDUALS, not box maxima; the maxima below are what `classify_box_units` grades,
|
|
15
|
+
and a normalized artifact's boxes top out near 1.0 while a pixel artifact's
|
|
16
|
+
approach imgsz.) Keying the verdict on
|
|
17
|
+
the layout refused a good artifact, which is why two models published
|
|
18
|
+
2026-08-27 shipped ONNX-only.
|
|
19
|
+
|
|
20
|
+
Stdlib only, like the rest of this suite, so the bare `detector-service` lane can
|
|
21
|
+
run it with no numpy and no artifact.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
import unittest
|
|
27
|
+
|
|
28
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "bench"))
|
|
29
|
+
|
|
30
|
+
from twin_parity_contract import ( # noqa: E402
|
|
31
|
+
ANCHOR,
|
|
32
|
+
BOX_TOL_PX,
|
|
33
|
+
ParityError,
|
|
34
|
+
box_scale_for,
|
|
35
|
+
verdict,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def row(**kw):
|
|
40
|
+
base = {
|
|
41
|
+
"layout": ANCHOR, "imgsz": 640, "cls_residual": 1e-7,
|
|
42
|
+
"box_scale_applied": 640.0, "box_residual_px": 0.0008,
|
|
43
|
+
"box_residual_unscaled": 641.0,
|
|
44
|
+
}
|
|
45
|
+
base.update(kw)
|
|
46
|
+
return base
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class BoxUnitsAreMeasured(unittest.TestCase):
|
|
50
|
+
def test_pixel_anchor_twin_passes(self):
|
|
51
|
+
"""The artifact the old rule refused. Anchor layout, PIXEL units."""
|
|
52
|
+
ok, why = verdict(row(box_abs_max=512.0, units="pixels",
|
|
53
|
+
box_scale_applied=1.0, box_residual_unscaled=0.0009,
|
|
54
|
+
box_residual_px=0.0009))
|
|
55
|
+
self.assertTrue(ok, why)
|
|
56
|
+
|
|
57
|
+
def test_normalised_anchor_twin_passes(self):
|
|
58
|
+
ok, why = verdict(row(box_abs_max=0.87, units="normalized"))
|
|
59
|
+
self.assertTrue(ok, why)
|
|
60
|
+
|
|
61
|
+
def test_the_640x_bug_is_still_caught(self):
|
|
62
|
+
"""Scaled by 640 and the scale changed nothing — the original defect."""
|
|
63
|
+
ok, why = verdict(row(box_abs_max=0.87, units="normalized",
|
|
64
|
+
box_residual_unscaled=0.0008))
|
|
65
|
+
self.assertFalse(ok)
|
|
66
|
+
self.assertIn("changed nothing", why)
|
|
67
|
+
|
|
68
|
+
def test_unmeasured_and_undiscriminating_is_refused(self):
|
|
69
|
+
"""No measurement AND residuals that do not separate = units unknown."""
|
|
70
|
+
ok, _ = verdict({"layout": ANCHOR, "imgsz": 640, "box_scale_applied": 640.0,
|
|
71
|
+
"box_residual_px": 0.0009,
|
|
72
|
+
"box_residual_unscaled": BOX_TOL_PX * 2, "cls_residual": 1e-7})
|
|
73
|
+
self.assertFalse(ok)
|
|
74
|
+
|
|
75
|
+
def test_historical_rows_establish_units_without_the_field(self):
|
|
76
|
+
"""The 22 committed rows predate box_abs_max and are still decisive.
|
|
77
|
+
|
|
78
|
+
Demanding the field when the evidence is present in another form would
|
|
79
|
+
refuse good rows to satisfy a schema.
|
|
80
|
+
"""
|
|
81
|
+
ok, why = verdict({"layout": ANCHOR, "imgsz": 640, "box_scale_applied": 640.0,
|
|
82
|
+
"box_residual_px": 0.00087, "box_residual_unscaled": 641.03,
|
|
83
|
+
"cls_residual": 2e-6})
|
|
84
|
+
self.assertTrue(ok, why)
|
|
85
|
+
|
|
86
|
+
def test_measurement_overrides_the_layout(self):
|
|
87
|
+
"""Same layout, same imgsz, opposite scales — decided by the artifact."""
|
|
88
|
+
self.assertEqual(box_scale_for(ANCHOR, 640, box_abs_max=0.87), 640.0)
|
|
89
|
+
self.assertEqual(box_scale_for(ANCHOR, 640, box_abs_max=512.0), 1.0)
|
|
90
|
+
|
|
91
|
+
def test_a_useless_measurement_refuses_rather_than_guessing(self):
|
|
92
|
+
with self.assertRaises(ParityError):
|
|
93
|
+
box_scale_for(ANCHOR, 640, box_abs_max=0.0)
|
|
94
|
+
|
|
95
|
+
def test_layout_route_survives_for_callers_with_no_measurement(self):
|
|
96
|
+
self.assertEqual(box_scale_for(ANCHOR, 640), 640.0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TheVerifierUsesTheContractsVerdict(unittest.TestCase):
|
|
100
|
+
"""One rule, one home — the verifier must not carry its own copy.
|
|
101
|
+
|
|
102
|
+
A faithful duplicate of `verdict` sat in verify_litert_twin_parity.py and
|
|
103
|
+
SHADOWED the import. It was identical when written, so nothing looked wrong;
|
|
104
|
+
then the contract's copy learned to derive box units from the artifact and
|
|
105
|
+
this one kept keying on the layout. The verifier went on refusing the
|
|
106
|
+
pixel-space anchor twin the fix existed to admit — and every contract test
|
|
107
|
+
passed throughout, because they exercise the module that is not the one
|
|
108
|
+
running. Caught by review on the PR that introduced the divergence.
|
|
109
|
+
|
|
110
|
+
Source-level, deliberately: importing the verifier needs numpy, and this
|
|
111
|
+
suite runs on the bare `detector-service` lane where numpy is absent.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def test_verifier_defines_no_verdict_of_its_own(self):
|
|
115
|
+
path = os.path.join(
|
|
116
|
+
os.path.dirname(__file__), "..", "bench", "verify_litert_twin_parity.py"
|
|
117
|
+
)
|
|
118
|
+
with open(path, encoding="utf-8") as fh:
|
|
119
|
+
src = fh.read()
|
|
120
|
+
self.assertNotIn(
|
|
121
|
+
"\ndef verdict(", src,
|
|
122
|
+
"verify_litert_twin_parity.py defines its own verdict(), which shadows the "
|
|
123
|
+
"imported contract copy — the two then drift silently and the tests exercise "
|
|
124
|
+
"the wrong one",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
def test_verifier_imports_the_contract_verdict(self):
|
|
128
|
+
path = os.path.join(
|
|
129
|
+
os.path.dirname(__file__), "..", "bench", "verify_litert_twin_parity.py"
|
|
130
|
+
)
|
|
131
|
+
with open(path, encoding="utf-8") as fh:
|
|
132
|
+
src = fh.read()
|
|
133
|
+
self.assertIn("verdict", src.split("from twin_parity_contract import (")[1][:400])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
unittest.main()
|
|
@@ -574,7 +574,29 @@ GH_PPE_VINAYAK_L = ModelVocabulary(
|
|
|
574
574
|
),
|
|
575
575
|
)
|
|
576
576
|
|
|
577
|
-
|
|
577
|
+
#: Fire and Smoke Detector — published 2026-08-27 by
|
|
578
|
+
#: scripts/sensing/publish-sensing-model.js. The class list is the artifact's own
|
|
579
|
+
#: (graph metadata key 'names'); the footprint is MEASURED, 123.7031 MiB after a
|
|
580
|
+
#: probe inference via rusage-maxrss, not an estimate from the file size.
|
|
581
|
+
#: Coordinate space MEASURED, not assumed: max|box channel| = 945.3029 over 1670 real frames; / imgsz 640 = 1.4770. pixels at >= 0.2, normalized at <= 0.02, refused in between.
|
|
582
|
+
FIRE_SMOKE_WESLLEYSKAH_11N_CLASS_NAMES: "MappingProxyType[int, str]" = MappingProxyType({
|
|
583
|
+
0: "fire",
|
|
584
|
+
1: "smoke",
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
FIRE_SMOKE_WESLLEYSKAH_11N = ModelVocabulary(
|
|
588
|
+
model_name="fire_smoke_weslleyskah_11n",
|
|
589
|
+
slot_names=FIRE_SMOKE_WESLLEYSKAH_11N_CLASS_NAMES,
|
|
590
|
+
logit_slots=2,
|
|
591
|
+
approx_ram_bytes=129712102,
|
|
592
|
+
onnx_variant=VariantContract(
|
|
593
|
+
output_layout="anchor_4_plus_nc",
|
|
594
|
+
box_units="pixels",
|
|
595
|
+
box_scale_to_pixels=1.0,
|
|
596
|
+
),
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
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,)
|
|
578
600
|
|
|
579
601
|
|
|
580
602
|
class ClassIndex:
|
|
@@ -668,6 +690,7 @@ class ClassIndex:
|
|
|
668
690
|
|
|
669
691
|
|
|
670
692
|
__all__ = [
|
|
693
|
+
"FIRE_SMOKE_WESLLEYSKAH_11N",
|
|
671
694
|
"GH_PPE_VINAYAK_L",
|
|
672
695
|
"FALL_1CLS_11X",
|
|
673
696
|
"PPE_KILLUMINATI_19CLS_V8S",
|
|
@@ -45,12 +45,16 @@ static BOOL acquireSetupInstanceLock(void) {
|
|
|
45
45
|
@property(nonatomic, strong) NSTextField *metric;
|
|
46
46
|
@property(nonatomic, strong) NSProgressIndicator *progress;
|
|
47
47
|
@property(nonatomic, strong) NSButton *actionButton;
|
|
48
|
+
@property(nonatomic, strong) NSButton *closeButton;
|
|
49
|
+
@property(nonatomic, strong) NSButton *updateButton;
|
|
50
|
+
@property(nonatomic, strong) NSTextField *updateStatus;
|
|
48
51
|
@property(nonatomic, strong) NSArray<NSTextField *> *stepLabels;
|
|
49
52
|
@property(nonatomic, strong) NSTimer *timer;
|
|
50
53
|
@property(nonatomic, assign) pid_t pairProcessIdentifier;
|
|
51
54
|
@property(nonatomic, strong) dispatch_source_t pairProcessSource;
|
|
52
55
|
@property(nonatomic, copy) NSString *lastStage;
|
|
53
56
|
@property(nonatomic, copy) NSString *portalURL;
|
|
57
|
+
@property(nonatomic, copy) NSString *requiredUpdateURL;
|
|
54
58
|
@property(nonatomic, assign) BOOL setupStarted;
|
|
55
59
|
@property(nonatomic, assign) BOOL terminating;
|
|
56
60
|
@end
|
|
@@ -88,6 +92,8 @@ static BOOL acquireSetupInstanceLock(void) {
|
|
|
88
92
|
repeats:YES];
|
|
89
93
|
[self.window makeKeyAndOrderFront:nil];
|
|
90
94
|
[NSApp activateIgnoringOtherApps:YES];
|
|
95
|
+
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.6 * NSEC_PER_SEC)),
|
|
96
|
+
dispatch_get_main_queue(), ^{ [self checkForUpdates:NO]; });
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
- (void)activateSetupWindow:(NSNotification *)notification {
|
|
@@ -279,6 +285,21 @@ static BOOL acquireSetupInstanceLock(void) {
|
|
|
279
285
|
self.progress.maxValue = 100;
|
|
280
286
|
[root addSubview:self.progress];
|
|
281
287
|
|
|
288
|
+
self.updateButton = [[NSButton alloc] initWithFrame:NSMakeRect(236, 101, 145, 30)];
|
|
289
|
+
self.updateButton.title = @"Check for updates";
|
|
290
|
+
self.updateButton.bezelStyle = NSBezelStyleRounded;
|
|
291
|
+
self.updateButton.target = self;
|
|
292
|
+
self.updateButton.action = @selector(checkForUpdatesPressed);
|
|
293
|
+
[root addSubview:self.updateButton];
|
|
294
|
+
|
|
295
|
+
NSString *installedVersion = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"0.0.0";
|
|
296
|
+
self.updateStatus = [self label:[NSString stringWithFormat:@"Connector v%@", installedVersion]
|
|
297
|
+
size:12
|
|
298
|
+
weight:NSFontWeightRegular
|
|
299
|
+
frame:NSMakeRect(393, 104, 308, 24)];
|
|
300
|
+
self.updateStatus.textColor = NSColor.secondaryLabelColor;
|
|
301
|
+
[root addSubview:self.updateStatus];
|
|
302
|
+
|
|
282
303
|
NSTextField *privacy = [self label:@"Camera credentials stay on this computer. Cloud inference is off by default."
|
|
283
304
|
size:12
|
|
284
305
|
weight:NSFontWeightRegular
|
|
@@ -292,6 +313,14 @@ static BOOL acquireSetupInstanceLock(void) {
|
|
|
292
313
|
self.actionButton.target = self;
|
|
293
314
|
self.actionButton.action = @selector(actionPressed);
|
|
294
315
|
[root addSubview:self.actionButton];
|
|
316
|
+
|
|
317
|
+
self.closeButton = [[NSButton alloc] initWithFrame:NSMakeRect(450, 36, 100, 40)];
|
|
318
|
+
self.closeButton.title = @"Close";
|
|
319
|
+
self.closeButton.bezelStyle = NSBezelStyleRounded;
|
|
320
|
+
self.closeButton.target = self;
|
|
321
|
+
self.closeButton.action = @selector(closePressed);
|
|
322
|
+
self.closeButton.hidden = YES;
|
|
323
|
+
[root addSubview:self.closeButton];
|
|
295
324
|
}
|
|
296
325
|
|
|
297
326
|
- (void)showWelcome {
|
|
@@ -304,6 +333,7 @@ static BOOL acquireSetupInstanceLock(void) {
|
|
|
304
333
|
self.progress.doubleValue = 0;
|
|
305
334
|
self.actionButton.title = @"Start setup";
|
|
306
335
|
self.actionButton.enabled = YES;
|
|
336
|
+
self.closeButton.hidden = YES;
|
|
307
337
|
[self updateSteps:0];
|
|
308
338
|
}
|
|
309
339
|
|
|
@@ -317,9 +347,109 @@ static BOOL acquireSetupInstanceLock(void) {
|
|
|
317
347
|
self.progress.doubleValue = 0;
|
|
318
348
|
self.actionButton.title = @"Try again";
|
|
319
349
|
self.actionButton.enabled = YES;
|
|
350
|
+
self.closeButton.hidden = YES;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
- (void)closePressed {
|
|
354
|
+
[self.window close];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
- (void)checkForUpdatesPressed {
|
|
358
|
+
[self checkForUpdates:YES];
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
- (void)checkForUpdates:(BOOL)manual {
|
|
362
|
+
if (!self.updateButton.enabled) return;
|
|
363
|
+
self.updateButton.enabled = NO;
|
|
364
|
+
self.updateButton.title = @"Checking...";
|
|
365
|
+
self.updateStatus.stringValue = @"Checking for connector updates...";
|
|
366
|
+
|
|
367
|
+
NSURL *url = [NSURL URLWithString:@"https://screen.trillboards.com/connector/trillboards-connector-macos.version.json"];
|
|
368
|
+
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url
|
|
369
|
+
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
|
|
370
|
+
timeoutInterval:15.0];
|
|
371
|
+
[request setValue:@"no-cache" forHTTPHeaderField:@"Cache-Control"];
|
|
372
|
+
NSURLSessionDataTask *task = [NSURLSession.sharedSession dataTaskWithRequest:request
|
|
373
|
+
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
|
|
374
|
+
(void)response;
|
|
375
|
+
dispatch_async(dispatch_get_main_queue(), ^{
|
|
376
|
+
self.updateButton.enabled = YES;
|
|
377
|
+
self.updateButton.title = @"Check for updates";
|
|
378
|
+
NSDictionary *manifest = data && !error
|
|
379
|
+
? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]
|
|
380
|
+
: nil;
|
|
381
|
+
NSString *availableText = [manifest[@"connectorVersion"] isKindOfClass:NSString.class]
|
|
382
|
+
? manifest[@"connectorVersion"] : nil;
|
|
383
|
+
NSString *downloadURL = [manifest[@"downloadUrl"] isKindOfClass:NSString.class]
|
|
384
|
+
? manifest[@"downloadUrl"] : nil;
|
|
385
|
+
NSString *installedText = [NSBundle.mainBundle objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @"0.0.0";
|
|
386
|
+
if (!availableText || !downloadURL) {
|
|
387
|
+
self.updateStatus.stringValue = @"Could not check for updates";
|
|
388
|
+
if (manual) {
|
|
389
|
+
NSAlert *alert = [[NSAlert alloc] init];
|
|
390
|
+
alert.messageText = @"Update check unavailable";
|
|
391
|
+
alert.informativeText = @"The update service could not be reached. Your connector can keep running; try again later.";
|
|
392
|
+
[alert runModal];
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
NSComparisonResult order = [availableText compare:installedText options:NSNumericSearch];
|
|
398
|
+
if (order != NSOrderedDescending) {
|
|
399
|
+
self.updateStatus.stringValue = [NSString stringWithFormat:@"Connector v%@ is up to date", installedText];
|
|
400
|
+
if (manual) {
|
|
401
|
+
NSAlert *alert = [[NSAlert alloc] init];
|
|
402
|
+
alert.messageText = @"No updates available";
|
|
403
|
+
alert.informativeText = [NSString stringWithFormat:@"Sense Anything Connector v%@ is up to date.", installedText];
|
|
404
|
+
[alert runModal];
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
NSInteger availableMajor = [availableText componentsSeparatedByString:@"."].firstObject.integerValue;
|
|
410
|
+
NSInteger installedMajor = [installedText componentsSeparatedByString:@"."].firstObject.integerValue;
|
|
411
|
+
if (availableMajor > installedMajor) {
|
|
412
|
+
if (self.setupStarted) {
|
|
413
|
+
self.setupStarted = NO;
|
|
414
|
+
[self stopPairProcess];
|
|
415
|
+
}
|
|
416
|
+
self.requiredUpdateURL = downloadURL;
|
|
417
|
+
self.lastStage = @"update_required";
|
|
418
|
+
self.headline.stringValue = @"Update required before setup can continue";
|
|
419
|
+
self.detail.stringValue = @"Install the latest Sense Anything Connector, then reopen it to finish camera setup.";
|
|
420
|
+
self.statusValue.stringValue = [NSString stringWithFormat:@"Version %@", availableText];
|
|
421
|
+
self.metric.stringValue = @"Camera setup is paused safely until the update is installed.";
|
|
422
|
+
self.progress.doubleValue = 0;
|
|
423
|
+
self.closeButton.hidden = YES;
|
|
424
|
+
self.updateStatus.stringValue = [NSString stringWithFormat:@"Required update v%@ available", availableText];
|
|
425
|
+
self.actionButton.title = @"Install required update";
|
|
426
|
+
self.actionButton.enabled = YES;
|
|
427
|
+
NSAlert *alert = [[NSAlert alloc] init];
|
|
428
|
+
alert.messageText = @"Required connector update";
|
|
429
|
+
alert.informativeText = [NSString stringWithFormat:@"Sense Anything Connector v%@ is a required major update. Install it before continuing.", availableText];
|
|
430
|
+
[alert addButtonWithTitle:@"Install Required Update"];
|
|
431
|
+
[alert runModal];
|
|
432
|
+
[self openURL:downloadURL];
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
self.updateStatus.stringValue = [NSString stringWithFormat:@"Optional update v%@ available", availableText];
|
|
437
|
+
NSAlert *alert = [[NSAlert alloc] init];
|
|
438
|
+
alert.messageText = @"Optional connector update";
|
|
439
|
+
alert.informativeText = [NSString stringWithFormat:@"Sense Anything Connector v%@ is available. You can install it now or keep this version.", availableText];
|
|
440
|
+
[alert addButtonWithTitle:@"Install Update"];
|
|
441
|
+
[alert addButtonWithTitle:@"Not Now"];
|
|
442
|
+
if ([alert runModal] == NSAlertFirstButtonReturn) [self openURL:downloadURL];
|
|
443
|
+
});
|
|
444
|
+
}];
|
|
445
|
+
[task resume];
|
|
320
446
|
}
|
|
321
447
|
|
|
322
448
|
- (void)actionPressed {
|
|
449
|
+
if (self.requiredUpdateURL) {
|
|
450
|
+
[self openURL:self.requiredUpdateURL];
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
323
453
|
if ([self.lastStage isEqualToString:@"waiting_for_account"] ||
|
|
324
454
|
[self.lastStage isEqualToString:@"account_attention"]) {
|
|
325
455
|
[self openURL:self.portalURL];
|
|
@@ -435,6 +565,7 @@ static BOOL acquireSetupInstanceLock(void) {
|
|
|
435
565
|
|
|
436
566
|
self.progress.doubleValue = [self progressForStage:stage];
|
|
437
567
|
[self updateSteps:[self stepForStage:stage]];
|
|
568
|
+
self.closeButton.hidden = ![stage isEqualToString:@"ready"];
|
|
438
569
|
if ([stage isEqualToString:@"waiting_for_account"]) {
|
|
439
570
|
self.actionButton.title = @"Open Sense Anything";
|
|
440
571
|
self.actionButton.enabled = YES;
|
|
@@ -454,6 +585,10 @@ static BOOL acquireSetupInstanceLock(void) {
|
|
|
454
585
|
self.actionButton.title = @"Working...";
|
|
455
586
|
self.actionButton.enabled = NO;
|
|
456
587
|
}
|
|
588
|
+
if (self.requiredUpdateURL) {
|
|
589
|
+
self.actionButton.title = @"Install required update";
|
|
590
|
+
self.actionButton.enabled = YES;
|
|
591
|
+
}
|
|
457
592
|
}
|
|
458
593
|
|
|
459
594
|
- (NSInteger)stepForStage:(NSString *)stage {
|
|
@@ -8,6 +8,7 @@ Set-StrictMode -Version Latest
|
|
|
8
8
|
|
|
9
9
|
Add-Type -AssemblyName System.Windows.Forms
|
|
10
10
|
Add-Type -AssemblyName System.Drawing
|
|
11
|
+
Add-Type -AssemblyName System.Net.Http
|
|
11
12
|
[System.Windows.Forms.Application]::EnableVisualStyles()
|
|
12
13
|
|
|
13
14
|
$BinDir = $PSScriptRoot
|
|
@@ -22,6 +23,14 @@ $script:SetupStarted = $false
|
|
|
22
23
|
$script:PortalUrl = 'https://app.senseanything.com/sense/cameras'
|
|
23
24
|
$script:PairProcess = $null
|
|
24
25
|
$script:SetupMutex = $null
|
|
26
|
+
$script:UpdateTask = $null
|
|
27
|
+
$script:UpdateClient = $null
|
|
28
|
+
$script:UpdateWasManual = $false
|
|
29
|
+
$script:RequiredUpdateUrl = $null
|
|
30
|
+
$UpdateManifestUrl = 'https://screen.trillboards.com/connector/trillboards-connector-windows.version.json'
|
|
31
|
+
$InstalledVersion = try {
|
|
32
|
+
[string](Get-ItemPropertyValue -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\TrillboardsEdge' -Name DisplayVersion -ErrorAction Stop)
|
|
33
|
+
} catch { '0.0.0' }
|
|
25
34
|
$StepNames = @('Welcome', 'Account', 'Recorder', 'Cameras', 'Local AI', 'Ready')
|
|
26
35
|
$StepLabels = New-Object System.Collections.Generic.List[System.Windows.Forms.Label]
|
|
27
36
|
|
|
@@ -168,6 +177,19 @@ $Progress.Minimum = 0
|
|
|
168
177
|
$Progress.Maximum = 100
|
|
169
178
|
$Form.Controls.Add($Progress)
|
|
170
179
|
|
|
180
|
+
$UpdateAction = New-Object System.Windows.Forms.Button
|
|
181
|
+
$UpdateAction.Text = 'Check for updates'
|
|
182
|
+
$UpdateAction.Font = New-Object System.Drawing.Font('Segoe UI', 8.5)
|
|
183
|
+
$UpdateAction.Location = New-Object System.Drawing.Point(236, 414)
|
|
184
|
+
$UpdateAction.Size = New-Object System.Drawing.Size(145, 30)
|
|
185
|
+
$Form.Controls.Add($UpdateAction)
|
|
186
|
+
|
|
187
|
+
$UpdateStatus = New-Label -Text "Connector v$InstalledVersion" -Size 8.5
|
|
188
|
+
$UpdateStatus.ForeColor = [System.Drawing.Color]::FromArgb(96, 113, 122)
|
|
189
|
+
$UpdateStatus.Location = New-Object System.Drawing.Point(393, 416)
|
|
190
|
+
$UpdateStatus.Size = New-Object System.Drawing.Size(308, 26)
|
|
191
|
+
$Form.Controls.Add($UpdateStatus)
|
|
192
|
+
|
|
171
193
|
$Privacy = New-Label -Text 'Camera credentials stay on this computer. Cloud inference is off by default.' -Size 8.5
|
|
172
194
|
$Privacy.ForeColor = [System.Drawing.Color]::FromArgb(96, 113, 122)
|
|
173
195
|
$Privacy.Location = New-Object System.Drawing.Point(236, 468)
|
|
@@ -185,6 +207,14 @@ $Action.FlatStyle = [System.Windows.Forms.FlatStyle]::Flat
|
|
|
185
207
|
$Action.FlatAppearance.BorderSize = 0
|
|
186
208
|
$Form.Controls.Add($Action)
|
|
187
209
|
|
|
210
|
+
$CloseAction = New-Object System.Windows.Forms.Button
|
|
211
|
+
$CloseAction.Text = 'Close'
|
|
212
|
+
$CloseAction.Font = New-Object System.Drawing.Font('Segoe UI', 10)
|
|
213
|
+
$CloseAction.Location = New-Object System.Drawing.Point(450, 466)
|
|
214
|
+
$CloseAction.Size = New-Object System.Drawing.Size(100, 42)
|
|
215
|
+
$CloseAction.Visible = $false
|
|
216
|
+
$Form.Controls.Add($CloseAction)
|
|
217
|
+
|
|
188
218
|
function Show-Welcome {
|
|
189
219
|
$script:LastStage = 'welcome'
|
|
190
220
|
$Headline.Text = 'Set up this computer for CCTV'
|
|
@@ -194,6 +224,7 @@ function Show-Welcome {
|
|
|
194
224
|
$Progress.Value = 0
|
|
195
225
|
$Action.Text = 'Start setup'
|
|
196
226
|
$Action.Enabled = $true
|
|
227
|
+
$CloseAction.Visible = $false
|
|
197
228
|
Update-Steps 0
|
|
198
229
|
}
|
|
199
230
|
|
|
@@ -206,6 +237,123 @@ function Show-LocalError([string]$Title, [string]$Message) {
|
|
|
206
237
|
$Progress.Value = 0
|
|
207
238
|
$Action.Text = 'Try again'
|
|
208
239
|
$Action.Enabled = $true
|
|
240
|
+
$CloseAction.Visible = $false
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function Convert-ToVersion([string]$Value) {
|
|
244
|
+
try { return [version](($Value -split '[-+]', 2)[0]) } catch { return $null }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function Open-ConnectorUpdate([string]$Url) {
|
|
248
|
+
if ([string]::IsNullOrWhiteSpace($Url)) { return }
|
|
249
|
+
try { Start-Process $Url } catch {
|
|
250
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
251
|
+
"Open this address to update:`r`n$Url",
|
|
252
|
+
'Sense Anything Connector update',
|
|
253
|
+
[System.Windows.Forms.MessageBoxButtons]::OK,
|
|
254
|
+
[System.Windows.Forms.MessageBoxIcon]::Information
|
|
255
|
+
) | Out-Null
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function Start-UpdateCheck([bool]$Manual = $true) {
|
|
260
|
+
if ($null -ne $script:UpdateTask) { return }
|
|
261
|
+
$script:UpdateWasManual = $Manual
|
|
262
|
+
$UpdateAction.Enabled = $false
|
|
263
|
+
$UpdateAction.Text = 'Checking...'
|
|
264
|
+
$UpdateStatus.Text = 'Checking for connector updates...'
|
|
265
|
+
try {
|
|
266
|
+
$script:UpdateClient = New-Object System.Net.Http.HttpClient
|
|
267
|
+
$script:UpdateClient.Timeout = [TimeSpan]::FromSeconds(15)
|
|
268
|
+
$script:UpdateClient.DefaultRequestHeaders.CacheControl = New-Object System.Net.Http.Headers.CacheControlHeaderValue
|
|
269
|
+
$script:UpdateClient.DefaultRequestHeaders.CacheControl.NoCache = $true
|
|
270
|
+
$script:UpdateTask = $script:UpdateClient.GetStringAsync($UpdateManifestUrl)
|
|
271
|
+
} catch {
|
|
272
|
+
$script:UpdateTask = $null
|
|
273
|
+
if ($null -ne $script:UpdateClient) { $script:UpdateClient.Dispose(); $script:UpdateClient = $null }
|
|
274
|
+
$UpdateAction.Enabled = $true
|
|
275
|
+
$UpdateAction.Text = 'Check for updates'
|
|
276
|
+
$UpdateStatus.Text = 'Update check unavailable'
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function Complete-UpdateCheck {
|
|
281
|
+
$task = $script:UpdateTask
|
|
282
|
+
if ($null -eq $task -or -not $task.IsCompleted) { return }
|
|
283
|
+
$script:UpdateTask = $null
|
|
284
|
+
$manual = $script:UpdateWasManual
|
|
285
|
+
$UpdateAction.Enabled = $true
|
|
286
|
+
$UpdateAction.Text = 'Check for updates'
|
|
287
|
+
try {
|
|
288
|
+
$manifest = ($task.GetAwaiter().GetResult() | ConvertFrom-Json)
|
|
289
|
+
$availableText = [string]$manifest.connectorVersion
|
|
290
|
+
$downloadUrl = [string]$manifest.downloadUrl
|
|
291
|
+
$available = Convert-ToVersion $availableText
|
|
292
|
+
$installed = Convert-ToVersion $InstalledVersion
|
|
293
|
+
if ($null -eq $available -or $null -eq $installed -or [string]::IsNullOrWhiteSpace($downloadUrl)) {
|
|
294
|
+
throw 'The update manifest is incomplete.'
|
|
295
|
+
}
|
|
296
|
+
if ($available -le $installed) {
|
|
297
|
+
$UpdateStatus.Text = "Connector v$InstalledVersion is up to date"
|
|
298
|
+
if ($manual) {
|
|
299
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
300
|
+
"Sense Anything Connector v$InstalledVersion is up to date.",
|
|
301
|
+
'No updates available',
|
|
302
|
+
[System.Windows.Forms.MessageBoxButtons]::OK,
|
|
303
|
+
[System.Windows.Forms.MessageBoxIcon]::Information
|
|
304
|
+
) | Out-Null
|
|
305
|
+
}
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if ($available.Major -gt $installed.Major) {
|
|
310
|
+
if ($script:SetupStarted) {
|
|
311
|
+
$script:SetupStarted = $false
|
|
312
|
+
Stop-SetupProcess
|
|
313
|
+
Resume-BackgroundSensing
|
|
314
|
+
}
|
|
315
|
+
$script:RequiredUpdateUrl = $downloadUrl
|
|
316
|
+
$script:LastStage = 'update_required'
|
|
317
|
+
$Headline.Text = 'Update required before setup can continue'
|
|
318
|
+
$Detail.Text = 'Install the latest Sense Anything Connector, then reopen it to finish camera setup.'
|
|
319
|
+
$StatusValue.Text = "Version $availableText"
|
|
320
|
+
$Metric.Text = 'Any existing camera service has been restored while setup is paused.'
|
|
321
|
+
$Progress.Value = 0
|
|
322
|
+
$CloseAction.Visible = $false
|
|
323
|
+
$UpdateStatus.Text = "Required update v$availableText available"
|
|
324
|
+
$Action.Text = 'Install required update'
|
|
325
|
+
$Action.Enabled = $true
|
|
326
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
327
|
+
"Sense Anything Connector v$availableText is a required major update. Install it before continuing.",
|
|
328
|
+
'Required connector update',
|
|
329
|
+
[System.Windows.Forms.MessageBoxButtons]::OK,
|
|
330
|
+
[System.Windows.Forms.MessageBoxIcon]::Warning
|
|
331
|
+
) | Out-Null
|
|
332
|
+
Open-ConnectorUpdate $downloadUrl
|
|
333
|
+
return
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
$UpdateStatus.Text = "Optional update v$availableText available"
|
|
337
|
+
$choice = [System.Windows.Forms.MessageBox]::Show(
|
|
338
|
+
"Sense Anything Connector v$availableText is available. Install it now?`r`n`r`nChoose No to keep this version.",
|
|
339
|
+
'Optional connector update',
|
|
340
|
+
[System.Windows.Forms.MessageBoxButtons]::YesNo,
|
|
341
|
+
[System.Windows.Forms.MessageBoxIcon]::Information
|
|
342
|
+
)
|
|
343
|
+
if ($choice -eq [System.Windows.Forms.DialogResult]::Yes) { Open-ConnectorUpdate $downloadUrl }
|
|
344
|
+
} catch {
|
|
345
|
+
$UpdateStatus.Text = 'Could not check for updates'
|
|
346
|
+
if ($manual) {
|
|
347
|
+
[System.Windows.Forms.MessageBox]::Show(
|
|
348
|
+
'The update service could not be reached. Your connector can keep running; try again later.',
|
|
349
|
+
'Update check unavailable',
|
|
350
|
+
[System.Windows.Forms.MessageBoxButtons]::OK,
|
|
351
|
+
[System.Windows.Forms.MessageBoxIcon]::Information
|
|
352
|
+
) | Out-Null
|
|
353
|
+
}
|
|
354
|
+
} finally {
|
|
355
|
+
if ($null -ne $script:UpdateClient) { $script:UpdateClient.Dispose(); $script:UpdateClient = $null }
|
|
356
|
+
}
|
|
209
357
|
}
|
|
210
358
|
|
|
211
359
|
function Start-Setup {
|
|
@@ -378,6 +526,7 @@ function Render-State($State) {
|
|
|
378
526
|
|
|
379
527
|
$Progress.Value = [Math]::Max(0, [Math]::Min(100, (Get-ProgressValue $stage $State)))
|
|
380
528
|
Update-Steps (Get-StepIndex $stage) ($stage -eq 'ready')
|
|
529
|
+
$CloseAction.Visible = $stage -eq 'ready'
|
|
381
530
|
switch ($stage) {
|
|
382
531
|
'waiting_for_account' { $Action.Text = 'Open Sense Anything'; $Action.Enabled = $true }
|
|
383
532
|
'account_attention' { $Action.Text = 'Switch account'; $Action.Enabled = $true }
|
|
@@ -386,9 +535,17 @@ function Render-State($State) {
|
|
|
386
535
|
'error' { $Action.Text = 'Try again'; $Action.Enabled = $true }
|
|
387
536
|
default { $Action.Text = 'Working...'; $Action.Enabled = $false }
|
|
388
537
|
}
|
|
538
|
+
if ($null -ne $script:RequiredUpdateUrl) {
|
|
539
|
+
$Action.Text = 'Install required update'
|
|
540
|
+
$Action.Enabled = $true
|
|
541
|
+
}
|
|
389
542
|
}
|
|
390
543
|
|
|
391
544
|
$Action.Add_Click({
|
|
545
|
+
if ($null -ne $script:RequiredUpdateUrl) {
|
|
546
|
+
Open-ConnectorUpdate $script:RequiredUpdateUrl
|
|
547
|
+
return
|
|
548
|
+
}
|
|
392
549
|
switch ($script:LastStage) {
|
|
393
550
|
'waiting_for_account' { Start-Process $script:PortalUrl }
|
|
394
551
|
'account_attention' { Start-Process $script:PortalUrl }
|
|
@@ -402,6 +559,9 @@ $Action.Add_Click({
|
|
|
402
559
|
}
|
|
403
560
|
})
|
|
404
561
|
|
|
562
|
+
$UpdateAction.Add_Click({ Start-UpdateCheck $true })
|
|
563
|
+
$CloseAction.Add_Click({ $Form.Close() })
|
|
564
|
+
|
|
405
565
|
$Form.Add_FormClosing({
|
|
406
566
|
Stop-SetupProcess
|
|
407
567
|
Resume-BackgroundSensing
|
|
@@ -410,11 +570,16 @@ $Form.Add_FormClosing({
|
|
|
410
570
|
$script:SetupMutex.Dispose()
|
|
411
571
|
$script:SetupMutex = $null
|
|
412
572
|
}
|
|
573
|
+
if ($null -ne $script:UpdateClient) {
|
|
574
|
+
$script:UpdateClient.Dispose()
|
|
575
|
+
$script:UpdateClient = $null
|
|
576
|
+
}
|
|
413
577
|
})
|
|
414
578
|
|
|
415
579
|
$Timer = New-Object System.Windows.Forms.Timer
|
|
416
580
|
$Timer.Interval = 500
|
|
417
581
|
$Timer.Add_Tick({
|
|
582
|
+
Complete-UpdateCheck
|
|
418
583
|
if ($script:SetupStarted -and (Test-Path $StatePath)) {
|
|
419
584
|
try {
|
|
420
585
|
$state = Get-Content -Raw -Path $StatePath | ConvertFrom-Json
|
|
@@ -477,5 +642,6 @@ if (-not $createdNew) {
|
|
|
477
642
|
}
|
|
478
643
|
|
|
479
644
|
$Timer.Start()
|
|
645
|
+
Start-UpdateCheck $false
|
|
480
646
|
[void]$Form.ShowDialog()
|
|
481
647
|
$Timer.Stop()
|
package/dist/EdgeAgent.js
CHANGED
|
@@ -44,7 +44,7 @@ const edge_ads_1 = require("@trillboards/edge-ads");
|
|
|
44
44
|
const CommandRouter_1 = require("./CommandRouter");
|
|
45
45
|
const config_1 = require("./config");
|
|
46
46
|
const dataDir_1 = require("./dataDir");
|
|
47
|
-
const SDK_VERSION = '0.2.
|
|
47
|
+
const SDK_VERSION = '0.2.111';
|
|
48
48
|
class EdgeAgent extends events_1.EventEmitter {
|
|
49
49
|
constructor(config, deps) {
|
|
50
50
|
super();
|
package/dist/diagnose.d.ts
CHANGED
|
@@ -31,7 +31,7 @@ export interface DiagnosticReport {
|
|
|
31
31
|
skipped: number;
|
|
32
32
|
};
|
|
33
33
|
}
|
|
34
|
-
declare const SDK_VERSION = "0.2.
|
|
34
|
+
declare const SDK_VERSION = "0.2.111";
|
|
35
35
|
declare function checkNodeVersion(): CheckResult;
|
|
36
36
|
declare function checkPlatform(): CheckResult;
|
|
37
37
|
declare function checkMemory(): CheckResult;
|
package/dist/diagnose.js
CHANGED
|
@@ -63,7 +63,7 @@ const localNetworkAccess_1 = require("./localNetworkAccess");
|
|
|
63
63
|
// ---------------------------------------------------------------------------
|
|
64
64
|
// SDK version — mirrors package.json; kept in sync by CI
|
|
65
65
|
// ---------------------------------------------------------------------------
|
|
66
|
-
const SDK_VERSION = '0.2.
|
|
66
|
+
const SDK_VERSION = '0.2.111';
|
|
67
67
|
exports._SDK_VERSION = SDK_VERSION;
|
|
68
68
|
// ---------------------------------------------------------------------------
|
|
69
69
|
// ANSI helpers (no external deps)
|
package/package.json
CHANGED