';
-
- 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) {