Skip to content

feat: Add custom model training annotation UI#4667

Open
pliablepixels wants to merge 27 commits intoZoneMinder:masterfrom
pliablepixels:feature/custom-model-training
Open

feat: Add custom model training annotation UI#4667
pliablepixels wants to merge 27 commits intoZoneMinder:masterfrom
pliablepixels:feature/custom-model-training

Conversation

@pliablepixels
Copy link
Member

Summary

  • Built-in annotation editor on the event view for correcting object detection results
  • Saves training data in YOLO format for custom model training via pyzm
  • Two-panel training data browser with preview and inline delete
  • Frame navigation with thumbnails, skip, pagination
  • Gated behind ZM_OPT_TRAINING config option — zero impact when disabled

Refs #4666

Test plan

  • Enable ZM_OPT_TRAINING in Options > Config
  • Open an event, click the Annotate button
  • Draw boxes, label objects, save annotations
  • Run detection, accept/reject results
  • Navigate frames (prev/next, skip, browse thumbnails)
  • Browse training data folder, preview files, delete
  • Verify unsaved-changes warning on navigation
  • Verify no UI changes when ZM_OPT_TRAINING is disabled
  • Screenshots will be posted on feat: Add custom model training annotation UI to event view #4666

🤖 Generated with Claude Code

pliablepixels and others added 16 commits February 28, 2026 21:35
Add canvas-based bounding box annotation editor to the event view for
correcting object detection results and building YOLO training datasets.

