diff --git a/db/AI_Models.sql b/db/AI_Models.sql new file mode 100644 index 000000000..e515bf8b0 --- /dev/null +++ b/db/AI_Models.sql @@ -0,0 +1,80 @@ +-- +-- Add AI Models tables for ZoneMinder +-- + +-- AI Datasets table - stores datasets like COCO, ImageNet, etc. +CREATE TABLE IF NOT EXISTS `AI_Datasets` ( + `Id` int(10) unsigned NOT NULL auto_increment, + `Name` varchar(64) NOT NULL, + `Description` TEXT, + `Version` varchar(32), + `NumClasses` int(10) unsigned NOT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `AI_Datasets_Name_idx` (`Name`) +) ENGINE=InnoDB; + +-- AI Models table - stores AI model implementations +CREATE TABLE IF NOT EXISTS `AI_Models` ( + `Id` int(10) unsigned NOT NULL auto_increment, + `Name` varchar(64) NOT NULL, + `Description` TEXT, + `ModelPath` varchar(255), + `Framework` enum('TensorFlow','PyTorch','ONNX','OpenVINO','TensorRT','Other') NOT NULL default 'ONNX', + `Version` varchar(32), + `DatasetId` int(10) unsigned, + `Enabled` tinyint(1) unsigned NOT NULL default 0, + PRIMARY KEY (`Id`), + UNIQUE KEY `AI_Models_Name_idx` (`Name`), + FOREIGN KEY (`DatasetId`) REFERENCES `AI_Datasets` (`Id`) ON DELETE SET NULL +) ENGINE=InnoDB; + +-- AI Object Classes table - stores object classes from datasets +CREATE TABLE IF NOT EXISTS `AI_Object_Classes` ( + `Id` int(10) unsigned NOT NULL auto_increment, + `DatasetId` int(10) unsigned NOT NULL, + `ClassName` varchar(64) NOT NULL, + `ClassIndex` int(10) unsigned NOT NULL, + `Description` TEXT, + PRIMARY KEY (`Id`), + KEY `AI_Object_Classes_DatasetId_idx` (`DatasetId`), + UNIQUE KEY `AI_Object_Classes_Dataset_Class_idx` (`DatasetId`, `ClassName`), + FOREIGN KEY (`DatasetId`) REFERENCES `AI_Datasets` (`Id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- AI Detection Settings table - stores detection settings per monitor and object class +CREATE TABLE IF NOT EXISTS `AI_Detection_Settings` ( + `Id` int(10) unsigned NOT NULL auto_increment, + `MonitorId` int(10) unsigned NULL, + `ObjectClassId` int(10) unsigned NOT NULL, + `Enabled` tinyint(1) unsigned NOT NULL default 1, + `ReportDetection` tinyint(1) unsigned NOT NULL default 1, + `ConfidenceThreshold` tinyint(3) unsigned NOT NULL default 50, + `BoxColor` varchar(7) NOT NULL default '#FF0000', + PRIMARY KEY (`Id`), + KEY `AI_Detection_Settings_MonitorId_idx` (`MonitorId`), + KEY `AI_Detection_Settings_ObjectClassId_idx` (`ObjectClassId`), + UNIQUE KEY `AI_Detection_Settings_Monitor_Object_idx` (`MonitorId`, `ObjectClassId`), + FOREIGN KEY (`MonitorId`) REFERENCES `Monitors` (`Id`) ON DELETE CASCADE, + FOREIGN KEY (`ObjectClassId`) REFERENCES `AI_Object_Classes` (`Id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- AI Detections table - stores actual detection results +CREATE TABLE IF NOT EXISTS `AI_Detections` ( + `Id` BIGINT unsigned NOT NULL auto_increment, + `EventId` BIGINT unsigned NOT NULL, + `FrameId` BIGINT unsigned, + `ObjectClassId` int(10) unsigned NOT NULL, + `Confidence` decimal(5,4) NOT NULL, + `BoundingBoxX` int(10) unsigned, + `BoundingBoxY` int(10) unsigned, + `BoundingBoxWidth` int(10) unsigned, + `BoundingBoxHeight` int(10) unsigned, + `DetectedAt` TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`Id`), + KEY `AI_Detections_EventId_idx` (`EventId`), + KEY `AI_Detections_FrameId_idx` (`FrameId`), + KEY `AI_Detections_ObjectClassId_idx` (`ObjectClassId`), + FOREIGN KEY (`EventId`) REFERENCES `Events` (`Id`) ON DELETE CASCADE, + FOREIGN KEY (`FrameId`) REFERENCES `Frames` (`Id`) ON DELETE SET NULL, + FOREIGN KEY (`ObjectClassId`) REFERENCES `AI_Object_Classes` (`Id`) ON DELETE CASCADE +) ENGINE=InnoDB; diff --git a/db/coco_dataset.sql b/db/coco_dataset.sql new file mode 100644 index 000000000..502231d05 --- /dev/null +++ b/db/coco_dataset.sql @@ -0,0 +1,110 @@ +-- +-- Pre-populate COCO 2017 Dataset with 80 object classes +-- + +-- Insert COCO 2017 Dataset +INSERT IGNORE INTO AI_Datasets (Id, Name, Description, Version, NumClasses) VALUES +(1, 'COCO', 'Microsoft Common Objects in Context', '2017', 80); + +-- Insert all 80 COCO object classes with correct indices (0-79) +INSERT IGNORE INTO AI_Object_Classes (DatasetId, ClassName, ClassIndex, Description) VALUES +(1, 'person', 0, 'Person'), +(1, 'bicycle', 1, 'Bicycle'), +(1, 'car', 2, 'Car'), +(1, 'motorcycle', 3, 'Motorcycle'), +(1, 'airplane', 4, 'Airplane'), +(1, 'bus', 5, 'Bus'), +(1, 'train', 6, 'Train'), +(1, 'truck', 7, 'Truck'), +(1, 'boat', 8, 'Boat'), +(1, 'traffic light', 9, 'Traffic light'), +(1, 'fire hydrant', 10, 'Fire hydrant'), +(1, 'stop sign', 11, 'Stop sign'), +(1, 'parking meter', 12, 'Parking meter'), +(1, 'bench', 13, 'Bench'), +(1, 'bird', 14, 'Bird'), +(1, 'cat', 15, 'Cat'), +(1, 'dog', 16, 'Dog'), +(1, 'horse', 17, 'Horse'), +(1, 'sheep', 18, 'Sheep'), +(1, 'cow', 19, 'Cow'), +(1, 'elephant', 20, 'Elephant'), +(1, 'bear', 21, 'Bear'), +(1, 'zebra', 22, 'Zebra'), +(1, 'giraffe', 23, 'Giraffe'), +(1, 'backpack', 24, 'Backpack'), +(1, 'umbrella', 25, 'Umbrella'), +(1, 'handbag', 26, 'Handbag'), +(1, 'tie', 27, 'Tie'), +(1, 'suitcase', 28, 'Suitcase'), +(1, 'frisbee', 29, 'Frisbee'), +(1, 'skis', 30, 'Skis'), +(1, 'snowboard', 31, 'Snowboard'), +(1, 'sports ball', 32, 'Sports ball'), +(1, 'kite', 33, 'Kite'), +(1, 'baseball bat', 34, 'Baseball bat'), +(1, 'baseball glove', 35, 'Baseball glove'), +(1, 'skateboard', 36, 'Skateboard'), +(1, 'surfboard', 37, 'Surfboard'), +(1, 'tennis racket', 38, 'Tennis racket'), +(1, 'bottle', 39, 'Bottle'), +(1, 'wine glass', 40, 'Wine glass'), +(1, 'cup', 41, 'Cup'), +(1, 'fork', 42, 'Fork'), +(1, 'knife', 43, 'Knife'), +(1, 'spoon', 44, 'Spoon'), +(1, 'bowl', 45, 'Bowl'), +(1, 'banana', 46, 'Banana'), +(1, 'apple', 47, 'Apple'), +(1, 'sandwich', 48, 'Sandwich'), +(1, 'orange', 49, 'Orange'), +(1, 'broccoli', 50, 'Broccoli'), +(1, 'carrot', 51, 'Carrot'), +(1, 'hot dog', 52, 'Hot dog'), +(1, 'pizza', 53, 'Pizza'), +(1, 'donut', 54, 'Donut'), +(1, 'cake', 55, 'Cake'), +(1, 'chair', 56, 'Chair'), +(1, 'couch', 57, 'Couch'), +(1, 'potted plant', 58, 'Potted plant'), +(1, 'bed', 59, 'Bed'), +(1, 'dining table', 60, 'Dining table'), +(1, 'toilet', 61, 'Toilet'), +(1, 'tv', 62, 'TV'), +(1, 'laptop', 63, 'Laptop'), +(1, 'mouse', 64, 'Mouse'), +(1, 'remote', 65, 'Remote'), +(1, 'keyboard', 66, 'Keyboard'), +(1, 'cell phone', 67, 'Cell phone'), +(1, 'microwave', 68, 'Microwave'), +(1, 'oven', 69, 'Oven'), +(1, 'toaster', 70, 'Toaster'), +(1, 'sink', 71, 'Sink'), +(1, 'refrigerator', 72, 'Refrigerator'), +(1, 'book', 73, 'Book'), +(1, 'clock', 74, 'Clock'), +(1, 'vase', 75, 'Vase'), +(1, 'scissors', 76, 'Scissors'), +(1, 'teddy bear', 77, 'Teddy bear'), +(1, 'hair drier', 78, 'Hair drier'), +(1, 'toothbrush', 79, 'Toothbrush'); + +-- Create default detection settings for common security monitoring objects +-- Only enable person and vehicles by default +INSERT IGNORE INTO AI_Detection_Settings (MonitorId, ObjectClassId, Enabled, ReportDetection, ConfidenceThreshold, BoxColor) +SELECT NULL, Id, 1, 1, + CASE + WHEN ClassName = 'person' THEN 60 + ELSE 50 + END, + CASE + WHEN ClassName = 'person' THEN '#FF0000' + WHEN ClassName = 'car' THEN '#0000FF' + WHEN ClassName = 'truck' THEN '#0066FF' + WHEN ClassName = 'bus' THEN '#0099FF' + WHEN ClassName = 'motorcycle' THEN '#00CCFF' + ELSE '#808080' + END +FROM AI_Object_Classes +WHERE DatasetId = (SELECT Id FROM AI_Datasets WHERE Name = 'COCO' LIMIT 1) +AND ClassName IN ('person', 'car', 'truck', 'bus', 'motorcycle'); diff --git a/db/zm_create.sql.in b/db/zm_create.sql.in index 6ab1d17ae..d012ea0a9 100644 --- a/db/zm_create.sql.in +++ b/db/zm_create.sql.in @@ -610,6 +610,11 @@ CREATE TABLE `Monitors` ( `Analysing` enum('None','Always') NOT NULL default 'Always', `AnalysisSource` enum('Primary','Secondary') NOT NULL DEFAULT 'Primary', `AnalysisImage` enum('FullColour','YChannel') NOT NULL DEFAULT 'FullColour', + `AnalysisImageOpacity` TINYINT UNSIGNED NOT NULL DEFAULT '128', + `ObjectDetection` VARCHAR(16) NOT NULL DEFAULT 'none', + `ObjectDetectionModel` VARCHAR(255) NOT NULL DEFAULT '', + `ObjectDetectionObjectThreshold` FLOAT NOT NULL DEFAULT 0.4, + `ObjectDetectionNMSThreshold` FLOAT NOT NULL DEFAULT 0.25, `Recording` enum('None', 'OnMotion', 'Always') NOT NULL default 'Always', `Enabled` tinyint(3) unsigned NOT NULL default '1', `DecodingEnabled` tinyint(3) unsigned NOT NULL default '1', @@ -1467,6 +1472,10 @@ source @PKGDATADIR@/db/triggers.sql source @PKGDATADIR@/db/manufacturers.sql source @PKGDATADIR@/db/models.sql + +-- AI object-detection data model: tables then the COCO 2017 seed. +source @PKGDATADIR@/db/AI_Models.sql +source @PKGDATADIR@/db/coco_dataset.sql -- -- Apply the initial configuration -- diff --git a/db/zm_update-1.39.17.sql b/db/zm_update-1.39.17.sql new file mode 100644 index 000000000..26071570f --- /dev/null +++ b/db/zm_update-1.39.17.sql @@ -0,0 +1,251 @@ +-- +-- AI object-detection data model. +-- +-- Adds the per-monitor object-detection columns and the AI_* tables used by +-- the object-detection management UI, then seeds the COCO 2017 dataset. +-- +-- All statements are idempotent so re-running (or running after a fresh +-- install that already has these objects) is a no-op. +-- + +-- +-- Monitors columns +-- + +-- AnalysisImageOpacity: alpha applied when compositing the analysis/annotated +-- image over the source frame (0-255). +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = DATABASE() + AND table_name = 'Monitors' AND column_name = 'AnalysisImageOpacity') > 0, +"SELECT 'Column AnalysisImageOpacity already exists in Monitors'", +"ALTER TABLE `Monitors` ADD COLUMN `AnalysisImageOpacity` TINYINT UNSIGNED NOT NULL DEFAULT '128' AFTER `AnalysisImage`" +)); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ObjectDetection: name of the detection backend for this monitor, or 'none'. +-- VARCHAR(16) so backends can be added without a schema change. +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = DATABASE() + AND table_name = 'Monitors' AND column_name = 'ObjectDetection') > 0, +"SELECT 'Column ObjectDetection already exists in Monitors'", +"ALTER TABLE `Monitors` ADD COLUMN `ObjectDetection` VARCHAR(16) NOT NULL DEFAULT 'none' AFTER `AnalysisImageOpacity`" +)); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ObjectDetectionModel: path/name of the model file used by the backend. +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = DATABASE() + AND table_name = 'Monitors' AND column_name = 'ObjectDetectionModel') > 0, +"SELECT 'Column ObjectDetectionModel already exists in Monitors'", +"ALTER TABLE `Monitors` ADD COLUMN `ObjectDetectionModel` VARCHAR(255) NOT NULL DEFAULT '' AFTER `ObjectDetection`" +)); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ObjectDetectionObjectThreshold: minimum confidence for a detection to count. +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = DATABASE() + AND table_name = 'Monitors' AND column_name = 'ObjectDetectionObjectThreshold') > 0, +"SELECT 'Column ObjectDetectionObjectThreshold already exists in Monitors'", +"ALTER TABLE `Monitors` ADD COLUMN `ObjectDetectionObjectThreshold` FLOAT NOT NULL DEFAULT 0.4 AFTER `ObjectDetectionModel`" +)); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ObjectDetectionNMSThreshold: non-maximum-suppression IoU threshold. +SET @s = (SELECT IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE table_schema = DATABASE() + AND table_name = 'Monitors' AND column_name = 'ObjectDetectionNMSThreshold') > 0, +"SELECT 'Column ObjectDetectionNMSThreshold already exists in Monitors'", +"ALTER TABLE `Monitors` ADD COLUMN `ObjectDetectionNMSThreshold` FLOAT NOT NULL DEFAULT 0.25 AFTER `ObjectDetectionObjectThreshold`" +)); +PREPARE stmt FROM @s; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- Widen ObjectDetection to VARCHAR(16) in case an older upgrade path left it as +-- an ENUM or a narrower VARCHAR. +ALTER TABLE `Monitors` MODIFY `ObjectDetection` VARCHAR(16) NOT NULL DEFAULT 'none'; + +-- +-- AI tables +-- + +-- AI Datasets table - stores datasets like COCO, ImageNet, etc. +CREATE TABLE IF NOT EXISTS `AI_Datasets` ( + `Id` int(10) unsigned NOT NULL auto_increment, + `Name` varchar(64) NOT NULL, + `Description` TEXT, + `Version` varchar(32), + `NumClasses` int(10) unsigned NOT NULL, + PRIMARY KEY (`Id`), + UNIQUE KEY `AI_Datasets_Name_idx` (`Name`) +) ENGINE=InnoDB; + +-- AI Models table - stores AI model implementations +CREATE TABLE IF NOT EXISTS `AI_Models` ( + `Id` int(10) unsigned NOT NULL auto_increment, + `Name` varchar(64) NOT NULL, + `Description` TEXT, + `ModelPath` varchar(255), + `Framework` enum('TensorFlow','PyTorch','ONNX','OpenVINO','TensorRT','Other') NOT NULL default 'ONNX', + `Version` varchar(32), + `DatasetId` int(10) unsigned, + `Enabled` tinyint(1) unsigned NOT NULL default 0, + PRIMARY KEY (`Id`), + UNIQUE KEY `AI_Models_Name_idx` (`Name`), + FOREIGN KEY (`DatasetId`) REFERENCES `AI_Datasets` (`Id`) ON DELETE SET NULL +) ENGINE=InnoDB; + +-- AI Object Classes table - stores object classes from datasets +CREATE TABLE IF NOT EXISTS `AI_Object_Classes` ( + `Id` int(10) unsigned NOT NULL auto_increment, + `DatasetId` int(10) unsigned NOT NULL, + `ClassName` varchar(64) NOT NULL, + `ClassIndex` int(10) unsigned NOT NULL, + `Description` TEXT, + PRIMARY KEY (`Id`), + KEY `AI_Object_Classes_DatasetId_idx` (`DatasetId`), + UNIQUE KEY `AI_Object_Classes_Dataset_Class_idx` (`DatasetId`, `ClassName`), + FOREIGN KEY (`DatasetId`) REFERENCES `AI_Datasets` (`Id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- AI Detection Settings table - stores detection settings per monitor and object class +CREATE TABLE IF NOT EXISTS `AI_Detection_Settings` ( + `Id` int(10) unsigned NOT NULL auto_increment, + `MonitorId` int(10) unsigned NULL, + `ObjectClassId` int(10) unsigned NOT NULL, + `Enabled` tinyint(1) unsigned NOT NULL default 1, + `ReportDetection` tinyint(1) unsigned NOT NULL default 1, + `ConfidenceThreshold` tinyint(3) unsigned NOT NULL default 50, + `BoxColor` varchar(7) NOT NULL default '#FF0000', + PRIMARY KEY (`Id`), + KEY `AI_Detection_Settings_MonitorId_idx` (`MonitorId`), + KEY `AI_Detection_Settings_ObjectClassId_idx` (`ObjectClassId`), + UNIQUE KEY `AI_Detection_Settings_Monitor_Object_idx` (`MonitorId`, `ObjectClassId`), + FOREIGN KEY (`MonitorId`) REFERENCES `Monitors` (`Id`) ON DELETE CASCADE, + FOREIGN KEY (`ObjectClassId`) REFERENCES `AI_Object_Classes` (`Id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- AI Detections table - stores actual detection results +CREATE TABLE IF NOT EXISTS `AI_Detections` ( + `Id` BIGINT unsigned NOT NULL auto_increment, + `EventId` BIGINT unsigned NOT NULL, + `FrameId` BIGINT unsigned, + `ObjectClassId` int(10) unsigned NOT NULL, + `Confidence` decimal(5,4) NOT NULL, + `BoundingBoxX` int(10) unsigned, + `BoundingBoxY` int(10) unsigned, + `BoundingBoxWidth` int(10) unsigned, + `BoundingBoxHeight` int(10) unsigned, + `DetectedAt` TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`Id`), + KEY `AI_Detections_EventId_idx` (`EventId`), + KEY `AI_Detections_FrameId_idx` (`FrameId`), + KEY `AI_Detections_ObjectClassId_idx` (`ObjectClassId`), + FOREIGN KEY (`EventId`) REFERENCES `Events` (`Id`) ON DELETE CASCADE, + FOREIGN KEY (`FrameId`) REFERENCES `Frames` (`Id`) ON DELETE SET NULL, + FOREIGN KEY (`ObjectClassId`) REFERENCES `AI_Object_Classes` (`Id`) ON DELETE CASCADE +) ENGINE=InnoDB; + +-- +-- Seed the COCO 2017 dataset (80 classes) and default detection settings. +-- + +INSERT IGNORE INTO AI_Datasets (Id, Name, Description, Version, NumClasses) VALUES +(1, 'COCO', 'Microsoft Common Objects in Context', '2017', 80); + +INSERT IGNORE INTO AI_Object_Classes (DatasetId, ClassName, ClassIndex, Description) VALUES +(1, 'person', 0, 'Person'), +(1, 'bicycle', 1, 'Bicycle'), +(1, 'car', 2, 'Car'), +(1, 'motorcycle', 3, 'Motorcycle'), +(1, 'airplane', 4, 'Airplane'), +(1, 'bus', 5, 'Bus'), +(1, 'train', 6, 'Train'), +(1, 'truck', 7, 'Truck'), +(1, 'boat', 8, 'Boat'), +(1, 'traffic light', 9, 'Traffic light'), +(1, 'fire hydrant', 10, 'Fire hydrant'), +(1, 'stop sign', 11, 'Stop sign'), +(1, 'parking meter', 12, 'Parking meter'), +(1, 'bench', 13, 'Bench'), +(1, 'bird', 14, 'Bird'), +(1, 'cat', 15, 'Cat'), +(1, 'dog', 16, 'Dog'), +(1, 'horse', 17, 'Horse'), +(1, 'sheep', 18, 'Sheep'), +(1, 'cow', 19, 'Cow'), +(1, 'elephant', 20, 'Elephant'), +(1, 'bear', 21, 'Bear'), +(1, 'zebra', 22, 'Zebra'), +(1, 'giraffe', 23, 'Giraffe'), +(1, 'backpack', 24, 'Backpack'), +(1, 'umbrella', 25, 'Umbrella'), +(1, 'handbag', 26, 'Handbag'), +(1, 'tie', 27, 'Tie'), +(1, 'suitcase', 28, 'Suitcase'), +(1, 'frisbee', 29, 'Frisbee'), +(1, 'skis', 30, 'Skis'), +(1, 'snowboard', 31, 'Snowboard'), +(1, 'sports ball', 32, 'Sports ball'), +(1, 'kite', 33, 'Kite'), +(1, 'baseball bat', 34, 'Baseball bat'), +(1, 'baseball glove', 35, 'Baseball glove'), +(1, 'skateboard', 36, 'Skateboard'), +(1, 'surfboard', 37, 'Surfboard'), +(1, 'tennis racket', 38, 'Tennis racket'), +(1, 'bottle', 39, 'Bottle'), +(1, 'wine glass', 40, 'Wine glass'), +(1, 'cup', 41, 'Cup'), +(1, 'fork', 42, 'Fork'), +(1, 'knife', 43, 'Knife'), +(1, 'spoon', 44, 'Spoon'), +(1, 'bowl', 45, 'Bowl'), +(1, 'banana', 46, 'Banana'), +(1, 'apple', 47, 'Apple'), +(1, 'sandwich', 48, 'Sandwich'), +(1, 'orange', 49, 'Orange'), +(1, 'broccoli', 50, 'Broccoli'), +(1, 'carrot', 51, 'Carrot'), +(1, 'hot dog', 52, 'Hot dog'), +(1, 'pizza', 53, 'Pizza'), +(1, 'donut', 54, 'Donut'), +(1, 'cake', 55, 'Cake'), +(1, 'chair', 56, 'Chair'), +(1, 'couch', 57, 'Couch'), +(1, 'potted plant', 58, 'Potted plant'), +(1, 'bed', 59, 'Bed'), +(1, 'dining table', 60, 'Dining table'), +(1, 'toilet', 61, 'Toilet'), +(1, 'tv', 62, 'TV'), +(1, 'laptop', 63, 'Laptop'), +(1, 'mouse', 64, 'Mouse'), +(1, 'remote', 65, 'Remote'), +(1, 'keyboard', 66, 'Keyboard'), +(1, 'cell phone', 67, 'Cell phone'), +(1, 'microwave', 68, 'Microwave'), +(1, 'oven', 69, 'Oven'), +(1, 'toaster', 70, 'Toaster'), +(1, 'sink', 71, 'Sink'), +(1, 'refrigerator', 72, 'Refrigerator'), +(1, 'book', 73, 'Book'), +(1, 'clock', 74, 'Clock'), +(1, 'vase', 75, 'Vase'), +(1, 'scissors', 76, 'Scissors'), +(1, 'teddy bear', 77, 'Teddy bear'), +(1, 'hair drier', 78, 'Hair drier'), +(1, 'toothbrush', 79, 'Toothbrush'); + +-- Default detection settings (MonitorId NULL = global default): enable person +-- and common vehicles. +INSERT IGNORE INTO AI_Detection_Settings (MonitorId, ObjectClassId, Enabled, ReportDetection, ConfidenceThreshold, BoxColor) +SELECT NULL, Id, 1, 1, + CASE WHEN ClassName = 'person' THEN 60 ELSE 50 END, + CASE + WHEN ClassName = 'person' THEN '#FF0000' + WHEN ClassName = 'car' THEN '#0000FF' + WHEN ClassName = 'truck' THEN '#0066FF' + WHEN ClassName = 'bus' THEN '#0099FF' + WHEN ClassName = 'motorcycle' THEN '#00CCFF' + ELSE '#808080' + END +FROM AI_Object_Classes +WHERE DatasetId = (SELECT Id FROM AI_Datasets WHERE Name = 'COCO' LIMIT 1) +AND ClassName IN ('person', 'car', 'truck', 'bus', 'motorcycle'); diff --git a/scripts/ZoneMinder/lib/ZoneMinder/Memory.pm.in b/scripts/ZoneMinder/lib/ZoneMinder/Memory.pm.in index 7b8c96d28..669f40127 100644 --- a/scripts/ZoneMinder/lib/ZoneMinder/Memory.pm.in +++ b/scripts/ZoneMinder/lib/ZoneMinder/Memory.pm.in @@ -185,6 +185,9 @@ our %mem_data = ( video_fifo => { type=>'int8[64]', seq=>$mem_seq++ }, audio_fifo => { type=>'int8[64]', seq=>$mem_seq++ }, janus_pin => { type=>'int8[64]', seq=>$mem_seq++ }, + last_analysis_index => { type=>'int32', seq=>$mem_seq++ }, + analysis_image_count => { type=>'int32', seq=>$mem_seq++ }, + analysis_pad => { type=>'uint8[8]', seq=>$mem_seq++ }, } }, trigger_data => { type=>'TriggerData', seq=>$mem_seq++, 'contents'=> { diff --git a/src/zm_monitor.cpp b/src/zm_monitor.cpp index 89f41c7f0..bb0d6ff58 100644 --- a/src/zm_monitor.cpp +++ b/src/zm_monitor.cpp @@ -300,7 +300,7 @@ Monitor::Monitor() : shared_timestamps(nullptr), shared_images(nullptr), image_pixelformats(nullptr), - alarm_image_pixelformat(nullptr), + analysis_image_pixelformats(nullptr), shm_slot_size(0), video_stream_id(-1), audio_stream_id(-1), @@ -1016,9 +1016,9 @@ bool Monitor::connect() { + sizeof(VideoStoreData) //Information to pass back to the capture process + (image_buffer_count*sizeof(struct timeval)) + (image_buffer_count*image_size) - + (image_buffer_count*image_size) // alarm_images + + (image_buffer_count*image_size) // analysis image ring (alarm_images) + (image_buffer_count*sizeof(AVPixelFormat)) // per-slot capture pix fmt - + sizeof(AVPixelFormat) // alarm_image pix fmt (cross-process sync) + + (image_buffer_count*sizeof(AVPixelFormat)) // per-slot analysis pix fmt (cross-process sync) // Padding covers two independent alignment adjustments: // * up to 63 bytes to push shared_images to a 64-byte boundary // * up to alignof(AVPixelFormat)-1 bytes to push @@ -1156,23 +1156,28 @@ bool Monitor::connect() { &(shared_images[i*image_size]), image_size, 0); image_buffer[i]->HoldBuffer(true); /* Don't release the internal buffer or replace it with another */ } - // alarm_image follows the per-slot format convention. Initial format is a - // placeholder; consumers should sync via GetAlarmImage() which reads the - // cross-process *alarm_image_pixelformat. - alarm_image.AssignDirect(width, height, ZM_COLOUR_YUV420P, ZM_SUBPIX_ORDER_YUV420P, - &(shared_images[image_buffer_count*image_size]), image_size, ZM_BUFTYPE_DONTFREE); - alarm_image.HoldBuffer(true); /* Don't release the internal buffer or replace it with another */ - if (alarm_image.Buffer() + image_size > mem_ptr + mem_size) { - Warning("We will exceed memsize by %td bytes!", (alarm_image.Buffer() + image_size) - (mem_ptr + mem_size)); + // Analysis image ring: one Image per slot in the alarm_images SHM region + // (the second image_buffer_count*image_size block). Each slot follows the + // per-slot format convention; consumers sync via GetAlarmImage() which reads + // the cross-process analysis_image_pixelformats[last_analysis_index]. + analysis_image_buffer.resize(image_buffer_count); + for (int32_t i = 0; i < image_buffer_count; i++) { + analysis_image_buffer[i] = new Image(width, height, ZM_COLOUR_YUV420P, ZM_SUBPIX_ORDER_YUV420P, + &(shared_images[(image_buffer_count+i)*image_size]), image_size, 0); + analysis_image_buffer[i]->HoldBuffer(true); /* Don't release the internal buffer or replace it with another */ + } + if (analysis_image_buffer[image_buffer_count-1]->Buffer() + image_size > mem_ptr + mem_size) { + Warning("We will exceed memsize by %td bytes!", + (analysis_image_buffer[image_buffer_count-1]->Buffer() + image_size) - (mem_ptr + mem_size)); } // Layout in SHM is: image_buffer_count*image_size for image_buffer slots, - // then image_buffer_count*image_size for alarm_image slots, THEN the - // image_pixelformats[image_buffer_count] array, THEN the single - // alarm_image_pixelformat slot. Placing image_pixelformats at - // +1*image_buffer_count*image_size would collide with the alarm_image - // buffer region — zmc's writes to image_pixelformats[index] would corrupt - // the alarm image, and zms would read alarm-image bytes back as - // AVPixelFormat enum values, producing per-frame garble. + // then image_buffer_count*image_size for analysis_image_buffer slots, THEN + // the image_pixelformats[image_buffer_count] array, THEN the + // analysis_image_pixelformats[image_buffer_count] array. Placing + // image_pixelformats at +1*image_buffer_count*image_size would collide with + // the analysis image buffer region — zmc's writes to image_pixelformats[index] + // would corrupt the analysis image, and zms would read analysis-image bytes + // back as AVPixelFormat enum values, producing per-frame garble. // // image_size may not be a multiple of alignof(AVPixelFormat) (for // example GRAY8 with odd width when image_size comes from @@ -1187,7 +1192,7 @@ bool Monitor::connect() { const uintptr_t pixfmt_align = alignof(AVPixelFormat); pixfmt_addr = (pixfmt_addr + pixfmt_align - 1) & ~(pixfmt_align - 1); image_pixelformats = reinterpret_cast(pixfmt_addr); - alarm_image_pixelformat = image_pixelformats + image_buffer_count; + analysis_image_pixelformats = image_pixelformats + image_buffer_count; if (purpose == CAPTURE) { memset(mem_ptr, 0, mem_size); @@ -1203,6 +1208,8 @@ bool Monitor::connect() { shared_data->state = state = IDLE; shared_data->last_write_index = image_buffer_count; shared_data->last_read_index = image_buffer_count; + shared_data->last_analysis_index = image_buffer_count; // sentinel: nothing published yet + shared_data->analysis_image_count = 0; shared_data->last_write_time = 0; shared_data->last_event_id = 0; shared_data->action = (Action)0; @@ -1219,8 +1226,8 @@ bool Monitor::connect() { // for "format not yet published". Initialise explicitly. for (int32_t i = 0; i < image_buffer_count; i++) { image_pixelformats[i] = AV_PIX_FMT_NONE; + analysis_image_pixelformats[i] = AV_PIX_FMT_NONE; } - *alarm_image_pixelformat = AV_PIX_FMT_NONE; shared_data->alarm_cause[0] = 0; shared_data->video_fifo_path[0] = 0; shared_data->audio_fifo_path[0] = 0; @@ -1315,7 +1322,6 @@ bool Monitor::disconnect() { return true; } - alarm_image.HoldBuffer(false); /* Allow to reset buffer when we connect */ if (purpose == CAPTURE) { if (unlink(mem_file.c_str()) < 0) { Warning("Can't unlink '%s': %s", mem_file.c_str(), strerror(errno)); @@ -1356,6 +1362,10 @@ bool Monitor::disconnect() { // We delete the image because it is an object pointing to space that won't be free'd. delete image_buffer[i]; image_buffer[i] = nullptr; + // analysis_image_buffer entries point into the same SHM mapping (with + // HoldBuffer set) so deleting them won't free the SHM bytes. + delete analysis_image_buffer[i]; + analysis_image_buffer[i] = nullptr; } return true; @@ -1418,56 +1428,70 @@ void Monitor::AddPrivacyBitmask() { } Image *Monitor::GetAlarmImage() { - // alarm_image's bytes live in SHM and are written by the capture/analysis - // process; alarm_image_pixelformat carries the format that process used. - // Without this sync, a reader process (zms) would interpret the bytes - // with whatever placeholder format alarm_image was constructed with — + // Return the most recently published analysis-ring slot. The ring bytes live + // in SHM and are written by the capture/analysis process; + // analysis_image_pixelformats[index] carries the format that process used. + // Without this sync, a reader process (zms) would interpret the bytes with + // whatever placeholder format the slot Image was constructed with — // producing garbled output whenever the writer used RGB24/RGBA/etc. - if (alarm_image_pixelformat != nullptr) { - AVPixelFormat fmt = *alarm_image_pixelformat; - if (fmt != AV_PIX_FMT_NONE && alarm_image.PixFormat() != fmt) { + int32_t index = shared_data->last_analysis_index; + // Sentinel (image_buffer_count) or an out-of-range value means "nothing + // published yet" — fall back to slot 0 so callers still get a valid Image. + if (index < 0 || index >= image_buffer_count) index = 0; + Image *img = analysis_image_buffer[index]; + if (analysis_image_pixelformats != nullptr) { + AVPixelFormat fmt = analysis_image_pixelformats[index]; + if (fmt != AV_PIX_FMT_NONE && img->PixFormat() != fmt) { unsigned int probe_colours, probe_subpix; if (!zm_colours_from_pixformat(fmt, probe_colours, probe_subpix)) { Warning("GetAlarmImage: ignoring unsupported pixelformat %d; keeping current %s", - fmt, zm_get_pix_fmt_name(alarm_image.PixFormat())); + fmt, zm_get_pix_fmt_name(img->PixFormat())); } else { int required = av_image_get_buffer_size(fmt, width, height, 32); if (required < 0 || static_cast(required) > shm_slot_size) { Warning("GetAlarmImage: format %s requires %d bytes but slot capacity is %zu; " "keeping current %s", zm_get_pix_fmt_name(fmt), required, shm_slot_size, - zm_get_pix_fmt_name(alarm_image.PixFormat())); + zm_get_pix_fmt_name(img->PixFormat())); } else { - alarm_image.AVPixFormat(fmt); + img->AVPixFormat(fmt); } } } } - return &alarm_image; + return img; } void Monitor::WriteAlarmImage(const Image &src) { - // Mirror WriteShmFrame's contract for alarm_image: copy bytes then - // publish the canonical AVPixelFormat so reader processes can interpret - // the SHM correctly via GetAlarmImage(). + // Publish src into the next analysis-ring slot. Mirror WriteShmFrame's + // contract: copy bytes then publish the canonical AVPixelFormat so reader + // processes can interpret the SHM correctly via GetAlarmImage(). // - // Only publish *alarm_image_pixelformat if Assign actually adopted the - // source format. Image::Assign silently leaves the destination untouched - // on failure (held-buffer undersize, unknown src format), so publishing - // a new format whose bytes never landed would make readers misinterpret - // the previous alarm-image contents. + // The slot is chosen from analysis_image_count so successive writes rotate + // through the ring. last_analysis_index is published LAST (after the bytes + // and format are in place) so a reader that samples last_analysis_index + // always sees a fully written slot. + // + // Only publish the format if Assign actually adopted the source format. + // Image::Assign silently leaves the destination untouched on failure + // (held-buffer undersize, unknown src format), so publishing a new format + // whose bytes never landed would make readers misinterpret the slot. + int32_t index = shared_data->analysis_image_count % image_buffer_count; const AVPixelFormat src_fmt = src.PixFormat(); - alarm_image.Assign(src); - if (alarm_image_pixelformat != nullptr) { - if (alarm_image.PixFormat() == src_fmt) { - *alarm_image_pixelformat = src_fmt; + Image *dst = analysis_image_buffer[index]; + dst->Assign(src); + if (analysis_image_pixelformats != nullptr) { + if (dst->PixFormat() == src_fmt) { + analysis_image_pixelformats[index] = src_fmt; } else { Warning("WriteAlarmImage: assign failed (dst fmt %s != src fmt %s); " "keeping previously published pixelformat", - zm_get_pix_fmt_name(alarm_image.PixFormat()), + zm_get_pix_fmt_name(dst->PixFormat()), zm_get_pix_fmt_name(src_fmt)); } } + shared_data->last_analysis_index = index; + shared_data->analysis_image_count++; } int Monitor::GetImage(int32_t index, int scale) { @@ -3432,6 +3456,28 @@ bool Monitor::Decode() { } } + // Capture paths that deliver a raw Image without an ffmpeg decode (e.g. + // LocalCamera/V4L2) leave packet->in_frame null even though the pixels are + // already present. Wrap the image's planes in an AVFrame — no copy, just + // pointers via PopulateFrame — so in_frame consumers work without a real + // decode step. Any format the Image supports is fine; consumers that need a + // specific layout check for themselves (get_y_image, for instance, rejects + // RGB with a precise "no Y plane" message rather than "no frame"). + // + // Done here, after PHASE 5, so the frame reflects the oriented/masked image + // (we don't re-run orientation on a shared Y plane), and after the codec + // phases so transfer_hwframe is never called with a null codec context. + // videostore is unaffected: it prefers packet->image for frame data and + // always derives pts from packet->timestamp, never in_frame->pts. + if (packet->image && !packet->in_frame) { + av_frame_ptr synth{av_frame_alloc()}; + if (synth && (packet->image->PopulateFrame(synth.get()) >= 0)) { + packet->in_frame = std::move(synth); + Debug(2, "Synthesized in_frame from image for packet %d (%s)", + packet->image_index, av_get_pix_fmt_name(packet->image->PixFormat())); + } + } + packet->decoded = true; packet->notify_all(); packetqueue.notify_all(); // Wake up analysis thread waiting for decoded packets diff --git a/src/zm_monitor.h b/src/zm_monitor.h index 31048228f..dd9cb05e3 100644 --- a/src/zm_monitor.h +++ b/src/zm_monitor.h @@ -246,8 +246,27 @@ class Monitor : public std::enable_shared_from_this { char video_fifo_path[64]; /* +672 */ char audio_fifo_path[64]; /* +736 */ char janus_pin[64]; /* +800 */ - /* 864 total */ + /* Analysis image ring: the annotated/analysis image is published into a + * ring of image_buffer_count slots (reusing the alarm_images SHM region). + * last_analysis_index is the slot most recently written (or + * image_buffer_count as the "nothing written yet" sentinel); + * analysis_image_count is a monotonic counter of analysis images published. + * Appended at the end so no earlier SharedData offset shifts. */ + int32_t last_analysis_index; /* +864 */ + int32_t analysis_image_count; /* +868 */ + uint32_t analysis_pad[2]; /* +872 keep 16-byte multiple */ + /* 880 total */ } SharedData; + // Cross-process ABI guard: zmc/zma/zms plus the Perl (Memory.pm) and PHP + // (Monitor.php) SHM readers all assume this exact layout. If it changes, + // update those readers in lockstep and bump the size below. + // Cross-process ABI guard. The struct is naturally aligned (NOT packed), so + // two 4-byte pads exist (before capture_fps and before the startup_time + // union); the /* +N */ comments above are the packed-layout ideal and do NOT + // reflect real offsets. zmc/zma/zms and the Perl (Memory.pm, which computes + // alignment) SHM reader assume this exact layout. If it changes, update the + // readers in lockstep and bump the size here. + static_assert(sizeof(SharedData) == 888, "SharedData layout changed; update Memory.pm and Monitor.php offsets"); enum TriggerState : uint32 { TRIGGER_CANCEL, @@ -659,7 +678,10 @@ class Monitor : public std::enable_shared_from_this { unsigned char *shared_images; std::vector image_buffer; AVPixelFormat *image_pixelformats; - AVPixelFormat *alarm_image_pixelformat; // cross-process format for alarm_image + // Per-slot cross-process format for the analysis image ring (one entry per + // analysis_image_buffer slot), mirroring image_pixelformats for the capture + // ring. Replaces the former single alarm_image_pixelformat. + AVPixelFormat *analysis_image_pixelformats; size_t shm_slot_size; // per-slot byte capacity, sized to RGBA upper bound int video_stream_id; // will be filled in PrimeCapture @@ -705,7 +727,11 @@ class Monitor : public std::enable_shared_from_this { Image delta_image; Image ref_image; - Image alarm_image; // Used in creating analysis images, will be initialized in Analysis + // Analysis image ring: the annotated/analysis image is published into a ring + // of image_buffer_count slots living in the alarm_images SHM region. Readers + // pick up the newest via shared_data->last_analysis_index. Replaces the + // former single alarm_image. + std::vector analysis_image_buffer; Image write_image; // Used when creating snapshot images std::string diag_path_ref; std::string diag_path_delta; diff --git a/version.txt b/version.txt index 296d6a4a8..5dcf03349 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.39.16 +1.39.17 diff --git a/web/ajax/modals/ai_class.php b/web/ajax/modals/ai_class.php new file mode 100644 index 000000000..866be378e --- /dev/null +++ b/web/ajax/modals/ai_class.php @@ -0,0 +1,83 @@ + AI Classes +if ( !isset($_REQUEST['id']) ) { + ajaxError('Class Id Not Provided'); + return; +} + +$result = ''; +$checked = ' checked="checked"'; +$null = ''; +$cid = validCardinal($_REQUEST['id']); + +if ( !canEdit('System') ) return; + +if ( $cid ) { + $class = dbFetchOne('SELECT * FROM AI_Object_Classes WHERE Id=?', NULL, [$cid]); + if ( !$class ) return; +} else { + $class = array( + 'Id' => 0, + 'DatasetId' => null, + 'ClassName' => '', + 'ClassIndex' => 0, + 'Description' => '' + ); +} + +// Get datasets for dropdown +$datasets = array(); +$result = dbQuery('SELECT Id, Name FROM AI_Datasets ORDER BY Name'); +if ($result) { + while ($row = dbFetchNext($result)) { + $datasets[$row['Id']] = $row['Name']; + } +} + +?> + diff --git a/web/ajax/modals/ai_dataset.php b/web/ajax/modals/ai_dataset.php new file mode 100644 index 000000000..28082c2fd --- /dev/null +++ b/web/ajax/modals/ai_dataset.php @@ -0,0 +1,74 @@ + AI Datasets +if ( !isset($_REQUEST['id']) ) { + ajaxError('Dataset Id Not Provided'); + return; +} + +$result = ''; +$checked = ' checked="checked"'; +$null = ''; +$did = validCardinal($_REQUEST['id']); + +if ( !canEdit('System') ) return; + +if ( $did ) { + $dataset = dbFetchOne('SELECT * FROM AI_Datasets WHERE Id=?', NULL, [$did]); + if ( !$dataset ) return; +} else { + $dataset = array( + 'Id' => 0, + 'Name' => '', + 'Description' => '', + 'Version' => '', + 'NumClasses' => 0 + ); +} + +?> + diff --git a/web/ajax/modals/ai_model.php b/web/ajax/modals/ai_model.php new file mode 100644 index 000000000..3ad762124 --- /dev/null +++ b/web/ajax/modals/ai_model.php @@ -0,0 +1,110 @@ + AI Models +if ( !isset($_REQUEST['id']) ) { + ajaxError('Model Id Not Provided'); + return; +} + +$result = ''; +$checked = ' checked="checked"'; +$null = ''; +$mid = validCardinal($_REQUEST['id']); + +if ( !canEdit('System') ) return; + +if ( $mid ) { + $model = dbFetchOne('SELECT * FROM AI_Models WHERE Id=?', NULL, [$mid]); + if ( !$model ) return; +} else { + $model = array( + 'Id' => 0, + 'Name' => '', + 'Description' => '', + 'ModelPath' => '', + 'Framework' => 'ONNX', + 'Version' => '', + 'DatasetId' => null, + 'Enabled' => 0 + ); +} + +// Get datasets for dropdown +$datasets = array('' => translate('None')); +$result = dbQuery('SELECT Id, Name FROM AI_Datasets ORDER BY Name'); +if ($result) { + while ($row = dbFetchNext($result)) { + $datasets[$row['Id']] = $row['Name']; + } +} + +$framework_options = array( + 'TensorFlow' => 'TensorFlow', + 'PyTorch' => 'PyTorch', + 'ONNX' => 'ONNX', + 'OpenVINO' => 'OpenVINO', + 'TensorRT' => 'TensorRT', + 'Other' => 'Other' +); + +?> + diff --git a/web/includes/Monitor.php b/web/includes/Monitor.php index 618941f92..dcfb65518 100644 --- a/web/includes/Monitor.php +++ b/web/includes/Monitor.php @@ -14,57 +14,72 @@ class Monitor extends ZM_Object { private $shm_id = null; private $connected = false; + // These offsets are the REAL (naturally-aligned) byte offsets of the C++ + // SharedData/TriggerData structs, not the packed-layout ideal implied by the + // struct's /* +N */ comments. The C++ struct is not packed, so the compiler + // inserts a 4-byte pad before capture_fps (after state) and another before + // the startup_time union (after audio_channels); every field from capture_fps + // onward therefore sits 4 or 8 bytes later than the naive packed offset. With + // the analysis image ring counters appended, SharedData is 888 bytes and + // TriggerData starts at 888. The authoritative source is the same alignment + // computation ZoneMinder::Memory (Memory.pm) performs; a + // sizeof(SharedData)==888 static_assert in zm_monitor.h guards the layout. private $shm_offsets = ['SharedData' => [ 'size' => [ 'type'=>'uint32', 'offset'=>0, 'size'=>4 ], 'last_write_index' => [ 'type'=>'int32', 'offset'=>4, 'size'=>4 ], 'last_read_index' => [ 'type'=>'int32', 'offset'=>8, 'size'=>4 ], 'image_count' => [ 'type'=>'int32', 'offset'=>12, 'size'=>4 ], 'state' => [ 'type'=>'uint32', 'offset'=>16, 'size'=>4 ], - 'capture_fps' => [ 'type'=>'double', 'offset'=>20, 'size'=>8 ], - 'analysis_fps' => [ 'type'=>'double', 'offset'=>28, 'size'=>8 ], - 'latitude' => [ 'type'=>'double', 'offset'=>36, 'size'=>8 ], - 'longitude' => [ 'type'=>'double', 'offset'=>44, 'size'=>8 ], - 'last_event' => [ 'type'=>'uint64', 'offset'=>52, 'size'=>8 ], - 'action' => [ 'type'=>'uint32', 'offset'=>60, 'size'=>4 ], - 'brightness' => [ 'type'=>'int32', 'offset'=>64, 'size'=>4 ], - 'hue' => [ 'type'=>'int32', 'offset'=>68, 'size'=>4 ], - 'colour' => [ 'type'=>'int32', 'offset'=>72, 'size'=>4 ], - 'contrast' => [ 'type'=>'int32', 'offset'=>76, 'size'=>4 ], - 'alarm_x' => [ 'type'=>'int32', 'offset'=>80, 'size'=>4 ], - 'alarm_y' => [ 'type'=>'int32', 'offset'=>84, 'size'=>4 ], - 'valid' => [ 'type'=>'uint8', 'offset'=>88, 'size'=>1 ], - 'capturing' => [ 'type'=>'uint8', 'offset'=>89, 'size'=>1 ], - 'analysing' => [ 'type'=>'uint8', 'offset'=>90, 'size'=>1 ], - 'recording' => [ 'type'=>'uint8', 'offset'=>91, 'size'=>1 ], - 'signal' => [ 'type'=>'uint8', 'offset'=>92, 'size'=>1 ], - 'format' => [ 'type'=>'uint8', 'offset'=>93, 'size'=>1 ], - 'reserved1' => [ 'type'=>'uint8', 'offset'=>94, 'size'=>1 ], - 'reserved2' => [ 'type'=>'uint8', 'offset'=>95, 'size'=>1 ], - 'imagesize' => [ 'type'=>'uint32', 'offset'=>96, 'size'=>4 ], - 'last_frame_score' => [ 'type'=>'uint32', 'offset'=>100, 'size'=>4 ], - 'audio_frequency' => [ 'type'=>'uint32', 'offset'=>104, 'size'=>4 ], - 'audio_channels' => [ 'type'=>'uint32', 'offset'=>108, 'size'=>4 ], - 'startup_time' => [ 'type'=>'time_t64', 'offset'=>112, 'size'=>8 ], - 'heartbeat_time' => [ 'type'=>'time_t64', 'offset'=>120, 'size'=>8 ], - 'last_write_time' => [ 'type'=>'time_t64', 'offset'=>128, 'size'=>8 ], - 'last_read_time' => [ 'type'=>'time_t64', 'offset'=>136, 'size'=>8 ], - 'last_viewed_time' => [ 'type'=>'time_t64', 'offset'=>144, 'size'=>8 ], - 'last_analysis_viewed_time' => [ 'type'=>'time_t64', 'offset'=>152, 'size'=>8 ], - 'control_state' => [ 'type'=>'uint8[256]', 'offset'=>160, 'size'=>256 ], - 'alarm_cause' => [ 'type'=>'int8[256]', 'offset'=>416, 'size'=>256 ], - 'video_fifo' => [ 'type'=>'int8[64]', 'offset'=>672, 'size'=>64 ], - 'audio_fifo' => [ 'type'=>'int8[64]', 'offset'=>736, 'size'=>64 ], - 'janus_pin' => [ 'type'=>'int8[64]', 'offset'=>800, 'size'=>64 ], - ], + 'capture_fps' => [ 'type'=>'double', 'offset'=>24, 'size'=>8 ], + 'analysis_fps' => [ 'type'=>'double', 'offset'=>32, 'size'=>8 ], + 'latitude' => [ 'type'=>'double', 'offset'=>40, 'size'=>8 ], + 'longitude' => [ 'type'=>'double', 'offset'=>48, 'size'=>8 ], + 'last_event' => [ 'type'=>'uint64', 'offset'=>56, 'size'=>8 ], + 'action' => [ 'type'=>'uint32', 'offset'=>64, 'size'=>4 ], + 'brightness' => [ 'type'=>'int32', 'offset'=>68, 'size'=>4 ], + 'hue' => [ 'type'=>'int32', 'offset'=>72, 'size'=>4 ], + 'colour' => [ 'type'=>'int32', 'offset'=>76, 'size'=>4 ], + 'contrast' => [ 'type'=>'int32', 'offset'=>80, 'size'=>4 ], + 'alarm_x' => [ 'type'=>'int32', 'offset'=>84, 'size'=>4 ], + 'alarm_y' => [ 'type'=>'int32', 'offset'=>88, 'size'=>4 ], + 'valid' => [ 'type'=>'uint8', 'offset'=>92, 'size'=>1 ], + 'capturing' => [ 'type'=>'uint8', 'offset'=>93, 'size'=>1 ], + 'analysing' => [ 'type'=>'uint8', 'offset'=>94, 'size'=>1 ], + 'recording' => [ 'type'=>'uint8', 'offset'=>95, 'size'=>1 ], + 'signal' => [ 'type'=>'uint8', 'offset'=>96, 'size'=>1 ], + 'format' => [ 'type'=>'uint8', 'offset'=>97, 'size'=>1 ], + 'reserved1' => [ 'type'=>'uint8', 'offset'=>98, 'size'=>1 ], + 'reserved2' => [ 'type'=>'uint8', 'offset'=>99, 'size'=>1 ], + 'imagesize' => [ 'type'=>'uint32', 'offset'=>100, 'size'=>4 ], + 'last_frame_score' => [ 'type'=>'uint32', 'offset'=>104, 'size'=>4 ], + 'audio_frequency' => [ 'type'=>'uint32', 'offset'=>108, 'size'=>4 ], + 'audio_channels' => [ 'type'=>'uint32', 'offset'=>112, 'size'=>4 ], + 'startup_time' => [ 'type'=>'time_t64', 'offset'=>120, 'size'=>8 ], + 'heartbeat_time' => [ 'type'=>'time_t64', 'offset'=>128, 'size'=>8 ], + 'last_write_time' => [ 'type'=>'time_t64', 'offset'=>136, 'size'=>8 ], + 'last_read_time' => [ 'type'=>'time_t64', 'offset'=>144, 'size'=>8 ], + 'last_viewed_time' => [ 'type'=>'time_t64', 'offset'=>152, 'size'=>8 ], + 'last_analysis_viewed_time' => [ 'type'=>'time_t64', 'offset'=>160, 'size'=>8 ], + 'control_state' => [ 'type'=>'uint8[256]', 'offset'=>168, 'size'=>256 ], + 'alarm_cause' => [ 'type'=>'int8[256]', 'offset'=>424, 'size'=>256 ], + 'video_fifo' => [ 'type'=>'int8[64]', 'offset'=>680, 'size'=>64 ], + 'audio_fifo' => [ 'type'=>'int8[64]', 'offset'=>744, 'size'=>64 ], + 'janus_pin' => [ 'type'=>'int8[64]', 'offset'=>808, 'size'=>64 ], + // Analysis image ring counters, appended at the end of SharedData followed + // by 8 bytes of padding (kept for the 16-byte-multiple layout invariant), + // so SharedData is now 888 bytes and TriggerData starts at 888. + 'last_analysis_index' => [ 'type'=>'int32', 'offset'=>872, 'size'=>4 ], + 'analysis_image_count' => [ 'type'=>'int32', 'offset'=>876, 'size'=>4 ], + ], 'TriggerData' => [ - 'size' => [ 'type'=>'uint32', 'offset'=>864, 'size'=>4 ], - 'state' => [ 'type'=>'uint32', 'offset'=>868, 'size'=>4 ], - 'score' => [ 'type'=>'uint32', 'offset'=>872, 'size'=>4 ], - 'padding' => [ 'type'=>'uint32', 'offset'=>876, 'size'=>4 ], - 'cause' => [ 'type'=>'int8[32]', 'offset'=>880, 'size'=>32 ], - 'text' => [ 'type'=>'int8[256]', 'offset'=>912, 'size'=>256 ], - 'showtext' => [ 'type'=>'int8[256]', 'offset'=>1168, 'size'=>256 ], - // 1424 + 'size' => [ 'type'=>'uint32', 'offset'=>888, 'size'=>4 ], + 'state' => [ 'type'=>'uint32', 'offset'=>892, 'size'=>4 ], + 'score' => [ 'type'=>'uint32', 'offset'=>896, 'size'=>4 ], + 'padding' => [ 'type'=>'uint32', 'offset'=>900, 'size'=>4 ], + 'cause' => [ 'type'=>'int8[32]', 'offset'=>904, 'size'=>32 ], + 'text' => [ 'type'=>'int8[256]', 'offset'=>936, 'size'=>256 ], + 'showtext' => [ 'type'=>'int8[256]', 'offset'=>1192, 'size'=>256 ], + // 1448 ] ]; diff --git a/web/includes/actions/ai_class.php b/web/includes/actions/ai_class.php new file mode 100644 index 000000000..fc38b76ba --- /dev/null +++ b/web/includes/actions/ai_class.php @@ -0,0 +1,58 @@ + diff --git a/web/includes/actions/ai_dataset.php b/web/includes/actions/ai_dataset.php new file mode 100644 index 000000000..961df9f89 --- /dev/null +++ b/web/includes/actions/ai_dataset.php @@ -0,0 +1,58 @@ + diff --git a/web/includes/actions/ai_model.php b/web/includes/actions/ai_model.php new file mode 100644 index 000000000..8f97681e9 --- /dev/null +++ b/web/includes/actions/ai_model.php @@ -0,0 +1,58 @@ + diff --git a/web/includes/actions/options.php b/web/includes/actions/options.php index 98a93a62f..02ebd169a 100644 --- a/web/includes/actions/options.php +++ b/web/includes/actions/options.php @@ -26,6 +26,14 @@ if ( !canEdit('System') ) { global $error_message; +// AI object-detection CRUD lives in dedicated action files. The list views post +// here (view=options) with object=ai_* for deletes; route those to the matching +// handler, which processes both save and delete via $action. +if ( isset($_REQUEST['object']) && in_array($_REQUEST['object'], array('ai_model', 'ai_dataset', 'ai_class'), true) ) { + require_once('includes/actions/'.$_REQUEST['object'].'.php'); + return; +} + if ( $action == 'delete' ) { if ( isset($_REQUEST['object']) ) { if ( $_REQUEST['object'] == 'server' ) { diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index 2f0cd3d1b..1f4a3b6ba 100644 --- a/web/js/MonitorStream.js +++ b/web/js/MonitorStream.js @@ -782,7 +782,7 @@ function MonitorStream(monitorData) { Very, very rarely, on the MONTAGE PAGE THERE MAY BE AN ERROR OF THE TYPE: TypeError: Failed to execute 'remove' on 'SourceBuffer': The start provided (0) is outside the range (0, 0). Possibly due to high CPU load, the browser does not have time to process or the "src" attribute was removed from the object. */ - this.mseSourceBuffer.remove(0, Infinity); + if (this.mse.sourceBuffers.length > 0) this.mseSourceBuffer.remove(0, Infinity); } catch (e) { console.warn(`${dateTimeToISOLocal(new Date())} An error occurred while cleaning Source Buffer for ID=${this.id}`, e); reject(e); diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php index 34bfa8e9a..57fd73420 100644 --- a/web/lang/en_gb.php +++ b/web/lang/en_gb.php @@ -60,7 +60,10 @@ $SLANG = array( '24BitColour' => '24 bit colour', '32BitColour' => '32 bit colour', '8BitGrey' => '8 bit greyscale', + 'AddNewClass' => 'Add New Class', 'AddNewControl' => 'Add New Control', + 'AddNewDataset' => 'Add New Dataset', + 'AddNewModel' => 'Add New Model', 'AddNewMonitor' => 'Add', 'AddMonitorDisabled' => 'Your user is not allowed to add a new monitor', 'AddNewServer' => 'Add New Server', diff --git a/web/skins/classic/includes/functions.php b/web/skins/classic/includes/functions.php index b2212dd3f..cd278f947 100644 --- a/web/skins/classic/includes/functions.php +++ b/web/skins/classic/includes/functions.php @@ -1169,6 +1169,15 @@ function getOptionsHTML($forLeftBar = false, $customLabel = null) { ]); $zmMenu::buildSubMenuOptions($categoryDisplayOrder); + // AI object-detection management tabs. These are not Config categories, so + // register them explicitly with readable labels instead of the auto-generated + // "Ai_datasets" style names. + if ( canView('System') ) { + $zmMenu::$submenuOptionsItems['ai_datasets'] = translate('AI Datasets'); + $zmMenu::$submenuOptionsItems['ai_models'] = translate('AI Models'); + $zmMenu::$submenuOptionsItems['ai_classes'] = translate('AI Classes'); + } + if ( canView('System') ) { if ($forLeftBar) { global $view; diff --git a/web/skins/classic/views/_options_ai_classes.php b/web/skins/classic/views/_options_ai_classes.php new file mode 100644 index 000000000..9acc8eed6 --- /dev/null +++ b/web/skins/classic/views/_options_ai_classes.php @@ -0,0 +1,76 @@ +
+
+
+ + + + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
disabled="disabled"/>
+
+
+
+
+
diff --git a/web/skins/classic/views/_options_ai_datasets.php b/web/skins/classic/views/_options_ai_datasets.php new file mode 100644 index 000000000..63ba37772 --- /dev/null +++ b/web/skins/classic/views/_options_ai_datasets.php @@ -0,0 +1,60 @@ +
+
+
+ + + + +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
disabled="disabled"/>
+
+
+
+
+
diff --git a/web/skins/classic/views/_options_ai_models.php b/web/skins/classic/views/_options_ai_models.php new file mode 100644 index 000000000..dd8361cb7 --- /dev/null +++ b/web/skins/classic/views/_options_ai_models.php @@ -0,0 +1,66 @@ +
+
+
+ + + + +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
disabled="disabled"/>
+
+
+
+
+
diff --git a/web/skins/classic/views/js/monitor.js b/web/skins/classic/views/js/monitor.js index 1628b985a..c530dfeda 100644 --- a/web/skins/classic/views/js/monitor.js +++ b/web/skins/classic/views/js/monitor.js @@ -192,9 +192,15 @@ function initPage() { }); document.querySelectorAll('select[name="newMonitor[Type]"]').forEach(function(el) { el.onchange = function() { + // Reload the form to render the type-specific fields WITHOUT saving. The + // monitor view repopulates from the posted newMonitor values, so the + // selected Type and anything already entered are preserved. Saving here + // would fail for a brand-new monitor whose Width/Height are not set yet + // (both are NOT NULL), leaving the type change unpersisted. A plain + // submit posts no action=save, matching the Method field's submitTab. const form = document.getElementById('contentForm'); form.tab.value = 'general'; - saveMonitorDataPrepare(document.getElementById('contentForm'), false, 'reload'); + form.submit(); }; }); document.querySelectorAll('input[name="newMonitor[ImageBufferCount]"],input[name="newMonitor[MaxImageBufferCount]"],input[name="newMonitor[Width]"],input[name="newMonitor[Height]"],input[name="newMonitor[PreEventCount]"]').forEach(function(el) { diff --git a/web/skins/classic/views/js/options.js b/web/skins/classic/views/js/options.js index e87dba2c7..787907caf 100644 --- a/web/skins/classic/views/js/options.js +++ b/web/skins/classic/views/js/options.js @@ -54,6 +54,72 @@ function AddNewRole(el) { window.location.assign(url); } +// Load the Dataset Modal HTML via Ajax call +function getDatasetModal(did) { + $j.getJSON(thisUrl + '?request=modal&modal=ai_dataset&id=' + did) + .done(function(data) { + insertModalHtml('DatasetModal', data.html); + $j('#DatasetModal').modal('show'); + }) + .fail(logAjaxFail); +} + +function enableDatasetModal() { + $j(".datasetCol").click(function(evt) { + evt.preventDefault(); + const did = $j(this).data('did'); + getDatasetModal(did); + }); + $j('#NewDatasetBtn').click(function(evt) { + evt.preventDefault(); + getDatasetModal(0); + }); +} + +// Load the Model Modal HTML via Ajax call +function getModelModal(mid) { + $j.getJSON(thisUrl + '?request=modal&modal=ai_model&id=' + mid) + .done(function(data) { + insertModalHtml('ModelModal', data.html); + $j('#ModelModal').modal('show'); + }) + .fail(logAjaxFail); +} + +function enableModelModal() { + $j(".modelCol").click(function(evt) { + evt.preventDefault(); + const mid = $j(this).data('mid'); + getModelModal(mid); + }); + $j('#NewModelBtn').click(function(evt) { + evt.preventDefault(); + getModelModal(0); + }); +} + +// Load the Class Modal HTML via Ajax call +function getClassModal(cid) { + $j.getJSON(thisUrl + '?request=modal&modal=ai_class&id=' + cid) + .done(function(data) { + insertModalHtml('ClassModal', data.html); + $j('#ClassModal').modal('show'); + }) + .fail(logAjaxFail); +} + +function enableClassModal() { + $j(".classCol").click(function(evt) { + evt.preventDefault(); + const cid = $j(this).data('cid'); + getClassModal(cid); + }); + $j('#NewClassBtn').click(function(evt) { + evt.preventDefault(); + getClassModal(0); + }); +} + let newMenuItemIndex = 0; function addMenuItem() { @@ -144,12 +210,40 @@ function DeleteRole() { function initPage() { const NewStorageBtn = $j('#NewStorageBtn'); const NewServerBtn = $j('#NewServerBtn'); + const NewDatasetBtn = $j('#NewDatasetBtn'); + const NewModelBtn = $j('#NewModelBtn'); + const NewClassBtn = $j('#NewClassBtn'); if ( canEdit.System ) enableStorageModal(); if ( canEdit.System ) enableServerModal(); + if ( canEdit.System ) enableDatasetModal(); + if ( canEdit.System ) enableModelModal(); + if ( canEdit.System ) enableClassModal(); NewStorageBtn.prop('disabled', !canEdit.System); NewServerBtn.prop('disabled', !canEdit.System); + NewDatasetBtn.prop('disabled', !canEdit.System); + NewModelBtn.prop('disabled', !canEdit.System); + NewClassBtn.prop('disabled', !canEdit.System); + + // Dataset filter functionality for AI Classes tab + $j('#datasetFilter').change(function() { + var datasetId = $j(this).val(); + var $rows = $j('#contentTable tbody tr'); + if (datasetId === '') { + $rows.show(); + } else { + $rows.each(function() { + var $row = $j(this); + var rowDatasetId = $row.find('.classCol').data('dataset-id'); + if (rowDatasetId == datasetId) { + $row.show(); + } else { + $row.hide(); + } + }); + } + }); $j('.bootstraptable').bootstrapTable({icons: icons}).show(); diff --git a/web/skins/classic/views/options.php b/web/skins/classic/views/options.php index c89d9bbb5..cac4ac205 100644 --- a/web/skins/classic/views/options.php +++ b/web/skins/classic/views/options.php @@ -143,8 +143,14 @@ foreach (array_map('basename', glob('skins/'.$skin.'/css/*', GLOB_ONLYDIR)) as $ include('_options_menu.php'); } else if ($tab == 'encoderTemplates') { include('_options_encoderTemplates.php'); -} // $tab == API/menu/encoderTemplates - else { +} else if ($tab == 'ai_datasets') { + include('_options_ai_datasets.php'); +} else if ($tab == 'ai_models') { + include('_options_ai_models.php'); +} else if ($tab == 'ai_classes') { + include('_options_ai_classes.php'); +} // $tab == API/menu/encoderTemplates/ai_* + else { $config = array(); $configCats = array();