scrypted-animal-filter 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/dist/main.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { ScryptedDeviceBase, MixinProvider, Settings, Setting, ScryptedDeviceType, ScryptedInterface } from '@scrypted/sdk';
2
+ declare class AnimalPersonFilter extends ScryptedDeviceBase implements MixinProvider, Settings {
3
+ private iouThreshold;
4
+ private containmentThreshold;
5
+ constructor(nativeId?: string);
6
+ getSettings(): Promise<Setting[]>;
7
+ putSetting(key: string, value: string): Promise<void>;
8
+ canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]>;
9
+ getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: {
10
+ [key: string]: any;
11
+ }): Promise<any>;
12
+ releaseMixin(id: string, mixinDevice: any): Promise<void>;
13
+ }
14
+ export default AnimalPersonFilter;
package/dist/main.js ADDED
@@ -0,0 +1,189 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const sdk_1 = require("@scrypted/sdk");
4
+ // ─── IoU Calculation ─────────────────────────────────────────────────
5
+ /**
6
+ * Compute Intersection over Union between two bounding boxes.
7
+ * Boxes are [x, y, width, height].
8
+ */
9
+ function computeIoU(a, b) {
10
+ const [ax, ay, aw, ah] = a;
11
+ const [bx, by, bw, bh] = b;
12
+ const x1 = Math.max(ax, bx);
13
+ const y1 = Math.max(ay, by);
14
+ const x2 = Math.min(ax + aw, bx + bw);
15
+ const y2 = Math.min(ay + ah, by + bh);
16
+ const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
17
+ if (intersection === 0)
18
+ return 0;
19
+ const areaA = aw * ah;
20
+ const areaB = bw * bh;
21
+ const union = areaA + areaB - intersection;
22
+ return union > 0 ? intersection / union : 0;
23
+ }
24
+ /**
25
+ * Alternative: check if the smaller box is mostly INSIDE the larger one.
26
+ * This catches cases where the cat bbox is fully contained within the
27
+ * person bbox (IoU can be low if sizes differ a lot, but containment is high).
28
+ */
29
+ function computeContainment(inner, outer) {
30
+ const [ix, iy, iw, ih] = inner;
31
+ const [ox, oy, ow, oh] = outer;
32
+ const x1 = Math.max(ix, ox);
33
+ const y1 = Math.max(iy, oy);
34
+ const x2 = Math.min(ix + iw, ox + ow);
35
+ const y2 = Math.min(iy + ih, oy + oh);
36
+ const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
37
+ const innerArea = iw * ih;
38
+ return innerArea > 0 ? intersection / innerArea : 0;
39
+ }
40
+ // ─── Animal classes (COCO) ───────────────────────────────────────────
41
+ const ANIMAL_CLASSES = new Set([
42
+ 'cat', 'dog', 'bird', 'horse', 'sheep', 'cow',
43
+ 'elephant', 'bear', 'zebra', 'giraffe',
44
+ ]);
45
+ // ─── Mixin (per-camera filter) ───────────────────────────────────────
46
+ class AnimalFilterMixin extends sdk_1.MixinDeviceBase {
47
+ constructor(options, iouThreshold, containmentThreshold) {
48
+ super(options);
49
+ this.suppressedCount = 0;
50
+ this.iouThreshold = iouThreshold;
51
+ this.containmentThreshold = containmentThreshold;
52
+ // Listen to ObjectDetector events on the actual device
53
+ this.listener = this.mixinDevice.listen(sdk_1.ScryptedInterface.ObjectDetector, (source, details, data) => {
54
+ this.filterAndForward(data);
55
+ });
56
+ this.console.log(`Animal filter active (IoU≥${iouThreshold}, containment≥${containmentThreshold})`);
57
+ }
58
+ filterAndForward(detected) {
59
+ if (!detected?.detections?.length) {
60
+ // No detections — pass through as-is
61
+ this.onDeviceEvent(sdk_1.ScryptedInterface.ObjectDetector, detected);
62
+ return;
63
+ }
64
+ const animals = detected.detections.filter(d => d.className && ANIMAL_CLASSES.has(d.className.toLowerCase()));
65
+ const persons = detected.detections.filter(d => d.className?.toLowerCase() === 'person');
66
+ const others = detected.detections.filter(d => d.className &&
67
+ d.className.toLowerCase() !== 'person' &&
68
+ !ANIMAL_CLASSES.has(d.className.toLowerCase()));
69
+ // If no animals or no persons, nothing to filter
70
+ if (!animals.length || !persons.length) {
71
+ this.onDeviceEvent(sdk_1.ScryptedInterface.ObjectDetector, detected);
72
+ return;
73
+ }
74
+ // Check each person detection against all animal detections
75
+ const keptPersons = [];
76
+ let suppressed = 0;
77
+ for (const person of persons) {
78
+ const pBox = person.boundingBox;
79
+ if (!pBox) {
80
+ keptPersons.push(person);
81
+ continue;
82
+ }
83
+ let overlapsAnimal = false;
84
+ for (const animal of animals) {
85
+ const aBox = animal.boundingBox;
86
+ if (!aBox)
87
+ continue;
88
+ const iou = computeIoU(pBox, aBox);
89
+ const containment = computeContainment(aBox, pBox);
90
+ if (iou >= this.iouThreshold || containment >= this.containmentThreshold) {
91
+ overlapsAnimal = true;
92
+ this.console.log(`Suppressed "person" (${(person.score * 100).toFixed(0)}%) ` +
93
+ `overlapping "${animal.className}" (${(animal.score * 100).toFixed(0)}%) — ` +
94
+ `IoU=${iou.toFixed(2)}, containment=${containment.toFixed(2)}`);
95
+ break;
96
+ }
97
+ }
98
+ if (overlapsAnimal) {
99
+ suppressed++;
100
+ }
101
+ else {
102
+ keptPersons.push(person);
103
+ }
104
+ }
105
+ if (suppressed > 0) {
106
+ this.suppressedCount += suppressed;
107
+ // Rebuild detections without the suppressed persons
108
+ const filtered = {
109
+ ...detected,
110
+ detections: [...keptPersons, ...animals, ...others],
111
+ };
112
+ this.onDeviceEvent(sdk_1.ScryptedInterface.ObjectDetector, filtered);
113
+ }
114
+ else {
115
+ // Nothing filtered — pass through
116
+ this.onDeviceEvent(sdk_1.ScryptedInterface.ObjectDetector, detected);
117
+ }
118
+ }
119
+ async release() {
120
+ this.listener?.removeListener();
121
+ this.console.log(`Animal filter released. Suppressed ${this.suppressedCount} false person detections total.`);
122
+ }
123
+ }
124
+ // ─── Plugin (MixinProvider) ──────────────────────────────────────────
125
+ class AnimalPersonFilter extends sdk_1.ScryptedDeviceBase {
126
+ constructor(nativeId) {
127
+ super(nativeId);
128
+ this.iouThreshold = 0.3;
129
+ this.containmentThreshold = 0.5;
130
+ const iou = this.storage.getItem('iouThreshold');
131
+ if (iou)
132
+ this.iouThreshold = parseFloat(iou);
133
+ const cont = this.storage.getItem('containmentThreshold');
134
+ if (cont)
135
+ this.containmentThreshold = parseFloat(cont);
136
+ this.console.log('Animal Person Filter loaded');
137
+ }
138
+ // ─── Settings ────────────────────────────────────────────────
139
+ async getSettings() {
140
+ return [
141
+ {
142
+ key: 'iouThreshold',
143
+ title: 'IoU Threshold',
144
+ description: 'Suppress "person" if its IoU with an animal detection exceeds this value. Lower = more aggressive filtering. (default: 0.3)',
145
+ value: this.iouThreshold.toString(),
146
+ type: 'number',
147
+ },
148
+ {
149
+ key: 'containmentThreshold',
150
+ title: 'Containment Threshold',
151
+ description: 'Suppress "person" if the animal bbox is this much contained inside the person bbox. Catches size-mismatched overlaps. (default: 0.5)',
152
+ value: this.containmentThreshold.toString(),
153
+ type: 'number',
154
+ },
155
+ ];
156
+ }
157
+ async putSetting(key, value) {
158
+ if (key === 'iouThreshold') {
159
+ this.iouThreshold = parseFloat(value) || 0.3;
160
+ this.storage.setItem('iouThreshold', this.iouThreshold.toString());
161
+ }
162
+ if (key === 'containmentThreshold') {
163
+ this.containmentThreshold = parseFloat(value) || 0.5;
164
+ this.storage.setItem('containmentThreshold', this.containmentThreshold.toString());
165
+ }
166
+ }
167
+ // ─── MixinProvider ───────────────────────────────────────────
168
+ async canMixin(type, interfaces) {
169
+ // Only attach to cameras/doorbells that have object detection
170
+ if ((type === sdk_1.ScryptedDeviceType.Camera || type === sdk_1.ScryptedDeviceType.Doorbell) &&
171
+ interfaces.includes(sdk_1.ScryptedInterface.ObjectDetector)) {
172
+ // We provide a filtered ObjectDetector
173
+ return [sdk_1.ScryptedInterface.ObjectDetector];
174
+ }
175
+ return [];
176
+ }
177
+ async getMixin(mixinDevice, mixinDeviceInterfaces, mixinDeviceState) {
178
+ return new AnimalFilterMixin({
179
+ mixinDevice,
180
+ mixinDeviceInterfaces,
181
+ mixinDeviceState: mixinDeviceState,
182
+ mixinProviderNativeId: this.nativeId,
183
+ }, this.iouThreshold, this.containmentThreshold);
184
+ }
185
+ async releaseMixin(id, mixinDevice) {
186
+ await mixinDevice.release();
187
+ }
188
+ }
189
+ exports.default = AnimalPersonFilter;
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "scrypted-animal-filter",
3
+ "version": "0.1.0",
4
+ "description": "Suppress false 'person' detections that overlap with animal detections. Fixes cats/dogs being detected as both animal AND person.",
5
+ "author": "Alan",
6
+ "license": "MIT",
7
+ "scrypted": {
8
+ "name": "Animal Person Filter",
9
+ "type": "Mixin",
10
+ "interfaces": [
11
+ "MixinProvider",
12
+ "Settings"
13
+ ]
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepublishOnly": "tsc",
18
+ "scrypted-deploy": "scrypted-deploy"
19
+ },
20
+ "dependencies": {
21
+ "@scrypted/sdk": "^0.3.69"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.3.0",
25
+ "@types/node": "^20.0.0"
26
+ }
27
+ }
Binary file
package/src/main.ts ADDED
@@ -0,0 +1,270 @@
1
+ import sdk, {
2
+ ScryptedDeviceBase,
3
+ MixinProvider,
4
+ MixinDeviceBase,
5
+ MixinDeviceOptions,
6
+ Settings,
7
+ Setting,
8
+ ScryptedDeviceType,
9
+ ScryptedInterface,
10
+ ObjectsDetected,
11
+ ObjectDetectionResult,
12
+ EventListenerRegister,
13
+ } from '@scrypted/sdk';
14
+
15
+ // ─── IoU Calculation ─────────────────────────────────────────────────
16
+
17
+ /**
18
+ * Compute Intersection over Union between two bounding boxes.
19
+ * Boxes are [x, y, width, height].
20
+ */
21
+ function computeIoU(a: number[], b: number[]): number {
22
+ const [ax, ay, aw, ah] = a;
23
+ const [bx, by, bw, bh] = b;
24
+
25
+ const x1 = Math.max(ax, bx);
26
+ const y1 = Math.max(ay, by);
27
+ const x2 = Math.min(ax + aw, bx + bw);
28
+ const y2 = Math.min(ay + ah, by + bh);
29
+
30
+ const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
31
+ if (intersection === 0) return 0;
32
+
33
+ const areaA = aw * ah;
34
+ const areaB = bw * bh;
35
+ const union = areaA + areaB - intersection;
36
+
37
+ return union > 0 ? intersection / union : 0;
38
+ }
39
+
40
+ /**
41
+ * Alternative: check if the smaller box is mostly INSIDE the larger one.
42
+ * This catches cases where the cat bbox is fully contained within the
43
+ * person bbox (IoU can be low if sizes differ a lot, but containment is high).
44
+ */
45
+ function computeContainment(inner: number[], outer: number[]): number {
46
+ const [ix, iy, iw, ih] = inner;
47
+ const [ox, oy, ow, oh] = outer;
48
+
49
+ const x1 = Math.max(ix, ox);
50
+ const y1 = Math.max(iy, oy);
51
+ const x2 = Math.min(ix + iw, ox + ow);
52
+ const y2 = Math.min(iy + ih, oy + oh);
53
+
54
+ const intersection = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
55
+ const innerArea = iw * ih;
56
+
57
+ return innerArea > 0 ? intersection / innerArea : 0;
58
+ }
59
+
60
+ // ─── Animal classes (COCO) ───────────────────────────────────────────
61
+
62
+ const ANIMAL_CLASSES = new Set([
63
+ 'cat', 'dog', 'bird', 'horse', 'sheep', 'cow',
64
+ 'elephant', 'bear', 'zebra', 'giraffe',
65
+ ]);
66
+
67
+ // ─── Mixin (per-camera filter) ───────────────────────────────────────
68
+
69
+ class AnimalFilterMixin extends MixinDeviceBase<any> {
70
+ private listener: EventListenerRegister;
71
+ private iouThreshold: number;
72
+ private containmentThreshold: number;
73
+ private suppressedCount: number = 0;
74
+
75
+ constructor(
76
+ options: MixinDeviceOptions<any>,
77
+ iouThreshold: number,
78
+ containmentThreshold: number,
79
+ ) {
80
+ super(options);
81
+ this.iouThreshold = iouThreshold;
82
+ this.containmentThreshold = containmentThreshold;
83
+
84
+ // Listen to ObjectDetector events on the actual device
85
+ this.listener = this.mixinDevice.listen(
86
+ ScryptedInterface.ObjectDetector,
87
+ (source, details, data: ObjectsDetected) => {
88
+ this.filterAndForward(data);
89
+ }
90
+ );
91
+
92
+ this.console.log(
93
+ `Animal filter active (IoU≥${iouThreshold}, containment≥${containmentThreshold})`
94
+ );
95
+ }
96
+
97
+ private filterAndForward(detected: ObjectsDetected) {
98
+ if (!detected?.detections?.length) {
99
+ // No detections — pass through as-is
100
+ this.onDeviceEvent(ScryptedInterface.ObjectDetector, detected);
101
+ return;
102
+ }
103
+
104
+ const animals = detected.detections.filter(
105
+ d => d.className && ANIMAL_CLASSES.has(d.className.toLowerCase())
106
+ );
107
+ const persons = detected.detections.filter(
108
+ d => d.className?.toLowerCase() === 'person'
109
+ );
110
+ const others = detected.detections.filter(
111
+ d => d.className &&
112
+ d.className.toLowerCase() !== 'person' &&
113
+ !ANIMAL_CLASSES.has(d.className.toLowerCase())
114
+ );
115
+
116
+ // If no animals or no persons, nothing to filter
117
+ if (!animals.length || !persons.length) {
118
+ this.onDeviceEvent(ScryptedInterface.ObjectDetector, detected);
119
+ return;
120
+ }
121
+
122
+ // Check each person detection against all animal detections
123
+ const keptPersons: ObjectDetectionResult[] = [];
124
+ let suppressed = 0;
125
+
126
+ for (const person of persons) {
127
+ const pBox = person.boundingBox;
128
+ if (!pBox) {
129
+ keptPersons.push(person);
130
+ continue;
131
+ }
132
+
133
+ let overlapsAnimal = false;
134
+
135
+ for (const animal of animals) {
136
+ const aBox = animal.boundingBox;
137
+ if (!aBox) continue;
138
+
139
+ const iou = computeIoU(pBox, aBox);
140
+ const containment = computeContainment(aBox, pBox);
141
+
142
+ if (iou >= this.iouThreshold || containment >= this.containmentThreshold) {
143
+ overlapsAnimal = true;
144
+ this.console.log(
145
+ `Suppressed "person" (${(person.score! * 100).toFixed(0)}%) ` +
146
+ `overlapping "${animal.className}" (${(animal.score! * 100).toFixed(0)}%) — ` +
147
+ `IoU=${iou.toFixed(2)}, containment=${containment.toFixed(2)}`
148
+ );
149
+ break;
150
+ }
151
+ }
152
+
153
+ if (overlapsAnimal) {
154
+ suppressed++;
155
+ } else {
156
+ keptPersons.push(person);
157
+ }
158
+ }
159
+
160
+ if (suppressed > 0) {
161
+ this.suppressedCount += suppressed;
162
+
163
+ // Rebuild detections without the suppressed persons
164
+ const filtered: ObjectsDetected = {
165
+ ...detected,
166
+ detections: [...keptPersons, ...animals, ...others],
167
+ };
168
+
169
+ this.onDeviceEvent(ScryptedInterface.ObjectDetector, filtered);
170
+ } else {
171
+ // Nothing filtered — pass through
172
+ this.onDeviceEvent(ScryptedInterface.ObjectDetector, detected);
173
+ }
174
+ }
175
+
176
+ async release() {
177
+ this.listener?.removeListener();
178
+ this.console.log(
179
+ `Animal filter released. Suppressed ${this.suppressedCount} false person detections total.`
180
+ );
181
+ }
182
+ }
183
+
184
+ // ─── Plugin (MixinProvider) ──────────────────────────────────────────
185
+
186
+ class AnimalPersonFilter extends ScryptedDeviceBase implements MixinProvider, Settings {
187
+ private iouThreshold: number = 0.3;
188
+ private containmentThreshold: number = 0.5;
189
+
190
+ constructor(nativeId?: string) {
191
+ super(nativeId);
192
+
193
+ const iou = this.storage.getItem('iouThreshold');
194
+ if (iou) this.iouThreshold = parseFloat(iou);
195
+
196
+ const cont = this.storage.getItem('containmentThreshold');
197
+ if (cont) this.containmentThreshold = parseFloat(cont);
198
+
199
+ this.console.log('Animal Person Filter loaded');
200
+ }
201
+
202
+ // ─── Settings ────────────────────────────────────────────────
203
+
204
+ async getSettings(): Promise<Setting[]> {
205
+ return [
206
+ {
207
+ key: 'iouThreshold',
208
+ title: 'IoU Threshold',
209
+ description: 'Suppress "person" if its IoU with an animal detection exceeds this value. Lower = more aggressive filtering. (default: 0.3)',
210
+ value: this.iouThreshold.toString(),
211
+ type: 'number',
212
+ },
213
+ {
214
+ key: 'containmentThreshold',
215
+ title: 'Containment Threshold',
216
+ description: 'Suppress "person" if the animal bbox is this much contained inside the person bbox. Catches size-mismatched overlaps. (default: 0.5)',
217
+ value: this.containmentThreshold.toString(),
218
+ type: 'number',
219
+ },
220
+ ];
221
+ }
222
+
223
+ async putSetting(key: string, value: string): Promise<void> {
224
+ if (key === 'iouThreshold') {
225
+ this.iouThreshold = parseFloat(value) || 0.3;
226
+ this.storage.setItem('iouThreshold', this.iouThreshold.toString());
227
+ }
228
+ if (key === 'containmentThreshold') {
229
+ this.containmentThreshold = parseFloat(value) || 0.5;
230
+ this.storage.setItem('containmentThreshold', this.containmentThreshold.toString());
231
+ }
232
+ }
233
+
234
+ // ─── MixinProvider ───────────────────────────────────────────
235
+
236
+ async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
237
+ // Only attach to cameras/doorbells that have object detection
238
+ if (
239
+ (type === ScryptedDeviceType.Camera || type === ScryptedDeviceType.Doorbell) &&
240
+ interfaces.includes(ScryptedInterface.ObjectDetector)
241
+ ) {
242
+ // We provide a filtered ObjectDetector
243
+ return [ScryptedInterface.ObjectDetector];
244
+ }
245
+ return [];
246
+ }
247
+
248
+ async getMixin(
249
+ mixinDevice: any,
250
+ mixinDeviceInterfaces: ScryptedInterface[],
251
+ mixinDeviceState: { [key: string]: any }
252
+ ): Promise<any> {
253
+ return new AnimalFilterMixin(
254
+ {
255
+ mixinDevice,
256
+ mixinDeviceInterfaces,
257
+ mixinDeviceState: mixinDeviceState as any,
258
+ mixinProviderNativeId: this.nativeId,
259
+ } as any,
260
+ this.iouThreshold,
261
+ this.containmentThreshold,
262
+ );
263
+ }
264
+
265
+ async releaseMixin(id: string, mixinDevice: any): Promise<void> {
266
+ await mixinDevice.release();
267
+ }
268
+ }
269
+
270
+ export default AnimalPersonFilter;
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2021",
4
+ "module": "commonjs",
5
+ "lib": ["es2021"],
6
+ "declaration": true,
7
+ "strict": true,
8
+ "noImplicitAny": false,
9
+ "strictNullChecks": false,
10
+ "noUnusedLocals": false,
11
+ "noUnusedParameters": false,
12
+ "outDir": "./dist",
13
+ "rootDir": "./src",
14
+ "esModuleInterop": true,
15
+ "skipLibCheck": true,
16
+ "forceConsistentCasingInFileNames": true,
17
+ "resolveJsonModule": true
18
+ },
19
+ "include": ["src/**/*"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }