diff --git a/db/zm_update-1.39.2.sql b/db/zm_update-1.39.1.sql similarity index 86% rename from db/zm_update-1.39.2.sql rename to db/zm_update-1.39.1.sql index 4072e46ea..3b8356773 100644 --- a/db/zm_update-1.39.2.sql +++ b/db/zm_update-1.39.1.sql @@ -1,4 +1,6 @@ -- +-- This updates a 1.39.0 database to 1.39.1 +-- -- Add custom model training configuration options -- @@ -21,6 +23,7 @@ SET @s = (SELECT IF( )); PREPARE stmt FROM @s; EXECUTE stmt; +DEALLOCATE PREPARE stmt; SET @s = (SELECT IF( (SELECT COUNT(*) FROM Config WHERE Name='ZM_TRAINING_DATA_DIR') > 0, @@ -32,15 +35,16 @@ SET @s = (SELECT IF( DefaultValue='', Hint='', Prompt='Training data directory', - Help='Filesystem path where corrected annotation images and YOLO label files are stored. The directory will be created automatically if it does not exist. Uses Roboflow-compatible YOLO directory layout (images/all/, labels/all/, data.yaml).', + Help='Filesystem path where corrected annotation images and YOLO label files are stored. The directory will be created automatically if it does not exist. Uses Roboflow-compatible YOLO directory layout (images/all/, labels/all/, data.yaml). The default is set from ConfigData after running zmupdate --freshen.', Category='config', Readonly='0', Private='0', System='0', - Requires='ZM_OPT_TRAINING'" + Requires='ZM_OPT_TRAINING=1'" )); PREPARE stmt FROM @s; EXECUTE stmt; +DEALLOCATE PREPARE stmt; SET @s = (SELECT IF( (SELECT COUNT(*) FROM Config WHERE Name='ZM_TRAINING_DETECT_SCRIPT') > 0, @@ -57,7 +61,8 @@ SET @s = (SELECT IF( Readonly='0', Private='0', System='0', - Requires='ZM_OPT_TRAINING'" + Requires='ZM_OPT_TRAINING=1'" )); PREPARE stmt FROM @s; EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/docs/plans/2026-02-28-custom-model-training-design.md b/docs/plans/2026-02-28-custom-model-training-design.md deleted file mode 100644 index 27dbff798..000000000 --- a/docs/plans/2026-02-28-custom-model-training-design.md +++ /dev/null @@ -1,256 +0,0 @@ -# Custom Model Training Annotation UI — Design Document - -**Date**: 2026-02-28 -**Status**: Approved - -## Goal - -Add an integrated annotation UI to ZoneMinder that lets users correct object detection -results (wrong classifications, missing detections) and export corrected images + -annotations in Roboflow-compatible YOLO format for custom model training via pyzm. - -## Decisions - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Architecture | Integrated ZM view with AJAX backend | Fits existing ZM patterns, no new deps | -| Frame selection | Detected frame default, fallback alarm/snapshot, any frame navigable | Maximum flexibility | -| Storage path | Configurable via ZM option (`ZM_TRAINING_DATA_DIR`) | Admin can point to SSD, shared mount, etc. | -| Class labels | Auto-populated from detections + user-editable | Adaptive, low friction | -| UI entry point | Button on event view | Simple, integrated | -| Box editor | HTML5 Canvas overlay | Lightweight, no external deps | - ---- - -## 1. ZM Options - -Three new Config entries in the `config` category: - -| Name | Type | Default | Description | -|------|------|---------|-------------| -| `ZM_OPT_TRAINING` | boolean | 0 | Master toggle for all training features | -| `ZM_TRAINING_DATA_DIR` | text | `@ZM_CACHEDIR@/training` | Path for annotated images + YOLO labels | -| `ZM_TRAINING_LABELS` | text | (empty) | Comma-separated class labels, auto-populated + user-editable | - -`ZM_TRAINING_DATA_DIR` and `ZM_TRAINING_LABELS` use the `Requires` field to depend on -`ZM_OPT_TRAINING`, so they only appear when training is enabled. - -Default path uses `@ZM_CACHEDIR@` (CMake-substituted) to respect the install prefix. - -## 2. Training Data Directory Layout - -Roboflow-compatible YOLO format, matching pyzm's `YOLODataset` staging convention: - -``` -{ZM_TRAINING_DATA_DIR}/ - images/ - all/ - event_{eid}_frame_{fid}.jpg - ... - labels/ - all/ - event_{eid}_frame_{fid}.txt # YOLO: class_id cx cy w h (normalized) - ... - data.yaml # Regenerated on each save -``` - -`data.yaml` example: - -```yaml -path: . -train: images/all -val: images/all -names: - 0: person - 1: car - 2: dog -``` - -Class-to-ID mapping derived from `ZM_TRAINING_LABELS` order. Order must not change once -training has started. New classes are appended only. - -File naming: `event_{eid}_frame_{fid}.jpg` ensures uniqueness and traceability. - -## 3. Annotation Editor UI - -### Entry Point - -An "Annotate" button in the event view toolbar (`event.php`), visible only when -`ZM_OPT_TRAINING` is enabled. Opens an inline editor panel below the video area. - -### Layout - -``` -+---------------------------------------------------+ -| Event View (existing toolbar, video, etc.) | -| [Annotate] <- new button | -+---------------------------------------------------+ -| Annotation Editor (slides open) | -| | -| Frame: [< Prev] [alarm] [snapshot] [objdetect] | -| [Next >] [Go to #___] | -| | -| +----------------------------+ +--------------+ | -| | | | Objects: | | -| | Canvas with frame image | | | | -| | + bounding box overlays | | [x] person | | -| | | | 95% | | -| | (click+drag to draw) | | [x] car | | -| | (click box to select) | | 87% | | -| | | | | | -| +----------------------------+ | [+ Add] | | -| +--------------+ | -| Label: [person v] [Delete Box] | -| | -| [Save to Training Set] [Cancel] | -+---------------------------------------------------+ -``` - -### Default Frame - -1. Detected frame (from `objects.json` `frame_id`) if detection exists -2. Fallback: alarm frame -3. Fallback: snapshot frame - -User can navigate to any frame in the event. - -### Interactions - -| Action | Gesture | Feedback | -|--------|---------|----------| -| Select box | Click on box | Thicker border, resize handles, sidebar highlight | -| Delete box | Click x on sidebar, or select + Delete key | Box fades out | -| Draw new box | Click-drag on empty area | Dashed rect follows cursor, label picker on release | -| Resize box | Drag corner/edge handles | Live resize | -| Move box | Drag selected box body | Box follows cursor | -| Relabel box | Select box, change dropdown | Box color updates | -| Cancel draw | Escape or right-click | In-progress box discarded | -| Undo | Ctrl+Z | Reverts last action (max 50 stack) | -| Navigate frame | Frame selector | Confirm if unsaved changes, load new frame | - -### Label Assignment - -On new box draw (mouse-up): -1. Dropdown anchored to box's top-right corner -2. Shows existing classes (most recently used first) -3. Text input at bottom for new class name -4. Enter/click confirms, Escape cancels the box - -### Visual Design - -- 2px box borders, class-specific colors from a fixed palette -- Selected: 3px border + corner/edge resize handles -- Semi-transparent fill on hover (10% opacity) -- Labels above boxes: "person 95%" (confidence shown for existing, omitted for user-drawn) -- Cursor changes: crosshair (drawing), move (over box), resize arrows (over handles) - -### Coordinate System - -Internal tracking in image-space pixels (native resolution). Canvas CSS-scales to fit -the panel. Mouse-to-image conversion: - -``` -imageX = (mouseX - canvasOffset) * (naturalWidth / displayWidth) -imageY = (mouseY - canvasOffset) * (naturalHeight / displayHeight) -``` - -YOLO normalized conversion on save: - -``` -cx = ((x1 + x2) / 2) / image_width -cy = ((y1 + y2) / 2) / image_height -w = (x2 - x1) / image_width -h = (y2 - y1) / image_height -``` - -## 4. Backend (AJAX Handler) - -### New File: `web/ajax/training.php` - -| Action | Method | Description | -|--------|--------|-------------| -| `load` | GET | Returns objects.json data + frame image URL for event/frame | -| `save` | POST | Saves image + YOLO label file to training dir, regenerates data.yaml | -| `labels` | GET | Returns current class label list | -| `addlabel` | POST | Appends new class to ZM_TRAINING_LABELS | -| `delete` | POST | Removes a saved annotation from training dir | -| `status` | GET | Returns annotation count, classes, dataset stats | - -### Security - -- All actions gated on `canEdit('Events')` -- `ZM_OPT_TRAINING` checked at entry (403 if disabled) -- CSRF token validation on POST actions (`getCSRFMagic()`) -- File path validation — no directory traversal -- Images copied from event path, never from user-supplied paths - -### Data Flow - -``` -Canvas annotations (pixel coords) - -> AJAX POST training.php?action=save {eid, fid, annotations[], width, height} - -> PHP: validate permissions + CSRF - -> Copy frame image to images/all/event_{eid}_frame_{fid}.jpg - -> Convert pixel coords to YOLO normalized - -> Write labels/all/event_{eid}_frame_{fid}.txt - -> Regenerate data.yaml - -> Audit log entry - -> Return success + count -``` - -## 5. Database Migration - -### Migration: `db/zm_update-1.37.78.sql` - -Three INSERT statements for `ZM_OPT_TRAINING`, `ZM_TRAINING_DATA_DIR`, -`ZM_TRAINING_LABELS` (see Section 1 for details). - -### Fresh Install: `db/zm_create.sql.in` - -Same three INSERT statements added to the Config data block. - -## 6. i18n - -All user-visible strings use ZM's `$SLANG` translation system: - -- PHP templates: `` -- JS strings: passed via `var translations = {...}` in `event.js.php` -- New `$SLANG` entries in `web/lang/en_gb.php` and `web/lang/en_us.php` -- `$OLANG` help text entries for the three new Config options - -## 7. Files Created / Modified - -| File | Change | -|------|--------| -| `db/zm_update-1.37.78.sql` | New — migration with 3 Config inserts | -| `db/zm_create.sql.in` | Modified — add same 3 Config inserts | -| `web/lang/en_gb.php` | Modified — add $SLANG entries | -| `web/lang/en_us.php` | Modified — add $SLANG + $OLANG entries | -| `web/skins/classic/views/event.php` | Modified — Annotate button + editor panel HTML | -| `web/skins/classic/views/js/event.js.php` | Modified — pass training config/translations to JS | -| `web/skins/classic/views/js/training.js` | New — Canvas annotation editor | -| `web/skins/classic/css/training.css` | New — editor panel styles | -| `web/ajax/training.php` | New — AJAX backend | - -## 8. Training Data Statistics - -The annotation editor sidebar shows live training dataset statistics: - -- **Total annotated images** — count of image files in `images/all/` -- **Total classes** — number of distinct classes with at least one annotation -- **Images per class** — breakdown showing count per class label -- **Training readiness guidance** — contextual help text: - - Below 50 images/class: yellow banner explaining minimum requirements - - 50+ images/class: green banner indicating training is possible - - Text: "Training is generally possible with at least 50-100 images per class. - For best results, aim for 200+ images per class with varied angles and lighting." - -Stats refresh after each save operation. The `status` AJAX action can also be called -independently to check dataset readiness. - -### Not Touched - -- No C++ changes -- No changes to zm_detect or pyzm (produces data pyzm already consumes) -- No changes to event recording or detection pipeline -- No new PHP dependencies diff --git a/docs/plans/2026-02-28-custom-model-training-plan.md b/docs/plans/2026-02-28-custom-model-training-plan.md deleted file mode 100644 index a95fbb3aa..000000000 --- a/docs/plans/2026-02-28-custom-model-training-plan.md +++ /dev/null @@ -1,2283 +0,0 @@ -# Custom Model Training Annotation UI — Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add an integrated bounding-box annotation editor to ZoneMinder's event view that lets users correct object detection results and save them in Roboflow-compatible YOLO format for custom model training via pyzm. - -**Architecture:** New ZM Config options gate the feature. A Canvas-based annotation editor opens inline on the event view. A new AJAX handler (`web/ajax/training.php`) loads detection data and saves corrected annotations to a configurable directory in YOLO format. No C++ changes, no new dependencies. - -**Tech Stack:** PHP 7+, HTML5 Canvas, vanilla JS + jQuery (existing ZM stack), MySQL Config table, YOLO annotation format. - -**Design doc:** `docs/plans/2026-02-28-custom-model-training-design.md` - ---- - -## Task 1: Add Config Options to ConfigData.pm.in - -Add the three new ZM options that gate and configure the training feature. - -**Files:** -- Modify: `scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in:4049` (before closing `);` of `@options`) - -**Step 1: Add config entries** - -Insert before line 4050 (the closing `);` of `@options`): - -```perl - { - name => 'ZM_OPT_TRAINING', - default => 'no', - description => 'Enable custom model training features', - help => q` - Enable annotation tools on the event view for correcting - object detection results. Corrected annotations are saved - in YOLO format for training custom models via pyzm. - `, - type => $types{boolean}, - category => 'config', - }, - { - name => 'ZM_TRAINING_DATA_DIR', - default => '@ZM_CACHEDIR@/training', - description => 'Training data directory', - help => q` - Filesystem path where corrected annotation images and YOLO - label files are stored. The directory will be created - automatically if it does not exist. Uses Roboflow-compatible - YOLO directory layout (images/all/, labels/all/, data.yaml). - `, - type => $types{string}, - category => 'config', - requires => [ { name => 'ZM_OPT_TRAINING', value => 'yes' } ], - }, - { - name => 'ZM_TRAINING_LABELS', - default => '', - description => 'Training class labels', - help => q` - Comma-separated list of object class labels for annotation - (e.g. person,car,dog). Auto-populated from detected objects. - New labels can be added during annotation. The order of labels - determines class IDs in YOLO files and must not be changed - once training has started. - `, - type => $types{string}, - category => 'config', - requires => [ { name => 'ZM_OPT_TRAINING', value => 'yes' } ], - }, -``` - -**Step 2: Verify build generates the config** - -Run: -```bash -cd build && cmake .. && cmake --build . --target generate_config -``` - -If `generate_config` target does not exist, a full `cmake --build .` will invoke `zmconfgen.pl` which reads ConfigData.pm.in and generates the SQL inserts into `db/zm_create.sql`. Verify the three new options appear: - -```bash -grep -c ZM_OPT_TRAINING build/db/zm_create.sql -``` -Expected: at least 1 match. - -**Step 3: Commit** - -```bash -git add scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in -git commit -m "feat: add ZM_OPT_TRAINING config options for custom model training - -Adds three new Config entries: -- ZM_OPT_TRAINING: master toggle for training features -- ZM_TRAINING_DATA_DIR: configurable path for YOLO training data -- ZM_TRAINING_LABELS: comma-separated class label list - -ZM_TRAINING_DATA_DIR defaults to @ZM_CACHEDIR@/training to respect -the install prefix. The latter two options require ZM_OPT_TRAINING." -``` - ---- - -## Task 2: Add Database Migration - -Create the migration file for existing installations. The `zmconfgen.pl` handles fresh installs via ConfigData.pm.in, but existing installs need a migration to add the new Config rows. - -**Files:** -- Create: `db/zm_update-1.39.2.sql` - -**Step 1: Create migration file** - -```sql --- --- Add custom model training configuration options --- - -SET @s = (SELECT IF( - (SELECT COUNT(*) FROM Config WHERE Name='ZM_OPT_TRAINING') > 0, - "SELECT 'ZM_OPT_TRAINING already exists'", - "INSERT INTO Config SET - Name='ZM_OPT_TRAINING', - Value='0', - Type='boolean', - DefaultValue='0', - Hint='yes|no', - Prompt='Enable custom model training features', - Help='Enable annotation tools on the event view for correcting object detection results. Corrected annotations are saved in YOLO format for training custom models via pyzm.', - Category='config', - Readonly='0', - Private='0', - System='0', - Requires=''" -)); -PREPARE stmt FROM @s; -EXECUTE stmt; - -SET @s = (SELECT IF( - (SELECT COUNT(*) FROM Config WHERE Name='ZM_TRAINING_DATA_DIR') > 0, - "SELECT 'ZM_TRAINING_DATA_DIR already exists'", - "INSERT INTO Config SET - Name='ZM_TRAINING_DATA_DIR', - Value='', - Type='string', - DefaultValue='', - Hint='', - Prompt='Training data directory', - Help='Filesystem path where corrected annotation images and YOLO label files are stored. The directory will be created automatically if it does not exist. Uses Roboflow-compatible YOLO directory layout (images/all/, labels/all/, data.yaml).', - Category='config', - Readonly='0', - Private='0', - System='0', - Requires='ZM_OPT_TRAINING'" -)); -PREPARE stmt FROM @s; -EXECUTE stmt; - -SET @s = (SELECT IF( - (SELECT COUNT(*) FROM Config WHERE Name='ZM_TRAINING_LABELS') > 0, - "SELECT 'ZM_TRAINING_LABELS already exists'", - "INSERT INTO Config SET - Name='ZM_TRAINING_LABELS', - Value='', - Type='string', - DefaultValue='', - Hint='', - Prompt='Training class labels', - Help='Comma-separated list of object class labels for annotation (e.g. person,car,dog). Auto-populated from detected objects. New labels can be added during annotation. The order of labels determines class IDs in YOLO files and must not be changed once training has started.', - Category='config', - Readonly='0', - Private='0', - System='0', - Requires='ZM_OPT_TRAINING'" -)); -PREPARE stmt FROM @s; -EXECUTE stmt; -``` - -Note: The migration uses `ZM_TRAINING_DATA_DIR` with an empty default because the CMake variable `@ZM_CACHEDIR@` is not available at migration time. The PHP code will fall back to `ZM_DIR_EVENTS . '/../training'` or similar at runtime if the value is empty. - -**Step 2: Commit** - -```bash -git add db/zm_update-1.39.2.sql -git commit -m "feat: add database migration for training config options - -Migration zm_update-1.39.2.sql adds ZM_OPT_TRAINING, -ZM_TRAINING_DATA_DIR, and ZM_TRAINING_LABELS to Config table -for existing installations. Uses IF/PREPARE pattern for -idempotent execution." -``` - ---- - -## Task 3: Add i18n Strings - -Add all translatable strings for the annotation UI. - -**Files:** -- Modify: `web/lang/en_gb.php` (primary language file with `$SLANG` and `$OLANG`) -- Modify: `web/lang/en_us.php` (US overrides, inherits from en_gb.php) - -**Step 1: Add $SLANG entries to en_gb.php** - -Find the `$SLANG` array (entries are in alphabetical order by convention) and add: - -```php -$SLANG['AddLabel'] = 'Add Label'; -$SLANG['Annotate'] = 'Annotate'; -$SLANG['AnnotationEditor'] = 'Annotation Editor'; -$SLANG['AnnotationSaved'] = 'Annotation saved to training set'; -$SLANG['AnnotationsRemoved'] = 'Annotation removed from training set'; -$SLANG['Cancel'] = 'Cancel'; // may already exist -$SLANG['ClassLabel'] = 'Label'; -$SLANG['DeleteBox'] = 'Delete Box'; -$SLANG['DrawBox'] = 'Draw a bounding box around the object'; -$SLANG['GoToFrame'] = 'Go to Frame'; -$SLANG['ImagesPerClass'] = 'Images per class'; -$SLANG['NewLabel'] = 'New Label'; -$SLANG['NextFrame'] = 'Next Frame'; -$SLANG['NoDetectionData'] = 'No detection data available for this event'; -$SLANG['PreviousFrame'] = 'Previous Frame'; -$SLANG['SaveToTrainingSet'] = 'Save to Training Set'; -$SLANG['SelectLabel'] = 'Select a label for this object'; -$SLANG['TotalAnnotatedImages'] = 'Total annotated images'; -$SLANG['TotalClasses'] = 'Total classes'; -$SLANG['TrainingDataStats'] = 'Training Data Statistics'; -$SLANG['TrainingGuidance'] = 'Training is generally possible with at least 50-100 images per class. For best results, aim for 200+ images per class with varied angles and lighting conditions.'; -$SLANG['UnsavedAnnotations'] = 'You have unsaved annotations. Discard changes?'; -``` - -Insert each entry at its alphabetical position within the existing `$SLANG` array. - -**Step 2: Add $OLANG help entries to en_gb.php** - -Find the `$OLANG` array and add entries for the three Config options: - -```php -$OLANG['ZM_OPT_TRAINING'] = array( - 'Prompt' => 'Enable custom model training features', - 'Help' => 'Enable annotation tools on the event view for correcting object detection results.~~Corrected annotations are saved in YOLO format for training custom models via pyzm.~~When enabled, an Annotate button appears on the event view toolbar.' -); - -$OLANG['ZM_TRAINING_DATA_DIR'] = array( - 'Prompt' => 'Training data directory', - 'Help' => 'Filesystem path where corrected annotation images and YOLO label files are stored.~~The directory will be created automatically if it does not exist.~~Uses Roboflow-compatible YOLO directory layout:~~images/all/ - annotated frame images~~labels/all/ - YOLO format label files~~data.yaml - class definitions' -); - -$OLANG['ZM_TRAINING_LABELS'] = array( - 'Prompt' => 'Training class labels', - 'Help' => 'Comma-separated list of object class labels for annotation (e.g. person,car,dog).~~Auto-populated from detected objects. New labels can be added during annotation.~~IMPORTANT: The order of labels determines class IDs in YOLO files.~~Do not reorder labels once training has started.' -); -``` - -**Step 3: Verify no lint errors** - -```bash -php -l web/lang/en_gb.php -php -l web/lang/en_us.php -``` -Expected: `No syntax errors detected` - -**Step 4: Commit** - -```bash -git add web/lang/en_gb.php web/lang/en_us.php -git commit -m "feat: add i18n strings for annotation editor UI - -Adds SLANG entries for all annotation editor UI strings and -OLANG help text for the three training Config options." -``` - ---- - -## Task 4: Create AJAX Backend (web/ajax/training.php) - -The backend handles loading detection data, saving annotations, managing labels, and returning training dataset statistics. - -**Files:** -- Create: `web/ajax/training.php` - -**Step 1: Create the AJAX handler** - -Follow the pattern in `web/ajax/event.php` — check permissions at entry, switch on action. - -```php - $label) { - $yaml .= " $i: $label\n"; - } - file_put_contents($base.'/data.yaml', $yaml); -} - -/** - * Collect training dataset statistics: - * total images, total classes, images per class. - */ -function getTrainingStats() { - $base = getTrainingDataDir(); - $labelsDir = $base.'/labels/all'; - $stats = [ - 'total_images' => 0, - 'total_classes' => 0, - 'images_per_class' => [], - 'class_labels' => getClassLabels(), - ]; - - if (!is_dir($labelsDir)) return $stats; - - $labels = getClassLabels(); - $classCounts = array_fill(0, count($labels), 0); - - $files = glob($labelsDir.'/*.txt'); - $stats['total_images'] = count($files); - - foreach ($files as $file) { - $seenClasses = []; - $lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); - foreach ($lines as $line) { - $parts = explode(' ', trim($line)); - if (count($parts) >= 5) { - $classId = intval($parts[0]); - if (!isset($seenClasses[$classId])) { - $seenClasses[$classId] = true; - if (isset($classCounts[$classId])) { - $classCounts[$classId]++; - } - } - } - } - } - - foreach ($labels as $i => $label) { - $stats['images_per_class'][$label] = $classCounts[$i]; - } - $stats['total_classes'] = count(array_filter($classCounts, function($c) { return $c > 0; })); - - return $stats; -} - -switch ($_REQUEST['action']) { - - case 'load': - // Load detection data for an event - if (empty($_REQUEST['eid'])) { - ajaxError('Event ID required'); - break; - } - $eid = validCardinal($_REQUEST['eid']); - $Event = ZM\Event::find_one(['Id' => $eid]); - if (!$Event) { - ajaxError('Event not found'); - break; - } - - $eventPath = $Event->Path(); - $objectsFile = $eventPath.'/objects.json'; - $detectionData = null; - - if (file_exists($objectsFile)) { - $json = file_get_contents($objectsFile); - $detectionData = json_decode($json, true); - } - - // Determine default frame - $defaultFrameId = null; - if ($detectionData && isset($detectionData['frame_id'])) { - $defaultFrameId = $detectionData['frame_id']; - } else if (file_exists($eventPath.'/alarm.jpg')) { - $defaultFrameId = 'alarm'; - } else if (file_exists($eventPath.'/snapshot.jpg')) { - $defaultFrameId = 'snapshot'; - } - - // Check which special frames exist - $availableFrames = []; - foreach (['alarm', 'snapshot', 'objdetect'] as $special) { - if (file_exists($eventPath.'/'.$special.'.jpg')) { - $availableFrames[] = $special; - } - } - - // Also check if we already have a saved annotation for this event - $base = getTrainingDataDir(); - $fid = $defaultFrameId ?: 'alarm'; - $savedFile = $base.'/labels/all/event_'.$eid.'_frame_'.$fid.'.txt'; - $hasSavedAnnotation = file_exists($savedFile); - - ajaxResponse([ - 'detectionData' => $detectionData, - 'defaultFrameId' => $defaultFrameId, - 'availableFrames' => $availableFrames, - 'totalFrames' => $Event->Frames(), - 'eventPath' => $Event->Relative_Path(), - 'width' => $Event->Width(), - 'height' => $Event->Height(), - 'hasSavedAnnotation' => $hasSavedAnnotation, - ]); - break; - - case 'save': - // Save annotation (image + YOLO label file) - if (empty($_REQUEST['eid']) || !isset($_REQUEST['fid'])) { - ajaxError('Event ID and Frame ID required'); - break; - } - - $eid = validCardinal($_REQUEST['eid']); - $fid = $_REQUEST['fid']; - - // Validate fid is either a known special name or a positive integer - if (!in_array($fid, ['alarm', 'snapshot', 'objdetect']) && !ctype_digit($fid)) { - ajaxError('Invalid frame ID'); - break; - } - - $Event = ZM\Event::find_one(['Id' => $eid]); - if (!$Event) { - ajaxError('Event not found'); - break; - } - - if (!ensureTrainingDirs()) break; - - $annotations = json_decode($_REQUEST['annotations'], true); - if (!is_array($annotations)) { - ajaxError('Invalid annotations data'); - break; - } - - $imgWidth = intval($_REQUEST['width']); - $imgHeight = intval($_REQUEST['height']); - if ($imgWidth <= 0 || $imgHeight <= 0) { - ajaxError('Invalid image dimensions'); - break; - } - - $base = getTrainingDataDir(); - $stem = 'event_'.$eid.'_frame_'.$fid; - - // Copy the frame image to training dir - $eventPath = $Event->Path(); - if (in_array($fid, ['alarm', 'snapshot', 'objdetect'])) { - $srcImage = $eventPath.'/'.$fid.'.jpg'; - } else { - $srcImage = $eventPath.'/'.sprintf('%06d', $fid).'-capture.jpg'; - } - - if (!file_exists($srcImage)) { - ajaxError('Source frame image not found: '.$fid); - break; - } - - $dstImage = $base.'/images/all/'.$stem.'.jpg'; - if (!copy($srcImage, $dstImage)) { - ajaxError('Failed to copy image'); - break; - } - - // Get current labels, add any new ones - $labels = getClassLabels(); - $labelsChanged = false; - foreach ($annotations as $ann) { - if (!in_array($ann['label'], $labels)) { - $labels[] = $ann['label']; - $labelsChanged = true; - } - } - - if ($labelsChanged) { - // Update ZM_TRAINING_LABELS in database - $newLabelsStr = implode(',', $labels); - dbQuery('UPDATE Config SET Value=? WHERE Name=?', [$newLabelsStr, 'ZM_TRAINING_LABELS']); - } - - // Write YOLO label file - $labelLines = []; - foreach ($annotations as $ann) { - $classId = array_search($ann['label'], $labels); - // Convert pixel coords [x1, y1, x2, y2] to YOLO normalized [cx, cy, w, h] - $cx = (($ann['x1'] + $ann['x2']) / 2) / $imgWidth; - $cy = (($ann['y1'] + $ann['y2']) / 2) / $imgHeight; - $w = ($ann['x2'] - $ann['x1']) / $imgWidth; - $h = ($ann['y2'] - $ann['y1']) / $imgHeight; - $labelLines[] = sprintf('%d %.6f %.6f %.6f %.6f', $classId, $cx, $cy, $w, $h); - } - $dstLabel = $base.'/labels/all/'.$stem.'.txt'; - file_put_contents($dstLabel, implode("\n", $labelLines)."\n"); - - // Regenerate data.yaml - regenerateDataYaml(); - - // Audit log - ZM\AuditAction('update', 'training', $eid, - 'Saved '.count($annotations).' annotations for event '.$eid.' frame '.$fid); - - $stats = getTrainingStats(); - - ajaxResponse([ - 'saved' => true, - 'annotations_count' => count($annotations), - 'stats' => $stats, - ]); - break; - - case 'labels': - // Return current class label list + auto-discovered labels - $labels = getClassLabels(); - ajaxResponse(['labels' => $labels]); - break; - - case 'delete': - // Remove a saved annotation - if (empty($_REQUEST['eid']) || !isset($_REQUEST['fid'])) { - ajaxError('Event ID and Frame ID required'); - break; - } - - $eid = validCardinal($_REQUEST['eid']); - $fid = $_REQUEST['fid']; - $base = getTrainingDataDir(); - $stem = 'event_'.$eid.'_frame_'.$fid; - - $imgFile = $base.'/images/all/'.$stem.'.jpg'; - $lblFile = $base.'/labels/all/'.$stem.'.txt'; - - $deleted = false; - if (file_exists($imgFile)) { unlink($imgFile); $deleted = true; } - if (file_exists($lblFile)) { unlink($lblFile); $deleted = true; } - - if ($deleted) { - regenerateDataYaml(); - ZM\AuditAction('delete', 'training', $eid, - 'Removed annotation for event '.$eid.' frame '.$fid); - } - - ajaxResponse([ - 'deleted' => $deleted, - 'stats' => getTrainingStats(), - ]); - break; - - case 'status': - // Return training dataset statistics - ajaxResponse(['stats' => getTrainingStats()]); - break; - - default: - ajaxError('Unknown action: '.$_REQUEST['action']); - break; -} -?> -``` - -**Step 2: Verify PHP syntax** - -```bash -php -l web/ajax/training.php -``` -Expected: `No syntax errors detected` - -**Step 3: Commit** - -```bash -git add web/ajax/training.php -git commit -m "feat: add AJAX backend for annotation training data - -Handles load (detection data from objects.json), save (image + YOLO -label file), delete, labels management, and training dataset -statistics. Writes Roboflow-compatible YOLO format to configurable -training directory." -``` - ---- - -## Task 5: Add CSS for Annotation Editor - -**Files:** -- Create: `web/skins/classic/css/base/views/training.css` - -Note: ZM auto-loads `css/base/views/{basename}.css` for the current view. Since our editor is part of the `event` view, we will name the file to match the event view loading OR explicitly include it. Since the annotation panel is conditionally loaded on the event view (not its own view), we should include it from event.php directly. The file should be named descriptively. - -**Step 1: Create the CSS file** - -```css -/* Annotation Editor Panel - Custom Model Training */ - -#annotationPanel { - display: none; - border-top: 2px solid #dee2e6; - padding: 15px; - margin-top: 10px; - background: #f8f9fa; -} - -#annotationPanel.open { - display: block; -} - -/* Frame selector bar */ -.annotation-frame-selector { - display: flex; - align-items: center; - gap: 8px; - margin-bottom: 12px; - flex-wrap: wrap; -} - -.annotation-frame-selector .btn { - padding: 4px 10px; - font-size: 0.85rem; -} - -.annotation-frame-selector .frame-input { - width: 80px; - display: inline-block; -} - -/* Canvas + sidebar layout */ -.annotation-workspace { - display: flex; - gap: 15px; - align-items: flex-start; -} - -/* Canvas container */ -.annotation-canvas-container { - position: relative; - flex: 1; - min-width: 0; - border: 1px solid #ced4da; - background: #000; - overflow: hidden; -} - -.annotation-canvas-container canvas { - display: block; - width: 100%; - cursor: crosshair; -} - -.annotation-canvas-container canvas.mode-select { - cursor: default; -} - -.annotation-canvas-container canvas.mode-move { - cursor: move; -} - -.annotation-canvas-container canvas.mode-resize-nw, -.annotation-canvas-container canvas.mode-resize-se { - cursor: nwse-resize; -} - -.annotation-canvas-container canvas.mode-resize-ne, -.annotation-canvas-container canvas.mode-resize-sw { - cursor: nesw-resize; -} - -.annotation-canvas-container canvas.mode-resize-n, -.annotation-canvas-container canvas.mode-resize-s { - cursor: ns-resize; -} - -.annotation-canvas-container canvas.mode-resize-e, -.annotation-canvas-container canvas.mode-resize-w { - cursor: ew-resize; -} - -/* Object sidebar */ -.annotation-sidebar { - width: 240px; - min-width: 240px; - border: 1px solid #ced4da; - border-radius: 4px; - background: #fff; - max-height: 500px; - overflow-y: auto; -} - -.annotation-sidebar-header { - padding: 8px 12px; - font-weight: 600; - border-bottom: 1px solid #dee2e6; - background: #e9ecef; - font-size: 0.9rem; -} - -.annotation-object-list { - list-style: none; - padding: 0; - margin: 0; -} - -.annotation-object-item { - display: flex; - align-items: center; - padding: 6px 12px; - border-bottom: 1px solid #f0f0f0; - cursor: pointer; - font-size: 0.85rem; -} - -.annotation-object-item:hover { - background: #f0f7ff; -} - -.annotation-object-item.selected { - background: #d4e8ff; -} - -.annotation-object-item .color-swatch { - width: 12px; - height: 12px; - border-radius: 2px; - margin-right: 8px; - flex-shrink: 0; -} - -.annotation-object-item .object-label { - flex: 1; -} - -.annotation-object-item .object-confidence { - color: #6c757d; - font-size: 0.8rem; - margin-left: 4px; -} - -.annotation-object-item .btn-remove { - padding: 0 4px; - font-size: 0.75rem; - line-height: 1; - color: #dc3545; - background: none; - border: none; - cursor: pointer; - opacity: 0.6; - margin-left: 4px; -} - -.annotation-object-item .btn-remove:hover { - opacity: 1; -} - -.annotation-add-btn { - display: block; - width: 100%; - padding: 6px 12px; - text-align: left; - font-size: 0.85rem; - border: none; - background: none; - color: #007bff; - cursor: pointer; -} - -.annotation-add-btn:hover { - background: #f0f7ff; -} - -/* Training stats section in sidebar */ -.annotation-stats { - padding: 8px 12px; - border-top: 1px solid #dee2e6; - background: #f8f9fa; - font-size: 0.8rem; -} - -.annotation-stats-header { - font-weight: 600; - margin-bottom: 4px; - font-size: 0.85rem; -} - -.annotation-stats dt { - font-weight: normal; - color: #6c757d; -} - -.annotation-stats dd { - margin-bottom: 2px; - margin-left: 0; -} - -.annotation-stats .class-count { - display: flex; - justify-content: space-between; - padding: 1px 0; -} - -.annotation-stats .class-count .count { - font-weight: 600; -} - -.annotation-stats .training-guidance { - margin-top: 6px; - padding: 6px 8px; - background: #fff3cd; - border-radius: 3px; - color: #856404; - font-size: 0.78rem; - line-height: 1.3; -} - -.annotation-stats .training-ready { - background: #d4edda; - color: #155724; -} - -/* Label picker dropdown (appears on new box) */ -.annotation-label-picker { - position: absolute; - z-index: 1000; - background: #fff; - border: 1px solid #ced4da; - border-radius: 4px; - box-shadow: 0 2px 8px rgba(0,0,0,0.15); - min-width: 160px; - max-height: 200px; - overflow-y: auto; -} - -.annotation-label-picker .label-option { - display: block; - width: 100%; - padding: 6px 12px; - border: none; - background: none; - text-align: left; - cursor: pointer; - font-size: 0.85rem; -} - -.annotation-label-picker .label-option:hover { - background: #007bff; - color: #fff; -} - -.annotation-label-picker .new-label-input { - display: flex; - border-top: 1px solid #dee2e6; - padding: 6px; -} - -.annotation-label-picker .new-label-input input { - flex: 1; - font-size: 0.85rem; - padding: 2px 6px; - border: 1px solid #ced4da; - border-radius: 3px; -} - -/* Bottom action bar */ -.annotation-actions { - display: flex; - align-items: center; - gap: 8px; - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid #dee2e6; -} - -.annotation-actions .label-select { - width: 150px; -} - -.annotation-status { - margin-left: auto; - font-size: 0.85rem; - color: #28a745; -} -``` - -**Step 2: Commit** - -```bash -git add web/skins/classic/css/base/views/training.css -git commit -m "feat: add CSS styles for annotation editor panel - -Styles for canvas workspace, object sidebar, label picker dropdown, -training statistics panel, and action buttons." -``` - ---- - -## Task 6: Create JavaScript Annotation Editor (web/skins/classic/views/js/training.js) - -This is the largest task. The Canvas-based annotation editor with full interaction support. - -**Files:** -- Create: `web/skins/classic/views/js/training.js` - -**Step 1: Create the annotation editor JS** - -This file implements the `AnnotationEditor` class. Due to size, here is the structure with key methods. Each method should be implemented following the interaction spec from the design doc. - -```javascript -/** - * AnnotationEditor - Canvas-based bounding box annotation editor - * for ZoneMinder custom model training. - * - * Usage: - * var editor = new AnnotationEditor({ - * canvasId: 'annotationCanvas', - * sidebarId: 'annotationObjectList', - * eventId: eventData.Id, - * translations: trainingTranslations - * }); - * editor.open(); - */ - -// Box colors palette for class labels -var ANNOTATION_COLORS = [ - '#e6194b', '#3cb44b', '#4363d8', '#f58231', '#911eb4', - '#42d4f4', '#f032e6', '#bfef45', '#fabed4', '#469990', - '#dcbeff', '#9A6324', '#800000', '#aaffc3', '#808000', - '#000075', '#a9a9a9' -]; - -function AnnotationEditor(options) { - this.canvasId = options.canvasId; - this.canvas = null; - this.ctx = null; - this.sidebarEl = null; - this.statsEl = null; - this.eventId = options.eventId; - this.translations = options.translations || {}; - - // State - this.currentFrameId = null; - this.annotations = []; // [{label, x1, y1, x2, y2, confidence}] - this.selectedIndex = -1; - this.classLabels = []; - this.dirty = false; - - // Image state - this.image = null; - this.imageNaturalW = 0; - this.imageNaturalH = 0; - - // Drawing state - this.isDrawing = false; - this.drawStart = null; // {x, y} in image space - this.drawCurrent = null; - - // Drag/resize state - this.isDragging = false; - this.isResizing = false; - this.resizeHandle = null; // 'nw','n','ne','e','se','s','sw','w' - this.dragOffset = null; // {x, y} - - // Undo stack - this.undoStack = []; - this.maxUndo = 50; - - // Label picker - this.labelPickerEl = null; - - // Training stats - this.trainingStats = null; -} - -AnnotationEditor.prototype = { - - /** - * Initialize the editor: set up canvas, event listeners. - */ - init: function() { - this.canvas = document.getElementById(this.canvasId); - this.ctx = this.canvas.getContext('2d'); - this.sidebarEl = document.getElementById('annotationObjectList'); - this.statsEl = document.getElementById('annotationStats'); - - this._bindCanvasEvents(); - this._bindKeyboardEvents(); - }, - - /** - * Open the editor panel for the current event. - * Loads detection data via AJAX, displays default frame. - */ - open: function() { - var self = this; - var panel = document.getElementById('annotationPanel'); - - $j.ajax({ - url: '?request=training&action=load&eid=' + this.eventId, - dataType: 'json', - success: function(data) { - if (data.result === 'Error') { - alert(data.message || 'Failed to load detection data'); - return; - } - - panel.classList.add('open'); - - // Store metadata - self.imageNaturalW = parseInt(data.width); - self.imageNaturalH = parseInt(data.height); - self.totalFrames = parseInt(data.totalFrames); - - // Load annotations from detection data - if (data.detectionData) { - self._loadDetectionData(data.detectionData); - } - - // Set default frame - self.currentFrameId = data.defaultFrameId || 'alarm'; - self._updateFrameSelector(data.availableFrames); - - // Load frame image - self._loadFrameImage(self.currentFrameId); - - // Load class labels - self._loadLabels(); - - // Load training stats - self._loadStats(); - }, - error: function() { - alert('Failed to load training data'); - } - }); - }, - - /** - * Close the editor panel. Prompt if dirty. - */ - close: function() { - if (this.dirty) { - if (!confirm(this.translations.UnsavedAnnotations || - 'You have unsaved annotations. Discard changes?')) { - return; - } - } - document.getElementById('annotationPanel').classList.remove('open'); - this.annotations = []; - this.selectedIndex = -1; - this.dirty = false; - this.undoStack = []; - this._hideLabelPicker(); - }, - - // ---- Detection Data ---- - - /** - * Convert objects.json detection data into annotation objects. - */ - _loadDetectionData: function(data) { - this.annotations = []; - if (!data.labels || !data.boxes) return; - - for (var i = 0; i < data.labels.length; i++) { - this.annotations.push({ - label: data.labels[i], - x1: data.boxes[i][0], - y1: data.boxes[i][1], - x2: data.boxes[i][2], - y2: data.boxes[i][3], - confidence: data.confidences ? data.confidences[i] : null - }); - } - }, - - // ---- Frame Loading ---- - - /** - * Load a frame image onto the canvas. - */ - _loadFrameImage: function(frameId) { - var self = this; - var img = new Image(); - var src; - - if (['alarm', 'snapshot', 'objdetect'].indexOf(frameId) !== -1) { - src = '?view=image&eid=' + this.eventId + '&fid=' + frameId; - } else { - src = '?view=image&eid=' + this.eventId + '&fid=' + frameId + '&show=capture'; - } - - img.onload = function() { - self.image = img; - self.imageNaturalW = img.naturalWidth; - self.imageNaturalH = img.naturalHeight; - - // Set canvas dimensions to match image aspect ratio - self.canvas.width = img.naturalWidth; - self.canvas.height = img.naturalHeight; - - self._render(); - self._updateSidebar(); - }; - img.onerror = function() { - alert('Failed to load frame image'); - }; - img.src = src; - }, - - /** - * Switch to a different frame. Prompt if dirty. - */ - switchFrame: function(frameId) { - if (this.dirty) { - if (!confirm(this.translations.UnsavedAnnotations || - 'You have unsaved annotations. Discard changes?')) { - return; - } - } - this.currentFrameId = frameId; - this.annotations = []; - this.selectedIndex = -1; - this.dirty = false; - this.undoStack = []; - this._hideLabelPicker(); - this._loadFrameImage(frameId); - }, - - // ---- Rendering ---- - - /** - * Render the canvas: image + all bounding boxes + handles. - */ - _render: function() { - if (!this.image || !this.ctx) return; - - var ctx = this.ctx; - ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); - - // Draw image - ctx.drawImage(this.image, 0, 0); - - // Draw all annotation boxes - for (var i = 0; i < this.annotations.length; i++) { - this._drawBox(this.annotations[i], i === this.selectedIndex, i); - } - - // Draw in-progress drawing box - if (this.isDrawing && this.drawStart && this.drawCurrent) { - ctx.setLineDash([6, 3]); - ctx.strokeStyle = '#fff'; - ctx.lineWidth = 2; - var x = Math.min(this.drawStart.x, this.drawCurrent.x); - var y = Math.min(this.drawStart.y, this.drawCurrent.y); - var w = Math.abs(this.drawCurrent.x - this.drawStart.x); - var h = Math.abs(this.drawCurrent.y - this.drawStart.y); - ctx.strokeRect(x, y, w, h); - ctx.setLineDash([]); - } - }, - - /** - * Draw a single bounding box with label and optional handles. - */ - _drawBox: function(ann, isSelected, index) { - var ctx = this.ctx; - var color = this._getColorForLabel(ann.label); - var x = ann.x1; - var y = ann.y1; - var w = ann.x2 - ann.x1; - var h = ann.y2 - ann.y1; - - // Fill with semi-transparent color - ctx.fillStyle = color + '1a'; // 10% opacity - ctx.fillRect(x, y, w, h); - - // Border - ctx.strokeStyle = color; - ctx.lineWidth = isSelected ? 3 : 2; - ctx.strokeRect(x, y, w, h); - - // Label text above box - var labelText = ann.label; - if (ann.confidence !== null && ann.confidence !== undefined) { - labelText += ' ' + Math.round(ann.confidence * 100) + '%'; - } - ctx.font = '14px sans-serif'; - var textWidth = ctx.measureText(labelText).width; - ctx.fillStyle = color; - ctx.fillRect(x, y - 20, textWidth + 8, 20); - ctx.fillStyle = '#fff'; - ctx.fillText(labelText, x + 4, y - 5); - - // Resize handles if selected - if (isSelected) { - var handles = this._getHandlePositions(ann); - ctx.fillStyle = '#fff'; - ctx.strokeStyle = color; - ctx.lineWidth = 1; - for (var key in handles) { - var hp = handles[key]; - ctx.fillRect(hp.x - 4, hp.y - 4, 8, 8); - ctx.strokeRect(hp.x - 4, hp.y - 4, 8, 8); - } - } - }, - - /** - * Get resize handle positions for a box. - */ - _getHandlePositions: function(ann) { - var mx = (ann.x1 + ann.x2) / 2; - var my = (ann.y1 + ann.y2) / 2; - return { - nw: {x: ann.x1, y: ann.y1}, - n: {x: mx, y: ann.y1}, - ne: {x: ann.x2, y: ann.y1}, - e: {x: ann.x2, y: my}, - se: {x: ann.x2, y: ann.y2}, - s: {x: mx, y: ann.y2}, - sw: {x: ann.x1, y: ann.y2}, - w: {x: ann.x1, y: my} - }; - }, - - /** - * Get color for a class label (consistent assignment from palette). - */ - _getColorForLabel: function(label) { - var idx = this.classLabels.indexOf(label); - if (idx === -1) idx = this.annotations.findIndex(function(a) { return a.label === label; }); - if (idx === -1) idx = 0; - return ANNOTATION_COLORS[idx % ANNOTATION_COLORS.length]; - }, - - // ---- Mouse Coordinate Conversion ---- - - /** - * Convert mouse event to image-space coordinates. - */ - _mouseToImage: function(e) { - var rect = this.canvas.getBoundingClientRect(); - var scaleX = this.canvas.width / rect.width; - var scaleY = this.canvas.height / rect.height; - return { - x: (e.clientX - rect.left) * scaleX, - y: (e.clientY - rect.top) * scaleY - }; - }, - - // ---- Canvas Event Handlers ---- - - _bindCanvasEvents: function() { - var self = this; - - this.canvas.addEventListener('mousedown', function(e) { - if (e.button !== 0) return; // left click only - self._onMouseDown(e); - }); - - this.canvas.addEventListener('mousemove', function(e) { - self._onMouseMove(e); - }); - - this.canvas.addEventListener('mouseup', function(e) { - if (e.button !== 0) return; - self._onMouseUp(e); - }); - - this.canvas.addEventListener('contextmenu', function(e) { - e.preventDefault(); - // Cancel drawing - if (self.isDrawing) { - self.isDrawing = false; - self.drawStart = null; - self.drawCurrent = null; - self._render(); - } - }); - }, - - _onMouseDown: function(e) { - var pos = this._mouseToImage(e); - - // Check if clicking on a resize handle of selected box - if (this.selectedIndex >= 0) { - var handle = this._hitTestHandles(pos, this.annotations[this.selectedIndex]); - if (handle) { - this.isResizing = true; - this.resizeHandle = handle; - return; - } - } - - // Check if clicking on an existing box - var hitIndex = this._hitTestBoxes(pos); - if (hitIndex >= 0) { - this._pushUndo(); - this.selectedIndex = hitIndex; - this.isDragging = true; - var ann = this.annotations[hitIndex]; - this.dragOffset = {x: pos.x - ann.x1, y: pos.y - ann.y1}; - this._render(); - this._updateSidebar(); - return; - } - - // Start drawing a new box - this._hideLabelPicker(); - this.selectedIndex = -1; - this.isDrawing = true; - this.drawStart = pos; - this.drawCurrent = pos; - this._updateSidebar(); - }, - - _onMouseMove: function(e) { - var pos = this._mouseToImage(e); - - if (this.isDrawing) { - this.drawCurrent = pos; - this._render(); - return; - } - - if (this.isDragging && this.selectedIndex >= 0) { - var ann = this.annotations[this.selectedIndex]; - var w = ann.x2 - ann.x1; - var h = ann.y2 - ann.y1; - ann.x1 = Math.max(0, Math.min(pos.x - this.dragOffset.x, this.canvas.width - w)); - ann.y1 = Math.max(0, Math.min(pos.y - this.dragOffset.y, this.canvas.height - h)); - ann.x2 = ann.x1 + w; - ann.y2 = ann.y1 + h; - ann.confidence = null; // Clear confidence on user edit - this.dirty = true; - this._render(); - return; - } - - if (this.isResizing && this.selectedIndex >= 0) { - this._doResize(pos); - this._render(); - return; - } - - // Update cursor based on hover - this._updateCursor(pos); - }, - - _onMouseUp: function(e) { - var pos = this._mouseToImage(e); - - if (this.isDrawing) { - this.isDrawing = false; - var x1 = Math.min(this.drawStart.x, pos.x); - var y1 = Math.min(this.drawStart.y, pos.y); - var x2 = Math.max(this.drawStart.x, pos.x); - var y2 = Math.max(this.drawStart.y, pos.y); - - // Minimum box size (10px) - if ((x2 - x1) < 10 || (y2 - y1) < 10) { - this.drawStart = null; - this.drawCurrent = null; - this._render(); - return; - } - - // Show label picker at the box position - this._showLabelPicker(x1, y1, x2, y2, e); - this.drawStart = null; - this.drawCurrent = null; - return; - } - - if (this.isDragging) { - this.isDragging = false; - this.dragOffset = null; - return; - } - - if (this.isResizing) { - this.isResizing = false; - this.resizeHandle = null; - return; - } - }, - - // ---- Hit Testing ---- - - _hitTestBoxes: function(pos) { - // Test in reverse order (top-most box first) - for (var i = this.annotations.length - 1; i >= 0; i--) { - var a = this.annotations[i]; - if (pos.x >= a.x1 && pos.x <= a.x2 && pos.y >= a.y1 && pos.y <= a.y2) { - return i; - } - } - return -1; - }, - - _hitTestHandles: function(pos, ann) { - var handles = this._getHandlePositions(ann); - var threshold = 8; // pixels - for (var key in handles) { - var hp = handles[key]; - if (Math.abs(pos.x - hp.x) <= threshold && Math.abs(pos.y - hp.y) <= threshold) { - return key; - } - } - return null; - }, - - // ---- Resize ---- - - _doResize: function(pos) { - var ann = this.annotations[this.selectedIndex]; - ann.confidence = null; // Clear on edit - - switch (this.resizeHandle) { - case 'nw': ann.x1 = pos.x; ann.y1 = pos.y; break; - case 'n': ann.y1 = pos.y; break; - case 'ne': ann.x2 = pos.x; ann.y1 = pos.y; break; - case 'e': ann.x2 = pos.x; break; - case 'se': ann.x2 = pos.x; ann.y2 = pos.y; break; - case 's': ann.y2 = pos.y; break; - case 'sw': ann.x1 = pos.x; ann.y2 = pos.y; break; - case 'w': ann.x1 = pos.x; break; - } - - // Ensure x1 < x2, y1 < y2 - if (ann.x1 > ann.x2) { var t = ann.x1; ann.x1 = ann.x2; ann.x2 = t; } - if (ann.y1 > ann.y2) { var t = ann.y1; ann.y1 = ann.y2; ann.y2 = t; } - - // Clamp to canvas - ann.x1 = Math.max(0, ann.x1); - ann.y1 = Math.max(0, ann.y1); - ann.x2 = Math.min(this.canvas.width, ann.x2); - ann.y2 = Math.min(this.canvas.height, ann.y2); - - this.dirty = true; - }, - - // ---- Cursor ---- - - _updateCursor: function(pos) { - if (this.selectedIndex >= 0) { - var handle = this._hitTestHandles(pos, this.annotations[this.selectedIndex]); - if (handle) { - this.canvas.className = 'mode-resize-' + handle; - return; - } - } - var hit = this._hitTestBoxes(pos); - if (hit >= 0 && hit === this.selectedIndex) { - this.canvas.className = 'mode-move'; - } else if (hit >= 0) { - this.canvas.className = 'mode-select'; - } else { - this.canvas.className = ''; - } - }, - - // ---- Label Picker ---- - - _showLabelPicker: function(x1, y1, x2, y2, mouseEvent) { - var self = this; - this._hideLabelPicker(); - - var picker = document.createElement('div'); - picker.className = 'annotation-label-picker'; - this.labelPickerEl = picker; - - // Position near the box (screen coordinates) - var rect = this.canvas.getBoundingClientRect(); - var scaleX = rect.width / this.canvas.width; - picker.style.left = (rect.left + x2 * scaleX + window.scrollX + 5) + 'px'; - picker.style.top = (rect.top + y1 * (rect.height / this.canvas.height) + window.scrollY) + 'px'; - picker.style.position = 'absolute'; - - // Existing labels - var labels = this.classLabels.slice(); - // Sort by most recently used - var usedLabels = this.annotations.map(function(a) { return a.label; }).reverse(); - labels.sort(function(a, b) { - var ai = usedLabels.indexOf(a); - var bi = usedLabels.indexOf(b); - if (ai === -1 && bi === -1) return 0; - if (ai === -1) return 1; - if (bi === -1) return -1; - return ai - bi; - }); - - labels.forEach(function(label) { - var btn = document.createElement('button'); - btn.className = 'label-option'; - btn.textContent = label; - btn.onclick = function() { - self._addAnnotation(label, x1, y1, x2, y2); - self._hideLabelPicker(); - }; - picker.appendChild(btn); - }); - - // New label input - var inputDiv = document.createElement('div'); - inputDiv.className = 'new-label-input'; - var input = document.createElement('input'); - input.type = 'text'; - input.placeholder = self.translations.NewLabel || 'New label...'; - input.addEventListener('keydown', function(e) { - if (e.key === 'Enter' && input.value.trim()) { - var newLabel = input.value.trim().toLowerCase(); - self._addAnnotation(newLabel, x1, y1, x2, y2); - self._hideLabelPicker(); - } else if (e.key === 'Escape') { - self._hideLabelPicker(); - self._render(); - } - }); - inputDiv.appendChild(input); - picker.appendChild(inputDiv); - - document.body.appendChild(picker); - - // Focus the input if no existing labels - if (labels.length === 0) { - input.focus(); - } - }, - - _hideLabelPicker: function() { - if (this.labelPickerEl && this.labelPickerEl.parentNode) { - this.labelPickerEl.parentNode.removeChild(this.labelPickerEl); - } - this.labelPickerEl = null; - }, - - // ---- Annotation CRUD ---- - - _addAnnotation: function(label, x1, y1, x2, y2) { - this._pushUndo(); - - this.annotations.push({ - label: label, - x1: Math.round(x1), - y1: Math.round(y1), - x2: Math.round(x2), - y2: Math.round(y2), - confidence: null - }); - - // Add to class labels if new - if (this.classLabels.indexOf(label) === -1) { - this.classLabels.push(label); - } - - this.selectedIndex = this.annotations.length - 1; - this.dirty = true; - this._render(); - this._updateSidebar(); - }, - - deleteAnnotation: function(index) { - if (index < 0 || index >= this.annotations.length) return; - this._pushUndo(); - this.annotations.splice(index, 1); - if (this.selectedIndex === index) this.selectedIndex = -1; - else if (this.selectedIndex > index) this.selectedIndex--; - this.dirty = true; - this._render(); - this._updateSidebar(); - }, - - relabelAnnotation: function(index, newLabel) { - if (index < 0 || index >= this.annotations.length) return; - this._pushUndo(); - this.annotations[index].label = newLabel; - this.annotations[index].confidence = null; - if (this.classLabels.indexOf(newLabel) === -1) { - this.classLabels.push(newLabel); - } - this.dirty = true; - this._render(); - this._updateSidebar(); - }, - - selectAnnotation: function(index) { - this.selectedIndex = index; - this._render(); - this._updateSidebar(); - }, - - // ---- Undo ---- - - _pushUndo: function() { - this.undoStack.push(JSON.parse(JSON.stringify(this.annotations))); - if (this.undoStack.length > this.maxUndo) { - this.undoStack.shift(); - } - }, - - undo: function() { - if (this.undoStack.length === 0) return; - this.annotations = this.undoStack.pop(); - this.selectedIndex = -1; - this.dirty = true; - this._render(); - this._updateSidebar(); - }, - - // ---- Keyboard Events ---- - - _bindKeyboardEvents: function() { - var self = this; - document.addEventListener('keydown', function(e) { - // Only handle when annotation panel is open - if (!document.getElementById('annotationPanel').classList.contains('open')) return; - - if (e.key === 'Delete' || e.key === 'Backspace') { - if (self.selectedIndex >= 0) { - e.preventDefault(); - self.deleteAnnotation(self.selectedIndex); - } - } else if (e.key === 'Escape') { - if (self.isDrawing) { - self.isDrawing = false; - self.drawStart = null; - self.drawCurrent = null; - self._render(); - } else { - self._hideLabelPicker(); - } - } else if (e.key === 'z' && (e.ctrlKey || e.metaKey)) { - e.preventDefault(); - self.undo(); - } - }); - }, - - // ---- Sidebar ---- - - _updateSidebar: function() { - if (!this.sidebarEl) return; - var self = this; - var html = ''; - - this.annotations.forEach(function(ann, i) { - var color = self._getColorForLabel(ann.label); - var selected = (i === self.selectedIndex) ? ' selected' : ''; - var confStr = (ann.confidence !== null && ann.confidence !== undefined) - ? ' ' + Math.round(ann.confidence * 100) + '%' - : ''; - - html += '
  • '; - html += ''; - html += '' + ann.label + ''; - html += confStr; - html += ''; - html += '
  • '; - }); - - this.sidebarEl.innerHTML = html; - - // Bind click events - $j(this.sidebarEl).find('.annotation-object-item').on('click', function() { - var idx = parseInt($j(this).data('index')); - self.selectAnnotation(idx); - }); - - $j(this.sidebarEl).find('.btn-remove').on('click', function(e) { - e.stopPropagation(); - var idx = parseInt($j(this).data('index')); - self.deleteAnnotation(idx); - }); - - // Update label dropdown for selected box - this._updateLabelDropdown(); - }, - - _updateLabelDropdown: function() { - var select = document.getElementById('annotationLabelSelect'); - if (!select) return; - - var self = this; - select.innerHTML = ''; - - this.classLabels.forEach(function(label) { - var opt = document.createElement('option'); - opt.value = label; - opt.textContent = label; - if (self.selectedIndex >= 0 && self.annotations[self.selectedIndex].label === label) { - opt.selected = true; - } - select.appendChild(opt); - }); - - select.disabled = (this.selectedIndex < 0); - }, - - // ---- Label Management ---- - - _loadLabels: function() { - var self = this; - $j.ajax({ - url: '?request=training&action=labels', - dataType: 'json', - success: function(data) { - if (data.labels) { - self.classLabels = data.labels; - // Also add any labels from current annotations that aren't in the list - self.annotations.forEach(function(ann) { - if (self.classLabels.indexOf(ann.label) === -1) { - self.classLabels.push(ann.label); - } - }); - self._updateSidebar(); - } - } - }); - }, - - // ---- Training Stats ---- - - _loadStats: function() { - var self = this; - $j.ajax({ - url: '?request=training&action=status', - dataType: 'json', - success: function(data) { - if (data.stats) { - self.trainingStats = data.stats; - self._renderStats(); - } - } - }); - }, - - _renderStats: function() { - if (!this.statsEl || !this.trainingStats) return; - var stats = this.trainingStats; - var t = this.translations; - var html = ''; - - html += '
    ' + - (t.TrainingDataStats || 'Training Data Statistics') + '
    '; - - html += '
    '; - html += '
    ' + (t.TotalAnnotatedImages || 'Total annotated images') + '
    '; - html += '
    ' + stats.total_images + '
    '; - html += '
    ' + (t.TotalClasses || 'Total classes') + '
    '; - html += '
    ' + stats.total_classes + '
    '; - html += '
    '; - - if (stats.images_per_class && Object.keys(stats.images_per_class).length > 0) { - html += '
    ' + - (t.ImagesPerClass || 'Images per class') + ':
    '; - for (var label in stats.images_per_class) { - var count = stats.images_per_class[label]; - html += '
    '; - html += '' + label + ''; - html += '' + count + ''; - html += '
    '; - } - } - - // Training readiness guidance - var minReady = 50; - var goodReady = 200; - var allReady = true; - var anyClass = false; - - if (stats.images_per_class) { - for (var label in stats.images_per_class) { - anyClass = true; - if (stats.images_per_class[label] < minReady) { - allReady = false; - } - } - } - - if (anyClass) { - var guidanceClass = allReady ? 'training-ready' : ''; - html += '
    '; - if (allReady) { - html += (t.TrainingGuidance || - 'Training is generally possible with at least 50-100 images per class. ' + - 'For best results, aim for 200+ images per class with varied angles and lighting conditions.'); - } else { - html += (t.TrainingGuidance || - 'Training is generally possible with at least 50-100 images per class. ' + - 'For best results, aim for 200+ images per class with varied angles and lighting conditions.'); - } - html += '
    '; - } - - this.statsEl.innerHTML = html; - }, - - // ---- Frame Selector ---- - - _updateFrameSelector: function(availableFrames) { - var self = this; - var selector = document.getElementById('annotationFrameSelector'); - if (!selector) return; - - // Update button states - availableFrames.forEach(function(frame) { - var btn = selector.querySelector('[data-frame="' + frame + '"]'); - if (btn) btn.style.display = ''; - }); - - // Highlight current frame - selector.querySelectorAll('.btn').forEach(function(btn) { - btn.classList.remove('btn-primary'); - btn.classList.add('btn-normal'); - if (btn.dataset.frame === String(self.currentFrameId)) { - btn.classList.remove('btn-normal'); - btn.classList.add('btn-primary'); - } - }); - }, - - // ---- Save ---- - - save: function() { - var self = this; - var annotationsData = this.annotations.map(function(ann) { - return { - label: ann.label, - x1: Math.round(ann.x1), - y1: Math.round(ann.y1), - x2: Math.round(ann.x2), - y2: Math.round(ann.y2) - }; - }); - - $j.ajax({ - url: '?request=training&action=save', - method: 'POST', - data: { - eid: self.eventId, - fid: self.currentFrameId, - annotations: JSON.stringify(annotationsData), - width: self.imageNaturalW, - height: self.imageNaturalH - }, - dataType: 'json', - success: function(data) { - if (data.result === 'Error') { - alert(data.message || 'Save failed'); - return; - } - self.dirty = false; - - // Update stats display - if (data.stats) { - self.trainingStats = data.stats; - self._renderStats(); - } - - // Show success feedback - var statusEl = document.getElementById('annotationStatus'); - if (statusEl) { - statusEl.textContent = self.translations.AnnotationSaved || 'Annotation saved to training set'; - setTimeout(function() { statusEl.textContent = ''; }, 3000); - } - }, - error: function() { - alert('Failed to save annotation'); - } - }); - } -}; -``` - -**Step 2: Verify no lint errors** - -```bash -npx eslint web/skins/classic/views/js/training.js -``` - -Fix any lint errors per Google JS style guide (ZM's ESLint config). - -**Step 3: Commit** - -```bash -git add web/skins/classic/views/js/training.js -git commit -m "feat: add Canvas-based annotation editor JavaScript - -Implements AnnotationEditor class with: -- Canvas rendering of bounding boxes with labels/colors -- Click-drag to draw new boxes with label picker dropdown -- Select, move, resize existing boxes with handles -- Delete boxes via sidebar or keyboard -- Undo support (Ctrl+Z, max 50 actions) -- AJAX save to YOLO format via training.php backend -- Training dataset statistics display with readiness guidance -- Frame navigation (alarm/snapshot/objdetect/any frame)" -``` - ---- - -## Task 7: Modify Event View (event.php + event.js.php) - -Add the Annotate button and annotation editor panel HTML to the event view. - -**Files:** -- Modify: `web/skins/classic/views/event.php:197-217` (toolbar) and after line 455 (panel) -- Modify: `web/skins/classic/views/js/event.js.php:112-122` (translations) - -**Step 1: Add Annotate button to toolbar** - -In `web/skins/classic/views/event.php`, after the Toggle Zones button (line 214), add: - -```php - - - - - -``` - -Note: Check if `cache_bust()` and `getSkinFile()` are available at that scope. If not, the CSS can be included from `functions.php` conditionally or loaded in the annotation panel div inline. - -**Step 3: Add annotation editor panel HTML** - -In `web/skins/classic/views/event.php`, after the EventData div (after line 455, before the closing `` and `} // end if Event exists`), add: - -```php -Id()) { ?> -
    -
    - : - - - - - - - -
    - -
    -
    - -
    - -
    -
    -
      - -
    -
    - -
    -
    -
    - -
    - - - - - -
    -
    - -``` - -**Step 4: Add training translations and init to event.js.php** - -In `web/skins/classic/views/js/event.js.php`, after the existing `translate` object (around line 122), add: - -```javascript - -var trainingTranslations = { - "Annotate": "", - "AnnotationSaved": "", - "AnnotationsRemoved": "", - "DeleteBox": "", - "DrawBox": "", - "GoToFrame": "", - "NewLabel": "", - "NoDetectionData": "", - "SaveToTrainingSet": "", - "SelectLabel": "", - "UnsavedAnnotations": "", - "TrainingDataStats": "", - "TotalAnnotatedImages": "", - "TotalClasses": "", - "ImagesPerClass": "", - "TrainingGuidance": "" -}; - -``` - -**Step 5: Add JS include and initialization to event.php** - -At the bottom of `event.php` (before the closing ``), add: - -```php - - - - -``` - -**Step 6: Verify PHP syntax and lint JS** - -```bash -php -l web/skins/classic/views/event.php -npx eslint web/skins/classic/views/js/training.js -npx eslint --ext .js.php web/skins/classic/views/js/event.js.php -``` - -**Step 7: Commit** - -```bash -git add web/skins/classic/views/event.php web/skins/classic/views/js/event.js.php -git commit -m "feat: integrate annotation editor into event view - -Adds Annotate button to event toolbar (visible when ZM_OPT_TRAINING -enabled). Opens inline annotation panel with canvas editor, object -sidebar with training statistics, frame navigation, and save/cancel -actions. All strings use translate() for i18n." -``` - ---- - -## Task 8: Manual Testing Checklist - -This task has no code to write. It is a testing checklist. - -**Step 1: Enable training in the database** - -```sql -UPDATE Config SET Value='1' WHERE Name='ZM_OPT_TRAINING'; -UPDATE Config SET Value='/tmp/zm_training_test' WHERE Name='ZM_TRAINING_DATA_DIR'; -``` - -Then restart ZM or reload the config. - -**Step 2: Verify Options screen** - -- [ ] Navigate to Options > Config tab -- [ ] Verify ZM_OPT_TRAINING toggle appears -- [ ] Enable it — verify ZM_TRAINING_DATA_DIR and ZM_TRAINING_LABELS appear -- [ ] Disable it — verify the other two options hide - -**Step 3: Verify Annotate button** - -- [ ] Navigate to an event that has `objdetect.jpg` and `objects.json` -- [ ] Verify the Annotate button appears in the toolbar (pencil-square icon) -- [ ] Click it — annotation panel opens below video -- [ ] Existing detection boxes load from objects.json -- [ ] Boxes displayed on canvas with labels and confidence percentages - -**Step 4: Test annotation interactions** - -- [ ] Click a box — it becomes selected (thicker border, handles appear) -- [ ] Drag a box — it moves -- [ ] Drag a handle — box resizes -- [ ] Click-drag on empty area — draws new box, label picker appears -- [ ] Select a label — box solidifies with color -- [ ] Type a new label — box gets new label -- [ ] Click X on sidebar item — box deleted -- [ ] Select box + Delete key — box deleted -- [ ] Ctrl+Z — undo works - -**Step 5: Test frame navigation** - -- [ ] Click alarm/snapshot/objdetect buttons — correct frames load -- [ ] Type a frame number + Go — loads that frame -- [ ] Prev/Next arrows work -- [ ] Switching with unsaved changes — confirmation dialog appears - -**Step 6: Test save** - -- [ ] Draw/edit some boxes, click Save -- [ ] Verify image copied to `{training_dir}/images/all/event_{eid}_frame_{fid}.jpg` -- [ ] Verify label file at `{training_dir}/labels/all/event_{eid}_frame_{fid}.txt` -- [ ] Verify YOLO format: `class_id cx cy w h` (all normalized 0-1) -- [ ] Verify `data.yaml` generated with correct class names -- [ ] Verify success message shows briefly - -**Step 7: Test training stats display** - -- [ ] After saving, sidebar shows training data statistics -- [ ] Total images count is correct -- [ ] Images per class counts are correct -- [ ] Training guidance text appears -- [ ] After 50+ images for all classes, guidance turns green - -**Step 8: Test with no detection data** - -- [ ] Navigate to an event without objects.json -- [ ] Annotate button still works -- [ ] Falls back to alarm frame -- [ ] Can draw boxes from scratch - -**Step 9: Verify cancel and close** - -- [ ] Click Cancel — panel closes -- [ ] Make changes, click Cancel — confirmation dialog -- [ ] Decline — stays open -- [ ] Accept — panel closes, changes discarded - -**Step 10: Verify pyzm compatibility** - -```bash -cd /tmp/zm_training_test -# Verify directory structure -ls -la images/all/ labels/all/ data.yaml - -# Verify data.yaml format -cat data.yaml - -# Verify label file format -cat labels/all/*.txt -``` - -The directory should be directly importable by pyzm's `local_import.py` or Roboflow. - ---- - -## Summary - -| Task | Description | Key Files | -|------|-------------|-----------| -| 1 | Config options in ConfigData.pm.in | `scripts/.../ConfigData.pm.in` | -| 2 | Database migration | `db/zm_update-1.39.2.sql` | -| 3 | i18n strings | `web/lang/en_gb.php`, `web/lang/en_us.php` | -| 4 | AJAX backend | `web/ajax/training.php` | -| 5 | CSS styles | `web/skins/classic/css/base/views/training.css` | -| 6 | JS annotation editor | `web/skins/classic/views/js/training.js` | -| 7 | Event view integration | `web/skins/classic/views/event.php`, `event.js.php` | -| 8 | Manual testing | No code — verification checklist | diff --git a/utils/deploy-training.sh b/utils/deploy-training.sh deleted file mode 100755 index 4392be9f1..000000000 --- a/utils/deploy-training.sh +++ /dev/null @@ -1,134 +0,0 @@ -#!/bin/bash -# -# Deploy/rollback the custom model training annotation feature to a local ZM install. -# Usage: -# sudo ./deploy-training.sh deploy # Copy files + run migration -# sudo ./deploy-training.sh rollback # Restore backed-up originals -# - -set -euo pipefail - -REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -ZM_WWW="/usr/share/zoneminder/www" -BACKUP_DIR="/tmp/zm-training-backup" -ZM_DB="zm" # ZoneMinder database name - -# Files to deploy (source relative to repo -> destination relative to ZM_WWW) -declare -A FILES=( - ["web/ajax/training.php"]="ajax/training.php" - ["web/skins/classic/css/base/views/training.css"]="skins/classic/css/base/views/training.css" - ["web/skins/classic/views/js/training.js"]="skins/classic/views/js/training.js" - ["web/skins/classic/views/event.php"]="skins/classic/views/event.php" - ["web/skins/classic/views/js/event.js.php"]="skins/classic/views/js/event.js.php" - ["web/lang/en_gb.php"]="lang/en_gb.php" -) - -deploy() { - echo "=== Deploying training annotation feature ===" - - # Create backup directory - mkdir -p "$BACKUP_DIR" - echo "Backing up originals to $BACKUP_DIR/" - - for src_rel in "${!FILES[@]}"; do - dst_rel="${FILES[$src_rel]}" - dst="$ZM_WWW/$dst_rel" - - if [ -f "$dst" ]; then - # Preserve directory structure in backup - backup_path="$BACKUP_DIR/$dst_rel" - mkdir -p "$(dirname "$backup_path")" - cp "$dst" "$backup_path" - echo " Backed up: $dst_rel" - fi - done - - # Copy new files - echo "" - echo "Copying files to $ZM_WWW/" - for src_rel in "${!FILES[@]}"; do - dst_rel="${FILES[$src_rel]}" - src="$REPO_ROOT/$src_rel" - dst="$ZM_WWW/$dst_rel" - - mkdir -p "$(dirname "$dst")" - cp "$src" "$dst" - echo " Deployed: $dst_rel" - done - - # Set ownership to match existing files - chown -R www-data:www-data "$ZM_WWW/ajax/training.php" - chown -R www-data:www-data "$ZM_WWW/skins/classic/css/base/views/training.css" - chown -R www-data:www-data "$ZM_WWW/skins/classic/views/js/training.js" - - # Run database migration - echo "" - echo "Running database migration..." - if mysql "$ZM_DB" < "$REPO_ROOT/db/zm_update-1.39.2.sql"; then - echo " Migration applied successfully." - else - echo " WARNING: Migration may have failed. Check output above." - fi - - echo "" - echo "=== Deploy complete ===" - echo "Next steps:" - echo " 1. Go to ZM Options > Config tab" - echo " 2. Enable 'ZM_OPT_TRAINING'" - echo " 3. Set ZM_TRAINING_DATA_DIR (or leave empty for default)" - echo " 4. Navigate to an event and click the Annotate button" - echo "" - echo "To rollback: sudo $0 rollback" -} - -rollback() { - echo "=== Rolling back training annotation feature ===" - - if [ ! -d "$BACKUP_DIR" ]; then - echo "ERROR: No backup found at $BACKUP_DIR" - exit 1 - fi - - # Restore backed-up files - for src_rel in "${!FILES[@]}"; do - dst_rel="${FILES[$src_rel]}" - backup_path="$BACKUP_DIR/$dst_rel" - dst="$ZM_WWW/$dst_rel" - - if [ -f "$backup_path" ]; then - cp "$backup_path" "$dst" - echo " Restored: $dst_rel" - else - # New file (no backup) — remove it - if [ -f "$dst" ]; then - rm "$dst" - echo " Removed: $dst_rel (new file)" - fi - fi - done - - # Remove Config entries from database - echo "" - echo "Removing Config entries from database..." - mysql "$ZM_DB" -e " - DELETE FROM Config WHERE Name IN ('ZM_OPT_TRAINING', 'ZM_TRAINING_DATA_DIR'); - " - echo " Config entries removed." - - echo "" - echo "=== Rollback complete ===" - echo "Reload ZM in your browser to verify." -} - -case "${1:-}" in - deploy) - deploy - ;; - rollback) - rollback - ;; - *) - echo "Usage: sudo $0 {deploy|rollback}" - exit 1 - ;; -esac diff --git a/version.txt b/version.txt index ebeef2f2d..0c11aad2f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.38.0 +1.39.1 diff --git a/web/ajax/modals/objdetect.php b/web/ajax/modals/objdetect.php index 0ebc795a8..9ddb93c3a 100644 --- a/web/ajax/modals/objdetect.php +++ b/web/ajax/modals/objdetect.php @@ -23,7 +23,7 @@ if ( !validInt($eid) ) { diff --git a/web/ajax/training.php b/web/ajax/training.php index 8ff4d3ed7..c5bf4cfa1 100644 --- a/web/ajax/training.php +++ b/web/ajax/training.php @@ -3,26 +3,24 @@ ini_set('display_errors', '0'); // Training features require the option to be enabled if (!defined('ZM_OPT_TRAINING') or !ZM_OPT_TRAINING) { + ZM\Warning('Training: access denied — ZM_OPT_TRAINING is not enabled'); ajaxError('Training features are not enabled'); return; } -if (!canEdit('Events')) { - ajaxError('Insufficient permissions'); - return; -} +$action = isset($_REQUEST['action']) ? $_REQUEST['action'] : ''; +ZM\Debug('Training: action='.validHtmlStr($action).(isset($_REQUEST['eid']) ? ' eid='.validHtmlStr($_REQUEST['eid']) : '')); require_once('includes/Event.php'); /** - * Get the training data directory path. - * Falls back to ZM_DIR_EVENTS/../training if ZM_TRAINING_DATA_DIR is empty. + * Get the training data directory path from config. */ function getTrainingDataDir() { if (defined('ZM_TRAINING_DATA_DIR') && ZM_TRAINING_DATA_DIR != '') { return ZM_TRAINING_DATA_DIR; } - return dirname(ZM_DIR_EVENTS) . '/training'; + return ''; } /** @@ -31,11 +29,17 @@ function getTrainingDataDir() { */ function ensureTrainingDirs() { $base = getTrainingDataDir(); + if ($base === '') { + ajaxError('ZM_TRAINING_DATA_DIR is not configured. Please set it in Options or run zmupdate.pl --freshen.'); + return false; + } $dirs = [$base, $base.'/images', $base.'/images/all', $base.'/labels', $base.'/labels/all']; foreach ($dirs as $dir) { if (!is_dir($dir)) { + ZM\Debug('Training: creating directory '.$dir); if (!mkdir($dir, 0755, true)) { - ajaxError('Failed to create directory: '.$dir); + ZM\Error('Training: failed to create directory '.$dir); + ajaxError('Failed to create training directory'); return false; } } @@ -43,12 +47,20 @@ function ensureTrainingDirs() { return true; } +/** + * Validate a frame ID: must be a known special name or a positive integer. + */ +function validFrameId($fid) { + return in_array($fid, ['alarm', 'snapshot']) || ctype_digit($fid); +} + /** * Get current class labels from data.yaml in the training directory. * Returns array of label strings ordered by class ID. */ function getClassLabels() { $base = getTrainingDataDir(); + if ($base === '') return []; $yamlFile = $base.'/data.yaml'; if (!file_exists($yamlFile)) return []; @@ -84,6 +96,7 @@ function writeDataYaml($labels) { $yaml .= " $i: $label\n"; } file_put_contents($base.'/data.yaml', $yaml); + ZM\Debug('Training: wrote data.yaml with '.count($labels).' classes: '.implode(', ', $labels)); } /** @@ -92,6 +105,7 @@ function writeDataYaml($labels) { */ function getTrainingStats() { $base = getTrainingDataDir(); + if ($base === '') return ['total_images' => 0, 'total_classes' => 0, 'images_per_class' => [], 'class_labels' => []]; $labelsDir = $base.'/labels/all'; $labels = getClassLabels(); $stats = [ @@ -141,10 +155,50 @@ function getTrainingStats() { return $stats; } -switch ($_REQUEST['action']) { +/** + * Recursively build a directory tree, with symlink and depth protection. + */ +function buildTree($dir, $base, $depth = 0) { + $maxDepth = 5; + $entries = []; + if ($depth > $maxDepth || !is_dir($dir)) return $entries; + $items = scandir($dir); + sort($items); + foreach ($items as $item) { + if ($item === '.' || $item === '..') continue; + $fullPath = $dir.'/'.$item; + // Skip symlinks + if (is_link($fullPath)) continue; + $relPath = ltrim(str_replace($base, '', $fullPath), '/'); + if (is_dir($fullPath)) { + $entries[] = [ + 'name' => $item, + 'path' => $relPath, + 'type' => 'dir', + 'children' => buildTree($fullPath, $base, $depth + 1), + ]; + } else if (is_file($fullPath)) { + $entries[] = [ + 'name' => $item, + 'path' => $relPath, + 'type' => 'file', + 'size' => filesize($fullPath), + ]; + } + } + return $entries; +} + +switch ($action) { + + // ---- Read-only actions (canView) ---- case 'load': // Load detection data for an event + if (!canView('Events')) { + ajaxError('Insufficient permissions'); + break; + } if (empty($_REQUEST['eid'])) { ajaxError('Event ID required'); break; @@ -160,9 +214,13 @@ switch ($_REQUEST['action']) { $objectsFile = $eventPath.'/objects.json'; $detectionData = null; + ZM\Debug('Training: load event '.$eid.' path='.$eventPath); if (file_exists($objectsFile)) { $json = file_get_contents($objectsFile); $detectionData = json_decode($json, true); + ZM\Debug('Training: found objects.json with '.(is_array($detectionData) ? count($detectionData) : 0).' entries'); + } else { + ZM\Debug('Training: no objects.json found at '.$objectsFile); } // Determine default frame @@ -186,8 +244,11 @@ switch ($_REQUEST['action']) { // Also check if we already have a saved annotation for this event $base = getTrainingDataDir(); $fid = $defaultFrameId ?: 'alarm'; - $savedFile = $base.'/labels/all/event_'.$eid.'_frame_'.$fid.'.txt'; - $hasSavedAnnotation = file_exists($savedFile); + $hasSavedAnnotation = false; + if ($base !== '') { + $savedFile = $base.'/labels/all/event_'.$eid.'_frame_'.$fid.'.txt'; + $hasSavedAnnotation = file_exists($savedFile); + } $hasDetectScript = defined('ZM_TRAINING_DETECT_SCRIPT') && ZM_TRAINING_DETECT_SCRIPT != ''; @@ -205,8 +266,96 @@ switch ($_REQUEST['action']) { ]); break; + case 'labels': + // Return current class label list + if (!canView('Events')) { + ajaxError('Insufficient permissions'); + break; + } + $labels = getClassLabels(); + ajaxResponse(['labels' => $labels]); + break; + + case 'status': + // Return training dataset statistics + if (!canView('Events')) { + ajaxError('Insufficient permissions'); + break; + } + ajaxResponse(['stats' => getTrainingStats()]); + break; + + case 'browse': + // Return recursive directory tree of training folder + if (!canView('Events')) { + ajaxError('Insufficient permissions'); + break; + } + $base = getTrainingDataDir(); + if ($base === '') { + ajaxResponse(['tree' => []]); + break; + } + + $tree = buildTree($base, $base); + + ajaxResponse([ + 'tree' => $tree, + ]); + break; + + case 'browse_file': + // Serve an individual file from the training directory + if (!canView('Events')) { + ajaxError('Insufficient permissions'); + break; + } + if (empty($_REQUEST['path'])) { + ajaxError('Path required'); + break; + } + + $base = getTrainingDataDir(); + if ($base === '') { + ajaxError('Training data directory not configured'); + break; + } + $reqPath = detaintPath($_REQUEST['path']); + $fullPath = realpath($base.'/'.$reqPath); + + // Validate file is within the training directory + if ($fullPath === false || strpos($fullPath, realpath($base)) !== 0 || !is_file($fullPath)) { + ZM\Warning('Training: browse_file path rejected: '.validHtmlStr($_REQUEST['path'])); + ajaxError('File not found or access denied'); + break; + } + + $ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); + + if (in_array($ext, ['jpg', 'jpeg', 'png'])) { + // Serve raw image + $mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png']; + header('Content-Type: '.$mimeMap[$ext]); + header('Content-Length: '.filesize($fullPath)); + header('Cache-Control: private, max-age=300'); + readfile($fullPath); + exit; + } else if (in_array($ext, ['txt', 'yaml', 'yml'])) { + // Return text content as JSON + ajaxResponse(['content' => file_get_contents($fullPath)]); + } else { + ajaxError('Unsupported file type'); + } + break; + + // ---- Write actions (canEdit) ---- + case 'save': // Save annotation (image + YOLO label file) + if (!canEdit('Events')) { + ajaxError('Insufficient permissions'); + break; + } if (empty($_REQUEST['eid']) || !isset($_REQUEST['fid'])) { ajaxError('Event ID and Frame ID required'); break; @@ -215,8 +364,7 @@ switch ($_REQUEST['action']) { $eid = validCardinal($_REQUEST['eid']); $fid = $_REQUEST['fid']; - // Validate fid is either a known special name or a positive integer - if (!in_array($fid, ['alarm', 'snapshot']) && !ctype_digit($fid)) { + if (!validFrameId($fid)) { ajaxError('Invalid frame ID'); break; } @@ -254,12 +402,15 @@ switch ($_REQUEST['action']) { } if (!file_exists($srcImage)) { - ajaxError('Source frame image not found: '.$fid); + ZM\Warning('Training: source image not found: '.$srcImage); + ajaxError('Source frame image not found'); break; } $dstImage = $base.'/images/all/'.$stem.'.jpg'; + ZM\Debug('Training: copying '.$srcImage.' to '.$dstImage); if (!copy($srcImage, $dstImage)) { + ZM\Error('Training: failed to copy image from '.$srcImage.' to '.$dstImage); ajaxError('Failed to copy image'); break; } @@ -300,7 +451,7 @@ switch ($_REQUEST['action']) { } $savedType = empty($annotations) ? 'background' : count($annotations).' annotation(s)'; - ZM\Info('Saved '.$savedType.' for training event '.$eid.' frame '.$fid); + ZM\Debug('Training: saved '.$savedType.' for event '.$eid.' frame '.$fid); $stats = getTrainingStats(); @@ -311,14 +462,12 @@ switch ($_REQUEST['action']) { ]); break; - case 'labels': - // Return current class label list - $labels = getClassLabels(); - ajaxResponse(['labels' => $labels]); - break; - case 'delete': // Remove a saved annotation + if (!canEdit('Events')) { + ajaxError('Insufficient permissions'); + break; + } if (empty($_REQUEST['eid']) || !isset($_REQUEST['fid'])) { ajaxError('Event ID and Frame ID required'); break; @@ -327,13 +476,16 @@ switch ($_REQUEST['action']) { $eid = validCardinal($_REQUEST['eid']); $fid = $_REQUEST['fid']; - // Validate fid - if (!in_array($fid, ['alarm', 'snapshot']) && !ctype_digit($fid)) { + if (!validFrameId($fid)) { ajaxError('Invalid frame ID'); break; } $base = getTrainingDataDir(); + if ($base === '') { + ajaxError('Training data directory not configured'); + break; + } $stem = 'event_'.$eid.'_frame_'.$fid; $imgFile = $base.'/images/all/'.$stem.'.jpg'; @@ -344,7 +496,7 @@ switch ($_REQUEST['action']) { if (file_exists($lblFile)) { unlink($lblFile); $deleted = true; } if ($deleted) { - ZM\Info('Removed training annotation for event '.$eid.' frame '.$fid); + ZM\Debug('Training: removed annotation for event '.$eid.' frame '.$fid); } ajaxResponse([ @@ -353,94 +505,61 @@ switch ($_REQUEST['action']) { ]); break; - case 'status': - // Return training dataset statistics - ajaxResponse(['stats' => getTrainingStats()]); - break; + case 'delete_all': + // Delete ALL training data (images, labels, data.yaml) + if (!canEdit('Events')) { + ajaxError('Insufficient permissions'); + break; + } + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + ajaxError('POST method required for destructive operations'); + break; + } - case 'browse': - // Return recursive directory tree of training folder $base = getTrainingDataDir(); - - function buildTree($dir, $base) { - $entries = []; - if (!is_dir($dir)) return $entries; - $items = scandir($dir); - sort($items); - foreach ($items as $item) { - if ($item === '.' || $item === '..') continue; - $fullPath = $dir.'/'.$item; - $relPath = ltrim(str_replace($base, '', $fullPath), '/'); - if (is_dir($fullPath)) { - $entries[] = [ - 'name' => $item, - 'path' => $relPath, - 'type' => 'dir', - 'children' => buildTree($fullPath, $base), - ]; - } else if (is_file($fullPath)) { - $entries[] = [ - 'name' => $item, - 'path' => $relPath, - 'type' => 'file', - 'size' => filesize($fullPath), - ]; + if ($base === '') { + ajaxError('Training data directory not configured'); + break; + } + $deleted = 0; + foreach (['images/all', 'labels/all'] as $sub) { + $dir = $base.'/'.$sub; + if (is_dir($dir)) { + foreach (glob($dir.'/*') as $file) { + if (is_file($file)) { unlink($file); $deleted++; } } } - return $entries; } + $yamlFile = $base.'/data.yaml'; + if (file_exists($yamlFile)) { unlink($yamlFile); $deleted++; } - $tree = buildTree($base, $base); - + ZM\Warning('Training: deleted ALL training data ('.$deleted.' files)'); ajaxResponse([ - 'base' => $base, - 'tree' => $tree, + 'deleted' => $deleted, + 'stats' => getTrainingStats(), ]); break; - case 'browse_file': - // Serve an individual file from the training directory - if (empty($_REQUEST['path'])) { - ajaxError('Path required'); - break; - } - - $base = getTrainingDataDir(); - $reqPath = detaintPath($_REQUEST['path']); - $fullPath = realpath($base.'/'.$reqPath); - - // Validate file is within the training directory - if ($fullPath === false || strpos($fullPath, realpath($base)) !== 0 || !is_file($fullPath)) { - ajaxError('File not found or access denied'); - break; - } - - $ext = strtolower(pathinfo($fullPath, PATHINFO_EXTENSION)); - - if (in_array($ext, ['jpg', 'jpeg', 'png'])) { - // Serve raw image - $mimeMap = ['jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png']; - header('Content-Type: '.$mimeMap[$ext]); - header('Content-Length: '.filesize($fullPath)); - header('Cache-Control: private, max-age=300'); - readfile($fullPath); - exit; - } else if (in_array($ext, ['txt', 'yaml', 'yml'])) { - // Return text content as JSON - ajaxResponse(['content' => file_get_contents($fullPath)]); - } else { - ajaxError('Unsupported file type: '.$ext); - } - break; - case 'browse_delete': // Delete an image/label pair by file path, then update data.yaml + if (!canEdit('Events')) { + ajaxError('Insufficient permissions'); + break; + } + if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + ajaxError('POST method required for destructive operations'); + break; + } if (empty($_REQUEST['path'])) { ajaxError('Path required'); break; } $base = getTrainingDataDir(); + if ($base === '') { + ajaxError('Training data directory not configured'); + break; + } $reqPath = detaintPath($_REQUEST['path']); $fullPath = realpath($base.'/'.$reqPath); @@ -455,6 +574,7 @@ switch ($_REQUEST['action']) { $lblFile = $base.'/labels/all/'.$stem.'.txt'; $deletedFiles = []; + ZM\Debug('Training: browse_delete stem='.$stem); if (file_exists($imgFile)) { unlink($imgFile); $deletedFiles[] = 'images/all/'.$stem.'.jpg'; } if (file_exists($lblFile)) { unlink($lblFile); $deletedFiles[] = 'labels/all/'.$stem.'.txt'; } @@ -472,7 +592,7 @@ switch ($_REQUEST['action']) { } } - // Build old→new class ID mapping, keeping only classes still in use + // Build old->new class ID mapping, keeping only classes still in use $oldLabels = getClassLabels(); $newLabels = []; $idMap = []; // oldId => newId @@ -516,38 +636,18 @@ switch ($_REQUEST['action']) { if (file_exists($yamlFile)) unlink($yamlFile); } - ZM\Info('Browse-deleted training files for stem '.$stem); - ajaxResponse([ 'deleted' => $deletedFiles, 'stats' => getTrainingStats(), ]); break; - case 'delete_all': - // Delete ALL training data (images, labels, data.yaml) - $base = getTrainingDataDir(); - $deleted = 0; - foreach (['images/all', 'labels/all'] as $sub) { - $dir = $base.'/'.$sub; - if (is_dir($dir)) { - foreach (glob($dir.'/*') as $file) { - if (is_file($file)) { unlink($file); $deleted++; } - } - } - } - $yamlFile = $base.'/data.yaml'; - if (file_exists($yamlFile)) { unlink($yamlFile); $deleted++; } - - ZM\Info('Deleted all training data ('.$deleted.' files)'); - ajaxResponse([ - 'deleted' => $deleted, - 'stats' => getTrainingStats(), - ]); - break; - case 'detect': // Run object detection script on a frame image + if (!canEdit('Events')) { + ajaxError('Insufficient permissions'); + break; + } if (!defined('ZM_TRAINING_DETECT_SCRIPT') || ZM_TRAINING_DETECT_SCRIPT == '') { ajaxError('No detection script configured'); break; @@ -559,7 +659,7 @@ switch ($_REQUEST['action']) { $eid = validCardinal($_REQUEST['eid']); $fid = $_REQUEST['fid']; - if (!in_array($fid, ['alarm', 'snapshot']) && !ctype_digit($fid)) { + if (!validFrameId($fid)) { ajaxError('Invalid frame ID'); break; } @@ -578,31 +678,34 @@ switch ($_REQUEST['action']) { } if (!file_exists($srcImage)) { - ajaxError('Source frame image not found: '.$fid); + ajaxError('Source frame image not found'); + break; + } + + $script = ZM_TRAINING_DETECT_SCRIPT; + if (!file_exists($script) || !is_executable($script)) { + ajaxError('Detection script not found or not executable'); break; } // Copy to temp file so the script can read it - $tmpFile = tempnam(sys_get_temp_dir(), 'zm_detect_'); - rename($tmpFile, $tmpFile.'.jpg'); - $tmpFile = $tmpFile.'.jpg'; - copy($srcImage, $tmpFile); - - $script = ZM_TRAINING_DETECT_SCRIPT; - if (!file_exists($script)) { - unlink($tmpFile); - ajaxError('Detection script not found: '.$script); + $tmpFile = tempnam(sys_get_temp_dir(), 'zm_detect_').'.jpg'; + if (!copy($srcImage, $tmpFile)) { + ajaxError('Failed to create temp file for detection'); break; } $monitorId = $Event->MonitorId(); $cmd = escapeshellarg($script).' -f '.escapeshellarg($tmpFile).' -m '.escapeshellarg($monitorId).' 2>&1'; + ZM\Debug('Training: running detect command: '.$cmd); exec($cmd, $outputLines, $exitCode); $output = implode("\n", $outputLines); - unlink($tmpFile); + if (file_exists($tmpFile)) unlink($tmpFile); + ZM\Debug('Training: detect script exit code='.$exitCode.' output length='.strlen($output)); if ($exitCode !== 0 && empty($output)) { - ajaxError('Detection script failed to execute (exit code '.$exitCode.')'); + ZM\Warning('Training: detect script failed with exit code '.$exitCode.' for event '.$eid.' frame '.$fid); + ajaxError('Detection script failed (exit code '.$exitCode.')'); break; } @@ -624,6 +727,7 @@ switch ($_REQUEST['action']) { } } + ZM\Debug('Training: detect found '.count($detections).' objects for event '.$eid.' frame '.$fid); ajaxResponse([ 'detections' => $detections, 'raw_output' => $output, @@ -631,7 +735,7 @@ switch ($_REQUEST['action']) { break; default: - ajaxError('Unknown action: '.$_REQUEST['action']); + ajaxError('Unknown action'); break; } ?> diff --git a/web/lang/en_gb.php b/web/lang/en_gb.php index 23bc0d7c3..fc08977ed 100644 --- a/web/lang/en_gb.php +++ b/web/lang/en_gb.php @@ -82,10 +82,6 @@ $SLANG = array( 'AnalysisFPS' => 'Analysis FPS', 'AnalysisUpdateDelay' => 'Analysis Update Delay', 'AcceptDetection' => 'Accept', - 'Annotate' => 'Annotate', - 'AnnotationEditor' => 'Annotation Editor', - 'AnnotationSaved' => 'Annotation saved to training set', - 'AnnotationsRemoved' => 'Annotation removed from training set', 'APIEnabled' => 'API Enabled', 'ApplyingStateChange' => 'Applying State Change', 'ArchArchived' => 'Archived Only', @@ -129,9 +125,6 @@ $SLANG = array( 'AutoStopTimeout' => 'Auto Stop Timeout', 'AvgBrScore' => 'Avg.
    Score', 'BackgroundFilter' => 'Run filter in background', - 'BackgroundImageConfirm' => 'No objects marked. Save as a background image (no objects)? Background images help the model learn to reduce false positives.', - 'BackgroundImages' => 'Background images (no objects)', - 'BrowseTrainingData' => 'Browse Training Data', 'BadAlarmFrameCount' => 'Alarm frame count must be an integer of one or more', 'BadAlarmMaxFPS' => 'Alarm Maximum FPS must be a positive integer or floating point value', 'BadAnalysisFPS' => 'Analysis FPS must be a positive integer or floating point value', @@ -257,17 +250,7 @@ $SLANG = array( 'RTSPDescribe' => 'Use RTSP Response Media URL', 'DeleteAndNext' => 'Delete & Next', 'DeleteAndPrev' => 'Delete & Prev', - 'DeleteAllTrainingData' => 'Delete All Training Data', - 'DeleteBox' => 'Delete Box', - 'SelectBoxFirst' => 'Select a box first', 'DeleteSavedFilter' => 'Delete saved filter', - 'Detect' => 'Detect', - 'DetectObjects' => 'Run object detection on current frame', - 'DetectRunning' => 'Running detection...', - 'DetectNoScript' => 'No detection script configured. Set ZM_TRAINING_DETECT_SCRIPT in Options.', - 'DetectFailed' => 'Detection failed', - 'DetectNoResults' => 'No objects detected in this frame.', - 'DetectedObjects' => 'object(s) detected — accept or reject each', 'DetectedCameras' => 'Detected Cameras', 'DetectedProfiles' => 'Detected Profiles', 'DeviceChannel' => 'Device Channel', @@ -492,6 +475,7 @@ $SLANG = array( 'ONVIF_EVENTS_PATH' => 'ONVIF Events Path', 'ONVIF_Options' => 'ONVIF Options', 'ONVIF_URL' => 'ONVIF URL', + 'ObjectTraining' => 'Object Training', 'OpBlank' => 'is blank', 'OpEq' => 'equal to', 'OpGtEq' => 'greater than or equal to', @@ -614,7 +598,6 @@ $SLANG = array( 'SaveAs' => 'Save as', 'SaveFilter' => 'Save Filter', 'SaveJPEGs' => 'Save JPEGs', - 'SaveToTrainingSet' => 'Save to Training Set', 'Scan this QR code with the zmNg app to add this profile' => 'Scan this QR code with the zmNg app to add this profile', 'Sectionlength' => 'Section length', 'SelectMonitors' => 'Select Monitors', @@ -669,26 +652,51 @@ $SLANG = array( 'TimestampLabelSize' => 'Font Size', 'TimeStamp' => 'Time Stamp', 'TooManyEventsForTimeline' => 'Too many events for Timeline. Reduce the number of monitors or reduce the visible range of the Timeline', - 'TotalAnnotatedImages' => 'Total annotated images', 'TotalBrScore' => 'Total
    Score', - 'TotalClasses' => 'Total classes', 'TrackDelay' => 'Track Delay', 'TrackMotion' => 'Track Motion', - 'TrainingDataStats' => 'Training Data Statistics', - 'FailedToLoadEvent' => 'Failed to load event data', - 'FailedToLoadFrame' => 'Failed to load frame image', - 'LoadFrameFirst' => 'Load a frame first', - 'NoFrameLoaded' => 'No frame loaded', - 'NoTrainingData' => 'No training data yet. Save annotations to build your dataset.', - 'SaveFailed' => 'Save failed', + 'TrainingBackgroundConfirm' => 'No objects marked. Save as a background image (no objects)? Background images help the model learn to reduce false positives.', + 'TrainingBackgroundImages' => 'Background images (no objects)', + 'TrainingBrowse' => 'Browse Training Data', + 'TrainingBrowseFrames' => 'Browse Frames', + 'TrainingConfirmDeleteFile' => 'Delete this file and its paired image/label?', 'TrainingDataDeleted' => 'All training data has been deleted.', + 'TrainingDataStats' => 'Training Data Statistics', + 'TrainingDeleteAll' => 'Delete All Training Data', + 'TrainingDeleteBox' => 'Delete Box', + 'TrainingDeleteFailed' => 'Failed to delete', + 'TrainingDetect' => 'Detect', + 'TrainingDetectFailed' => 'Detection failed', + 'TrainingDetectNoResults' => 'No objects detected in this frame.', + 'TrainingDetectNoScript' => 'No detection script configured. Set ZM_TRAINING_DETECT_SCRIPT in Options.', + 'TrainingDetectObjects' => 'Run object detection on current frame', + 'TrainingDetectRunning' => 'Running detection...', + 'TrainingDetectedObjects' => 'object(s) detected — accept or reject each', + 'TrainingFailedToLoadEvent' => 'Failed to load event data', + 'TrainingFailedToLoadFrame' => 'Failed to load frame image', 'TrainingGuidance' => 'Training is generally possible with at least 50-100 images per class. For best results, aim for 200+ images per class with varied angles and lighting conditions.', + 'TrainingLoadFrameFirst' => 'Load a frame first', + 'TrainingLoading' => 'Loading...', + 'TrainingNoData' => 'No training data yet. Save annotations to build your dataset.', + 'TrainingNoFiles' => 'No files', + 'TrainingNoFrameLoaded' => 'No frame loaded', + 'TrainingPreviewUnavailable' => 'Preview not available for this file type', + 'TrainingRemoved' => 'Training annotation removed', + 'TrainingSave' => 'Save for Training', + 'TrainingSaved' => 'Training annotation saved', + 'TrainingSaveFailed' => 'Save failed', + 'TrainingSaving' => 'Saving...', + 'TrainingSelectBoxFirst' => 'Select a box first', + 'TrainingSkipBack' => 'Back 10 frames', + 'TrainingSkipForward' => 'Forward 10 frames', + 'TrainingTotalClasses' => 'Total classes', + 'TrainingTotalImages' => 'Total annotated images', + 'TrainingUnsaved' => 'You have unsaved annotations. Discard changes?', 'TurboPanSpeed' => 'Turbo Pan Speed', 'TurboTiltSpeed' => 'Turbo Tilt Speed', 'TZUnset' => 'Unset - use value in php.ini', 'UpdateAvailable' => 'An update to ZoneMinder is available.', 'UpdateNotNecessary' => 'No update is necessary.', - 'UnsavedAnnotations' => 'You have unsaved annotations. Discard changes?', 'UsedPlugins' => 'Used Plugins', 'Username' => 'Username', 'UseFilterExprsPost' => ' filter expressions', // This is used at the end of the phrase 'use N filter expressions' diff --git a/web/skins/classic/css/base/views/training.css b/web/skins/classic/css/base/views/training.css index 278d321ec..b28a4cf02 100644 --- a/web/skins/classic/css/base/views/training.css +++ b/web/skins/classic/css/base/views/training.css @@ -12,6 +12,12 @@ display: block; } +/* Remove parent padding when training panel is open */ +.training-open-noPad { + padding-left: 0 !important; + padding-right: 0 !important; +} + /* Frame selector bar */ .annotation-frame-selector { display: flex; @@ -336,6 +342,14 @@ color: #212529; } +/* Frame indicator text between canvas and action buttons */ +.annotation-frame-info { + font-size: 0.75rem; + color: #6c757d; + padding: 4px 0 0 0; + text-align: left; +} + /* Bottom action bar */ .annotation-actions { display: flex; @@ -676,3 +690,96 @@ border-color: #28a745 !important; color: #fff !important; } + +/* Frame browse overlay */ +.frame-browse-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.6); + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; +} + +.frame-browse-panel { + background: #fff; + border-radius: 6px; + width: 90vw; + max-width: 900px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0,0,0,0.3); +} + +.frame-browse-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid #dee2e6; + font-weight: 600; + font-size: 0.95rem; +} + +.frame-browse-close { + background: none; + border: none; + font-size: 1.4rem; + cursor: pointer; + color: #6c757d; + padding: 0 4px; + line-height: 1; +} + +.frame-browse-close:hover { + color: #333; +} + +.frame-browse-grid { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 12px; + overflow-y: auto; + justify-content: flex-start; +} + +.frame-browse-cell { + width: 120px; + cursor: pointer; + border: 2px solid transparent; + border-radius: 4px; + overflow: hidden; + background: #f8f9fa; + transition: border-color 0.15s; +} + +.frame-browse-cell:hover { + border-color: #4363d8; +} + +.frame-browse-cell.active { + border-color: #e6194b; +} + +.frame-browse-cell img { + width: 100%; + height: auto; + display: block; +} + +.frame-browse-label { + text-align: center; + font-size: 0.75rem; + padding: 2px 0; + color: #495057; + background: #e9ecef; +} + +.frame-browse-pagination { + padding: 8px 12px; + border-top: 1px solid #dee2e6; + flex-shrink: 0; +} diff --git a/web/skins/classic/views/event.php b/web/skins/classic/views/event.php index d67fb9f94..bb7a47f0d 100644 --- a/web/skins/classic/views/event.php +++ b/web/skins/classic/views/event.php @@ -215,7 +215,7 @@ if ( $Event->Id() and !file_exists($Event->Path()) ) - + Id @@ -461,11 +461,13 @@ if ($video_tag) {
    : - + + - - + + +
    @@ -477,7 +479,11 @@ if ($video_tag) {
    -
    +
    + + + +
    @@ -487,10 +493,12 @@ if ($video_tag) {
    +
    +
    - - - + + +
    diff --git a/web/skins/classic/views/js/event.js.php b/web/skins/classic/views/js/event.js.php index db54c661b..6ed40262d 100644 --- a/web/skins/classic/views/js/event.js.php +++ b/web/skins/classic/views/js/event.js.php @@ -123,42 +123,51 @@ var translate = { var trainingTranslations = { - "Annotate": "", - "AnnotationSaved": "", - "AnnotationsRemoved": "", - "DeleteBox": "", + "ObjectTraining": "", + "TrainingBackgroundConfirm": "", + "TrainingBackgroundImages": "", + "TrainingBrowse": "", + "TrainingBrowseFrames": "", + "TrainingConfirmDeleteFile": "", + "TrainingDataDeleted": "", + "TrainingDataStats": "", + "TrainingDeleteAll": "", + "TrainingDeleteBox": "", + "TrainingDeleteFailed": "", + "TrainingDetect": "", + "TrainingDetectFailed": "", + "TrainingDetectNoResults": "", + "TrainingDetectNoScript": "", + "TrainingDetectObjects": "", + "TrainingDetectRunning": "", + "TrainingDetectedObjects": "", + "TrainingFailedToLoadEvent": "", + "TrainingFailedToLoadFrame": "", + "TrainingGuidance": "", + "TrainingLoadFrameFirst": "", + "TrainingLoading": "", + "TrainingNoData": "", + "TrainingNoFiles": "", + "TrainingNoFrameLoaded": "", + "TrainingPreviewUnavailable": "", + "TrainingRemoved": "", + "TrainingSave": "", + "TrainingSaved": "", + "TrainingSaveFailed": "", + "TrainingSaving": "", + "TrainingSelectBoxFirst": "", + "TrainingTotalClasses": "", + "TrainingTotalImages": "", + "TrainingUnsaved": "", + "AcceptDetection": "", + "ConfirmDeleteTrainingData": "", "DrawBox": "", + "Frame": "", "GoToFrame": "", + "ImagesPerClass": "", "NewLabel": "", "NoDetectionData": "", - "SaveToTrainingSet": "", - "SelectLabel": "", - "UnsavedAnnotations": "", - "TrainingDataStats": "", - "TotalAnnotatedImages": "", - "TotalClasses": "", - "ImagesPerClass": "", - "TrainingGuidance": "", - "Detect": "", - "DetectObjects": "", - "DetectRunning": "", - "DetectNoScript": "", - "DetectNoResults": "", - "DetectFailed": "", - "DetectedObjects": "", - "AcceptDetection": "", - "FailedToLoadEvent": "", - "FailedToLoadFrame": "", - "LoadFrameFirst": "", - "NoFrameLoaded": "", - "NoTrainingData": "", - "SaveFailed": "", - "BackgroundImageConfirm": "", - "BackgroundImages": "", - "ConfirmDeleteTrainingData": "", - "DeleteAllTrainingData": "", - "TrainingDataDeleted": "", - "BrowseTrainingData": "" + "SelectLabel": "" }; @@ -176,9 +185,6 @@ $j(document).ready(function initAnnotationEditor() { }); annotationEditor.init(); - // Make accessible globally for debugging - window.annotationEditor = annotationEditor; - $j('#annotateBtn').on('click', function() { var panel = document.getElementById('annotationPanel'); if (panel && panel.classList.contains('open')) { @@ -213,7 +219,7 @@ $j(document).ready(function initAnnotationEditor() { if (annotationEditor.selectedIndex >= 0) { annotationEditor.deleteAnnotation(annotationEditor.selectedIndex); } else { - annotationEditor._setStatus('', 'error'); + annotationEditor._setStatus('', 'error'); } }); @@ -229,16 +235,26 @@ $j(document).ready(function initAnnotationEditor() { // Frame navigation $j('#annotationFrameSelector').on('click', '[data-frame]', function() { var frame = $j(this).data('frame'); + var current = parseInt(annotationEditor.currentFrameId); + var total = annotationEditor.totalFrames; if (frame === 'prev') { - var current = parseInt(annotationEditor.currentFrameId); if (!isNaN(current) && current > 1) { annotationEditor.switchFrame(String(current - 1)); } } else if (frame === 'next') { - var current = parseInt(annotationEditor.currentFrameId); - if (!isNaN(current)) { + if (!isNaN(current) && current < total) { annotationEditor.switchFrame(String(current + 1)); - } else { + } else if (isNaN(current)) { + annotationEditor.switchFrame('1'); + } + } else if (frame === 'skip-back') { + if (!isNaN(current)) { + annotationEditor.switchFrame(String(Math.max(1, current - 10))); + } + } else if (frame === 'skip-forward') { + if (!isNaN(current) && total > 0) { + annotationEditor.switchFrame(String(Math.min(total, current + 10))); + } else if (isNaN(current)) { annotationEditor.switchFrame('1'); } } else { @@ -258,5 +274,9 @@ $j(document).ready(function initAnnotationEditor() { $j('#annotationGoToFrame').click(); } }); + + $j('#annotationBrowseFramesBtn').on('click', function() { + annotationEditor.browseFrames(); + }); }); diff --git a/web/skins/classic/views/js/training.js b/web/skins/classic/views/js/training.js index b91fa7d70..e7bd30b75 100644 --- a/web/skins/classic/views/js/training.js +++ b/web/skins/classic/views/js/training.js @@ -80,7 +80,7 @@ function AnnotationEditor(options) { AnnotationEditor.prototype.init = function() { this.canvas = document.getElementById(this.canvasId); if (!this.canvas) { - console.error('AnnotationEditor: canvas not found: ' + this.canvasId); + console.error('Training: canvas not found: ' + this.canvasId); return; } this.ctx = this.canvas.getContext('2d'); @@ -184,7 +184,7 @@ AnnotationEditor.prototype.open = function() { self._loadFrameImage(defaultFrame); }) .fail(function(jqxhr) { - self._setStatus(self.translations.FailedToLoadEvent || 'Failed to load event data', 'error'); + self._setStatus(self.translations.TrainingFailedToLoadEvent || 'Failed to load event data', 'error'); logAjaxFail(jqxhr); }); }; @@ -194,7 +194,7 @@ AnnotationEditor.prototype.open = function() { */ AnnotationEditor.prototype.close = function() { if (this.dirty) { - var msg = this.translations.UnsavedAnnotations || + var msg = this.translations.TrainingUnsaved || 'You have unsaved annotations. Discard changes?'; if (!confirm(msg)) return; } @@ -278,6 +278,7 @@ AnnotationEditor.prototype._loadDetectionData = function(data) { AnnotationEditor.prototype._loadFrameImage = function(frameId) { var self = this; this.currentFrameId = frameId; + this._updateFrameInfo(); var img = new Image(); img.crossOrigin = 'anonymous'; @@ -293,19 +294,46 @@ AnnotationEditor.prototype._loadFrameImage = function(frameId) { self._render(); }; img.onerror = function() { - self._setStatus(self.translations.FailedToLoadFrame || 'Failed to load frame image', 'error'); + // If numeric frame exceeds total, clamp to max and retry + var num = parseInt(frameId, 10); + if (!isNaN(num) && self.totalFrames > 0 && num > self.totalFrames) { + self._setStatus(self.translations.TrainingFailedToLoadFrame || 'Failed to load frame image', 'error'); + self.switchFrame(String(self.totalFrames)); + return; + } + self._setStatus(self.translations.TrainingFailedToLoadFrame || 'Failed to load frame image', 'error'); }; img.src = thisUrl + '?view=image&eid=' + this.eventId + '&fid=' + frameId; }; +/** + * Update the "Frame: X of Y" indicator below the canvas. + */ +AnnotationEditor.prototype._updateFrameInfo = function() { + var el = $j('#annotationFrameInfo'); + if (!el.length) return; + + var frameLabel = this.currentFrameId; + var num = parseInt(frameLabel, 10); + if (!isNaN(num)) { + frameLabel = String(num); + } + if (this.totalFrames > 0) { + el.text((this.translations.Frame || 'Frame') + ': ' + + frameLabel + ' / ' + this.totalFrames); + } else { + el.text((this.translations.Frame || 'Frame') + ': ' + frameLabel); + } +}; + /** * Switch to a different frame. Prompts if dirty. * @param {string|number} frameId */ AnnotationEditor.prototype.switchFrame = function(frameId) { if (this.dirty) { - var msg = this.translations.UnsavedAnnotations || + var msg = this.translations.TrainingUnsaved || 'You have unsaved annotations. Discard changes?'; if (!confirm(msg)) return; } @@ -944,7 +972,7 @@ AnnotationEditor.prototype.deleteAnnotation = function(index) { var resp = data.response || data; self.dirty = false; self._setStatus( - self.translations.AnnotationsRemoved || 'Annotation removed from training set', + self.translations.TrainingRemoved || 'Training annotation removed', 'success' ); if (resp.stats) { @@ -1000,19 +1028,19 @@ AnnotationEditor.prototype.detect = function() { if (!this.hasDetectScript) { this._setStatus( - this.translations.DetectNoScript || 'No detection script configured', + this.translations.TrainingDetectNoScript || 'No detection script configured', 'error' ); return; } if (!this.currentFrameId) { - this._setStatus(this.translations.LoadFrameFirst || 'Load a frame first', 'error'); + this._setStatus(this.translations.TrainingLoadFrameFirst || 'Load a frame first', 'error'); return; } this._setStatus( - this.translations.DetectRunning || 'Running detection...', + this.translations.TrainingDetectRunning || 'Running detection...', 'saving' ); @@ -1039,7 +1067,7 @@ AnnotationEditor.prototype.detect = function() { if (detections.length === 0) { self._setStatus( - self.translations.DetectNoResults || 'No objects detected' + self.translations.TrainingDetectNoResults || 'No objects detected' ); return; } @@ -1079,12 +1107,12 @@ AnnotationEditor.prototype.detect = function() { self._updateSidebar(); self._render(); self._setStatus( - detections.length + ' ' + (self.translations.DetectedObjects || 'object(s) detected — accept or reject each'), + detections.length + ' ' + (self.translations.TrainingDetectedObjects || 'object(s) detected — accept or reject each'), 'success' ); }).fail(function(jqxhr) { $j('#annotationDetectBtn').prop('disabled', false); - self._setStatus(self.translations.DetectFailed || 'Detection failed', 'error'); + self._setStatus(self.translations.TrainingDetectFailed || 'Detection failed', 'error'); logAjaxFail(jqxhr); }); }; @@ -1153,8 +1181,6 @@ AnnotationEditor.prototype._updateSidebar = function() { for (var i = 0; i < this.annotations.length; i++) { var ann = this.annotations[i]; - var colorIndex = this._getColorIndex(ann.label); - var color = ANNOTATION_COLORS[colorIndex % ANNOTATION_COLORS.length]; var isSelected = (i === this.selectedIndex); var li = $j('
  • ') @@ -1191,7 +1217,7 @@ AnnotationEditor.prototype._updateSidebar = function() { var removeBtn = $j(''); + closeBtn.on('click', function() { cleanup(); }); + header.append(closeBtn); + panel.append(header); + + // Grid container + var grid = $j('
    '); + + // Pagination container + var paginationWrap = $j('