From d8d945217931fd9c82c71255b0457b7c1410d5e2 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 1 Jul 2026 18:50:42 -0400 Subject: [PATCH 1/8] feat: add AI object-detection data model Add the schema backing per-monitor object detection and the AI dataset/ model/class management UI: - Monitors gains AnalysisImageOpacity and ObjectDetection, ObjectDetectionModel, ObjectDetectionObjectThreshold, ObjectDetectionNMSThreshold columns. - New tables AI_Datasets, AI_Models, AI_Object_Classes, AI_Detection_Settings and AI_Detections. - Seed the COCO 2017 dataset (80 classes) and default per-class detection settings via db/coco_dataset.sql. Existing installs get zm_update-1.39.17.sql, which is idempotent and adds the columns with their final VARCHAR(16) ObjectDetection shape directly (no enum-churn intermediates). Fresh installs create the same objects from zm_create.sql.in sourcing AI_Models.sql and coco_dataset.sql. Both paths were verified to produce identical schema. Co-Authored-By: Claude Opus 4.8 --- db/AI_Models.sql | 80 +++++++++++++ db/coco_dataset.sql | 110 +++++++++++++++++ db/zm_create.sql.in | 9 ++ db/zm_update-1.39.17.sql | 251 +++++++++++++++++++++++++++++++++++++++ version.txt | 2 +- 5 files changed, 451 insertions(+), 1 deletion(-) create mode 100644 db/AI_Models.sql create mode 100644 db/coco_dataset.sql create mode 100644 db/zm_update-1.39.17.sql 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/version.txt b/version.txt index 296d6a4a8..5dcf03349 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.39.16 +1.39.17 From 787722d10344b25eaf9f277be0b8188ab0d46aa3 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 1 Jul 2026 18:50:51 -0400 Subject: [PATCH 2/8] feat: add AI dataset/model/class management UI to Options Add three Options tabs (AI Datasets, AI Models, AI Classes) with full CRUD, backed by the AI_* tables: - List views (_options_ai_{datasets,models,classes}.php), edit modals (ajax/modals/ai_{dataset,model,class}.php) and action handlers (actions/ai_{dataset,model,class}.php). - options.js loads the modals over ajax, wires the Add/edit buttons and the AI Classes dataset filter. - options.php dispatches the new tab includes; functions.php registers the three tabs in the Options sub-menu with readable labels (they are not Config categories, so they need explicit entries). - actions/options.php routes object=ai_* deletes to the matching handler; saves post directly to view=ai_*. All tabs and actions are gated on System permission. Co-Authored-By: Claude Opus 4.8 --- web/ajax/modals/ai_class.php | 83 +++++++++++++ web/ajax/modals/ai_dataset.php | 74 ++++++++++++ web/ajax/modals/ai_model.php | 110 ++++++++++++++++++ web/includes/actions/ai_class.php | 58 +++++++++ web/includes/actions/ai_dataset.php | 58 +++++++++ web/includes/actions/ai_model.php | 58 +++++++++ web/includes/actions/options.php | 8 ++ web/skins/classic/includes/functions.php | 9 ++ .../classic/views/_options_ai_classes.php | 70 +++++++++++ .../classic/views/_options_ai_datasets.php | 54 +++++++++ .../classic/views/_options_ai_models.php | 60 ++++++++++ web/skins/classic/views/js/options.js | 94 +++++++++++++++ web/skins/classic/views/options.php | 10 +- 13 files changed, 744 insertions(+), 2 deletions(-) create mode 100644 web/ajax/modals/ai_class.php create mode 100644 web/ajax/modals/ai_dataset.php create mode 100644 web/ajax/modals/ai_model.php create mode 100644 web/includes/actions/ai_class.php create mode 100644 web/includes/actions/ai_dataset.php create mode 100644 web/includes/actions/ai_model.php create mode 100644 web/skins/classic/views/_options_ai_classes.php create mode 100644 web/skins/classic/views/_options_ai_datasets.php create mode 100644 web/skins/classic/views/_options_ai_models.php 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/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/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..cca5a6343 --- /dev/null +++ b/web/skins/classic/views/_options_ai_classes.php @@ -0,0 +1,70 @@ +
+
+
+ + + + +
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
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..191cd017a --- /dev/null +++ b/web/skins/classic/views/_options_ai_datasets.php @@ -0,0 +1,54 @@ +
+
+
+ + + + +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
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..2b5eaf97b --- /dev/null +++ b/web/skins/classic/views/_options_ai_models.php @@ -0,0 +1,60 @@ +
+
+
+ + + + +
+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
disabled="disabled"/>
+
+
+
+
+
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(); From e417e950086939495cac15e9956498d390a34f9e Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Wed, 1 Jul 2026 22:06:48 -0400 Subject: [PATCH 3/8] Fix page reload when changing monitor type --- web/skins/classic/views/js/monitor.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) { From 944f8f2eced131c2e520cd4519b5fc756ac54293 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Thu, 2 Jul 2026 19:58:13 -0400 Subject: [PATCH 4/8] feat: add icons and labels to AI options view buttons Give the Add/Delete buttons on the AI Datasets, Models and Classes option tabs the same treatment as the console: an add_circle icon on the add button and a delete (trash) icon on the delete button, each with a labelled text span. Add the missing AddNewClass/AddNewDataset/AddNewModel language strings so the add buttons read "Add New Class/Dataset/Model" instead of the raw translation keys. Co-Authored-By: Claude Opus 4.8 --- web/lang/en_gb.php | 3 +++ web/skins/classic/views/_options_ai_classes.php | 10 ++++++++-- web/skins/classic/views/_options_ai_datasets.php | 10 ++++++++-- web/skins/classic/views/_options_ai_models.php | 10 ++++++++-- 4 files changed, 27 insertions(+), 6 deletions(-) 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/views/_options_ai_classes.php b/web/skins/classic/views/_options_ai_classes.php index cca5a6343..9acc8eed6 100644 --- a/web/skins/classic/views/_options_ai_classes.php +++ b/web/skins/classic/views/_options_ai_classes.php @@ -7,8 +7,14 @@
- - + +
diff --git a/web/skins/classic/views/_options_ai_datasets.php b/web/skins/classic/views/_options_ai_datasets.php index 191cd017a..63ba37772 100644 --- a/web/skins/classic/views/_options_ai_datasets.php +++ b/web/skins/classic/views/_options_ai_datasets.php @@ -7,8 +7,14 @@
- - + +
diff --git a/web/skins/classic/views/_options_ai_models.php b/web/skins/classic/views/_options_ai_models.php index 2b5eaf97b..dd8361cb7 100644 --- a/web/skins/classic/views/_options_ai_models.php +++ b/web/skins/classic/views/_options_ai_models.php @@ -7,8 +7,14 @@
- - + +
From e2ed9287d7bb3aca81de574a2a387294ca0536e1 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Jul 2026 14:30:23 -0400 Subject: [PATCH 5/8] fix: correct PHP SHM SharedData/TriggerData field offsets The C++ SharedData struct is naturally aligned, 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). Monitor.php used the naive packed offsets, so every field from capture_fps onward (capture_fps/analysis_fps, latitude/longitude, the time fields, alarm_cause and all of TriggerData) was read from an address 4-8 bytes too low, yielding garbage. Correct the offsets to the real aligned layout (SharedData is 872 bytes, TriggerData starts at 872), matching what ZoneMinder::Memory computes and what the C++ writes. No struct change; this is a reader-side fix. Co-Authored-By: Claude Opus 4.8 --- web/includes/Monitor.php | 99 ++++++++++++++++++++++------------------ 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/web/includes/Monitor.php b/web/includes/Monitor.php index 618941f92..758feb687 100644 --- a/web/includes/Monitor.php +++ b/web/includes/Monitor.php @@ -14,57 +14,68 @@ 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, and + // the whole SharedData struct is 872 bytes (not 864). The previous offsets + // were the naive packed values, so PHP had been reading capture_fps onward + // (fps, alarm_cause, all of TriggerData) from the wrong addresses. The + // authoritative source is the same alignment computation ZoneMinder::Memory + // (Memory.pm) performs. 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 ], + ], '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'=>872, 'size'=>4 ], + 'state' => [ 'type'=>'uint32', 'offset'=>876, 'size'=>4 ], + 'score' => [ 'type'=>'uint32', 'offset'=>880, 'size'=>4 ], + 'padding' => [ 'type'=>'uint32', 'offset'=>884, 'size'=>4 ], + 'cause' => [ 'type'=>'int8[32]', 'offset'=>888, 'size'=>32 ], + 'text' => [ 'type'=>'int8[256]', 'offset'=>920, 'size'=>256 ], + 'showtext' => [ 'type'=>'int8[256]', 'offset'=>1176, 'size'=>256 ], + // 1432 ] ]; From 92ab81172021748d6c585ffbd76eb46d56529571 Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Fri, 3 Jul 2026 14:32:51 -0400 Subject: [PATCH 6/8] feat: publish analysis images through a shared-memory ring Replace the single alarm_image slot with an analysis_image_buffer ring of image_buffer_count Images living in the already-reserved alarm_images SHM region. Successive WriteAlarmImage calls rotate through the ring and publish last_analysis_index last (after the bytes and per-slot format), so a reader sampling last_analysis_index always sees a fully written slot. GetAlarmImage returns that slot, syncing its AVPixelFormat from the per-slot analysis_image_pixelformats array. SharedData gains last_analysis_index and analysis_image_count (plus 8 bytes of padding to keep the 16-byte-multiple layout), making it 888 bytes. The Perl (Memory.pm) and PHP (Monitor.php) SHM readers are updated in lockstep, and a static_assert(sizeof(SharedData)==888) in zm_monitor.h guards the layout against silent drift. This lets multiple in-flight analysis/annotated frames be buffered and streamed in sync rather than always overwriting one slot, and gives the AI object-detection work a place to publish annotated frames. Co-Authored-By: Claude Opus 4.8 --- .../ZoneMinder/lib/ZoneMinder/Memory.pm.in | 3 + src/zm_monitor.cpp | 114 +++++++++++------- src/zm_monitor.h | 32 ++++- web/includes/Monitor.php | 32 ++--- 4 files changed, 119 insertions(+), 62 deletions(-) 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..fa9fd394c 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) { 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/web/includes/Monitor.php b/web/includes/Monitor.php index 758feb687..dcfb65518 100644 --- a/web/includes/Monitor.php +++ b/web/includes/Monitor.php @@ -19,12 +19,11 @@ class Monitor extends ZM_Object { // 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, and - // the whole SharedData struct is 872 bytes (not 864). The previous offsets - // were the naive packed values, so PHP had been reading capture_fps onward - // (fps, alarm_cause, all of TriggerData) from the wrong addresses. The - // authoritative source is the same alignment computation ZoneMinder::Memory - // (Memory.pm) performs. + // 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 ], @@ -66,16 +65,21 @@ class Monitor extends ZM_Object { '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'=>872, 'size'=>4 ], - 'state' => [ 'type'=>'uint32', 'offset'=>876, 'size'=>4 ], - 'score' => [ 'type'=>'uint32', 'offset'=>880, 'size'=>4 ], - 'padding' => [ 'type'=>'uint32', 'offset'=>884, 'size'=>4 ], - 'cause' => [ 'type'=>'int8[32]', 'offset'=>888, 'size'=>32 ], - 'text' => [ 'type'=>'int8[256]', 'offset'=>920, 'size'=>256 ], - 'showtext' => [ 'type'=>'int8[256]', 'offset'=>1176, 'size'=>256 ], - // 1432 + '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 ] ]; From 141023dd986503c5e64befeab94f7c3865012502 Mon Sep 17 00:00:00 2001 From: IgorA100 Date: Sun, 5 Jul 2026 17:41:45 +0300 Subject: [PATCH 7/8] Clear mseSourceBuffer only if the number of buffers is greater than zero. (MonitorStream.js) --- web/js/MonitorStream.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/js/MonitorStream.js b/web/js/MonitorStream.js index 865a53bc4..28787e60f 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); From 326e1b0ab22af2f93283e9e61f5d198c51129fec Mon Sep 17 00:00:00 2001 From: Isaac Connor Date: Sun, 5 Jul 2026 12:59:18 -0400 Subject: [PATCH 8/8] feat: synthesize in_frame from the captured image when not decoded Capture paths that deliver a raw Image without an ffmpeg decode (e.g. LocalCamera/V4L2) left packet->in_frame null even though the pixels were already present, so anything expecting a decoded frame failed. In particular YChannel analysis called get_y_image(), which needs in_frame->data[0], and logged "Can't get y_image without frame". At the end of Monitor::Decode(), when a packet has an image but no in_frame, wrap the image's planes in an AVFrame via Image::PopulateFrame (av_image_fill_arrays over a dont_free buffer ref: pointers, no copy). Any format is populated; consumers that need a specific layout check for themselves (get_y_image now reports RGB has no Y plane rather than "no frame"). Done after PHASE 5 so the frame reflects the oriented/masked image and we don't re-orient a shared Y plane, and after the codec phases so transfer_hwframe is never called with the null codec context a non-decoding camera has. videostore is unaffected: it prefers packet->image for frame data and derives pts from packet->timestamp. Co-Authored-By: Claude Opus 4.8 --- src/zm_monitor.cpp | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/zm_monitor.cpp b/src/zm_monitor.cpp index fa9fd394c..bb0d6ff58 100644 --- a/src/zm_monitor.cpp +++ b/src/zm_monitor.cpp @@ -3456,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