fix: code review fixes, version bump to 1.39.1, remove non-repo files

- Rename migration to zm_update-1.39.1.sql, add DEALLOCATE PREPARE,
  fix Requires format to 'ZM_OPT_TRAINING=1'
- Bump version.txt to 1.39.1
- Remove docs/plans/ and utils/deploy-training.sh from repo
- training.php: remove PHP fallback path, add validFrameId() helper,
  move buildTree() to top-level with symlink skip and depth limit,
  split canView/canEdit permissions, require POST for destructive ops,
  remove path disclosure, sanitize log output, add is_executable check
- training.js: remove console.log calls, fix _setStatus boolean to
  string type, fix double undo push, switch delete ops to POST,
  replace hardcoded English strings with translation keys
- event.js.php: remove window.annotationEditor global, add new
  translation keys to trainingTranslations
- event.php: add frame skip and browse buttons, break up long HTML
- en_gb.php: add missing translation keys in alphabetical order
- training.css: add frame browse overlay and pagination styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
pull/4667/head
Pliable Pixels 2026-03-01 10:25:46 -05:00
parent 948667c7d6
commit eaaa8f4016
12 changed files with 725 additions and 2937 deletions

View File

@ -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;

View File

@ -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: `<?php echo translate('Annotate') ?>`
- 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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -1 +1 @@
1.38.0
1.39.1

View File

@ -23,7 +23,7 @@ if ( !validInt($eid) ) {
</div>
<div class="modal-footer">
<?php if (defined('ZM_OPT_TRAINING') and ZM_OPT_TRAINING) { ?>
<a href="?view=event&eid=<?php echo $eid ?>&annotate=1" class="btn btn-primary mr-auto"><i class="fa fa-crosshairs"></i> <?php echo translate('Annotate') ?></a>
<a href="?view=event&eid=<?php echo $eid ?>&annotate=1" class="btn btn-primary mr-auto"><i class="fa fa-crosshairs"></i> <?php echo translate('ObjectTraining') ?></a>
<?php } ?>
<button type="button" class="btn btn-secondary" data-dismiss="modal">Close</button>
</div>

View File

@ -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 oldnew 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;
}
?>

View File

@ -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.<br/>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 &amp; Next',
'DeleteAndPrev' => 'Delete &amp; 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<br/>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' => '&nbsp;filter&nbsp;expressions', // This is used at the end of the phrase 'use N filter expressions'

View File

@ -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;
}

View File