- Two new config options: ZM_OPT_TRAINING (toggle) and ZM_TRAINING_DATA_DIR
- AJAX backend (training.php) with load/save/delete/status actions
- Canvas annotation editor (training.js) with draw/resize/relabel/undo
- Frame navigation (alarm/snapshot/objdetect/numbered frames)
- Roboflow-compatible YOLO output (images/all/, labels/all/, data.yaml)
- Training data statistics with per-class image counts and guidance
- Full i18n support via en_gb.php SLANG/OLANG entries
- Label validation, YOLO coordinate clamping, audit logging
- DB migration for existing installs (zm_update-1.39.2.sql)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ZM_DIR_EVENTS may not exist in the Config table on fresh builds,
causing REPLACE to return NULL and the UPDATE to fail with
ERROR 1048 on NOT NULL column. Use COALESCE to skip the update
when the derived path is NULL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
ZM_DIR_EVENTS is a compile-time substitution, never in the Config
table. The default is set via ConfigData.pm.in (@ZM_CACHEDIR@/training)
and the PHP fallback in getTrainingDataDir() handles empty values.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove objdetect from frame selector and fid validation
- Fix 500 error: replace non-existent ZM\AuditAction() with ZM\Info()
- Scale canvas text/borders/handles relative to canvas width for readability
- Change annotate button icon from fa-pencil-square-o to fa-crosshairs
- Bump CSS font sizes from 0.75-0.9rem to 0.95-1rem
- Use orange (#ff8c00) stroke for in-progress bounding box drawing
- Flash save button green (btn-saved class) on successful save
- Hide #eventVideo when annotation panel is open
- Skip empty label files in getTrainingStats() count
- Reject save with empty annotations array
- Add undo support for delete and AJAX delete when all boxes removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add ZM_TRAINING_DETECT_SCRIPT config option for specifying the path
to an external detection script (e.g. zm_detect.py). When configured,
a Detect button appears in the annotation editor that:
- Runs the script with -f <image> -m <monitor_id>
- Parses the --SPLIT-- JSON output for labels, boxes, confidences
- Displays results as orange (pending) bounding boxes
- Users can accept (checkmark) or reject (x) each detection
- Only accepted annotations are saved to the training set

Also adds accept/reject UI in the sidebar object list, with orange
color for pending detections and normal colors for accepted ones.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Was hardcoded to %06d but the actual padding depends on the
ZM_EVENT_IMAGE_DIGITS config (typically 5).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Accepted and manually-drawn annotations render in green (#28a745)
- Pending detections remain orange (#ff8c00)
- Removed the label select dropdown from the action bar (clutter)
- Left button text-transform as-is (ZM-wide uppercase)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Cancel button renamed to Exit
- Action bar buttons use smaller font (0.8rem) and padding
- Status text truncates with ellipsis instead of wrapping buttons
- Action bar uses flex-wrap: nowrap to keep buttons on one line

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove empty annotations rejection from PHP save action
- Empty label file = background/negative image in YOLO format
- JS prompts user when saving with no objects: "Save as background
  image? Background images help reduce false positives."
- Stats now show background image count separately
- Delete Box button already works on selected pending boxes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add SLANG entries for all training UI strings and wire them
through the trainingTranslations object:
- AcceptDetection, BackgroundImageConfirm, BackgroundImages
- DetectFailed, DetectedObjects
- FailedToLoadEvent, FailedToLoadFrame, LoadFrameFirst
- NoFrameLoaded, NoTrainingData, SaveFailed

All user-visible text in training.js now uses
this.translations.Key || 'fallback' pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Literal \n in the PHP string was output as newlines inside a JS
string literal in event.js.php, causing a SyntaxError that broke
the entire page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ll, UI polish

- Add Annotate button to objdetect modal (events list) with auto-open
  via ?annotate=1 URL parameter
- Compact stats layout: label and value on same row, trash icon to
  delete all training data with typed confirmation prompt
- Add delete_all AJAX action to training.php
- Reduce all annotation panel font sizes to 0.8rem to match ZM base
- Fix label picker white-on-white by setting explicit colors
- Add beforeunload guard for unsaved annotations
- Switch detect action from shell_exec to exec for better error handling
- Show "no objects detected" as info status, not error
- Increase status message display time to 10 seconds
- Add i18n strings: ConfirmDeleteTrainingData, DeleteAllTrainingData,
  TrainingDataDeleted

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…lete

- Rewrite browse overlay as two-panel layout: directory tree (left) + file listing/preview (right)
- Add browse_file PHP action to serve images and text files from training dir
- Add browse_delete PHP action with class ID remapping to keep data.yaml consistent
- Overlay bounding boxes on image previews from corresponding label files
- Add per-file delete button (trash icon) that removes both image and label pair
- Move delete-all trash icon to sidebar header, status messages below sidebar
- Use existing ZM utilities: human_filesize() for file sizes, detaintPath() for path security
- Add SelectBoxFirst translation for delete-without-selection feedback
- Fix orphaned class ID bug: remap IDs in all label files when classes are removed

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- 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>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace beforeunload with click-capture nav guard to avoid blank
  screen caused by ZM's global beforeunload content-hide animation
- Wrap streamPrev/streamNext to prompt before stream teardown
- Warn when saving with unaccepted (orange) detection boxes
- Add PHP fallback for empty TRAINING_DATA_DIR using ZM_DIR_CACHE
- Show spinning loader overlay with blur while frame images load
- Change detection running status to blue (info) color
- Fix label picker uppercase caused by ZM global button CSS
- Add TrainingPendingOnly, TrainingPendingDiscard translation keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings March 1, 2026 16:11
@pliablepixels pliablepixels changed the title Add custom model training annotation UI feat: Add custom model training annotation UI Mar 1, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new, config-gated (“ZM_OPT_TRAINING”) annotation workflow in the classic skin event view to correct object detections and persist training data in YOLO format for custom model training.

Changes:

  • Adds a canvas-based annotation editor UI to the event view, including frame navigation and an overlay-based training-data browser.
  • Introduces a new AJAX backend (web/ajax/training.php) to load/save annotations, browse/delete dataset files, and optionally run an external detection script.
  • Adds new configuration options (DB migration + ConfigData + translations) and bumps version to 1.39.1.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
web/skins/classic/views/js/training.js New AnnotationEditor implementation (drawing/editing boxes, browsing frames/data, saving YOLO).
web/skins/classic/views/js/event.js.php Initializes the editor and wires UI handlers when ZM_OPT_TRAINING is enabled.
web/skins/classic/views/event.php Adds the Annotate button, annotation panel markup, and loads training CSS/JS when enabled.
web/skins/classic/css/base/views/training.css Styles for the annotation panel, frame browser, and training-data browser overlays.
web/ajax/training.php Backend endpoints for training editor (load/save/status/browse/delete/detect).
web/ajax/modals/objdetect.php Adds a shortcut button into the objdetect modal to open the annotation UI.
web/lang/en_gb.php Adds translation keys for the new feature and new config option prompts/help.
scripts/ZoneMinder/lib/ZoneMinder/ConfigData.pm.in Adds new options ZM_OPT_TRAINING, ZM_TRAINING_DATA_DIR, ZM_TRAINING_DETECT_SCRIPT.
db/zm_update-1.39.1.sql Adds Config entries for the new options.
version.txt Version bump to 1.39.1.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +327 to +335
$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;
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The realpath containment check uses strpos($fullPath, realpath($base)) !== 0, which is vulnerable to prefix-matching paths (e.g. $base='/data/training' and $fullPath='/data/training_evil/file' passes). Use a boundary-safe check (e.g. compare against $baseReal . DIRECTORY_SEPARATOR or verify the next char is /).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62678b2. Now uses rtrim(realpath($base), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR before the strpos containment check, preventing prefix-matching against sibling directories.

Comment on lines +567 to +574
$reqPath = detaintPath($_REQUEST['path']);
$fullPath = realpath($base.'/'.$reqPath);

if ($fullPath === false || strpos($fullPath, realpath($base)) !== 0 || !is_file($fullPath)) {
ajaxError('File not found or access denied');
break;
}

Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same path containment issue as browse_file: strpos($fullPath, realpath($base)) !== 0 can be bypassed by sibling directories that share the same prefix as $base. Tighten the check to ensure $fullPath is actually under $base (boundary-aware).

Suggested change
$reqPath = detaintPath($_REQUEST['path']);
$fullPath = realpath($base.'/'.$reqPath);
if ($fullPath === false || strpos($fullPath, realpath($base)) !== 0 || !is_file($fullPath)) {
ajaxError('File not found or access denied');
break;
}
$baseReal = realpath($base);
if ($baseReal === false) {
ajaxError('Training data directory not found or invalid');
break;
}
$reqPath = detaintPath($_REQUEST['path']);
$fullPath = realpath($baseReal . '/' . $reqPath);
if ($fullPath === false) {
ajaxError('File not found or access denied');
break;
}
$basePrefix = rtrim($baseReal, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
if (strpos($fullPath, $basePrefix) !== 0 || !is_file($fullPath)) {
ajaxError('File not found or access denied');
break;
}

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62678b2. Same fix applied — appending DIRECTORY_SEPARATOR to the base path before strpos check.

Comment on lines +696 to +697
$tmpFile = tempnam(sys_get_temp_dir(), 'zm_detect_').'.jpg';
if (!copy($srcImage, $tmpFile)) {
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tempnam(...).'.jpg' creates (and leaks) the original temp file returned by tempnam() while writing to a different filename. This leaves stray files in the temp dir. Use the tempnam() return value directly (optionally rename it) and ensure it’s always cleaned up (also on error paths).

Suggested change
$tmpFile = tempnam(sys_get_temp_dir(), 'zm_detect_').'.jpg';
if (!copy($srcImage, $tmpFile)) {
$tmpFile = tempnam(sys_get_temp_dir(), 'zm_detect_');
if ($tmpFile === false) {
ajaxError('Failed to create temp file for detection');
break;
}
if (!copy($srcImage, $tmpFile)) {
if (file_exists($tmpFile)) {
unlink($tmpFile);
}

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62678b2. Now renames the tempnam() base file to .jpg instead of creating a second file, so no orphan is left behind. Also added cleanup on copy failure.

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). If left empty, defaults to a training folder alongside your events storage.',
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The migration Help text says an empty ZM_TRAINING_DATA_DIR defaults alongside events storage, but the runtime fallback is ZM_DIR_CACHE/training and ConfigData.pm.in defaults to @ZM_CACHEDIR@/training. Please align the migration help text with the actual default behavior (or change the default).

Suggested change
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). If left empty, defaults to a training folder alongside your events storage.',
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). If left empty, defaults to a training folder in the ZoneMinder cache directory (e.g. ZM_DIR_CACHE/training).',

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62678b2. Help text now says "defaults to a training folder inside the ZoneMinder cache directory" which matches the actual ZM_DIR_CACHE/training fallback.

<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>
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

training.js updates a .frame-total element inside #annotationFrameSelector, but the markup here doesn't include any element with class frame-total, so the total frame count will never render. Add the missing element (or adjust the JS to target an existing node).

Suggested change
<button class="btn btn-normal btn-sm" id="annotationGoToFrame"><?php echo translate('GoToFrame') ?></button>
<button class="btn btn-normal btn-sm" id="annotationGoToFrame"><?php echo translate('GoToFrame') ?></button>
<span class="frame-total"><?php echo $Event->Frames() ?></span>

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62678b2. Added the missing <span class="frame-total"></span> element to the frame selector markup in event.php.

} else if (e.key === 'Delete' || e.key === 'Backspace') {
if (self.selectedIndex >= 0) {
e.preventDefault();
self._pushUndo();
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Delete/Backspace key handler calls _pushUndo() and then calls deleteAnnotation(), which also calls _pushUndo(). This causes a single delete action to require two undos and bloats the undo stack. Remove one of the _pushUndo() calls (typically keep it inside deleteAnnotation()).

Suggested change
self._pushUndo();

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62678b2. Removed the extra _pushUndo() call from the Delete/Backspace keydown handler — deleteAnnotation() already calls it internally.

ZM\Debug('Training: detect found '.count($detections).' objects for event '.$eid.' frame '.$fid);
ajaxResponse([
'detections' => $detections,
'raw_output' => $output,
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The detect endpoint returns raw_output from the external detection script. The front-end doesn't appear to use it, and it can leak internal details or error output to the browser unnecessarily. Consider omitting it (or gating it behind a debug flag / admin-only).

Suggested change
'raw_output' => $output,

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62678b2. Removed raw_output from the detect response. The script output is still logged server-side via ZM\Debug for troubleshooting.

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).
If left empty, defaults to a training folder alongside your events storage.
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The help text says an empty ZM_TRAINING_DATA_DIR defaults to a folder alongside events storage, but the configured default is @ZM_CACHEDIR@/training and web/ajax/training.php falls back to ZM_DIR_CACHE.'/training'. Update the help text (or the implementation) so the documented default matches reality.

Suggested change
If left empty, defaults to a training folder alongside your events storage.
If left empty (or set to the default), ZoneMinder uses a "training"
subdirectory under the cache directory (ZM_DIR_CACHE.'/training',
typically @ZM_CACHEDIR@/training).

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 62678b2. Same correction as the migration — now says "inside the ZoneMinder cache directory."

Comment on lines +357 to +395
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;
}

$eid = validCardinal($_REQUEST['eid']);
$fid = $_REQUEST['fid'];

if (!validFrameId($fid)) {
ajaxError('Invalid frame ID');
break;
}

$Event = ZM\Event::find_one(['Id' => $eid]);
if (!$Event) {
ajaxError('Event not found');
break;
}

if (!ensureTrainingDirs()) break;

$annotations = json_decode($_REQUEST['annotations'], true);
if (!is_array($annotations)) {
ajaxError('Invalid annotations data');
break;
}

$imgWidth = intval($_REQUEST['width']);
$imgHeight = intval($_REQUEST['height']);
if ($imgWidth <= 0 || $imgHeight <= 0) {
ajaxError('Invalid image dimensions');
break;
}
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

State-changing training endpoints like action=save are callable via ?request=training without any CSRF protection, so an attacker can forge POST requests from another site that run in a logged-in user’s browser and modify or delete training annotations. If a user with canEdit('Events') visits a malicious page while logged in, that page can silently submit a form to ?request=training&action=save/delete/delete_all/browse_delete and corrupt or wipe the training dataset. To fix this, enforce CSRF tokens or the existing csrf-magic mechanism for request=training actions and validate them on these write operations before applying any changes.

Copilot uses AI. Check for mistakes.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping this one. ZoneMinder's existing AJAX endpoints (event delete, monitor config, etc. in web/ajax/) do not enforce CSRF tokens either — this is a codebase-wide pattern, not specific to the training feature. Adding CSRF validation here alone would be inconsistent. This should be addressed as a separate codebase-wide effort.

pliablepixels and others added 10 commits March 1, 2026 11:25
… help text

- Fix path traversal via directory prefix matching: append
  DIRECTORY_SEPARATOR to base path before strpos containment check
  (2 locations in training.php)
- Fix temp file leak: rename tempnam() base file to .jpg instead of
  creating a second file, leaving the original orphaned
- Remove raw_output from detect response (information disclosure)
- Fix double _pushUndo on Delete key (keydown handler + deleteAnnotation)
- Fix help text: "alongside events storage" → "inside the ZoneMinder
  cache directory" to match actual ZM_DIR_CACHE default (2 locations)
- Add missing .frame-total span element in event.php

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Parses event_{eid}_frame_{fid} from training image filenames and adds
a pencil button in the preview header. Clicking it navigates to the
event view with ?annotate=1&frame=FRAME_ID, auto-opening the annotation
editor on that specific frame. Adds initialFrame parameter to open().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Change default training data directory from @ZM_CACHEDIR@/training to
@ZM_CONTENTDIR@/training so it lives alongside events storage instead
of inside the web cache folder. Update PHP fallback to use
dirname(ZM_DIR_EVENTS) accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Hold Shift to bypass hit-testing on existing boxes and force draw
mode, allowing new boxes to be drawn inside larger ones. Cursor
reverts to crosshair while Shift is held. Adds internationalized
hint text below the canvas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add load_saved PHP action that reads YOLO label files and converts
normalized coordinates back to pixels. When navigating to an event
via the browse edit button, saved annotations are loaded into the
editor so users can review and modify previous work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add browse_objects PHP action that groups annotated images by class
label and collects background images separately. Inject a virtual
"Objects" folder at the top of the browse tree with fa-tags icon.
Expanding it shows per-class sub-items with count badges and a
background images category. Clicking a class displays a thumbnail
grid; clicking a thumbnail navigates to the annotation editor for
that event/frame.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Move the annotation/training interface from an inline panel in event.php
to a dedicated ?view=training screen. This cleanly separates the training
workflow from event playback and enables switching between events without
a full page reload.

- Add web/skins/classic/views/training.php as a standalone training view
- Add web/skins/classic/views/js/training.js.php with view-specific init
  and translations (moved from event.js.php)
- Add switchEvent() method to AnnotationEditor for in-place event switching
  when clicking thumbnails or edit buttons in the browse panel
- Update page title, URL bar, and back button on event switch
- Change annotate button in event.php from toggle to navigation link
- Update objdetect modal to link to training view
- Remove annotation panel HTML and training init code from event view
- Fix pre-existing broken </span tag on toggleZonesButton in event.php
- Add training view layout CSS, inline browse panel styles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Save annotations now refreshes browse tree and file listing
- Delete all training data now refreshes browse panel
- Delete file in browse always refreshes editor stats
- Invalidate objects cache on save, delete, and delete-all
- Expose browse state on editor instance for cross-panel sync
- Update training guidance text to 50+/100+ images per class

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ning files

- Rebuild data.yaml when deleting annotations (delete and browse_delete
  actions) so unused class names are removed and IDs re-indexed
- Extract rebuildDataYaml() helper, replacing duplicated inline rebuild
  logic in browse_delete
- Make sidebar trash button always visible for recovery from inconsistent
  states; _deleteAllTrainingData now also clears editor annotations
- Consolidate permission/POST checks into arrays before switch statement
- Extract helper functions: resolveTrainingPath, getFrameStem,
  getFramePaths, _loadEventData, _confirmDiscardIfDirty,
  _resetAnnotationState, _applyEventData, _getNormalizedRect
- Extract 8 pure browse utility functions to module level
- Add floatval() validation on bbox coordinates in save action
- Add LOCK_EX to writeDataYaml, add .fail(logAjaxFail) to AJAX calls
- Replace var with let/const, add 'use strict', remove IIFEs
- CSS custom properties for repeated values, requestAnimationFrame
  throttle for canvas mousemove
- Align with ZM conventions: error messages include username/action,
  trailing ajaxError after switch, "use strict" at line 1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Split the config value on the first whitespace so the executable path
is validated separately and extra arguments (e.g. -c config.yml) are
passed through to the command line.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
global $monitor;
?>

const eventData = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we could just output $Event->to_json() and make this a 1-liner.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca74d11. Replaced with const eventData = <?php echo $Event->to_json() ?>; — single line, uses the inherited Object::to_json() method.

return;
}

zm_session_start();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should not be here

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca74d11. Removed both lines — the training view never reads or writes session data, so the session open/close was a no-op.

-- Add AUDIT logging level between PANIC (-4) and NOLOG.
-- AUDIT is now -5; NOLOG shifts from -5 to -6.
-- Migrate any stored NOLOG config values from -5 to -6.
-- This updates a 1.39.0 database to 1.39.1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this. This wouldn't actually correctly update the config table anyways. zmupdate.pl -f will automatically add this field to config.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ca74d11. Deleted the migration file entirely — the three config entries are already defined in ConfigData.pm.in and zmupdate.pl -f handles syncing them to the database.

- Replace manual eventData construction with $Event->to_json()
- Remove unused zm_session_start()/session_write_close() from training view
- Delete zm_update-1.39.1.sql migration (zmupdate.pl -f handles Config
  entries via ConfigData.pm)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants