@trillboards/edge-sdk 0.2.4 → 0.2.6
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/README.md +4 -3
- package/deploy/docker/Dockerfile.cloud-sense +117 -0
- package/deploy/docker/Dockerfile.cpu +30 -21
- package/deploy/docker/Dockerfile.cuda +30 -21
- package/deploy/docker/Dockerfile.openvino +30 -20
- package/deploy/docker/README.md +9 -4
- package/deploy/docker/connector-entrypoint.sh +122 -0
- package/deploy/helm/README.md +1 -1
- package/deploy/k8s/daemonset.yaml +1 -1
- package/deploy/macos/README.md +161 -0
- package/deploy/macos/TrillboardsSetupApp.m +603 -0
- package/deploy/macos/build-macos-pkg.sh +588 -0
- package/deploy/macos/com.trillboards.edge.plist +62 -0
- package/deploy/macos/com.trillboards.litertlm.plist +45 -0
- package/deploy/macos/pair.command +519 -0
- package/deploy/macos/scripts/ensure-litertlm.sh +62 -0
- package/deploy/macos/scripts/postinstall +153 -0
- package/deploy/macos/scripts/setup-litertlm.sh +272 -0
- package/deploy/macos/test-install-pair.sh +383 -0
- package/deploy/macos/test-setup-litertlm-reinstall.sh +109 -0
- package/deploy/systemd/install.sh +25 -15
- package/deploy/systemd/trillboards-edge.service +4 -1
- package/deploy/windows/README.md +70 -0
- package/deploy/windows/build-windows.ps1 +225 -0
- package/deploy/windows/bundle-windows-installer.ps1 +153 -0
- package/deploy/windows/check-feeds.js +26 -0
- package/deploy/windows/e2e/fixtures/person.jpg +0 -0
- package/deploy/windows/e2e/test-local-sense.ps1 +185 -0
- package/deploy/windows/install-update.ps1 +101 -0
- package/deploy/windows/installer.nsi +138 -0
- package/deploy/windows/register-litertlm-task.ps1 +42 -0
- package/deploy/windows/register-task.ps1 +44 -0
- package/deploy/windows/run-connector.ps1 +69 -0
- package/deploy/windows/setup-litertlm.ps1 +361 -0
- package/deploy/windows/stop-existing.ps1 +72 -0
- package/deploy/windows/trillboards-edge-task.vbs +25 -0
- package/deploy/windows/trillboards-edge.cmd +33 -0
- package/deploy/windows/trillboards-pair.cmd +276 -0
- package/deploy/windows/trillboards-setup.ps1 +456 -0
- package/deploy/windows/trillboards-setup.vbs +13 -0
- package/dist/AudienceAnalyzeClient.d.ts +136 -0
- package/dist/AudienceAnalyzeClient.d.ts.map +1 -0
- package/dist/AudienceAnalyzeClient.js +246 -0
- package/dist/AudienceAnalyzeClient.js.map +1 -0
- package/dist/ChannelProber.d.ts +150 -0
- package/dist/ChannelProber.d.ts.map +1 -0
- package/dist/ChannelProber.js +887 -0
- package/dist/ChannelProber.js.map +1 -0
- package/dist/CloudEscalationPolicy.d.ts +143 -0
- package/dist/CloudEscalationPolicy.d.ts.map +1 -0
- package/dist/CloudEscalationPolicy.js +137 -0
- package/dist/CloudEscalationPolicy.js.map +1 -0
- package/dist/CloudSensingManager.d.ts +533 -0
- package/dist/CloudSensingManager.d.ts.map +1 -0
- package/dist/CloudSensingManager.js +1254 -0
- package/dist/CloudSensingManager.js.map +1 -0
- package/dist/DiscoveryReportClient.d.ts +66 -0
- package/dist/DiscoveryReportClient.d.ts.map +1 -0
- package/dist/DiscoveryReportClient.js +85 -0
- package/dist/DiscoveryReportClient.js.map +1 -0
- package/dist/EdgeAgent.d.ts +12 -2
- package/dist/EdgeAgent.d.ts.map +1 -1
- package/dist/EdgeAgent.js +55 -13
- package/dist/EdgeAgent.js.map +1 -1
- package/dist/LiteRtVlmProvider.d.ts +193 -0
- package/dist/LiteRtVlmProvider.d.ts.map +1 -0
- package/dist/LiteRtVlmProvider.js +436 -0
- package/dist/LiteRtVlmProvider.js.map +1 -0
- package/dist/MosaicSource.d.ts +108 -0
- package/dist/MosaicSource.d.ts.map +1 -0
- package/dist/MosaicSource.js +274 -0
- package/dist/MosaicSource.js.map +1 -0
- package/dist/OnvifClient.d.ts +83 -0
- package/dist/OnvifClient.d.ts.map +1 -0
- package/dist/OnvifClient.js +209 -0
- package/dist/OnvifClient.js.map +1 -0
- package/dist/PairClient.d.ts +106 -0
- package/dist/PairClient.d.ts.map +1 -0
- package/dist/PairClient.js +186 -0
- package/dist/PairClient.js.map +1 -0
- package/dist/RecorderAddressRecovery.d.ts +53 -0
- package/dist/RecorderAddressRecovery.d.ts.map +1 -0
- package/dist/RecorderAddressRecovery.js +359 -0
- package/dist/RecorderAddressRecovery.js.map +1 -0
- package/dist/SceneChangeDetector.d.ts +291 -0
- package/dist/SceneChangeDetector.d.ts.map +1 -0
- package/dist/SceneChangeDetector.js +682 -0
- package/dist/SceneChangeDetector.js.map +1 -0
- package/dist/SensingProfileClient.d.ts +59 -0
- package/dist/SensingProfileClient.d.ts.map +1 -0
- package/dist/SensingProfileClient.js +78 -0
- package/dist/SensingProfileClient.js.map +1 -0
- package/dist/SetupSessionClient.d.ts +114 -0
- package/dist/SetupSessionClient.d.ts.map +1 -0
- package/dist/SetupSessionClient.js +95 -0
- package/dist/SetupSessionClient.js.map +1 -0
- package/dist/VlmAdmissionGate.d.ts +126 -0
- package/dist/VlmAdmissionGate.d.ts.map +1 -0
- package/dist/VlmAdmissionGate.js +166 -0
- package/dist/VlmAdmissionGate.js.map +1 -0
- package/dist/autoSense.d.ts +216 -0
- package/dist/autoSense.d.ts.map +1 -0
- package/dist/autoSense.js +521 -0
- package/dist/autoSense.js.map +1 -0
- package/dist/cli.d.ts +27 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +511 -21
- package/dist/cli.js.map +1 -1
- package/dist/combinedSense.d.ts +128 -0
- package/dist/combinedSense.d.ts.map +1 -0
- package/dist/combinedSense.js +261 -0
- package/dist/combinedSense.js.map +1 -0
- package/dist/config.d.ts +351 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +99 -2
- package/dist/config.js.map +1 -1
- package/dist/connectorCadence.d.ts +135 -0
- package/dist/connectorCadence.d.ts.map +1 -0
- package/dist/connectorCadence.js +151 -0
- package/dist/connectorCadence.js.map +1 -0
- package/dist/dataDir.d.ts +33 -0
- package/dist/dataDir.d.ts.map +1 -0
- package/dist/dataDir.js +76 -0
- package/dist/dataDir.js.map +1 -0
- package/dist/deployedCadence.d.ts +167 -0
- package/dist/deployedCadence.d.ts.map +1 -0
- package/dist/deployedCadence.js +209 -0
- package/dist/deployedCadence.js.map +1 -0
- package/dist/deployedCadenceSource.d.ts +48 -0
- package/dist/deployedCadenceSource.d.ts.map +1 -0
- package/dist/deployedCadenceSource.js +74 -0
- package/dist/deployedCadenceSource.js.map +1 -0
- package/dist/diagnose.d.ts +1 -1
- package/dist/diagnose.d.ts.map +1 -1
- package/dist/diagnose.js +13 -12
- package/dist/diagnose.js.map +1 -1
- package/dist/discovery.d.ts +131 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +499 -0
- package/dist/discovery.js.map +1 -0
- package/dist/gemmaModelOta.d.ts +118 -0
- package/dist/gemmaModelOta.d.ts.map +1 -0
- package/dist/gemmaModelOta.js +291 -0
- package/dist/gemmaModelOta.js.map +1 -0
- package/dist/index.d.ts +16 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -1
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +41 -18
- package/dist/init.js.map +1 -1
- package/dist/keepAwake.d.ts +33 -0
- package/dist/keepAwake.d.ts.map +1 -0
- package/dist/keepAwake.js +54 -0
- package/dist/keepAwake.js.map +1 -0
- package/dist/legacyRecorderProfile.d.ts +20 -0
- package/dist/legacyRecorderProfile.d.ts.map +1 -0
- package/dist/legacyRecorderProfile.js +27 -0
- package/dist/legacyRecorderProfile.js.map +1 -0
- package/dist/localInferenceSchema.d.ts +132 -0
- package/dist/localInferenceSchema.d.ts.map +1 -0
- package/dist/localInferenceSchema.js +274 -0
- package/dist/localInferenceSchema.js.map +1 -0
- package/dist/localSenseE2eHarness.d.ts +101 -0
- package/dist/localSenseE2eHarness.d.ts.map +1 -0
- package/dist/localSenseE2eHarness.js +279 -0
- package/dist/localSenseE2eHarness.js.map +1 -0
- package/dist/localSensing.d.ts +268 -0
- package/dist/localSensing.d.ts.map +1 -0
- package/dist/localSensing.js +492 -0
- package/dist/localSensing.js.map +1 -0
- package/dist/modelIntegrity.d.ts +59 -0
- package/dist/modelIntegrity.d.ts.map +1 -0
- package/dist/modelIntegrity.js +165 -0
- package/dist/modelIntegrity.js.map +1 -0
- package/dist/pairVerify.d.ts +100 -0
- package/dist/pairVerify.d.ts.map +1 -0
- package/dist/pairVerify.js +296 -0
- package/dist/pairVerify.js.map +1 -0
- package/dist/qrScanner.d.ts +33 -0
- package/dist/qrScanner.d.ts.map +1 -0
- package/dist/qrScanner.js +103 -0
- package/dist/qrScanner.js.map +1 -0
- package/dist/runtimePaths.d.ts +10 -0
- package/dist/runtimePaths.d.ts.map +1 -0
- package/dist/runtimePaths.js +79 -0
- package/dist/runtimePaths.js.map +1 -0
- package/dist/sensingRunMode.d.ts +68 -0
- package/dist/sensingRunMode.d.ts.map +1 -0
- package/dist/sensingRunMode.js +346 -0
- package/dist/sensingRunMode.js.map +1 -0
- package/dist/setupMode.d.ts +98 -0
- package/dist/setupMode.d.ts.map +1 -0
- package/dist/setupMode.js +828 -0
- package/dist/setupMode.js.map +1 -0
- package/dist/setupProgress.d.ts +31 -0
- package/dist/setupProgress.d.ts.map +1 -0
- package/dist/setupProgress.js +173 -0
- package/dist/setupProgress.js.map +1 -0
- package/package.json +13 -4
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"SceneChangeDetector.d.ts","sourceRoot":"","sources":["../src/SceneChangeDetector.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* SceneChangeDetector + MotionGate — the connector's ONE motion implementation.
|
|
4
|
+
*
|
|
5
|
+
* This is the cheap stage of the cascade that stands in front of the expensive
|
|
6
|
+
* one: in a real store most frames are an empty aisle, and today every one of
|
|
7
|
+
* them is decoded, prompted and generated over by Gemma at 6–32 s a frame. The
|
|
8
|
+
* cheap stage answers "did anything change?" in single-digit milliseconds on a
|
|
9
|
+
* ~178x100 greyscale frame — about 116x fewer pixels than the 640x480 JPEG the
|
|
10
|
+
* VLM would otherwise chew through.
|
|
11
|
+
*
|
|
12
|
+
* PORTED FROM FRIGATE (MIT), `frigate/motion/improved_motion.py` +
|
|
13
|
+
* `frigate/config/camera/motion.py`, keeping its defaults and — more
|
|
14
|
+
* importantly — its two non-obvious behaviors:
|
|
15
|
+
*
|
|
16
|
+
* 1. THE BACKGROUND ONLY MOVES AFTER MOTION PERSISTS. `accumulateWeighted`
|
|
17
|
+
* runs every frame while the scene is still, but while motion is present
|
|
18
|
+
* it runs only once the motion has lasted `BACKGROUND_UPDATE_AFTER_FRAMES`
|
|
19
|
+
* (10) frames. Without that rule a slowly-moving object is absorbed into
|
|
20
|
+
* the background and becomes invisible mid-walk.
|
|
21
|
+
* 2. A CALIBRATION STATE MACHINE. A camera flipping its IR-cut filter at dusk
|
|
22
|
+
* changes every pixel at once; so does a cloud crossing the sun. Frigate
|
|
23
|
+
* treats "more than `lightningThreshold` of the frame moved" as a signal
|
|
24
|
+
* to re-enter calibration rather than as motion, and stays calibrating
|
|
25
|
+
* until the scene settles (<5% moved AND <=4 regions). We suppress
|
|
26
|
+
* VLM-triggering while calibrating for exactly that reason — otherwise
|
|
27
|
+
* dusk costs a 16-camera store one full inference storm per day.
|
|
28
|
+
*
|
|
29
|
+
* RELATIONSHIP TO THE ANDROID `SceneChangeDetector`. agent-core carries a class
|
|
30
|
+
* of the same name (`ctv/core/audience/SceneChangeDetector.kt`) built on
|
|
31
|
+
* luminance-HISTOGRAM chi-squared distance over Android `Bitmap`s. That is the
|
|
32
|
+
* right shape for a tablet whose camera frames a single face at arm's length,
|
|
33
|
+
* and the wrong shape here: a person crossing a wide CCTV frame moves a few
|
|
34
|
+
* percent of the pixels and barely perturbs a global histogram, which is why
|
|
35
|
+
* this port is region-based (threshold → dilate → connected components) instead.
|
|
36
|
+
* Two runtimes (Kotlin/Bitmap vs Node/Buffer) with two different jobs; there is
|
|
37
|
+
* exactly ONE motion implementation on each side, and this is the connector's.
|
|
38
|
+
*/
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.MotionGate = exports.SceneChangeDetector = void 0;
|
|
41
|
+
exports.resetGreyDecoderCache = resetGreyDecoderCache;
|
|
42
|
+
exports.decodeGreyFrame = decodeGreyFrame;
|
|
43
|
+
exports.downscaleRgbaToGrey = downscaleRgbaToGrey;
|
|
44
|
+
exports.motionSignature = motionSignature;
|
|
45
|
+
const DEFAULT_FRAME_HEIGHT = 100;
|
|
46
|
+
const DEFAULT_THRESHOLD = 30;
|
|
47
|
+
const DEFAULT_CONTOUR_AREA = 10;
|
|
48
|
+
const DEFAULT_FRAME_ALPHA = 0.01;
|
|
49
|
+
const DEFAULT_CALIBRATING_ALPHA = 0.2;
|
|
50
|
+
const DEFAULT_LIGHTNING_THRESHOLD = 0.8;
|
|
51
|
+
/** Frames motion must persist before the background is allowed to absorb it. */
|
|
52
|
+
const BACKGROUND_UPDATE_AFTER_FRAMES = 10;
|
|
53
|
+
/**
|
|
54
|
+
* Frigate keeps the last 50 contrast readings and stretches to their mean.
|
|
55
|
+
*
|
|
56
|
+
* Frigate SEEDS that ring with (0, 255) and lets real readings displace the
|
|
57
|
+
* seed one frame at a time. At its 5 fps that seed is gone in ten seconds. We
|
|
58
|
+
* look every `scanIntervalMs` — 30 s by default — so the same ring takes
|
|
59
|
+
* 50 x 30 s = 25 MINUTES to fill, and for all of it the stretch mapping is
|
|
60
|
+
* still crawling from the synthetic seed toward the scene's real percentiles.
|
|
61
|
+
*
|
|
62
|
+
* A moving mapping means the CONTRAST-STRETCHED frame changes every look even
|
|
63
|
+
* when the camera is bolted to a wall pointing at an empty aisle. Measured on a
|
|
64
|
+
* byte-identical still frame at the 30 s cadence: no motion through look 32,
|
|
65
|
+
* then phantom motion from look 33 to look 93 (wall clock 16.5 min -> 46.5 min
|
|
66
|
+
* after start) — 53 inferences, ~387 s of the single admission slot, per camera,
|
|
67
|
+
* against a scene where literally nothing happened. `reset()` re-arms it, so a
|
|
68
|
+
* feed that flaps re-enters the window every reconnect.
|
|
69
|
+
*
|
|
70
|
+
* Seeding from the FIRST real reading instead makes the mapping stationary from
|
|
71
|
+
* frame 1. Once the ring holds 50 real readings the behaviour is identical to
|
|
72
|
+
* Frigate's; only the startup transient differs, and only because our cadence
|
|
73
|
+
* is 150x slower than the one Frigate chose the seed for.
|
|
74
|
+
*/
|
|
75
|
+
const CONTRAST_HISTORY = 50;
|
|
76
|
+
/** Cells per axis in the motion signature grid (stationary suppression). */
|
|
77
|
+
const SIGNATURE_GRID = 16;
|
|
78
|
+
/** scipy `gaussian_filter(sigma=1, radius=1)`, normalized. Separable 1-D. */
|
|
79
|
+
const GAUSS_1D = [0.2740686, 0.4518628, 0.2740686];
|
|
80
|
+
/**
|
|
81
|
+
* The Frigate motion core, one instance per camera feed. Stateful: it owns the
|
|
82
|
+
* running background model, the contrast history and the calibration state, so
|
|
83
|
+
* frames must be fed to the SAME instance in capture order.
|
|
84
|
+
*/
|
|
85
|
+
class SceneChangeDetector {
|
|
86
|
+
constructor(options = {}) {
|
|
87
|
+
this.avgFrame = null;
|
|
88
|
+
this.avgWidth = 0;
|
|
89
|
+
this.avgHeight = 0;
|
|
90
|
+
this.motionFrameCount = 0;
|
|
91
|
+
this.calibrating = true;
|
|
92
|
+
this.contrastMin = new Int32Array(CONTRAST_HISTORY);
|
|
93
|
+
this.contrastMax = new Int32Array(CONTRAST_HISTORY).fill(255);
|
|
94
|
+
this.contrastIndex = 0;
|
|
95
|
+
/** False until the ring has been seeded from a real frame (see CONTRAST_HISTORY). */
|
|
96
|
+
this.contrastSeeded = false;
|
|
97
|
+
this.framesSeen = 0;
|
|
98
|
+
this.frameHeight = Math.max(16, Math.trunc(options.frameHeight ?? DEFAULT_FRAME_HEIGHT));
|
|
99
|
+
this.threshold = Math.min(255, Math.max(1, Math.trunc(options.threshold ?? DEFAULT_THRESHOLD)));
|
|
100
|
+
this.contourArea = Math.max(1, Math.trunc(options.contourArea ?? DEFAULT_CONTOUR_AREA));
|
|
101
|
+
this.frameAlpha = clamp01(options.frameAlpha ?? DEFAULT_FRAME_ALPHA);
|
|
102
|
+
this.calibratingAlpha = clamp01(options.calibratingAlpha ?? DEFAULT_CALIBRATING_ALPHA);
|
|
103
|
+
this.lightningThreshold = clamp01(options.lightningThreshold ?? DEFAULT_LIGHTNING_THRESHOLD);
|
|
104
|
+
this.improveContrast = options.improveContrast !== false;
|
|
105
|
+
}
|
|
106
|
+
/** Height (px) callers must downscale to before calling `detect`. */
|
|
107
|
+
getFrameHeight() {
|
|
108
|
+
return this.frameHeight;
|
|
109
|
+
}
|
|
110
|
+
isCalibrating() {
|
|
111
|
+
return this.calibrating;
|
|
112
|
+
}
|
|
113
|
+
/** Frames fed so far (a feed with 0 has never produced a motion verdict). */
|
|
114
|
+
getFramesSeen() {
|
|
115
|
+
return this.framesSeen;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Forget the background model. Call when the view itself changed (camera
|
|
119
|
+
* re-opened on a different channel, connector resumed after a long stop) so a
|
|
120
|
+
* stale baseline cannot report a whole-frame "change" that never happened.
|
|
121
|
+
*/
|
|
122
|
+
reset() {
|
|
123
|
+
this.avgFrame = null;
|
|
124
|
+
this.avgWidth = 0;
|
|
125
|
+
this.avgHeight = 0;
|
|
126
|
+
this.motionFrameCount = 0;
|
|
127
|
+
this.calibrating = true;
|
|
128
|
+
this.contrastMin.fill(0);
|
|
129
|
+
this.contrastMax.fill(255);
|
|
130
|
+
this.contrastIndex = 0;
|
|
131
|
+
this.contrastSeeded = false;
|
|
132
|
+
this.framesSeen = 0;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* One motion verdict. `frame` must already be greyscale at `frameHeight`
|
|
136
|
+
* (see `decodeGreyFrame`), which is what keeps this stage ~116x cheaper than
|
|
137
|
+
* handing the full JPEG to the model.
|
|
138
|
+
*/
|
|
139
|
+
detect(frame) {
|
|
140
|
+
const { width, height } = frame;
|
|
141
|
+
this.framesSeen++;
|
|
142
|
+
let working = this.improveContrast ? this.stretchContrast(frame) : Uint8Array.from(frame.data);
|
|
143
|
+
working = blur3x3(working, width, height);
|
|
144
|
+
// A new (or resized) view has no baseline: seed the background from this
|
|
145
|
+
// frame and report NO motion rather than a whole-frame false positive.
|
|
146
|
+
if (!this.avgFrame || this.avgWidth !== width || this.avgHeight !== height) {
|
|
147
|
+
this.avgFrame = Float32Array.from(working);
|
|
148
|
+
this.avgWidth = width;
|
|
149
|
+
this.avgHeight = height;
|
|
150
|
+
this.motionFrameCount = 0;
|
|
151
|
+
this.calibrating = true;
|
|
152
|
+
return { motion: false, boxes: [], pctMotion: 0, calibrating: true, signature: '' };
|
|
153
|
+
}
|
|
154
|
+
// |frame - background| > threshold, then dilate to close holes.
|
|
155
|
+
const avg = this.avgFrame;
|
|
156
|
+
const mask = new Uint8Array(width * height);
|
|
157
|
+
for (let i = 0; i < mask.length; i++) {
|
|
158
|
+
// cv2.convertScaleAbs rounds the float background before differencing.
|
|
159
|
+
if (Math.abs(working[i] - Math.round(avg[i])) > this.threshold)
|
|
160
|
+
mask[i] = 1;
|
|
161
|
+
}
|
|
162
|
+
const dilated = dilate3x3(mask, width, height);
|
|
163
|
+
const components = connectedComponents(dilated, width, height);
|
|
164
|
+
const boxes = [];
|
|
165
|
+
let totalArea = 0;
|
|
166
|
+
for (const c of components) {
|
|
167
|
+
totalArea += c.area;
|
|
168
|
+
if (c.area > this.contourArea)
|
|
169
|
+
boxes.push(c);
|
|
170
|
+
}
|
|
171
|
+
const pctMotion = totalArea / (width * height);
|
|
172
|
+
// Calibration state machine (Frigate's order: settle first, then re-arm).
|
|
173
|
+
if (pctMotion < 0.05 && boxes.length <= 4)
|
|
174
|
+
this.calibrating = false;
|
|
175
|
+
if (this.calibrating || pctMotion > this.lightningThreshold)
|
|
176
|
+
this.calibrating = true;
|
|
177
|
+
// Background update: every still frame, but only once motion has PERSISTED
|
|
178
|
+
// — otherwise a slow walker is absorbed and disappears mid-frame.
|
|
179
|
+
const alpha = this.calibrating ? this.calibratingAlpha : this.frameAlpha;
|
|
180
|
+
if (boxes.length > 0) {
|
|
181
|
+
this.motionFrameCount++;
|
|
182
|
+
if (this.motionFrameCount >= BACKGROUND_UPDATE_AFTER_FRAMES) {
|
|
183
|
+
accumulateWeighted(working, avg, alpha);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
accumulateWeighted(working, avg, alpha);
|
|
188
|
+
this.motionFrameCount = 0;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
motion: boxes.length > 0,
|
|
192
|
+
boxes,
|
|
193
|
+
pctMotion,
|
|
194
|
+
calibrating: this.calibrating,
|
|
195
|
+
signature: motionSignature(boxes, width, height),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Frigate's `improve_contrast`: stretch the 4th–96th percentile band to
|
|
200
|
+
* 0..255 using the MEAN of the last 50 readings, so one odd frame cannot
|
|
201
|
+
* yank the mapping and manufacture motion everywhere.
|
|
202
|
+
*/
|
|
203
|
+
stretchContrast(frame) {
|
|
204
|
+
const src = frame.data;
|
|
205
|
+
const out = new Uint8Array(src.length);
|
|
206
|
+
const hist = new Int32Array(256);
|
|
207
|
+
for (let i = 0; i < src.length; i++)
|
|
208
|
+
hist[src[i]]++;
|
|
209
|
+
const minValue = percentileFromHistogram(hist, src.length, 0.04);
|
|
210
|
+
const maxValue = percentileFromHistogram(hist, src.length, 0.96);
|
|
211
|
+
if (minValue < maxValue) {
|
|
212
|
+
if (!this.contrastSeeded) {
|
|
213
|
+
// First real reading fills the whole ring, so the stretch mapping is
|
|
214
|
+
// stationary from frame 1 instead of crawling off the (0,255) seed for
|
|
215
|
+
// the next 25 minutes of 30 s looks. See CONTRAST_HISTORY.
|
|
216
|
+
this.contrastMin.fill(minValue);
|
|
217
|
+
this.contrastMax.fill(maxValue);
|
|
218
|
+
this.contrastSeeded = true;
|
|
219
|
+
this.contrastIndex = 0;
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
this.contrastMin[this.contrastIndex] = minValue;
|
|
223
|
+
this.contrastMax[this.contrastIndex] = maxValue;
|
|
224
|
+
}
|
|
225
|
+
this.contrastIndex = (this.contrastIndex + 1) % CONTRAST_HISTORY;
|
|
226
|
+
}
|
|
227
|
+
let sumMin = 0;
|
|
228
|
+
let sumMax = 0;
|
|
229
|
+
for (let i = 0; i < CONTRAST_HISTORY; i++) {
|
|
230
|
+
sumMin += this.contrastMin[i];
|
|
231
|
+
sumMax += this.contrastMax[i];
|
|
232
|
+
}
|
|
233
|
+
const avgMin = sumMin / CONTRAST_HISTORY;
|
|
234
|
+
const avgMax = sumMax / CONTRAST_HISTORY;
|
|
235
|
+
if (!(avgMax > avgMin))
|
|
236
|
+
return Uint8Array.from(src);
|
|
237
|
+
const scale = 255 / (avgMax - avgMin);
|
|
238
|
+
for (let i = 0; i < src.length; i++) {
|
|
239
|
+
const clipped = src[i] < avgMin ? avgMin : src[i] > avgMax ? avgMax : src[i];
|
|
240
|
+
out[i] = (clipped - avgMin) * scale;
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
exports.SceneChangeDetector = SceneChangeDetector;
|
|
246
|
+
const DEFAULT_STATIONARY_REANALYZE_EVERY = 10;
|
|
247
|
+
/**
|
|
248
|
+
* Per-feed cascade decision: decode cheap → motion → should this frame cost a
|
|
249
|
+
* VLM call? Owns the feed's `SceneChangeDetector`, its heartbeat clock and its
|
|
250
|
+
* stationary-suppression counter.
|
|
251
|
+
*
|
|
252
|
+
* FAILS OPEN. Any decode failure analyzes the frame. A motion gate that cannot
|
|
253
|
+
* see must not be allowed to silence a camera — the whole product is the alert
|
|
254
|
+
* that fires.
|
|
255
|
+
*/
|
|
256
|
+
class MotionGate {
|
|
257
|
+
constructor(options) {
|
|
258
|
+
this.lastAnalyzedAtMs = null;
|
|
259
|
+
this.lastAnalyzedSignature = null;
|
|
260
|
+
this.stationarySuppressions = 0;
|
|
261
|
+
this.evaluated = 0;
|
|
262
|
+
this.analyzed = 0;
|
|
263
|
+
this.byReason = {
|
|
264
|
+
heartbeat: 0,
|
|
265
|
+
motion: 0,
|
|
266
|
+
no_motion: 0,
|
|
267
|
+
calibrating: 0,
|
|
268
|
+
stationary: 0,
|
|
269
|
+
rate_limited: 0,
|
|
270
|
+
undecodable: 0,
|
|
271
|
+
};
|
|
272
|
+
this.detector = new SceneChangeDetector(options);
|
|
273
|
+
this.heartbeatIntervalMs = Math.max(0, Math.trunc(options.heartbeatIntervalMs));
|
|
274
|
+
this.minAnalyzeIntervalMs = Math.max(0, Math.trunc(options.minAnalyzeIntervalMs ?? 0));
|
|
275
|
+
this.stationaryReanalyzeEvery = Math.max(0, Math.trunc(options.stationaryReanalyzeEvery ?? DEFAULT_STATIONARY_REANALYZE_EVERY));
|
|
276
|
+
this.decodeFrame = options.decode ?? decodeGreyFrame;
|
|
277
|
+
this.now = options.now ?? Date.now;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Decide whether this JPEG earns an inference. Decision order matters:
|
|
281
|
+
* heartbeat first (proof-of-life outranks everything), then the motion
|
|
282
|
+
* verdict, then the suppressions that make a busy-but-boring scene cheap.
|
|
283
|
+
*/
|
|
284
|
+
async evaluate(jpeg, nowMs) {
|
|
285
|
+
const now = nowMs ?? this.now();
|
|
286
|
+
this.evaluated++;
|
|
287
|
+
const heartbeatDue = this.lastAnalyzedAtMs === null || now - this.lastAnalyzedAtMs >= this.heartbeatIntervalMs;
|
|
288
|
+
let grey = null;
|
|
289
|
+
try {
|
|
290
|
+
grey = await this.decodeFrame(jpeg, this.detector.getFrameHeight());
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
grey = null;
|
|
294
|
+
}
|
|
295
|
+
if (!grey) {
|
|
296
|
+
// No eyes → no opinion → do not suppress. A gate that cannot see must
|
|
297
|
+
// never be the reason a camera goes quiet.
|
|
298
|
+
return this.decide(true, 'undecodable', false, 0, [], null);
|
|
299
|
+
}
|
|
300
|
+
const result = this.detector.detect(grey);
|
|
301
|
+
const live = result.motion && !result.calibrating;
|
|
302
|
+
if (heartbeatDue) {
|
|
303
|
+
return this.decide(true, 'heartbeat', live, result.pctMotion, result.boxes, result.signature);
|
|
304
|
+
}
|
|
305
|
+
if (!result.motion) {
|
|
306
|
+
return this.decide(false, 'no_motion', false, result.pctMotion, result.boxes, result.signature);
|
|
307
|
+
}
|
|
308
|
+
if (result.calibrating) {
|
|
309
|
+
// Dusk IR-cut flip / cloud shadow: everything "moved" and none of it is real.
|
|
310
|
+
return this.decide(false, 'calibrating', false, result.pctMotion, result.boxes, result.signature);
|
|
311
|
+
}
|
|
312
|
+
if (this.stationaryReanalyzeEvery > 0 &&
|
|
313
|
+
this.lastAnalyzedSignature !== null &&
|
|
314
|
+
result.signature === this.lastAnalyzedSignature) {
|
|
315
|
+
this.stationarySuppressions++;
|
|
316
|
+
if (this.stationarySuppressions < this.stationaryReanalyzeEvery) {
|
|
317
|
+
return this.decide(false, 'stationary', live, result.pctMotion, result.boxes, result.signature);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (this.minAnalyzeIntervalMs > 0 &&
|
|
321
|
+
this.lastAnalyzedAtMs !== null &&
|
|
322
|
+
now - this.lastAnalyzedAtMs < this.minAnalyzeIntervalMs) {
|
|
323
|
+
return this.decide(false, 'rate_limited', live, result.pctMotion, result.boxes, result.signature);
|
|
324
|
+
}
|
|
325
|
+
return this.decide(true, 'motion', live, result.pctMotion, result.boxes, result.signature);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Confirm the frame really reached the model. Called by the manager only
|
|
329
|
+
* after `analyze()` resolves, so a frame the admission gate or the network
|
|
330
|
+
* dropped does not reset the heartbeat and silence the feed.
|
|
331
|
+
*/
|
|
332
|
+
markAnalyzed(nowMs) {
|
|
333
|
+
this.lastAnalyzedAtMs = nowMs ?? this.now();
|
|
334
|
+
}
|
|
335
|
+
/** The still-scene reporting floor currently in force (ms). */
|
|
336
|
+
getHeartbeatIntervalMs() {
|
|
337
|
+
return this.heartbeatIntervalMs;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Retune the still-scene reporting floor WITHOUT forgetting anything.
|
|
341
|
+
*
|
|
342
|
+
* Changing a cadence is a statement about how often a still scene must report.
|
|
343
|
+
* It is NOT a statement that the scene changed, that the camera moved, or that
|
|
344
|
+
* the last analysis never happened — but rebuilding the gate says all three:
|
|
345
|
+
* a new `SceneChangeDetector` has no background model, is back in
|
|
346
|
+
* `calibrating`, has an un-seeded contrast ring (the exact state the
|
|
347
|
+
* CONTRAST_HISTORY seeding fix exists to avoid), and a null
|
|
348
|
+
* `lastAnalyzedAtMs`, which makes the very next look heartbeat-DUE and buys a
|
|
349
|
+
* forced inference per feed for a scene the gate had already proven still.
|
|
350
|
+
*
|
|
351
|
+
* The heartbeat is a scalar the next `evaluate()` reads. Moving it is the
|
|
352
|
+
* whole change.
|
|
353
|
+
*
|
|
354
|
+
* @returns true when the interval actually changed.
|
|
355
|
+
*/
|
|
356
|
+
setHeartbeatIntervalMs(intervalMs) {
|
|
357
|
+
const raw = Number(intervalMs);
|
|
358
|
+
if (!Number.isFinite(raw))
|
|
359
|
+
return false;
|
|
360
|
+
const next = Math.max(0, Math.trunc(raw));
|
|
361
|
+
if (next === this.heartbeatIntervalMs)
|
|
362
|
+
return false;
|
|
363
|
+
this.heartbeatIntervalMs = next;
|
|
364
|
+
return true;
|
|
365
|
+
}
|
|
366
|
+
/** Forget the background model (camera re-opened on a different view). */
|
|
367
|
+
reset() {
|
|
368
|
+
this.detector.reset();
|
|
369
|
+
this.lastAnalyzedSignature = null;
|
|
370
|
+
this.stationarySuppressions = 0;
|
|
371
|
+
}
|
|
372
|
+
getStats() {
|
|
373
|
+
return {
|
|
374
|
+
evaluated: this.evaluated,
|
|
375
|
+
analyzed: this.analyzed,
|
|
376
|
+
skipped: this.evaluated - this.analyzed,
|
|
377
|
+
byReason: { ...this.byReason },
|
|
378
|
+
calibrating: this.detector.isCalibrating(),
|
|
379
|
+
framesSeen: this.detector.getFramesSeen(),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
decide(analyze, reason, motion, pctMotion, boxes, signature) {
|
|
383
|
+
this.byReason[reason]++;
|
|
384
|
+
if (analyze) {
|
|
385
|
+
this.analyzed++;
|
|
386
|
+
// The signature/counter track what the MODEL last saw, so a suppressed
|
|
387
|
+
// frame never moves the stationary baseline.
|
|
388
|
+
//
|
|
389
|
+
// The heartbeat CLOCK is deliberately not touched here: only
|
|
390
|
+
// markAnalyzed() moves it, and only after the call landed. A feed whose
|
|
391
|
+
// inference failed therefore stays heartbeat-due and retries on its next
|
|
392
|
+
// look, instead of going quiet for a whole heartbeat window because of
|
|
393
|
+
// one bad request.
|
|
394
|
+
this.lastAnalyzedSignature = signature;
|
|
395
|
+
this.stationarySuppressions = 0;
|
|
396
|
+
}
|
|
397
|
+
return { analyze, reason, motion, pctMotion, boxes };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
exports.MotionGate = MotionGate;
|
|
401
|
+
let sharpModule;
|
|
402
|
+
let jpegModule;
|
|
403
|
+
function loadSharp() {
|
|
404
|
+
if (sharpModule === undefined) {
|
|
405
|
+
try {
|
|
406
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
407
|
+
sharpModule = require('sharp');
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
sharpModule = null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return sharpModule;
|
|
414
|
+
}
|
|
415
|
+
function loadJpeg() {
|
|
416
|
+
if (jpegModule === undefined) {
|
|
417
|
+
try {
|
|
418
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
419
|
+
jpegModule = require('jpeg-js');
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
jpegModule = null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
return jpegModule;
|
|
426
|
+
}
|
|
427
|
+
/** Test seam: forget which decoder was resolved. */
|
|
428
|
+
function resetGreyDecoderCache() {
|
|
429
|
+
sharpModule = undefined;
|
|
430
|
+
jpegModule = undefined;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* JPEG → greyscale at `targetHeight`, the ~116x pixel reduction the whole
|
|
434
|
+
* cascade rests on. `sharp` (a hard dependency of @trillboards/edge-sensing, so
|
|
435
|
+
* present wherever sensing runs) does it natively; `jpeg-js` is the pure-JS
|
|
436
|
+
* fallback. Returns null when neither is available, which makes the gate fail
|
|
437
|
+
* open rather than blind a camera.
|
|
438
|
+
*
|
|
439
|
+
* WHY NOT FRIGATE'S LITERAL `INTER_NEAREST`. Frigate picks nearest because
|
|
440
|
+
* `cv2.resize` nearest is the cheapest thing that exists and it blurs the
|
|
441
|
+
* result immediately afterwards anyway. sharp's "nearest" is not a true
|
|
442
|
+
* point-sample — it pre-shrinks on load — so asking for it buys none of
|
|
443
|
+
* Frigate's determinism and costs the two decoders their agreement (measured
|
|
444
|
+
* MAD 21.8 grey levels against a real point-sample, versus 4.2 for cubic
|
|
445
|
+
* against the fallback's box average). Both decoders low-pass before
|
|
446
|
+
* decimating, which is what the subsequent absdiff+threshold wants, and they
|
|
447
|
+
* land within ~4 grey levels of each other — far inside the threshold of 30, so
|
|
448
|
+
* a host with sharp and a host without cannot disagree about whether a scene
|
|
449
|
+
* moved.
|
|
450
|
+
*/
|
|
451
|
+
async function decodeGreyFrame(jpeg, targetHeight) {
|
|
452
|
+
const sharp = loadSharp();
|
|
453
|
+
if (sharp) {
|
|
454
|
+
const { data, info } = await sharp(jpeg)
|
|
455
|
+
.resize({ height: targetHeight, fit: 'inside', kernel: 'cubic', withoutEnlargement: false })
|
|
456
|
+
.greyscale()
|
|
457
|
+
.raw()
|
|
458
|
+
.toBuffer({ resolveWithObject: true });
|
|
459
|
+
return { width: info.width, height: info.height, data: new Uint8Array(data.buffer, data.byteOffset, data.length) };
|
|
460
|
+
}
|
|
461
|
+
const jpegJs = loadJpeg();
|
|
462
|
+
if (!jpegJs)
|
|
463
|
+
return null;
|
|
464
|
+
const decoded = jpegJs.decode(jpeg, { useTArray: true });
|
|
465
|
+
return downscaleRgbaToGrey(decoded.data, decoded.width, decoded.height, targetHeight);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* RGBA → greyscale + box-average downscale: each output pixel is the mean of
|
|
469
|
+
* the source rectangle it covers. Averaging (rather than point-sampling) is
|
|
470
|
+
* what keeps this in step with the sharp path and what stops a single noisy
|
|
471
|
+
* pixel from surviving a 4.8x decimation into the difference image.
|
|
472
|
+
*/
|
|
473
|
+
function downscaleRgbaToGrey(rgba, width, height, targetHeight) {
|
|
474
|
+
const outH = Math.max(1, Math.min(height, Math.round(targetHeight)));
|
|
475
|
+
const outW = Math.max(1, Math.round((width * outH) / height));
|
|
476
|
+
const out = new Uint8Array(outW * outH);
|
|
477
|
+
for (let y = 0; y < outH; y++) {
|
|
478
|
+
const sy0 = Math.floor((y * height) / outH);
|
|
479
|
+
const sy1 = Math.max(sy0 + 1, Math.floor(((y + 1) * height) / outH));
|
|
480
|
+
for (let x = 0; x < outW; x++) {
|
|
481
|
+
const sx0 = Math.floor((x * width) / outW);
|
|
482
|
+
const sx1 = Math.max(sx0 + 1, Math.floor(((x + 1) * width) / outW));
|
|
483
|
+
let sum = 0;
|
|
484
|
+
let n = 0;
|
|
485
|
+
for (let sy = sy0; sy < sy1 && sy < height; sy++) {
|
|
486
|
+
for (let sx = sx0; sx < sx1 && sx < width; sx++) {
|
|
487
|
+
const p = (sy * width + sx) * 4;
|
|
488
|
+
// ITU-R BT.601 luma, the same weights sharp's greyscale uses.
|
|
489
|
+
sum += 0.299 * rgba[p] + 0.587 * rgba[p + 1] + 0.114 * rgba[p + 2];
|
|
490
|
+
n++;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
out[y * outW + x] = n > 0 ? Math.round(sum / n) : 0;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return { width: outW, height: outH, data: out };
|
|
497
|
+
}
|
|
498
|
+
// ─── Pixel kernels ───────────────────────────────────────────────────────────
|
|
499
|
+
function clamp01(v) {
|
|
500
|
+
return v < 0 ? 0 : v > 1 ? 1 : v;
|
|
501
|
+
}
|
|
502
|
+
/** Value at quantile `q` from a 256-bin histogram (np.percentile, floored). */
|
|
503
|
+
function percentileFromHistogram(hist, total, q) {
|
|
504
|
+
if (total <= 0)
|
|
505
|
+
return 0;
|
|
506
|
+
const target = Math.round(q * (total - 1));
|
|
507
|
+
let seen = 0;
|
|
508
|
+
for (let v = 0; v < 256; v++) {
|
|
509
|
+
seen += hist[v];
|
|
510
|
+
if (seen > target)
|
|
511
|
+
return v;
|
|
512
|
+
}
|
|
513
|
+
return 255;
|
|
514
|
+
}
|
|
515
|
+
/** Separable 3x3 gaussian (sigma=1, radius=1) with edge clamping. */
|
|
516
|
+
function blur3x3(src, width, height) {
|
|
517
|
+
const tmp = new Float32Array(src.length);
|
|
518
|
+
const out = new Uint8Array(src.length);
|
|
519
|
+
const [k0, k1, k2] = GAUSS_1D;
|
|
520
|
+
for (let y = 0; y < height; y++) {
|
|
521
|
+
const row = y * width;
|
|
522
|
+
for (let x = 0; x < width; x++) {
|
|
523
|
+
const l = src[row + (x > 0 ? x - 1 : 0)];
|
|
524
|
+
const c = src[row + x];
|
|
525
|
+
const r = src[row + (x < width - 1 ? x + 1 : width - 1)];
|
|
526
|
+
tmp[row + x] = k0 * l + k1 * c + k2 * r;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
for (let y = 0; y < height; y++) {
|
|
530
|
+
const up = (y > 0 ? y - 1 : 0) * width;
|
|
531
|
+
const row = y * width;
|
|
532
|
+
const down = (y < height - 1 ? y + 1 : height - 1) * width;
|
|
533
|
+
for (let x = 0; x < width; x++) {
|
|
534
|
+
const v = k0 * tmp[up + x] + k1 * tmp[row + x] + k2 * tmp[down + x];
|
|
535
|
+
out[row + x] = v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* cv2.dilate with the default 3x3 rectangular element, 1 iteration. A rect
|
|
542
|
+
* element is separable, so this is two 1-D passes instead of a 9-tap window.
|
|
543
|
+
*/
|
|
544
|
+
function dilate3x3(src, width, height) {
|
|
545
|
+
const tmp = new Uint8Array(src.length);
|
|
546
|
+
const out = new Uint8Array(src.length);
|
|
547
|
+
for (let y = 0; y < height; y++) {
|
|
548
|
+
const row = y * width;
|
|
549
|
+
for (let x = 0; x < width; x++) {
|
|
550
|
+
const l = x > 0 ? src[row + x - 1] : 0;
|
|
551
|
+
const r = x < width - 1 ? src[row + x + 1] : 0;
|
|
552
|
+
tmp[row + x] = src[row + x] | l | r;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (let y = 0; y < height; y++) {
|
|
556
|
+
const row = y * width;
|
|
557
|
+
const up = y > 0 ? row - width : row;
|
|
558
|
+
const down = y < height - 1 ? row + width : row;
|
|
559
|
+
for (let x = 0; x < width; x++) {
|
|
560
|
+
out[row + x] = tmp[row + x] | tmp[up + x] | tmp[down + x];
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* 8-connected components — the counterpart of `cv2.findContours(RETR_EXTERNAL)`
|
|
567
|
+
* + `contourArea` + `boundingRect`. Area is the foreground PIXEL count, which
|
|
568
|
+
* is marginally more permissive than OpenCV's polygon area for the same blob;
|
|
569
|
+
* on a 100 px-tall frame with a floor of 10 px that difference is well inside
|
|
570
|
+
* the noise the dilate step already introduces.
|
|
571
|
+
*/
|
|
572
|
+
function connectedComponents(src, width, height) {
|
|
573
|
+
const labels = new Int32Array(src.length);
|
|
574
|
+
// Union-find over provisional labels (index 0 unused).
|
|
575
|
+
const parent = [0];
|
|
576
|
+
const find = (a) => {
|
|
577
|
+
let root = a;
|
|
578
|
+
while (parent[root] !== root)
|
|
579
|
+
root = parent[root];
|
|
580
|
+
let cur = a;
|
|
581
|
+
while (parent[cur] !== root) {
|
|
582
|
+
const next = parent[cur];
|
|
583
|
+
parent[cur] = root;
|
|
584
|
+
cur = next;
|
|
585
|
+
}
|
|
586
|
+
return root;
|
|
587
|
+
};
|
|
588
|
+
const union = (a, b) => {
|
|
589
|
+
const ra = find(a);
|
|
590
|
+
const rb = find(b);
|
|
591
|
+
if (ra !== rb)
|
|
592
|
+
parent[Math.max(ra, rb)] = Math.min(ra, rb);
|
|
593
|
+
};
|
|
594
|
+
let next = 1;
|
|
595
|
+
// Already-scanned 8-neighbours of (x, y): NW, N, NE, W. Reused, never
|
|
596
|
+
// reallocated — this runs once per foreground pixel on every frame.
|
|
597
|
+
const neighbours = new Int32Array(4);
|
|
598
|
+
for (let y = 0; y < height; y++) {
|
|
599
|
+
for (let x = 0; x < width; x++) {
|
|
600
|
+
const i = y * width + x;
|
|
601
|
+
if (!src[i])
|
|
602
|
+
continue;
|
|
603
|
+
neighbours[0] = y > 0 && x > 0 ? labels[i - width - 1] : 0;
|
|
604
|
+
neighbours[1] = y > 0 ? labels[i - width] : 0;
|
|
605
|
+
neighbours[2] = y > 0 && x < width - 1 ? labels[i - width + 1] : 0;
|
|
606
|
+
neighbours[3] = x > 0 ? labels[i - 1] : 0;
|
|
607
|
+
let best = 0;
|
|
608
|
+
let bestRoot = 0;
|
|
609
|
+
for (let n = 0; n < 4; n++) {
|
|
610
|
+
const label = neighbours[n];
|
|
611
|
+
if (!label)
|
|
612
|
+
continue;
|
|
613
|
+
const root = find(label);
|
|
614
|
+
if (best === 0 || root < bestRoot) {
|
|
615
|
+
best = label;
|
|
616
|
+
bestRoot = root;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (best === 0) {
|
|
620
|
+
labels[i] = next;
|
|
621
|
+
parent[next] = next;
|
|
622
|
+
next++;
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
labels[i] = best;
|
|
626
|
+
for (let n = 0; n < 4; n++)
|
|
627
|
+
if (neighbours[n])
|
|
628
|
+
union(best, neighbours[n]);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
const boxes = new Map();
|
|
633
|
+
for (let y = 0; y < height; y++) {
|
|
634
|
+
for (let x = 0; x < width; x++) {
|
|
635
|
+
const i = y * width + x;
|
|
636
|
+
if (!labels[i])
|
|
637
|
+
continue;
|
|
638
|
+
const root = find(labels[i]);
|
|
639
|
+
const box = boxes.get(root);
|
|
640
|
+
if (!box) {
|
|
641
|
+
boxes.set(root, { x, y, w: 1, h: 1, area: 1 });
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
const x1 = Math.max(box.x + box.w - 1, x);
|
|
645
|
+
const y1 = Math.max(box.y + box.h - 1, y);
|
|
646
|
+
const x0 = Math.min(box.x, x);
|
|
647
|
+
const y0 = Math.min(box.y, y);
|
|
648
|
+
box.x = x0;
|
|
649
|
+
box.y = y0;
|
|
650
|
+
box.w = x1 - x0 + 1;
|
|
651
|
+
box.h = y1 - y0 + 1;
|
|
652
|
+
box.area++;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return [...boxes.values()];
|
|
657
|
+
}
|
|
658
|
+
/** `avg = (1 - alpha) * avg + alpha * src` — cv2.accumulateWeighted. */
|
|
659
|
+
function accumulateWeighted(src, avg, alpha) {
|
|
660
|
+
const keep = 1 - alpha;
|
|
661
|
+
for (let i = 0; i < avg.length; i++)
|
|
662
|
+
avg[i] = keep * avg[i] + alpha * src[i];
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Quantize motion regions onto a coarse grid so "the same thing is still there"
|
|
666
|
+
* produces a stable string. Deliberately lossy: a person walking one cell over
|
|
667
|
+
* changes it, a swinging sign does not.
|
|
668
|
+
*/
|
|
669
|
+
function motionSignature(boxes, width, height) {
|
|
670
|
+
if (boxes.length === 0)
|
|
671
|
+
return '';
|
|
672
|
+
const cells = new Set();
|
|
673
|
+
for (const b of boxes) {
|
|
674
|
+
const cx0 = Math.floor((b.x * SIGNATURE_GRID) / width);
|
|
675
|
+
const cy0 = Math.floor((b.y * SIGNATURE_GRID) / height);
|
|
676
|
+
const cx1 = Math.floor(((b.x + b.w - 1) * SIGNATURE_GRID) / width);
|
|
677
|
+
const cy1 = Math.floor(((b.y + b.h - 1) * SIGNATURE_GRID) / height);
|
|
678
|
+
cells.add(`${cx0},${cy0},${cx1},${cy1}`);
|
|
679
|
+
}
|
|
680
|
+
return [...cells].sort().join('|');
|
|
681
|
+
}
|
|
682
|
+
//# sourceMappingURL=SceneChangeDetector.js.map
|