@ -215,7 +215,7 @@ if ( $Event->Id() and !file_exists($Event->Path()) )
<?php
}
if (defined('ZM_OPT_TRAINING') and ZM_OPT_TRAINING) { ?>
<button id="annotateBtn" class="btn btn-normal" data-toggle="tooltip" data-placement="top" title="<?php echo translate('Annotate') ?>"><i class="fa fa-crosshairs"></i></button>
<button id="annotateBtn" class="btn btn-normal" data-toggle="tooltip" data-placement="top" title="<?php echo translate('ObjectTraining') ?>"><i class="fa fa-crosshairs"></i></button>
<?php
}
} // end if Event->Id
@ -461,11 +461,13 @@ if ($video_tag) {
<div id="annotationPanel">
<div id="annotationFrameSelector" class="annotation-frame-selector">
<span><strong><?php echo translate('Frame') ?>:</strong></span>
<button class="btn btn-normal btn-sm" data-frame="prev" title="<?php echo translate('PreviousFrame') ?>"><i class="fa fa-chevron-left"></i></button>
<button class="btn btn-normal btn-sm" data-frame="skip-back" title="<?php echo translate('TrainingSkipBack') ?>"><i class="fa fa-backward"></i></button>
<button class="btn btn-normal btn-sm" data-frame="prev" title="<?php echo translate('PreviousFrame') ?>"><i class="fa fa-caret-left fa-lg"></i></button>
<button class="btn btn-normal btn-sm frame-btn" data-frame="alarm" style="display:none"><?php echo translate('Alarm') ?></button>
<button class="btn btn-normal btn-sm frame-btn" data-frame="snapshot" style="display:none"><?php echo translate('Snapshot') ?></button>
<button class="btn btn-normal btn-sm" data-frame="next" title="<?php echo translate('NextFrame') ?>"><i class="fa fa-chevron-right"></i></button>
<button class="btn btn-normal btn-sm" data-frame="next" title="<?php echo translate('NextFrame') ?>"><i class="fa fa-caret-right fa-lg"></i></button>
<button class="btn btn-normal btn-sm" data-frame="skip-forward" title="<?php echo translate('TrainingSkipForward') ?>"><i class="fa fa-forward"></i></button>
<button class="btn btn-normal btn-sm" id="annotationBrowseFramesBtn" title="<?php echo translate('TrainingBrowseFrames') ?>"><i class="fa fa-th"></i></button>
<input type="number" class="form-control form-control-sm frame-input" id="annotationFrameInput" min="1" max="<?php echo $Event->Frames() ?>" placeholder="#" title="<?php echo translate('GoToFrame') ?>"/>
<button class="btn btn-normal btn-sm" id="annotationGoToFrame"><?php echo translate('GoToFrame') ?></button>
</div>
@ -477,7 +479,11 @@ if ($video_tag) {
<div class="annotation-sidebar-wrap">
<div class="annotation-sidebar">
<div class="annotation-sidebar-header"><button id="annotationDeleteAllBtn" class="btn-delete-all" title="<?php echo translate('DeleteAllTrainingData') ?>" style="display:none"><i class="fa fa-trash"></i></button><span><?php echo translate('Objects') ?></span><button id="annotationBrowseBtn" class="btn-browse" title="<?php echo translate('BrowseTrainingData') ?>"><i class="fa fa-folder-open"></i></button></div>
<div class="annotation-sidebar-header">
<button id="annotationDeleteAllBtn" class="btn-delete-all" style="display:none" title="<?php echo translate('TrainingDeleteAll') ?>"><i class="fa fa-trash"></i></button>
<span><?php echo translate('Objects') ?></span>
<button id="annotationBrowseBtn" class="btn-browse" title="<?php echo translate('TrainingBrowse') ?>"><i class="fa fa-folder-open"></i></button>
</div>
<ul id="annotationObjectList" class="annotation-object-list">
</ul>
<div id="annotationStats" class="annotation-stats">
@ -487,10 +493,12 @@ if ($video_tag) {
</div>
</div>
<div id="annotationFrameInfo" class="annotation-frame-info"></div>
<div class="annotation-actions">
<button id="annotationDetectBtn" class="btn btn-warning btn-sm" style="display:none" title="<?php echo translate('DetectObjects') ?>"><i class="fa fa-search"></i> <?php echo translate('Detect') ?></button>
<button id="annotationDeleteBtn" class="btn btn-danger btn-sm"><?php echo translate('DeleteBox') ?></button>
<button id="annotationSaveBtn" class="btn btn-success btn-sm"><?php echo translate('SaveToTrainingSet') ?></button>
<button id="annotationDetectBtn" class="btn btn-warning btn-sm" style="display:none" title="<?php echo translate('TrainingDetectObjects') ?>"><i class="fa fa-search"></i> <?php echo translate('TrainingDetect') ?></button>
<button id="annotationDeleteBtn" class="btn btn-danger btn-sm"><?php echo translate('TrainingDeleteBox') ?></button>
<button id="annotationSaveBtn" class="btn btn-success btn-sm"><?php echo translate('TrainingSave') ?></button>
<button id="annotationCancelBtn" class="btn btn-normal btn-sm"><?php echo translate('Exit') ?></button>
</div>
</div>

View File

@ -123,42 +123,51 @@ var translate = {
<?php if (defined('ZM_OPT_TRAINING') and ZM_OPT_TRAINING) { ?>
var trainingTranslations = {
"Annotate": "<?php echo translate('Annotate') ?>",
"AnnotationSaved": "<?php echo translate('AnnotationSaved') ?>",
"AnnotationsRemoved": "<?php echo translate('AnnotationsRemoved') ?>",
"DeleteBox": "<?php echo translate('DeleteBox') ?>",
"ObjectTraining": "<?php echo translate('ObjectTraining') ?>",
"TrainingBackgroundConfirm": "<?php echo translate('TrainingBackgroundConfirm') ?>",
"TrainingBackgroundImages": "<?php echo translate('TrainingBackgroundImages') ?>",
"TrainingBrowse": "<?php echo translate('TrainingBrowse') ?>",
"TrainingBrowseFrames": "<?php echo translate('TrainingBrowseFrames') ?>",
"TrainingConfirmDeleteFile": "<?php echo translate('TrainingConfirmDeleteFile') ?>",
"TrainingDataDeleted": "<?php echo translate('TrainingDataDeleted') ?>",
"TrainingDataStats": "<?php echo translate('TrainingDataStats') ?>",
"TrainingDeleteAll": "<?php echo translate('TrainingDeleteAll') ?>",
"TrainingDeleteBox": "<?php echo translate('TrainingDeleteBox') ?>",
"TrainingDeleteFailed": "<?php echo translate('TrainingDeleteFailed') ?>",
"TrainingDetect": "<?php echo translate('TrainingDetect') ?>",
"TrainingDetectFailed": "<?php echo translate('TrainingDetectFailed') ?>",
"TrainingDetectNoResults": "<?php echo translate('TrainingDetectNoResults') ?>",
"TrainingDetectNoScript": "<?php echo translate('TrainingDetectNoScript') ?>",
"TrainingDetectObjects": "<?php echo translate('TrainingDetectObjects') ?>",
"TrainingDetectRunning": "<?php echo translate('TrainingDetectRunning') ?>",
"TrainingDetectedObjects": "<?php echo translate('TrainingDetectedObjects') ?>",
"TrainingFailedToLoadEvent": "<?php echo translate('TrainingFailedToLoadEvent') ?>",
"TrainingFailedToLoadFrame": "<?php echo translate('TrainingFailedToLoadFrame') ?>",
"TrainingGuidance": "<?php echo translate('TrainingGuidance') ?>",
"TrainingLoadFrameFirst": "<?php echo translate('TrainingLoadFrameFirst') ?>",
"TrainingLoading": "<?php echo translate('TrainingLoading') ?>",
"TrainingNoData": "<?php echo translate('TrainingNoData') ?>",
"TrainingNoFiles": "<?php echo translate('TrainingNoFiles') ?>",
"TrainingNoFrameLoaded": "<?php echo translate('TrainingNoFrameLoaded') ?>",
"TrainingPreviewUnavailable": "<?php echo translate('TrainingPreviewUnavailable') ?>",
"TrainingRemoved": "<?php echo translate('TrainingRemoved') ?>",
"TrainingSave": "<?php echo translate('TrainingSave') ?>",
"TrainingSaved": "<?php echo translate('TrainingSaved') ?>",
"TrainingSaveFailed": "<?php echo translate('TrainingSaveFailed') ?>",
"TrainingSaving": "<?php echo translate('TrainingSaving') ?>",
"TrainingSelectBoxFirst": "<?php echo translate('TrainingSelectBoxFirst') ?>",
"TrainingTotalClasses": "<?php echo translate('TrainingTotalClasses') ?>",
"TrainingTotalImages": "<?php echo translate('TrainingTotalImages') ?>",
"TrainingUnsaved": "<?php echo translate('TrainingUnsaved') ?>",
"AcceptDetection": "<?php echo translate('AcceptDetection') ?>",
"ConfirmDeleteTrainingData": "<?php echo translate('ConfirmDeleteTrainingData') ?>",
"DrawBox": "<?php echo translate('DrawBox') ?>",
"Frame": "<?php echo translate('Frame') ?>",
"GoToFrame": "<?php echo translate('GoToFrame') ?>",
"ImagesPerClass": "<?php echo translate('ImagesPerClass') ?>",
"NewLabel": "<?php echo translate('NewLabel') ?>",
"NoDetectionData": "<?php echo translate('NoDetectionData') ?>",
"SaveToTrainingSet": "<?php echo translate('SaveToTrainingSet') ?>",
"SelectLabel": "<?php echo translate('SelectLabel') ?>",
"UnsavedAnnotations": "<?php echo translate('UnsavedAnnotations') ?>",
"TrainingDataStats": "<?php echo translate('TrainingDataStats') ?>",
"TotalAnnotatedImages": "<?php echo translate('TotalAnnotatedImages') ?>",
"TotalClasses": "<?php echo translate('TotalClasses') ?>",
"ImagesPerClass": "<?php echo translate('ImagesPerClass') ?>",
"TrainingGuidance": "<?php echo translate('TrainingGuidance') ?>",
"Detect": "<?php echo translate('Detect') ?>",
"DetectObjects": "<?php echo translate('DetectObjects') ?>",
"DetectRunning": "<?php echo translate('DetectRunning') ?>",
"DetectNoScript": "<?php echo translate('DetectNoScript') ?>",
"DetectNoResults": "<?php echo translate('DetectNoResults') ?>",
"DetectFailed": "<?php echo translate('DetectFailed') ?>",
"DetectedObjects": "<?php echo translate('DetectedObjects') ?>",
"AcceptDetection": "<?php echo translate('AcceptDetection') ?>",
"FailedToLoadEvent": "<?php echo translate('FailedToLoadEvent') ?>",
"FailedToLoadFrame": "<?php echo translate('FailedToLoadFrame') ?>",
"LoadFrameFirst": "<?php echo translate('LoadFrameFirst') ?>",
"NoFrameLoaded": "<?php echo translate('NoFrameLoaded') ?>",
"NoTrainingData": "<?php echo translate('NoTrainingData') ?>",
"SaveFailed": "<?php echo translate('SaveFailed') ?>",
"BackgroundImageConfirm": "<?php echo translate('BackgroundImageConfirm') ?>",
"BackgroundImages": "<?php echo translate('BackgroundImages') ?>",
"ConfirmDeleteTrainingData": "<?php echo translate('ConfirmDeleteTrainingData') ?>",
"DeleteAllTrainingData": "<?php echo translate('DeleteAllTrainingData') ?>",
"TrainingDataDeleted": "<?php echo translate('TrainingDataDeleted') ?>",
"BrowseTrainingData": "<?php echo translate('BrowseTrainingData') ?>"
"SelectLabel": "<?php echo translate('SelectLabel') ?>"
};
<?php } ?>
@ -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('<?php echo translate('SelectBoxFirst') ?>', 'error');
annotationEditor._setStatus('<?php echo translate('TrainingSelectBoxFirst') ?>', '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();
});
});
<?php } ?>

