@mavware/bug-surveillance 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michael
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,214 @@
1
+ # @mavware/bug-surveillance
2
+
3
+ Point a phone or laptop camera at a room, leave it running all night, and get
4
+ back every time something walked through the frame: when, where it came in,
5
+ where it left, and a snapshot of each sighting.
6
+
7
+ Detection, tracking and reporting all run in the browser. There is no server,
8
+ no upload and no machine learning model to download. A night can be kept in the
9
+ browser's own IndexedDB store, so the library works with no backend at all.
10
+
11
+ Live demo: <https://mavware.github.io/bugtracker/>
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ npm install @mavware/bug-surveillance
17
+ ```
18
+
19
+ TypeScript declarations are included. This is a browser library, so a consumer's
20
+ `tsconfig.json` needs `"lib": ["ES2022", "DOM"]`.
21
+
22
+ ## How it fits together
23
+
24
+ Four pieces, which you wire into your own page:
25
+
26
+ - **`Camera`** owns the video stream and two canvases, one at full resolution
27
+ for reference photos and snapshots, one downscaled for per-frame work.
28
+ - **`Detector`** keeps a running-average background and reports the dark,
29
+ bug-sized blobs that differ from it. A frame holding something far larger than
30
+ a bug, such as a person or a pet, is dropped whole rather than reported as a
31
+ swarm.
32
+ - **`Tracker`** joins those blobs into paths across frames and hands you each
33
+ path once it ends.
34
+ - **A sink** receives those closed paths. `LocalNightSink` writes them to
35
+ IndexedDB. Implement the same handful of methods to upload them instead.
36
+
37
+ The library never touches your markup, your router or your framework. It decides;
38
+ your page plumbs.
39
+
40
+ ## Watching a room
41
+
42
+ ```js
43
+ import {
44
+ calibrate,
45
+ calibrationOutcome,
46
+ Camera,
47
+ DEFAULT_PARAMS,
48
+ Detector,
49
+ LEAVE_ROOM_SECONDS,
50
+ LocalNightSink,
51
+ openNightStore,
52
+ PREFLIGHT_MESSAGE,
53
+ Tracker,
54
+ WakeLock,
55
+ } from '@mavware/bug-surveillance';
56
+
57
+ const camera = new Camera(document.querySelector('video'), DEFAULT_PARAMS.procWidth);
58
+ const wakeLock = new WakeLock(() => console.warn('This screen will not stay on by itself.'));
59
+
60
+ // The order matters. Ask before the camera opens, and let the room empty before
61
+ // anything is measured: whoever pressed start would otherwise be baked into the
62
+ // background model and recorded walking out.
63
+ if (!window.confirm(PREFLIGHT_MESSAGE)) {
64
+ return;
65
+ }
66
+
67
+ await camera.start();
68
+ await new Promise((resolve) => setTimeout(resolve, LEAVE_ROOM_SECONDS * 1000));
69
+
70
+ const calibration = await calibrate(camera);
71
+
72
+ if (calibrationOutcome(calibration).blocked) {
73
+ throw new Error('Too dark to see anything.');
74
+ }
75
+
76
+ const settings = { ...DEFAULT_PARAMS, diffThreshold: calibration.diffThreshold };
77
+ const sink = new LocalNightSink({
78
+ store: await openNightStore(),
79
+ reportUrlTemplate: 'report.html?night=<LOCAL_ID_PLACEHOLDER>',
80
+ });
81
+
82
+ await sink.storeReference({
83
+ blob: await camera.captureReferenceJpeg(),
84
+ frameWidth: camera.frameWidth,
85
+ frameHeight: camera.frameHeight,
86
+ settings,
87
+ });
88
+
89
+ const startedAt = Date.now();
90
+ const detector = new Detector(settings);
91
+ const tracker = new Tracker({
92
+ scale: camera.scale,
93
+ sessionStartTime: startedAt,
94
+ captureCrop: (x, y) => camera.captureCropBase64(x, y),
95
+ onTrackClosed: (track) => sink.enqueue(track),
96
+ });
97
+
98
+ sink.start();
99
+ await wakeLock.acquire();
100
+
101
+ const loop = setInterval(() => {
102
+ tracker.update(detector.detect(camera.grabProcessedFrame()));
103
+ }, 1000 / settings.processFps);
104
+ ```
105
+
106
+ Ending the night flushes what is queued and hands back where its report lives:
107
+
108
+ ```js
109
+ clearInterval(loop);
110
+ tracker.flush();
111
+ sink.stop();
112
+ await sink.flush();
113
+ camera.stop();
114
+ await wakeLock.release();
115
+
116
+ const { reportUrl } = await sink.end({
117
+ endedAtOffsetMs: Date.now() - startedAt,
118
+ aborted: false,
119
+ });
120
+
121
+ window.location.assign(reportUrl);
122
+ ```
123
+
124
+ ## Reading a night back
125
+
126
+ `Replay` draws the trails and the animated playback over the reference photo,
127
+ and `mountReportControls` wires the play, speed, scrub and trail controls to it.
128
+
129
+ ```js
130
+ import {
131
+ buildLocalReportPayload,
132
+ mountReportControls,
133
+ openNightStore,
134
+ sightingRows,
135
+ statTiles,
136
+ } from '@mavware/bug-surveillance';
137
+
138
+ const store = await openNightStore();
139
+ const night = await store.getNight(nightId);
140
+ const tracks = await store.listTracks(nightId);
141
+
142
+ const { rebuild } = mountReportControls(document.getElementById('report'), {
143
+ loadData: async () => ({ data: buildLocalReportPayload(night, tracks), referenceImage }),
144
+ });
145
+ ```
146
+
147
+ `mountReportControls` is the one part with a markup contract. Inside the element
148
+ you hand it, it looks for `data-report="canvas"`, `"play"`, `"speed"`, `"scrub"`,
149
+ `"clock"` and `"trails"`, and treats a click on any element carrying
150
+ `data-track-id` as a request to highlight that trail. Use `Replay` directly if
151
+ you would rather own the controls.
152
+
153
+ `sightingRows` and `statTiles` give you the table rows and headline figures
154
+ ready to render, and `computeNightAnalytics` produces the summary behind them:
155
+ how many sightings, and which edges of the frame they clustered against.
156
+
157
+ ## Sending nights somewhere else
158
+
159
+ Anything with these methods can stand in for `LocalNightSink`, so the capture
160
+ code above is unchanged whether nights stay on the device or go to a server:
161
+
162
+ ```js
163
+ class UploadingSink {
164
+ async storeReference({ blob, frameWidth, frameHeight, settings }) {}
165
+ start() {}
166
+ stop() {}
167
+ enqueue(closedTrack) {}
168
+ async flush({ keepalive } = {}) {}
169
+ async end({ endedAtOffsetMs, aborted }) {
170
+ return { ok: true, status: 200, reportUrl: '/report' };
171
+ }
172
+ }
173
+ ```
174
+
175
+ A closed track arrives with a client-generated id, its start and end offsets in
176
+ milliseconds, its points as `[offsetMs, x, y]` in full-frame pixels, and two
177
+ optional snapshots as raw base64 JPEG.
178
+
179
+ ## Wording
180
+
181
+ Copy ships as exported constants, and the helpers that produce copy take an
182
+ optional overrides object, so you can reword or translate without forking:
183
+
184
+ ```js
185
+ watchingState(true, { largeMotion: 'Quelqu\'un est dans la pièce.' });
186
+ calibrationOutcome(calibration, { tooDark: 'Trop sombre.' });
187
+ ```
188
+
189
+ ## Browser support
190
+
191
+ Needs `getUserMedia`, canvas and IndexedDB, so any current version of Chrome,
192
+ Edge, Firefox or Safari. The camera requires a secure context, meaning HTTPS or
193
+ localhost. The screen wake lock is used when available and degrades to a warning
194
+ through the `WakeLock` callback when it is not. When IndexedDB is unavailable,
195
+ such as in some private browsing modes, `openNightStore` falls back to an
196
+ in-memory store and marks it `volatile` so you can tell the user their night
197
+ will not survive the tab.
198
+
199
+ ## Tuning detection
200
+
201
+ `DEFAULT_PARAMS` is sized for cockroaches on a kitchen floor at about a
202
+ two-metre camera distance. The knobs worth reaching for first:
203
+
204
+ - `minArea` and `maxArea` bound a single bug in processing pixels
205
+ - `maxChangedArea` is the whole-frame budget above which a frame is treated as a
206
+ person or a pet and dropped
207
+ - `diffThreshold` is normally supplied by `calibrate`, which measures this
208
+ camera's noise floor in this light
209
+ - `darkerThanBackground` assumes dark bugs on a lighter floor; turn it off to
210
+ track anything that moves
211
+
212
+ ## License
213
+
214
+ MIT
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@mavware/bug-surveillance",
3
+ "version": "0.1.0",
4
+ "description": "Overnight bug surveillance from a browser camera: detection, tracking, a local night store, analytics and replay, with no server required.",
5
+ "keywords": [
6
+ "camera",
7
+ "motion-detection",
8
+ "object-tracking",
9
+ "computer-vision",
10
+ "indexeddb",
11
+ "offline",
12
+ "pest-control",
13
+ "surveillance",
14
+ "browser"
15
+ ],
16
+ "license": "MIT",
17
+ "author": "Michael <mlgreer1430@gmail.com>",
18
+ "homepage": "https://mavware.github.io/bugtracker/",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/mavware/bugtracker.git",
22
+ "directory": "packages/surveillance"
23
+ },
24
+ "bugs": {
25
+ "url": "https://github.com/mavware/bugtracker/issues"
26
+ },
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "types": "./src/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./src/index.d.ts",
33
+ "default": "./src/index.js"
34
+ }
35
+ },
36
+ "files": [
37
+ "src"
38
+ ],
39
+ "scripts": {
40
+ "typecheck": "npx --yes -p typescript@5 tsc -p tsconfig.json"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "provenance": true
45
+ }
46
+ }
@@ -0,0 +1,65 @@
1
+ // Calibrates against the actual night-time scene: measures mean luminance to
2
+ // warn when the room is too dark, and the sensor noise floor so the motion
3
+ // threshold adapts to this camera in this light.
4
+ export const BRIGHTNESS_WARN = 12;
5
+ export const BRIGHTNESS_BLOCK = 5;
6
+
7
+ export async function calibrate(camera, durationMs = 3000, sampleIntervalMs = 200) {
8
+ const luminances = [];
9
+ const diffs = [];
10
+ let previous = null;
11
+
12
+ const deadline = performance.now() + durationMs;
13
+
14
+ while (performance.now() < deadline) {
15
+ const frame = camera.grabProcessedFrame();
16
+ const gray = toGrayscale(frame);
17
+
18
+ luminances.push(mean(gray));
19
+
20
+ if (previous !== null) {
21
+ for (let i = 0; i < gray.length; i += 7) {
22
+ diffs.push(Math.abs(gray[i] - previous[i]));
23
+ }
24
+ }
25
+
26
+ previous = gray;
27
+ await new Promise((resolve) => setTimeout(resolve, sampleIntervalMs));
28
+ }
29
+
30
+ const meanLuminance = mean(luminances);
31
+ const diffMean = mean(diffs);
32
+ const diffStd = Math.sqrt(mean(diffs.map((d) => (d - diffMean) ** 2)));
33
+
34
+ return {
35
+ meanLuminance,
36
+ tooDark: meanLuminance < BRIGHTNESS_BLOCK,
37
+ dim: meanLuminance < BRIGHTNESS_WARN,
38
+ diffThreshold: Math.min(40, Math.max(14, Math.round(diffMean + 4 * diffStd))),
39
+ };
40
+ }
41
+
42
+ export function toGrayscale(imageData) {
43
+ const { data } = imageData;
44
+ const gray = new Float32Array(data.length / 4);
45
+
46
+ for (let i = 0; i < gray.length; i++) {
47
+ const o = i * 4;
48
+ gray[i] = 0.299 * data[o] + 0.587 * data[o + 1] + 0.114 * data[o + 2];
49
+ }
50
+
51
+ return gray;
52
+ }
53
+
54
+ function mean(values) {
55
+ if (values.length === 0) {
56
+ return 0;
57
+ }
58
+
59
+ let sum = 0;
60
+ for (const value of values) {
61
+ sum += value;
62
+ }
63
+
64
+ return sum / values.length;
65
+ }
package/src/camera.js ADDED
@@ -0,0 +1,69 @@
1
+ // Owns the camera stream and the two canvases: a full-resolution one for
2
+ // reference frames and crops, and a downscaled one for per-frame processing.
3
+ export class Camera {
4
+ constructor(videoElement, processingWidth = 320) {
5
+ this.video = videoElement;
6
+ this.processingWidth = processingWidth;
7
+ this.stream = null;
8
+ this.fullCanvas = document.createElement('canvas');
9
+ this.procCanvas = document.createElement('canvas');
10
+ this.fullCtx = this.fullCanvas.getContext('2d', { willReadFrequently: true });
11
+ this.procCtx = this.procCanvas.getContext('2d', { willReadFrequently: true });
12
+ }
13
+
14
+ async start() {
15
+ this.stream = await navigator.mediaDevices.getUserMedia({
16
+ video: {
17
+ width: { ideal: 1280 },
18
+ height: { ideal: 720 },
19
+ facingMode: 'environment',
20
+ },
21
+ audio: false,
22
+ });
23
+
24
+ this.video.srcObject = this.stream;
25
+ await this.video.play();
26
+
27
+ this.frameWidth = this.video.videoWidth;
28
+ this.frameHeight = this.video.videoHeight;
29
+ this.fullCanvas.width = this.frameWidth;
30
+ this.fullCanvas.height = this.frameHeight;
31
+ this.procCanvas.width = this.processingWidth;
32
+ this.procCanvas.height = Math.round(this.frameHeight * (this.processingWidth / this.frameWidth));
33
+ this.scale = this.frameWidth / this.procCanvas.width;
34
+ }
35
+
36
+ stop() {
37
+ this.stream?.getTracks().forEach((track) => track.stop());
38
+ this.stream = null;
39
+ }
40
+
41
+ grabProcessedFrame() {
42
+ this.procCtx.drawImage(this.video, 0, 0, this.procCanvas.width, this.procCanvas.height);
43
+
44
+ return this.procCtx.getImageData(0, 0, this.procCanvas.width, this.procCanvas.height);
45
+ }
46
+
47
+ async captureReferenceJpeg() {
48
+ this.fullCtx.drawImage(this.video, 0, 0, this.frameWidth, this.frameHeight);
49
+
50
+ return new Promise((resolve) => this.fullCanvas.toBlob(resolve, 'image/jpeg', 0.85));
51
+ }
52
+
53
+ // Capture a small crop around a full-frame position, returned as raw base64
54
+ // (no data: prefix) so it can ride inline in a JSON payload.
55
+ captureCropBase64(centerX, centerY, size = 48) {
56
+ this.fullCtx.drawImage(this.video, 0, 0, this.frameWidth, this.frameHeight);
57
+
58
+ const half = size / 2;
59
+ const x = Math.max(0, Math.min(this.frameWidth - size, Math.round(centerX - half)));
60
+ const y = Math.max(0, Math.min(this.frameHeight - size, Math.round(centerY - half)));
61
+
62
+ const crop = document.createElement('canvas');
63
+ crop.width = size;
64
+ crop.height = size;
65
+ crop.getContext('2d').drawImage(this.fullCanvas, x, y, size, size, 0, 0, size, size);
66
+
67
+ return crop.toDataURL('image/jpeg', 0.7).split(',')[1];
68
+ }
69
+ }
@@ -0,0 +1,124 @@
1
+ // The decisions a capture page makes that do not depend on where the night is
2
+ // sent: what a calibration result means, how the leave-the-room countdown and
3
+ // the status line read, where the debug boxes land, and how the clock is
4
+ // formatted. The copy ships as exported defaults; functions that return copy
5
+ // take an optional messages object so a consumer can word things their own way.
6
+
7
+ export const TOO_DARK_MESSAGE =
8
+ 'The scene is pitch black — the camera cannot see anything. Add a nightlight or dim lamp, then start again.';
9
+
10
+ export const DIM_MESSAGE =
11
+ 'The scene is very dim. Detection will run, but a small extra light source would improve it.';
12
+
13
+ /**
14
+ * Detection is frame differencing: anything that moves or changes colour reads as
15
+ * a bug. A fan, a television, a charger LED or a swaying curtain will fill the
16
+ * night with false sightings, so the checklist goes in front of the user while
17
+ * they can still act on it rather than in a page they have already scrolled past.
18
+ */
19
+ export const PREFLIGHT_MESSAGE = [
20
+ 'Before you start, check the room:',
21
+ '',
22
+ '• Turn on a light — a dim lamp or nightlight is enough, but the camera cannot see in pitch darkness.',
23
+ '• Turn off fans, heaters, and anything else that moves.',
24
+ '• Turn off televisions and screens, and cover blinking LEDs — changing colour reads as movement.',
25
+ '• Draw the curtains if you can, so passing headlights do not sweep the room.',
26
+ '',
27
+ 'Then leave the room. You have five seconds once you press OK.',
28
+ ].join('\n');
29
+
30
+ /**
31
+ * How long the room is left alone before anything is measured. The person who
32
+ * pressed start must be out of shot first: in the reference frame they become
33
+ * part of the background model, and walking out is recorded as a sighting.
34
+ */
35
+ export const LEAVE_ROOM_SECONDS = 5;
36
+
37
+ export function countdownMessage(secondsLeft) {
38
+ return `Leave the room — starting in ${secondsLeft}…`;
39
+ }
40
+
41
+ /**
42
+ * A camera check opens the preview on its own, watching nothing and measuring
43
+ * nothing, purely so the framing can be settled before a night is committed to.
44
+ */
45
+ export const CAMERA_CHECK_MESSAGE = 'Camera check — aim the device, then start watching.';
46
+
47
+ /** A toggle's label says what the next press does, not what the camera is doing. */
48
+ export function cameraCheckLabel(previewing, { stop = 'Stop camera', check = 'Check camera' } = {}) {
49
+ return previewing ? stop : check;
50
+ }
51
+
52
+ /**
53
+ * What to say when the screen wake lock could not be taken. Naming the actual
54
+ * setting matters here: this banner is read on the device itself, in a dark room,
55
+ * by someone who has to fix it now or lose the night. Menu paths are kept to one
56
+ * line because they drift between OS versions — the setting names survive longer
57
+ * than the paths and are what a settings search box will match.
58
+ */
59
+ export function wakeLockMessage(userAgent = '') {
60
+ const prefix = 'This device would not keep its screen on by itself — ';
61
+
62
+ if (/iPhone|iPad|iPod/i.test(userAgent)) {
63
+ return `${prefix}set Auto-Lock to Never (Settings › Display & Brightness), and turn Low Power Mode off, which blocks the screen lock on its own.`;
64
+ }
65
+
66
+ if (/Android/i.test(userAgent)) {
67
+ return `${prefix}set Screen timeout to its longest option (Settings › Display), and keep the device charging.`;
68
+ }
69
+
70
+ return `${prefix}turn off screen sleep in your system power settings so the display stays on all night.`;
71
+ }
72
+
73
+ /**
74
+ * Whether a calibrated scene can be watched, and what to tell the user about it.
75
+ * A pitch-black room is refused outright: with nothing to see, every frame is
76
+ * sensor noise and the night would be wasted.
77
+ */
78
+ export function calibrationOutcome(calibration, { tooDark = TOO_DARK_MESSAGE, dim = DIM_MESSAGE } = {}) {
79
+ if (calibration.tooDark) {
80
+ return { blocked: true, banner: tooDark };
81
+ }
82
+
83
+ return { blocked: false, banner: calibration.dim ? dim : null };
84
+ }
85
+
86
+ export const WATCHING_MESSAGE = 'Watching';
87
+
88
+ export const LARGE_MOTION_MESSAGE = 'Watching — ignoring something too large to be a bug';
89
+
90
+ /**
91
+ * The status line while a night runs. When the detector drops a frame for
92
+ * holding a person or a pet, the page says so: a room full of movement that
93
+ * reports nothing looks broken otherwise, and the absence of a flurry of
94
+ * sightings is the whole point.
95
+ */
96
+ export function watchingState(largeMotion, { watching = WATCHING_MESSAGE, largeMotion: largeMotionMessage = LARGE_MOTION_MESSAGE } = {}) {
97
+ return largeMotion ? largeMotionMessage : watching;
98
+ }
99
+
100
+ /**
101
+ * Detection boxes scaled from the small processing canvas up to the on-screen
102
+ * overlay, padded slightly so the outline sits around the blob rather than on it.
103
+ */
104
+ export function overlayBoxes(blobs, { canvasWidth, canvasHeight, procWidth, procHeight }) {
105
+ const scaleX = canvasWidth / procWidth;
106
+ const scaleY = canvasHeight / procHeight;
107
+
108
+ return blobs.map((blob) => ({
109
+ x: (blob.box.x - 2) * scaleX,
110
+ y: (blob.box.y - 2) * scaleY,
111
+ width: (blob.box.width + 4) * scaleX,
112
+ height: (blob.box.height + 4) * scaleY,
113
+ }));
114
+ }
115
+
116
+ /** Milliseconds as HH:MM:SS, for a clock that may run all night. */
117
+ export function formatClock(ms) {
118
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
119
+ const hours = String(Math.floor(totalSeconds / 3600)).padStart(2, '0');
120
+ const minutes = String(Math.floor((totalSeconds % 3600) / 60)).padStart(2, '0');
121
+ const seconds = String(totalSeconds % 60).padStart(2, '0');
122
+
123
+ return `${hours}:${minutes}:${seconds}`;
124
+ }
@@ -0,0 +1,137 @@
1
+ import { toGrayscale } from './brightness.js';
2
+
3
+ export const DEFAULT_PARAMS = {
4
+ processFps: 6,
5
+ procWidth: 320,
6
+ bgAlpha: 0.03, // slow background adaptation so a pausing bug isn't absorbed instantly
7
+ diffThreshold: 22, // overridden by calibration
8
+ minArea: 4,
9
+ maxArea: 300,
10
+ // Moving pixels a whole frame may hold before it is dropped as a person, a pet
11
+ // or the light changing: five roaches at maxArea. A body does not arrive as one
12
+ // oversized blob but as dozens of roach-sized fragments (folds, edges, patterned
13
+ // clothing), so per-blob limits never see it — only the frame total does.
14
+ maxChangedArea: 1200,
15
+ maxAspectRatio: 5,
16
+ darkerThanBackground: true, // roaches are dark blobs against the scene
17
+ darkMargin: 5,
18
+ };
19
+
20
+ // Frame-differencing blob detector: maintains a running-average background,
21
+ // thresholds the difference, and extracts roach-sized connected components.
22
+ export class Detector {
23
+ constructor(params = {}) {
24
+ this.params = { ...DEFAULT_PARAMS, ...params };
25
+ this.background = null;
26
+ this.mask = null;
27
+ this.stack = null;
28
+ // True while the last frame was dropped for holding something far larger
29
+ // than a roach, so the page can say so instead of reporting silence.
30
+ this.largeMotion = false;
31
+ }
32
+
33
+ // Returns blobs as [{cx, cy, area}] in processing-canvas coordinates.
34
+ detect(imageData) {
35
+ const { width, height } = imageData;
36
+ const gray = toGrayscale(imageData);
37
+
38
+ if (this.background === null) {
39
+ this.background = Float32Array.from(gray);
40
+ this.mask = new Uint8Array(gray.length);
41
+ this.stack = new Int32Array(gray.length);
42
+
43
+ return [];
44
+ }
45
+
46
+ const { bgAlpha, diffThreshold, darkerThanBackground, darkMargin, maxChangedArea } = this.params;
47
+ const background = this.background;
48
+ const mask = this.mask;
49
+ let changedArea = 0;
50
+
51
+ for (let i = 0; i < gray.length; i++) {
52
+ const diff = gray[i] - background[i];
53
+ const moving = Math.abs(diff) > diffThreshold;
54
+ const changed = moving && (!darkerThanBackground || diff < -darkMargin);
55
+ mask[i] = changed ? 1 : 0;
56
+ changedArea += changed ? 1 : 0;
57
+ background[i] += bgAlpha * diff;
58
+ }
59
+
60
+ // Something that big is not roaches, however many fragments it breaks into.
61
+ // The background keeps adapting underneath it on purpose: a pet that curls
62
+ // up and sleeps, or a chair left in shot, is absorbed within seconds and
63
+ // detection resumes around it, rather than the night going blind until it
64
+ // moves again.
65
+ this.largeMotion = changedArea > maxChangedArea;
66
+
67
+ if (this.largeMotion) {
68
+ return [];
69
+ }
70
+
71
+ return this.extractBlobs(mask, width, height);
72
+ }
73
+
74
+ reset() {
75
+ this.background = null;
76
+ this.largeMotion = false;
77
+ }
78
+
79
+ // Iterative flood-fill connected components over the binary mask.
80
+ extractBlobs(mask, width, height) {
81
+ const { minArea, maxArea, maxAspectRatio } = this.params;
82
+ const blobs = [];
83
+ const stack = this.stack;
84
+
85
+ for (let start = 0; start < mask.length; start++) {
86
+ if (mask[start] !== 1) {
87
+ continue;
88
+ }
89
+
90
+ let stackSize = 0;
91
+ stack[stackSize++] = start;
92
+ mask[start] = 2;
93
+
94
+ let area = 0;
95
+ let sumX = 0;
96
+ let sumY = 0;
97
+ let minX = width;
98
+ let maxX = 0;
99
+ let minY = height;
100
+ let maxY = 0;
101
+
102
+ while (stackSize > 0) {
103
+ const index = stack[--stackSize];
104
+ const x = index % width;
105
+ const y = (index / width) | 0;
106
+
107
+ area++;
108
+ sumX += x;
109
+ sumY += y;
110
+ if (x < minX) minX = x;
111
+ if (x > maxX) maxX = x;
112
+ if (y < minY) minY = y;
113
+ if (y > maxY) maxY = y;
114
+
115
+ if (x > 0 && mask[index - 1] === 1) { mask[index - 1] = 2; stack[stackSize++] = index - 1; }
116
+ if (x < width - 1 && mask[index + 1] === 1) { mask[index + 1] = 2; stack[stackSize++] = index + 1; }
117
+ if (y > 0 && mask[index - width] === 1) { mask[index - width] = 2; stack[stackSize++] = index - width; }
118
+ if (y < height - 1 && mask[index + width] === 1) { mask[index + width] = 2; stack[stackSize++] = index + width; }
119
+ }
120
+
121
+ const boxWidth = maxX - minX + 1;
122
+ const boxHeight = maxY - minY + 1;
123
+ const aspect = Math.max(boxWidth, boxHeight) / Math.max(1, Math.min(boxWidth, boxHeight));
124
+
125
+ if (area >= minArea && area <= maxArea && aspect <= maxAspectRatio) {
126
+ blobs.push({
127
+ cx: sumX / area,
128
+ cy: sumY / area,
129
+ area,
130
+ box: { x: minX, y: minY, width: boxWidth, height: boxHeight },
131
+ });
132
+ }
133
+ }
134
+
135
+ return blobs;
136
+ }
137
+ }