mirror of
https://github.com/koush/scrypted.git
synced 2026-03-15 22:52:44 +00:00
1070 lines
36 KiB
TypeScript
1070 lines
36 KiB
TypeScript
import sdk, { Camera, DeviceState, EventListenerRegister, MediaObject, MixinDeviceBase, MixinProvider, MotionSensor, ObjectDetection, ObjectDetectionCallbacks, ObjectDetectionModel, ObjectDetectionResult, ObjectDetectionTypes, ObjectDetector, ObjectsDetected, ScryptedDevice, ScryptedDeviceType, ScryptedInterface, ScryptedNativeId, Setting, Settings, VideoCamera } from '@scrypted/sdk';
|
|
import { StorageSettings } from '@scrypted/sdk/storage-settings';
|
|
import crypto from 'crypto';
|
|
import cloneDeep from 'lodash/cloneDeep';
|
|
import { AutoenableMixinProvider } from "../../../common/src/autoenable-mixin-provider";
|
|
import { SettingsMixinDeviceBase } from "../../../common/src/settings-mixin";
|
|
import { DenoisedDetectionEntry, DenoisedDetectionState, denoiseDetections } from './denoise';
|
|
import { serverSupportsMixinEventMasking } from './server-version';
|
|
import { sleep } from './sleep';
|
|
import { safeParseJson } from './util';
|
|
|
|
const polygonOverlap = require('polygon-overlap');
|
|
const insidePolygon = require('point-inside-polygon');
|
|
|
|
const { systemManager } = sdk;
|
|
|
|
const defaultDetectionDuration = 20;
|
|
const defaultDetectionInterval = 60;
|
|
const defaultDetectionTimeout = 60;
|
|
const defaultMotionDuration = 10;
|
|
const defaultScoreThreshold = .2;
|
|
const defaultSecondScoreThreshold = .7;
|
|
|
|
const BUILTIN_MOTION_SENSOR_ASSIST = 'Assist';
|
|
const BUILTIN_MOTION_SENSOR_REPLACE = 'Replace';
|
|
|
|
type ClipPath = [number, number][];
|
|
type Zones = { [zone: string]: ClipPath };
|
|
interface ZoneInfo {
|
|
exclusion?: boolean;
|
|
type?: 'Intersect' | 'Contain';
|
|
classes?: string[];
|
|
scoreThreshold?: number;
|
|
secondScoreThreshold?: number;
|
|
}
|
|
type ZoneInfos = { [zone: string]: ZoneInfo };
|
|
|
|
type TrackedDetection = ObjectDetectionResult & {
|
|
newOrBetterDetection?: boolean;
|
|
bestScore?: number;
|
|
bestSecondPassScore?: number;
|
|
};
|
|
|
|
class ObjectDetectionMixin extends SettingsMixinDeviceBase<VideoCamera & Camera & MotionSensor & ObjectDetector> implements ObjectDetector, Settings, ObjectDetectionCallbacks {
|
|
motionListener: EventListenerRegister;
|
|
detectorListener: EventListenerRegister;
|
|
motionMixinListener: EventListenerRegister;
|
|
detections = new Map<string, MediaObject>();
|
|
cameraDevice: ScryptedDevice & Camera & VideoCamera & MotionSensor & ObjectDetector;
|
|
storageSettings = new StorageSettings(this, {
|
|
motionSensorSupplementation: {
|
|
title: 'Built-In Motion Sensor',
|
|
description: `This camera has a built in motion sensor. Using ${this.objectDetection.name} may be unnecessary and will use additional CPU. Replace will ignore the built in motion sensor. Filter will verify the motion sent by built in motion sensor. The Default is ${BUILTIN_MOTION_SENSOR_REPLACE}.`,
|
|
choices: [
|
|
'Default',
|
|
BUILTIN_MOTION_SENSOR_ASSIST,
|
|
BUILTIN_MOTION_SENSOR_REPLACE,
|
|
],
|
|
defaultValue: "Default",
|
|
},
|
|
captureMode: {
|
|
title: 'Capture Mode',
|
|
description: 'The method to capture frames for analysis. Video will require more processing power.',
|
|
choices: [
|
|
'Default',
|
|
'Video',
|
|
'Snapshot',
|
|
],
|
|
defaultValue: 'Default',
|
|
},
|
|
detectionDuration: {
|
|
title: 'Detection Duration',
|
|
subgroup: 'Advanced',
|
|
description: 'The duration in seconds to analyze video when motion occurs.',
|
|
type: 'number',
|
|
defaultValue: defaultDetectionDuration,
|
|
},
|
|
detectionTimeout: {
|
|
title: 'Detection Timeout',
|
|
subgroup: 'Advanced',
|
|
description: 'Timeout in seconds before removing an object that is no longer detected.',
|
|
type: 'number',
|
|
defaultValue: defaultDetectionTimeout,
|
|
},
|
|
motionDuration: {
|
|
title: 'Motion Duration',
|
|
description: 'The duration in seconds to wait to reset the motion sensor.',
|
|
type: 'number',
|
|
defaultValue: defaultMotionDuration,
|
|
},
|
|
motionAsObjects: {
|
|
title: 'Motion Detection Objects',
|
|
description: 'Report motion detections as objects (useful for debugging).',
|
|
type: 'boolean',
|
|
},
|
|
detectionInterval: {
|
|
type: 'number',
|
|
defaultValue: defaultDetectionInterval,
|
|
hide: true,
|
|
},
|
|
scoreThreshold: {
|
|
title: 'Minimum Detection Confidence',
|
|
subgroup: 'Advanced',
|
|
description: 'Higher values eliminate false positives and low quality recognition candidates.',
|
|
type: 'number',
|
|
placeholder: '.2',
|
|
defaultValue: defaultScoreThreshold,
|
|
},
|
|
secondScoreThreshold: {
|
|
title: 'Second Pass Confidence',
|
|
subgroup: 'Advanced',
|
|
description: 'Crop and reanalyze a result from the initial detection pass to get more accurate results.',
|
|
key: 'secondScoreThreshold',
|
|
type: 'number',
|
|
defaultValue: defaultSecondScoreThreshold,
|
|
placeholder: '.7',
|
|
},
|
|
});
|
|
motionTimeout: NodeJS.Timeout;
|
|
zones = this.getZones();
|
|
zoneInfos = this.getZoneInfos();
|
|
detectionIntervalTimeout: NodeJS.Timeout;
|
|
detectionState: DenoisedDetectionState<TrackedDetection> = {};
|
|
detectionId: string;
|
|
detectorRunning = false;
|
|
analyzeStop = 0;
|
|
lastDetectionInput = 0;
|
|
|
|
constructor(mixinDevice: VideoCamera & Camera & MotionSensor & ObjectDetector & Settings, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any }, providerNativeId: string, public objectDetection: ObjectDetection & ScryptedDevice, modelName: string, group: string, public hasMotionType: boolean, public settings: Setting[]) {
|
|
super({
|
|
mixinDevice, mixinDeviceState,
|
|
mixinProviderNativeId: providerNativeId,
|
|
mixinDeviceInterfaces,
|
|
group,
|
|
groupKey: "objectdetectionplugin:" + objectDetection.id,
|
|
mixinStorageSuffix: objectDetection.id,
|
|
});
|
|
|
|
this.cameraDevice = systemManager.getDeviceById<Camera & VideoCamera & MotionSensor & ObjectDetector>(this.id);
|
|
this.detectionId = modelName + '-' + this.cameraDevice.id;
|
|
|
|
this.bindObjectDetection();
|
|
this.register();
|
|
this.resetDetectionTimeout();
|
|
}
|
|
|
|
clearDetectionTimeout() {
|
|
clearTimeout(this.detectionIntervalTimeout);
|
|
this.detectionIntervalTimeout = undefined;
|
|
}
|
|
|
|
resetDetectionTimeout() {
|
|
this.clearDetectionTimeout();
|
|
this.detectionIntervalTimeout = setInterval(async () => {
|
|
if (this.hasMotionType) {
|
|
// force a motion detection restart if it quit
|
|
if (this.motionSensorSupplementation === BUILTIN_MOTION_SENSOR_REPLACE)
|
|
await this.startVideoDetection();
|
|
return;
|
|
}
|
|
}, this.storageSettings.values.detectionInterval * 1000);
|
|
}
|
|
|
|
clearMotionTimeout() {
|
|
clearTimeout(this.motionTimeout);
|
|
this.motionTimeout = undefined;
|
|
}
|
|
|
|
resetMotionTimeout() {
|
|
this.clearMotionTimeout();
|
|
this.motionTimeout = setTimeout(() => {
|
|
this.motionDetected = false;
|
|
// if (this.motionSensorSupplementation === BUILTIN_MOTION_SENSOR_ASSIST) {
|
|
// this.console.log(`${this.objectDetection.name} timed out confirming motion, stopping video detection.`)
|
|
// this.endObjectDetection();
|
|
// }
|
|
}, this.storageSettings.values.motionDuration * 1000);
|
|
}
|
|
|
|
getCurrentSettings() {
|
|
if (!this.settings)
|
|
return;
|
|
|
|
const ret: { [key: string]: any } = {};
|
|
for (const setting of this.settings) {
|
|
ret[setting.key] = (setting.multiple ? safeParseJson(this.storage.getItem(setting.key)) : this.storage.getItem(setting.key))
|
|
|| setting.value;
|
|
}
|
|
|
|
return ret;
|
|
}
|
|
|
|
async snapshotDetection() {
|
|
const picture = await this.cameraDevice.takePicture();
|
|
const detections = await this.objectDetection.detectObjects(picture, {
|
|
detectionId: this.detectionId,
|
|
settings: this.getCurrentSettings(),
|
|
});
|
|
this.trackObjects(detections, true);
|
|
this.reportObjectDetections(detections);
|
|
}
|
|
|
|
async maybeStartMotionDetection() {
|
|
if (!this.hasMotionType)
|
|
return;
|
|
if (this.motionSensorSupplementation !== BUILTIN_MOTION_SENSOR_REPLACE)
|
|
return;
|
|
await this.startVideoDetection();
|
|
}
|
|
|
|
endObjectDetection() {
|
|
this.detectorRunning = false;
|
|
this.objectDetection?.detectObjects(undefined, {
|
|
detectionId: this.detectionId,
|
|
});
|
|
}
|
|
|
|
bindObjectDetection() {
|
|
if (this.hasMotionType)
|
|
this.motionDetected = false;
|
|
|
|
this.detectorRunning = false;
|
|
this.detectorListener?.removeListener();
|
|
this.detectorListener = undefined;
|
|
this.endObjectDetection();
|
|
|
|
this.maybeStartMotionDetection();
|
|
}
|
|
async register() {
|
|
const model = await this.objectDetection.getDetectionModel();
|
|
|
|
if (!this.hasMotionType) {
|
|
if (model.triggerClasses?.includes('motion')) {
|
|
this.motionListener = this.cameraDevice.listen(ScryptedInterface.MotionSensor, async () => {
|
|
if (!this.cameraDevice.motionDetected) {
|
|
if (this.detectorRunning) {
|
|
// allow anaysis due to user request.
|
|
if (this.analyzeStop > Date.now())
|
|
return;
|
|
|
|
this.console.log('motion stopped, cancelling ongoing detection')
|
|
this.endObjectDetection();
|
|
}
|
|
return;
|
|
}
|
|
|
|
await this.startStreamAnalysis();
|
|
});
|
|
}
|
|
|
|
const nonMotion = model.triggerClasses?.find(t => t !== 'motion');
|
|
if (nonMotion) {
|
|
this.detectorListener = this.cameraDevice.listen(ScryptedInterface.ObjectDetector, async (s, d, data: ObjectsDetected) => {
|
|
if (!model.triggerClasses)
|
|
return;
|
|
if (!data.detectionId)
|
|
return;
|
|
const { detections } = data;
|
|
if (!detections?.length)
|
|
return;
|
|
|
|
const set = new Set(detections.map(d => d.className));
|
|
for (const trigger of model.triggerClasses) {
|
|
if (trigger === 'motion')
|
|
continue;
|
|
|
|
if (set.has(trigger)) {
|
|
const jpeg = await this.cameraDevice.getDetectionInput(data.detectionId, data.eventId);
|
|
const found = await this.objectDetection.detectObjects(jpeg);
|
|
found.detectionId = data.detectionId;
|
|
this.handleDetectionEvent(found, undefined, jpeg);
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (this.hasMotionType) {
|
|
this.motionMixinListener = this.cameraDevice.listen({
|
|
event: ScryptedInterface.MotionSensor,
|
|
mixinId: this.id,
|
|
}, async (source, details, data) => {
|
|
if (this.motionSensorSupplementation !== BUILTIN_MOTION_SENSOR_ASSIST)
|
|
return;
|
|
if (data) {
|
|
if (this.motionDetected)
|
|
return;
|
|
if (!this.detectorRunning)
|
|
this.console.log('built in motion sensor started motion, starting video detection.');
|
|
await this.startVideoDetection();
|
|
return;
|
|
}
|
|
|
|
this.clearMotionTimeout();
|
|
if (this.detectorRunning) {
|
|
this.console.log('built in motion sensor ended motion, stopping video detection.')
|
|
this.endObjectDetection();
|
|
}
|
|
if (this.motionDetected)
|
|
this.motionDetected = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
async handleDetectionEvent(detection: ObjectsDetected, redetect?: (boundingBox: [number, number, number, number]) => Promise<ObjectDetectionResult[]>, mediaObject?: MediaObject) {
|
|
this.detectorRunning = detection.running;
|
|
|
|
// track the objects on a pre-zoned set.
|
|
this.trackObjects(detection);
|
|
|
|
// apply the zones to the detections and get a shallow copy list of detections after
|
|
// exclusion zones have applied
|
|
const zonedDetections = this.applyZones(detection)
|
|
.filter(d => {
|
|
if (!d.zones?.length)
|
|
return d.bestSecondPassScore >= this.secondScoreThreshold || d.score >= this.scoreThreshold;
|
|
|
|
for (const zone of d.zones || []) {
|
|
const zi = this.zoneInfos[zone];
|
|
const scoreThreshold = zi?.scoreThreshold || this.scoreThreshold;
|
|
const secondScoreThreshold = zi?.secondScoreThreshold || this.secondScoreThreshold;
|
|
// keep the object if it passes the score check, or has already passed a second score check.
|
|
if (d.bestSecondPassScore >= secondScoreThreshold || d.score >= scoreThreshold)
|
|
return true;
|
|
}
|
|
});
|
|
|
|
let retainImage = false;
|
|
|
|
if (!this.hasMotionType && redetect && this.secondScoreThreshold && detection.detections) {
|
|
const detections = detection.detections as TrackedDetection[];
|
|
const newOrBetterDetections = zonedDetections.filter(d => d.newOrBetterDetection);
|
|
detections?.forEach(d => d.newOrBetterDetection = false);
|
|
|
|
// anything with a higher pass initial score should be redetected
|
|
// as it may yield a better second pass score and thus a better thumbnail.
|
|
await Promise.allSettled(newOrBetterDetections.map(async d => {
|
|
const maybeUpdateSecondPassScore = (secondPassScore: number) => {
|
|
let better = false;
|
|
// initialize second pass result
|
|
if (!d.bestSecondPassScore) {
|
|
better = true;
|
|
d.bestSecondPassScore = 0;
|
|
}
|
|
// retain passing the second pass threshold for first time.
|
|
if (d.bestSecondPassScore < this.secondScoreThreshold && secondPassScore >= this.secondScoreThreshold) {
|
|
this.console.log('improved', d.id, d.bestSecondPassScore, d.score);
|
|
better = true;
|
|
retainImage = true;
|
|
}
|
|
else if (secondPassScore > d.bestSecondPassScore * 1.1) {
|
|
this.console.log('improved', d.id, secondPassScore, d.score);
|
|
better = true;
|
|
retainImage = true;
|
|
}
|
|
if (better)
|
|
d.bestSecondPassScore = secondPassScore;
|
|
return better;
|
|
}
|
|
|
|
// the initial score may be sufficient.
|
|
if (d.score >= this.secondScoreThreshold) {
|
|
maybeUpdateSecondPassScore(d.score);
|
|
return;
|
|
}
|
|
|
|
const redetected = await redetect(d.boundingBox);
|
|
const best = redetected.filter(r => r.className === d.className).sort((a, b) => b.score - a.score)?.[0];
|
|
if (best) {
|
|
if (maybeUpdateSecondPassScore(best.score)) {
|
|
d.boundingBox = best.boundingBox;
|
|
}
|
|
}
|
|
}));
|
|
|
|
const secondPassDetections = zonedDetections.filter(d => d.bestSecondPassScore >= this.secondScoreThreshold)
|
|
.map(d => ({
|
|
...d,
|
|
score: d.bestSecondPassScore,
|
|
}));
|
|
detection.detections = secondPassDetections;
|
|
}
|
|
else {
|
|
detection.detections = zonedDetections;
|
|
}
|
|
|
|
if (detection.detections) {
|
|
const trackedDetections = cloneDeep(detection.detections) as TrackedDetection[];
|
|
for (const d of trackedDetections) {
|
|
delete d.bestScore;
|
|
delete d.bestSecondPassScore;
|
|
delete d.newOrBetterDetection;
|
|
}
|
|
detection.detections = trackedDetections;
|
|
}
|
|
|
|
const now = Date.now();
|
|
if (this.lastDetectionInput + this.storageSettings.values.detectionTimeout * 1000 < Date.now())
|
|
retainImage = true;
|
|
|
|
if (retainImage) {
|
|
this.lastDetectionInput = now;
|
|
this.console.log('retaining detection image');
|
|
this.setDetection(detection, mediaObject);
|
|
}
|
|
|
|
this.reportObjectDetections(detection);
|
|
return retainImage;
|
|
}
|
|
|
|
get scoreThreshold() {
|
|
return parseFloat(this.storage.getItem('scoreThreshold')) || defaultScoreThreshold;
|
|
}
|
|
|
|
get secondScoreThreshold() {
|
|
const r = parseFloat(this.storage.getItem('secondScoreThreshold'));
|
|
if (isNaN(r))
|
|
return defaultSecondScoreThreshold;
|
|
return r;
|
|
}
|
|
|
|
async onDetection(detection: ObjectsDetected, redetect?: (boundingBox: [number, number, number, number]) => Promise<ObjectDetectionResult[]>, mediaObject?: MediaObject): Promise<boolean> {
|
|
// detection.detections = detection.detections?.filter(d => d.score >= this.scoreThreshold);
|
|
return this.handleDetectionEvent(detection, redetect, mediaObject);
|
|
}
|
|
|
|
async onDetectionEnded(detection: ObjectsDetected): Promise<void> {
|
|
this.handleDetectionEvent(detection);
|
|
}
|
|
|
|
async startSnapshotAnalysis() {
|
|
if (this.detectorRunning)
|
|
return;
|
|
|
|
this.detectorRunning = true;
|
|
while (this.detectorRunning) {
|
|
const now = Date.now();
|
|
if (now > this.analyzeStop)
|
|
break;
|
|
try {
|
|
const mo = await this.mixinDevice.takePicture({
|
|
reason: 'event',
|
|
});
|
|
const found = await this.objectDetection.detectObjects(mo, {
|
|
settings: this.getCurrentSettings(),
|
|
});
|
|
found.running = this.detectorRunning;
|
|
this.handleDetectionEvent(found, undefined, mo);
|
|
}
|
|
catch (e) {
|
|
}
|
|
// cameras tend to only refresh every 1s at best.
|
|
// maybe get this value from somewhere? or sha the jpeg?
|
|
const diff = now + 1100 - Date.now();
|
|
if (diff > 0)
|
|
await sleep(diff);
|
|
}
|
|
this.detectorRunning = false;
|
|
this.handleDetectionEvent({
|
|
detectionId: this.detectionId,
|
|
running: false,
|
|
detections: [],
|
|
timestamp: Date.now(),
|
|
}, undefined, undefined);
|
|
}
|
|
|
|
async startStreamAnalysis() {
|
|
if (!this.hasMotionType && this.storageSettings.values.captureMode === 'Snapshot') {
|
|
await this.startSnapshotAnalysis();
|
|
}
|
|
else {
|
|
await this.startVideoDetection();
|
|
}
|
|
}
|
|
|
|
async extendedObjectDetect(force?: boolean) {
|
|
if (!this.hasMotionType && this.storageSettings.values.captureMode === 'Snapshot') {
|
|
this.analyzeStop = Date.now() + this.getDetectionDuration();
|
|
}
|
|
else {
|
|
try {
|
|
if (!force && !this.motionDetected)
|
|
return;
|
|
await this.objectDetection?.detectObjects(undefined, {
|
|
detectionId: this.detectionId,
|
|
duration: this.getDetectionDuration(),
|
|
}, this);
|
|
}
|
|
catch (e) {
|
|
// ignore any
|
|
}
|
|
}
|
|
}
|
|
|
|
async startVideoDetection() {
|
|
try {
|
|
const settings = this.getCurrentSettings();
|
|
|
|
// prevent stream retrieval noise until notified that the detection is no longer running.
|
|
if (this.detectorRunning) {
|
|
const session = await this.objectDetection?.detectObjects(undefined, {
|
|
detectionId: this.detectionId,
|
|
duration: this.getDetectionDuration(),
|
|
settings,
|
|
}, this);
|
|
this.detectorRunning = session.running;
|
|
if (this.detectorRunning)
|
|
return;
|
|
}
|
|
|
|
// dummy up the last detection time to prevent the idle timers from purging everything.
|
|
this.detectionState.lastDetection = Date.now();
|
|
|
|
this.detectorRunning = true;
|
|
let stream: MediaObject;
|
|
|
|
stream = await this.cameraDevice.getVideoStream({
|
|
destination: !this.hasMotionType ? 'local-recorder' : 'low-resolution',
|
|
// ask rebroadcast to mute audio, not needed.
|
|
audio: null,
|
|
});
|
|
const session = await this.objectDetection?.detectObjects(stream, {
|
|
detectionId: this.detectionId,
|
|
duration: this.getDetectionDuration(),
|
|
settings,
|
|
}, this);
|
|
|
|
this.detectorRunning = session.running;
|
|
}
|
|
catch (e) {
|
|
this.console.log('failure retrieving stream', e);
|
|
this.detectorRunning = false;
|
|
}
|
|
}
|
|
|
|
getDetectionDuration() {
|
|
// when motion type, the detection interval is a keepalive reset.
|
|
// the duration needs to simply be an arbitrarily longer time.
|
|
return this.hasMotionType ? this.storageSettings.values.detectionInterval * 1000 * 5 : this.storageSettings.values.detectionDuration * 1000;
|
|
}
|
|
|
|
applyZones(detection: ObjectsDetected) {
|
|
// determine zones of the objects, if configured.
|
|
if (!detection.detections)
|
|
return;
|
|
let copy = detection.detections.slice();
|
|
for (const o of detection.detections) {
|
|
if (!o.boundingBox)
|
|
continue;
|
|
|
|
o.zones = []
|
|
let [x, y, width, height] = o.boundingBox;
|
|
let x2 = x + width;
|
|
let y2 = y + height;
|
|
// the zones are point paths in percentage format
|
|
x = x * 100 / detection.inputDimensions[0];
|
|
y = y * 100 / detection.inputDimensions[1];
|
|
x2 = x2 * 100 / detection.inputDimensions[0];
|
|
y2 = y2 * 100 / detection.inputDimensions[1];
|
|
const box = [[x, y], [x2, y], [x2, y2], [x, y2]];
|
|
|
|
let included: boolean;
|
|
for (const [zone, zoneValue] of Object.entries(this.zones)) {
|
|
if (zoneValue.length < 3) {
|
|
// this.console.warn(zone, 'Zone is unconfigured, skipping.');
|
|
continue;
|
|
}
|
|
|
|
const zoneInfo = this.zoneInfos[zone];
|
|
// track if there are any inclusion zones
|
|
if (!zoneInfo?.exclusion && !included)
|
|
included = false;
|
|
|
|
let match = false;
|
|
if (zoneInfo?.type === 'Contain') {
|
|
match = insidePolygon(box[0], zoneValue) &&
|
|
insidePolygon(box[1], zoneValue) &&
|
|
insidePolygon(box[2], zoneValue) &&
|
|
insidePolygon(box[3], zoneValue);
|
|
}
|
|
else {
|
|
match = polygonOverlap(box, zoneValue);
|
|
}
|
|
|
|
if (match && zoneInfo?.classes?.length) {
|
|
match = zoneInfo.classes.includes(o.className);
|
|
}
|
|
if (match) {
|
|
o.zones.push(zone);
|
|
|
|
if (zoneInfo?.exclusion && match) {
|
|
copy = copy.filter(c => c !== o);
|
|
break;
|
|
}
|
|
|
|
included = true;
|
|
}
|
|
}
|
|
|
|
// if there are inclusion zones and this object
|
|
// was not in any of them, filter it out.
|
|
if (included === false)
|
|
copy = copy.filter(c => c !== o);
|
|
}
|
|
|
|
return copy as TrackedDetection[];
|
|
}
|
|
|
|
reportObjectDetections(detection: ObjectsDetected) {
|
|
if (this.hasMotionType) {
|
|
const found = detection.detections?.find(d => d.className === 'motion');
|
|
if (found) {
|
|
if (!this.motionDetected)
|
|
this.motionDetected = true;
|
|
this.resetMotionTimeout();
|
|
|
|
// if (this.motionSensorSupplementation === BUILTIN_MOTION_SENSOR_ASSIST) {
|
|
// if (!this.motionDetected) {
|
|
// this.motionDetected = true;
|
|
// this.console.log(`${this.objectDetection.name} confirmed motion, stopping video detection.`)
|
|
// this.endObjectDetection();
|
|
// this.clearMotionTimeout();
|
|
// }
|
|
// }
|
|
// else {
|
|
// if (!this.motionDetected)
|
|
// this.motionDetected = true;
|
|
// this.resetMotionTimeout();
|
|
// }
|
|
|
|
const areas = detection.detections.filter(d => d.className === 'motion' && d.score !== 1).map(d => d.score)
|
|
if (areas.length)
|
|
this.console.log('detection areas', areas);
|
|
}
|
|
}
|
|
|
|
if (!this.hasMotionType || this.storageSettings.values.motionAsObjects)
|
|
this.onDeviceEvent(ScryptedInterface.ObjectDetector, detection);
|
|
}
|
|
|
|
trackObjects(detectionResult: ObjectsDetected, showAll?: boolean) {
|
|
// do not denoise
|
|
if (this.hasMotionType) {
|
|
return;
|
|
}
|
|
|
|
if (!detectionResult?.detections) {
|
|
// detection session ended.
|
|
return;
|
|
}
|
|
|
|
const { detections } = detectionResult;
|
|
|
|
const found: DenoisedDetectionEntry<TrackedDetection>[] = [];
|
|
denoiseDetections<TrackedDetection>(this.detectionState, detections.map(detection => ({
|
|
get id() {
|
|
return detection.id;
|
|
},
|
|
set id(id) {
|
|
detection.id = id;
|
|
},
|
|
name: detection.className,
|
|
score: detection.score,
|
|
detection,
|
|
get firstSeen() {
|
|
return detection.history?.firstSeen
|
|
},
|
|
set firstSeen(value) {
|
|
detection.history = detection.history || {
|
|
firstSeen: value,
|
|
lastSeen: value,
|
|
};
|
|
detection.history.firstSeen = value;
|
|
},
|
|
get lastSeen() {
|
|
return detection.history?.lastSeen
|
|
},
|
|
set lastSeen(value) {
|
|
detection.history = detection.history || {
|
|
firstSeen: value,
|
|
lastSeen: value,
|
|
};
|
|
detection.history.lastSeen = value;
|
|
},
|
|
boundingBox: detection.boundingBox,
|
|
})), {
|
|
timeout: this.storageSettings.values.detectionTimeout * 1000,
|
|
added: d => {
|
|
found.push(d);
|
|
d.detection.bestScore = d.detection.score;
|
|
d.detection.newOrBetterDetection = true;
|
|
},
|
|
removed: d => {
|
|
this.console.log('expired detection:', `${d.detection.className} (${d.detection.score})`);
|
|
if (detectionResult.running)
|
|
this.extendedObjectDetect();
|
|
},
|
|
retained: (d, o) => {
|
|
if (d.detection.score > o.detection.bestScore) {
|
|
d.detection.bestScore = d.detection.score;
|
|
d.detection.newOrBetterDetection = true;
|
|
}
|
|
else {
|
|
d.detection.bestScore = o.detection.bestScore;
|
|
}
|
|
d.detection.bestSecondPassScore = o.detection.bestSecondPassScore;
|
|
},
|
|
expiring: (d) => {
|
|
},
|
|
});
|
|
if (found.length) {
|
|
this.console.log('new detection:', found.map(d => `${d.id} ${d.detection.className} (${d.detection.score})`).join(', '));
|
|
if (detectionResult.running)
|
|
this.extendedObjectDetect();
|
|
}
|
|
if (found.length || showAll) {
|
|
this.console.log('current detections:', this.detectionState.previousDetections.map(d => `${d.detection.className} (${d.detection.score}, ${d.detection.boundingBox?.join(', ')})`).join(', '));
|
|
}
|
|
}
|
|
|
|
setDetection(detection: ObjectsDetected, detectionInput: MediaObject) {
|
|
if (!detection.detectionId)
|
|
detection.detectionId = crypto.randomBytes(4).toString('hex');
|
|
|
|
const { detectionId } = detection;
|
|
this.detections.set(detectionId, detectionInput);
|
|
setTimeout(() => {
|
|
this.detections.delete(detectionId);
|
|
}, 2000);
|
|
}
|
|
|
|
async getNativeObjectTypes(): Promise<ObjectDetectionTypes> {
|
|
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.ObjectDetector))
|
|
return this.mixinDevice.getObjectTypes();
|
|
return {};
|
|
}
|
|
|
|
async getObjectTypes(): Promise<ObjectDetectionTypes> {
|
|
const ret = await this.getNativeObjectTypes();
|
|
if (!ret.classes)
|
|
ret.classes = [];
|
|
ret.classes.push(...(await this.objectDetection.getDetectionModel()).classes);
|
|
return ret;
|
|
}
|
|
|
|
async getDetectionInput(detectionId: any): Promise<MediaObject> {
|
|
const detection = this.detections.get(detectionId);
|
|
if (detection)
|
|
return detection;
|
|
if (this.mixinDeviceInterfaces.includes(ScryptedInterface.ObjectDetector))
|
|
return this.mixinDevice.getDetectionInput(detectionId);
|
|
throw new Error('Detection not found. It may have expired.');
|
|
}
|
|
|
|
get motionSensorSupplementation() {
|
|
if (!serverSupportsMixinEventMasking() || !this.interfaces.includes(ScryptedInterface.MotionSensor))
|
|
return BUILTIN_MOTION_SENSOR_REPLACE;
|
|
|
|
const supp = this.storage.getItem('motionSensorSupplementation');
|
|
switch (supp) {
|
|
case BUILTIN_MOTION_SENSOR_REPLACE:
|
|
return BUILTIN_MOTION_SENSOR_REPLACE;
|
|
case BUILTIN_MOTION_SENSOR_ASSIST:
|
|
return BUILTIN_MOTION_SENSOR_ASSIST;
|
|
}
|
|
|
|
// if (this.mixinDeviceInterfaces?.includes(ScryptedInterface.MotionSensor))
|
|
// return BUILTIN_MOTION_SENSOR_ASSIST;
|
|
return BUILTIN_MOTION_SENSOR_REPLACE;
|
|
}
|
|
|
|
async getMixinSettings(): Promise<Setting[]> {
|
|
const settings: Setting[] = [];
|
|
|
|
if (this.settings) {
|
|
settings.push(...this.settings.map(setting =>
|
|
Object.assign({}, setting, {
|
|
placeholder: setting.placeholder?.toString(),
|
|
value: (setting.multiple ? safeParseJson(this.storage.getItem(setting.key)) : this.storage.getItem(setting.key))
|
|
|| setting.value,
|
|
} as Setting))
|
|
);
|
|
}
|
|
|
|
settings.push(...await this.storageSettings.getSettings());
|
|
|
|
this.storageSettings.settings.motionSensorSupplementation.hide = !this.hasMotionType || !this.mixinDeviceInterfaces.includes(ScryptedInterface.MotionSensor);
|
|
this.storageSettings.settings.captureMode.hide = this.hasMotionType;
|
|
this.storageSettings.settings.detectionDuration.hide = this.hasMotionType;
|
|
this.storageSettings.settings.detectionTimeout.hide = this.hasMotionType;
|
|
this.storageSettings.settings.motionDuration.hide = !this.hasMotionType;
|
|
this.storageSettings.settings.motionAsObjects.hide = !this.hasMotionType;
|
|
|
|
let hideThreshold = true;
|
|
if (!this.hasMotionType) {
|
|
let hasInclusionZone = false;
|
|
for (const zone of Object.keys(this.zones)) {
|
|
const zi = this.zoneInfos[zone];
|
|
if (!zi?.exclusion) {
|
|
hasInclusionZone = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasInclusionZone) {
|
|
hideThreshold = false;
|
|
}
|
|
}
|
|
this.storageSettings.settings.scoreThreshold.hide = hideThreshold;
|
|
this.storageSettings.settings.secondScoreThreshold.hide = hideThreshold;
|
|
|
|
settings.push({
|
|
key: 'zones',
|
|
title: 'Zones',
|
|
type: 'string',
|
|
description: 'Enter the name of a new zone or delete an existing zone.',
|
|
multiple: true,
|
|
value: Object.keys(this.zones),
|
|
choices: Object.keys(this.zones),
|
|
combobox: true,
|
|
});
|
|
|
|
for (const [name, value] of Object.entries(this.zones)) {
|
|
const zi = this.zoneInfos[name];
|
|
|
|
const subgroup = `Zone: ${name}`;
|
|
settings.push({
|
|
subgroup,
|
|
key: `zone-${name}`,
|
|
title: `Edit Zone`,
|
|
type: 'clippath',
|
|
value: JSON.stringify(value),
|
|
});
|
|
|
|
settings.push({
|
|
subgroup,
|
|
key: `zoneinfo-exclusion-${name}`,
|
|
title: `Exclusion Zone`,
|
|
description: 'Detections in this zone will be excluded.',
|
|
type: 'boolean',
|
|
value: zi?.exclusion,
|
|
});
|
|
|
|
settings.push({
|
|
subgroup,
|
|
key: `zoneinfo-type-${name}`,
|
|
title: `Zone Type`,
|
|
description: 'An Intersect zone will match objects that are partially or fully inside the zone. A Contain zone will only match objects that are fully inside the zone.',
|
|
choices: [
|
|
'Intersect',
|
|
'Contain',
|
|
],
|
|
value: zi?.type || 'Intersect',
|
|
});
|
|
|
|
if (!this.hasMotionType) {
|
|
settings.push(
|
|
{
|
|
subgroup,
|
|
key: `zoneinfo-classes-${name}`,
|
|
title: `Detection Classes`,
|
|
description: 'The detection classes to match inside this zone. An empty list will match all classes.',
|
|
choices: (await this.getObjectTypes())?.classes || [],
|
|
value: zi?.classes || [],
|
|
multiple: true,
|
|
},
|
|
{
|
|
subgroup,
|
|
title: 'Minimum Detection Confidence',
|
|
description: 'Higher values eliminate false positives and low quality recognition candidates.',
|
|
key: `zoneinfo-scoreThreshold-${name}`,
|
|
type: 'number',
|
|
value: zi?.scoreThreshold || this.scoreThreshold,
|
|
placeholder: '.2',
|
|
},
|
|
{
|
|
subgroup,
|
|
title: 'Second Pass Confidence',
|
|
description: 'Crop and reanalyze a result from the initial detection pass to get more accurate results.',
|
|
key: `zoneinfo-secondScoreThreshold-${name}`,
|
|
type: 'number',
|
|
value: zi?.secondScoreThreshold || this.secondScoreThreshold,
|
|
placeholder: '.7',
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
if (!this.hasMotionType) {
|
|
settings.push(
|
|
{
|
|
title: 'Analyze',
|
|
description: 'Analyzes the video stream for 1 minute. Results will be shown in the Console.',
|
|
key: 'analyzeButton',
|
|
type: 'button',
|
|
},
|
|
);
|
|
}
|
|
|
|
return settings;
|
|
}
|
|
|
|
getZones(): Zones {
|
|
try {
|
|
return JSON.parse(this.storage.getItem('zones'));
|
|
}
|
|
catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
getZoneInfos(): ZoneInfos {
|
|
try {
|
|
return JSON.parse(this.storage.getItem('zoneInfos'));
|
|
}
|
|
catch (e) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
async putMixinSetting(key: string, value: string | number | boolean | string[] | number[]): Promise<void> {
|
|
let vs = value?.toString();
|
|
|
|
if (key === 'zones') {
|
|
const newZones: Zones = {};
|
|
const newZoneInfos: ZoneInfos = {};
|
|
for (const name of value as string[]) {
|
|
newZones[name] = this.zones[name] || [];
|
|
newZoneInfos[name] = this.zoneInfos[name];
|
|
}
|
|
this.zones = newZones;
|
|
this.zoneInfos = newZoneInfos;
|
|
this.storage.setItem('zones', JSON.stringify(newZones));
|
|
this.storage.setItem('zoneInfos', JSON.stringify(newZoneInfos));
|
|
return;
|
|
}
|
|
if (key.startsWith('zone-')) {
|
|
const zoneName = key.substring('zone-'.length);
|
|
if (this.zones[zoneName]) {
|
|
this.zones[zoneName] = JSON.parse(vs);
|
|
this.storage.setItem('zones', JSON.stringify(this.zones));
|
|
}
|
|
return;
|
|
}
|
|
if (key.startsWith('zoneinfo-')) {
|
|
const [zkey, zoneName] = key.substring('zoneinfo-'.length).split('-');
|
|
this.zoneInfos[zoneName] ||= {};
|
|
this.zoneInfos[zoneName][zkey] = value;
|
|
this.storage.setItem('zoneInfos', JSON.stringify(this.zoneInfos));
|
|
return;
|
|
}
|
|
|
|
if (this.storageSettings.settings[key]) {
|
|
return this.storageSettings.putSetting(key, value);
|
|
}
|
|
|
|
if (value && this.settings?.find(s => s.key === key)?.multiple) {
|
|
vs = JSON.stringify(value);
|
|
}
|
|
|
|
if (key === 'analyzeButton') {
|
|
this.analyzeStop = Date.now() + 60000;
|
|
// await this.snapshotDetection();
|
|
await this.startStreamAnalysis();
|
|
}
|
|
else {
|
|
const settings = this.getCurrentSettings();
|
|
if (settings && settings[key]) {
|
|
this.storage.setItem(key, vs);
|
|
settings[key] = value;
|
|
}
|
|
this.bindObjectDetection();
|
|
}
|
|
}
|
|
|
|
async release() {
|
|
super.release();
|
|
this.clearDetectionTimeout();
|
|
this.clearMotionTimeout();
|
|
this.motionListener?.removeListener();
|
|
this.motionMixinListener?.removeListener();
|
|
this.detectorListener?.removeListener();
|
|
this.endObjectDetection();
|
|
}
|
|
}
|
|
|
|
class ObjectDetectorMixin extends MixinDeviceBase<ObjectDetection> implements MixinProvider {
|
|
constructor(mixinDevice: ObjectDetection, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: DeviceState, mixinProviderNativeId: ScryptedNativeId, public model: ObjectDetectionModel) {
|
|
super({ mixinDevice, mixinDeviceInterfaces, mixinDeviceState, mixinProviderNativeId });
|
|
|
|
// trigger mixin creation. todo: fix this to not be stupid hack.
|
|
for (const id of Object.keys(systemManager.getSystemState())) {
|
|
const device = systemManager.getDeviceById<VideoCamera & Settings>(id);
|
|
if (!device.mixins?.includes(this.id))
|
|
continue;
|
|
device.probe();
|
|
}
|
|
}
|
|
|
|
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
|
|
// filter out
|
|
for (const iface of interfaces) {
|
|
if (iface.startsWith(`${ScryptedInterface.ObjectDetection}:`)) {
|
|
const deviceMatch = this.mixinDeviceInterfaces.find(miface => miface.startsWith(iface));
|
|
if (deviceMatch)
|
|
continue;
|
|
return null;
|
|
}
|
|
}
|
|
|
|
if ((type === ScryptedDeviceType.Camera || type === ScryptedDeviceType.Doorbell) && (interfaces.includes(ScryptedInterface.VideoCamera) || interfaces.includes(ScryptedInterface.Camera))) {
|
|
const ret: string[] = [ScryptedInterface.ObjectDetector, ScryptedInterface.Settings];
|
|
const model = await this.mixinDevice.getDetectionModel();
|
|
if (model.classes?.includes('motion')) {
|
|
// const vamotion = 'mixin:@scrypted/objectdetector:motion';
|
|
// if (interfaces.includes(vamotion))
|
|
// return;
|
|
|
|
ret.push(
|
|
ScryptedInterface.MotionSensor,
|
|
// vamotion,
|
|
);
|
|
}
|
|
return ret;
|
|
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async getMixin(mixinDevice: any, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any }) {
|
|
let objectDetection = systemManager.getDeviceById<ObjectDetection>(this.id);
|
|
const group = objectDetection.name.replace('Plugin', '').trim();
|
|
|
|
const hasMotionType = this.model.classes.includes('motion');
|
|
const settings = this.model.settings;
|
|
|
|
return new ObjectDetectionMixin(mixinDevice, mixinDeviceInterfaces, mixinDeviceState, this.mixinProviderNativeId, objectDetection, this.model.name, group, hasMotionType, settings);
|
|
}
|
|
|
|
async releaseMixin(id: string, mixinDevice: any) {
|
|
return mixinDevice.release();
|
|
}
|
|
}
|
|
|
|
class ObjectDetectionPlugin extends AutoenableMixinProvider {
|
|
constructor(nativeId?: ScryptedNativeId) {
|
|
super(nativeId);
|
|
}
|
|
|
|
async canMixin(type: ScryptedDeviceType, interfaces: string[]): Promise<string[]> {
|
|
if (!interfaces.includes(ScryptedInterface.ObjectDetection))
|
|
return;
|
|
return [ScryptedInterface.MixinProvider];
|
|
}
|
|
|
|
async getMixin(mixinDevice: ObjectDetection, mixinDeviceInterfaces: ScryptedInterface[], mixinDeviceState: { [key: string]: any; }): Promise<any> {
|
|
const model = await mixinDevice.getDetectionModel();
|
|
return new ObjectDetectorMixin(mixinDevice, mixinDeviceInterfaces, mixinDeviceState, this.nativeId, model);
|
|
}
|
|
|
|
async releaseMixin(id: string, mixinDevice: any): Promise<void> {
|
|
// what does this mean to make a mixin provider no longer available?
|
|
// just ignore it until reboot?
|
|
}
|
|
}
|
|
|
|
export default ObjectDetectionPlugin;
|