View File

@ -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('<li>')
@ -1191,7 +1217,7 @@ AnnotationEditor.prototype._updateSidebar = function() {
var removeBtn = $j('<button>')
.addClass('btn-remove')
.attr('title', self.translations.DeleteBox || 'Delete')
.attr('title', self.translations.TrainingDeleteBox || 'Delete')
.html('&times;')
.attr('data-index', i);
@ -1215,7 +1241,6 @@ AnnotationEditor.prototype._updateSidebar = function() {
list.find('.btn-remove').on('click', function(e) {
e.stopPropagation();
var idx = parseInt($j(this).attr('data-index'), 10);
self._pushUndo();
self.deleteAnnotation(idx);
});
};
@ -1299,9 +1324,9 @@ AnnotationEditor.prototype._renderStats = function() {
.append($j('<span>').addClass('stat-value').text(value));
};
container.append(row(t.TotalAnnotatedImages || 'Annotated images', stats.total_images));
container.append(row(t.TrainingTotalImages || 'Annotated images', stats.total_images));
if (stats.background_images > 0) {
container.append(row(t.BackgroundImages || 'Background images', stats.background_images));
container.append(row(t.TrainingBackgroundImages || 'Background images', stats.background_images));
}
// Per-class image counts
@ -1322,7 +1347,7 @@ AnnotationEditor.prototype._renderStats = function() {
guidance.text(t.TrainingGuidance || 'Aim for 50-100+ images per class.');
container.append(guidance);
} else {
container.append($j('<div>').css({'color': '#6c757d', 'padding': '4px 0'}).text(t.NoTrainingData || 'No training data yet.'));
container.append($j('<div>').css({'color': '#6c757d', 'padding': '4px 0'}).text(t.TrainingNoData || 'No training data yet.'));
}
};
@ -1332,7 +1357,7 @@ AnnotationEditor.prototype._deleteAllTrainingData = function() {
var answer = prompt(t.ConfirmDeleteTrainingData || 'This will permanently delete ALL training data. Type "agree" to confirm:');
if (answer !== 'agree') return;
$j.getJSON(thisUrl + '?request=training&action=delete_all')
$j.ajax({url: thisUrl + '?request=training&action=delete_all', method: 'POST', dataType: 'json'})
.done(function(resp) {
if (resp.result === 'Ok') {
self.trainingStats = resp.stats || {};
@ -1340,18 +1365,186 @@ AnnotationEditor.prototype._deleteAllTrainingData = function() {
self._renderStats();
self._setStatus(t.TrainingDataDeleted || 'All training data deleted.');
} else {
self._setStatus(resp.message || 'Delete failed', true);
self._setStatus(resp.message || 'Delete failed', 'error');
}
})
.fail(function() {
self._setStatus(t.SaveFailed || 'Request failed', true);
self._setStatus(t.TrainingSaveFailed || 'Request failed', 'error');
});
};
/**
* Open an overlay showing thumbnail grid of all event frames with pagination.
* Click a thumbnail to switch to that frame.
*/
AnnotationEditor.prototype.browseFrames = function() {
var self = this;
var total = this.totalFrames;
if (!total || total <= 0) {
this._setStatus(this.translations.TrainingLoadFrameFirst || 'Load a frame first', 'error');
return;
}
// Remove any existing overlay
$j('#frameBrowseOverlay').remove();
var thumbWidth = 160;
var perPage = 50;
var totalPages = Math.ceil(total / perPage);
// Start on the page containing the current frame
var curNum = parseInt(this.currentFrameId, 10);
var currentPage = (!isNaN(curNum) && curNum > 0)
? Math.ceil(curNum / perPage) : 1;
var overlay = $j('<div id="frameBrowseOverlay" class="frame-browse-overlay">');
var panel = $j('<div class="frame-browse-panel">');
// Header
var header = $j('<div class="frame-browse-header">');
header.append($j('<span>').text(
(this.translations.TrainingBrowseFrames || 'Browse Frames') +
' (' + total + ')'));
var closeBtn = $j('<button class="frame-browse-close">&times;</button>');
closeBtn.on('click', function() { cleanup(); });
header.append(closeBtn);
panel.append(header);
// Grid container
var grid = $j('<div class="frame-browse-grid">');
// Pagination container
var paginationWrap = $j('<nav class="frame-browse-pagination">');
var paginationUl = $j('<ul class="pagination pagination-sm justify-content-center mb-0">');
paginationWrap.append(paginationUl);
function renderPage(page) {
currentPage = page;
grid.empty();
var start = (page - 1) * perPage + 1;
var end = Math.min(page * perPage, total);
for (var i = start; i <= end; i++) {
(function(fid) {
var cell = $j('<div class="frame-browse-cell">');
var img = $j('<img>')
.attr('loading', 'lazy')
.attr('src', thisUrl + '?view=image&eid=' + self.eventId +
'&fid=' + fid + '&width=' + thumbWidth)
.attr('alt', 'Frame ' + fid);
var label = $j('<div class="frame-browse-label">').text(fid);
if (String(fid) === String(self.currentFrameId)) {
cell.addClass('active');
}
cell.on('click', function() {
cleanup();
self.switchFrame(String(fid));
});
cell.append(img).append(label);
grid.append(cell);
})(i);
}
// Scroll grid to top
grid.scrollTop(0);
// Rebuild pagination
renderPagination(page);
}
function renderPagination(page) {
paginationUl.empty();
// Prev
var prevLi = $j('<li class="page-item">').toggleClass('disabled', page <= 1);
prevLi.append($j('<a class="page-link" href="#">&laquo;</a>')
.on('click', function(e) {
e.preventDefault();
if (page > 1) renderPage(page - 1);
}));
paginationUl.append(prevLi);
// Determine which page numbers to show
var pages = buildPageNumbers(page, totalPages);
for (var p = 0; p < pages.length; p++) {
var val = pages[p];
if (val === '...') {
paginationUl.append(
$j('<li class="page-item disabled">')
.append($j('<span class="page-link">').text('...'))
);
} else {
(function(num) {
var li = $j('<li class="page-item">').toggleClass('active', num === page);
li.append($j('<a class="page-link" href="#">').text(num)
.on('click', function(e) {
e.preventDefault();
renderPage(num);
}));
paginationUl.append(li);
})(val);
}
}
// Next
var nextLi = $j('<li class="page-item">')
.toggleClass('disabled', page >= totalPages);
nextLi.append($j('<a class="page-link" href="#">&raquo;</a>')
.on('click', function(e) {
e.preventDefault();
if (page < totalPages) renderPage(page + 1);
}));
paginationUl.append(nextLi);
}
function buildPageNumbers(current, last) {
// Always show first, last, and a window around current
if (last <= 7) {
var all = [];
for (var i = 1; i <= last; i++) all.push(i);
return all;
}
var pages = [];
pages.push(1);
var rangeStart = Math.max(2, current - 1);
var rangeEnd = Math.min(last - 1, current + 1);
if (rangeStart > 2) pages.push('...');
for (var i = rangeStart; i <= rangeEnd; i++) pages.push(i);
if (rangeEnd < last - 1) pages.push('...');
pages.push(last);
return pages;
}
function cleanup() {
overlay.remove();
$j(document).off('keydown.frameBrowse');
}
renderPage(currentPage);
panel.append(grid);
panel.append(paginationWrap);
overlay.append(panel);
$j('body').append(overlay);
// Close on overlay background click
overlay.on('click', function(e) {
if (e.target === overlay[0]) cleanup();
});
// Close on Escape
$j(document).on('keydown.frameBrowse', function(e) {
if (e.key === 'Escape') cleanup();
});
};
/**
* Open a read-only browse overlay showing training folder contents.
*/
AnnotationEditor.prototype.browseTrainingData = function() {
var self = this;
var t = this.translations;
// Helper: file icon class from extension
@ -1381,11 +1574,28 @@ AnnotationEditor.prototype.browseTrainingData = function() {
// Header
var header = $j('<div>').addClass('browse-header');
header.append($j('<span>').text(t.BrowseTrainingData || 'Browse Training Data'));
header.append($j('<span>').text(t.TrainingBrowse || 'Browse Training Data'));
var pathSpan = $j('<span>').addClass('browse-path');
header.append(pathSpan);
var browseChanged = false;
var closeBrowse = function() {
overlay.remove();
if (browseChanged) {
$j.getJSON(thisUrl + '?request=training&action=status').done(function(data) {
var resp = data.response || data;
if (resp.stats) {
self.trainingStats = resp.stats;
if (resp.stats.class_labels) self.classLabels = resp.stats.class_labels;
self._renderStats();
self._updateSidebar();
}
});
}
};
var closeBtn = $j('<button>').addClass('browse-close').html('&times;');
closeBtn.on('click', function() { overlay.remove(); });
closeBtn.on('click', closeBrowse);
header.append(closeBtn);
panel.append(header);
@ -1403,11 +1613,11 @@ AnnotationEditor.prototype.browseTrainingData = function() {
panel.append(body);
// Loading state
treePanel.html('<div class="browse-empty-msg">Loading...</div>');
treePanel.html('<div class="browse-empty-msg">' + (t.TrainingLoading || 'Loading...') + '</div>');
overlay.append(panel);
overlay.on('click', function(e) {
if (e.target === overlay[0]) overlay.remove();
if (e.target === overlay[0]) closeBrowse();
});
$j('body').append(overlay);
@ -1502,7 +1712,7 @@ AnnotationEditor.prototype.browseTrainingData = function() {
filesArea.append(fHeader);
if (files.length === 0) {
filesArea.append($j('<div>').addClass('browse-empty-msg').text('No files'));
filesArea.append($j('<div>').addClass('browse-empty-msg').text(t.TrainingNoFiles || 'No files'));
return;
}
@ -1522,12 +1732,13 @@ AnnotationEditor.prototype.browseTrainingData = function() {
.html('<i class="fa fa-trash"></i>');
delBtn.on('click', function(e) {
e.stopPropagation();
if (!confirm('Delete this file and its paired image/label?')) return;
if (!confirm(t.TrainingConfirmDeleteFile || 'Delete this file and its paired image/label?')) return;
delBtn.prop('disabled', true);
$j.getJSON(thisUrl + '?request=training&action=browse_delete&path=' +
encodeURIComponent(file.path))
$j.ajax({url: thisUrl + '?request=training&action=browse_delete&path=' +
encodeURIComponent(file.path), method: 'POST', dataType: 'json'})
.done(function(data) {
var resp = data.response || data;
browseChanged = true;
// Remove deleted files from treeData
if (resp.deleted) {
for (var d = 0; d < resp.deleted.length; d++) {
@ -1543,7 +1754,7 @@ AnnotationEditor.prototype.browseTrainingData = function() {
showFiles(selectedDirPath);
})
.fail(function() {
alert('Failed to delete');
alert(t.TrainingDeleteFailed || 'Failed to delete');
delBtn.prop('disabled', false);
});
});
@ -1587,7 +1798,7 @@ AnnotationEditor.prototype.browseTrainingData = function() {
var img = $j('<img>').attr('src', fileUrl)
.attr('alt', file.name)
.on('error', function() {
pvContent.html('<em>Failed to load image</em>');
pvContent.html('<em>' + (t.TrainingFailedToLoadFrame || 'Failed to load image') + '</em>');
});
var canvas = $j('<canvas>').addClass('browse-preview-canvas')[0];
container.append(img);
@ -1663,16 +1874,16 @@ AnnotationEditor.prototype.browseTrainingData = function() {
});
});
} else if (isText(file.name)) {
pvContent.html('<em>Loading...</em>');
pvContent.html('<em>' + (t.TrainingLoading || 'Loading...') + '</em>');
$j.getJSON(fileUrl).done(function(data) {
var resp = data.response || data;
pvContent.empty();
pvContent.append($j('<pre>').text(resp.content || '(empty)'));
}).fail(function() {
pvContent.html('<em>Failed to load file</em>');
pvContent.html('<em>' + (t.TrainingSaveFailed || 'Failed to load file') + '</em>');
});
} else {
pvContent.html('<em>Preview not available for this file type</em>');
pvContent.html('<em>' + (t.TrainingPreviewUnavailable || 'Preview not available for this file type') + '</em>');
}
}
@ -1758,9 +1969,9 @@ AnnotationEditor.prototype.browseTrainingData = function() {
if (treeData.length === 0) {
treePanel.html('<div class="browse-empty-msg">' +
(t.NoTrainingData || 'No training data yet.') + '</div>');
(t.TrainingNoData || 'No training data yet.') + '</div>');
filesArea.html('<div class="browse-empty-msg">' +
(t.NoTrainingData || 'No training data yet.') + '</div>');
(t.TrainingNoData || 'No training data yet.') + '</div>');
return;
}
@ -1802,7 +2013,7 @@ AnnotationEditor.prototype.browseTrainingData = function() {
.fail(function() {
treePanel.empty();
treePanel.html('<div class="browse-empty-msg" style="color:#dc3545">' +
(t.SaveFailed || 'Failed to load') + '</div>');
(t.TrainingSaveFailed || 'Failed to load') + '</div>');
});
};
@ -1865,7 +2076,7 @@ AnnotationEditor.prototype.save = function() {
var self = this;
if (!this.currentFrameId) {
this._setStatus(this.translations.NoFrameLoaded || 'No frame loaded', 'error');
this._setStatus(this.translations.TrainingNoFrameLoaded || 'No frame loaded', 'error');
return;
}
@ -1878,12 +2089,12 @@ AnnotationEditor.prototype.save = function() {
}
if (accepted.length === 0) {
var msg = this.translations.BackgroundImageConfirm ||
var msg = this.translations.TrainingBackgroundConfirm ||
'No objects marked. Save as a background image (no objects)?\n\nBackground images help the model learn to reduce false positives.';
if (!confirm(msg)) return;
}
this._setStatus(this.translations.Saving || 'Saving...', 'saving');
this._setStatus(this.translations.TrainingSaving || 'Saving...', 'saving');
$j.ajax({
url: thisUrl + '?request=training&action=save',
@ -1899,13 +2110,13 @@ AnnotationEditor.prototype.save = function() {
})
.done(function(data) {
if (data.result === 'Error') {
self._setStatus(data.message || self.translations.SaveFailed || 'Save failed', 'error');
self._setStatus(data.message || self.translations.TrainingSaveFailed || 'Save failed', 'error');
return;
}
var resp = data.response || data;
self.dirty = false;
self._setStatus(
self.translations.AnnotationSaved || 'Annotation saved to training set',
self.translations.TrainingSaved || 'Training annotation saved',
'success'
);
$j('#annotationSaveBtn').removeClass('btn-success').addClass('btn-saved');
@ -1926,7 +2137,7 @@ AnnotationEditor.prototype.save = function() {
}
})
.fail(function(jqxhr) {
self._setStatus(self.translations.SaveFailed || 'Save failed', 'error');
self._setStatus(self.translations.TrainingSaveFailed || 'Save failed', 'error');
logAjaxFail(jqxhr);
});
};
@ -1944,12 +2155,10 @@ AnnotationEditor.prototype._setStatus = function(msg, type) {
.removeClass('error saving')
.addClass(type === 'error' ? 'error' : (type === 'saving' ? 'saving' : ''));
// Clear success/saving messages after a delay
if (type !== 'error') {
setTimeout(function() {
el.text('');
}, 8000);
}
// Auto-clear all messages after a delay
setTimeout(function() {
el.text('').removeClass('error saving');
}, 8000);
};
/**