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
parent
948667c7d6
commit
eaaa8f4016
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -1 +1 @@
|
|||
1.38.0
|
||||
1.39.1
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
?>
|
||||
|
|
|
|||
|
|
@ -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 & 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<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' => ' filter expressions', // This is used at the end of the phrase 'use N filter expressions'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 } ?>
|
||||
|
|
|
|||
|
|
@ -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('×')
|
||||
.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">×</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="#">«</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="#">»</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('×');
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue