838 lines
26 KiB
PHP
838 lines
26 KiB
PHP
<?php
|
|
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;
|
|
}
|
|
|
|
$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 from config.
|
|
*/
|
|
function getTrainingDataDir() {
|
|
if (defined('ZM_TRAINING_DATA_DIR') && ZM_TRAINING_DATA_DIR != '') {
|
|
return ZM_TRAINING_DATA_DIR;
|
|
}
|
|
// Fall back to a training folder inside the content directory (alongside events)
|
|
if (defined('ZM_DIR_EVENTS') && ZM_DIR_EVENTS != '') {
|
|
return dirname(ZM_DIR_EVENTS) . '/training';
|
|
}
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Ensure the training directory structure exists.
|
|
* Creates images/all/ and labels/all/ subdirectories.
|
|
*/
|
|
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)) {
|
|
ZM\Error('Training: failed to create directory '.$dir);
|
|
ajaxError('Failed to create training directory');
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
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) && intval($fid) > 0);
|
|
}
|
|
|
|
/**
|
|
* 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 [];
|
|
|
|
$labels = [];
|
|
$inNames = false;
|
|
foreach (file($yamlFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
|
if (preg_match('/^names:\s*$/', $line)) {
|
|
$inNames = true;
|
|
continue;
|
|
}
|
|
if ($inNames) {
|
|
if (preg_match('/^\s+(\d+):\s*(.+)$/', $line, $m)) {
|
|
$labels[intval($m[1])] = trim($m[2]);
|
|
} else {
|
|
break; // End of names block
|
|
}
|
|
}
|
|
}
|
|
ksort($labels);
|
|
return array_values($labels);
|
|
}
|
|
|
|
/**
|
|
* Resolve and validate a relative path within the training directory.
|
|
* Returns the full filesystem path, or false if invalid/outside base.
|
|
*/
|
|
function resolveTrainingPath($reqPath) {
|
|
$base = getTrainingDataDir();
|
|
if ($base === '') return false;
|
|
$clean = detaintPath($reqPath);
|
|
$full = realpath($base.'/'.$clean);
|
|
$baseReal = rtrim(realpath($base), DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
|
if ($full === false || strpos($full, $baseReal) !== 0 || !is_file($full)) return false;
|
|
return $full;
|
|
}
|
|
|
|
/**
|
|
* Build the stem filename for a training data pair (e.g. "event_123_frame_alarm").
|
|
*/
|
|
function getFrameStem($eid, $fid) {
|
|
return 'event_'.$eid.'_frame_'.$fid;
|
|
}
|
|
|
|
/**
|
|
* Get image and label paths for a training data pair.
|
|
* Returns ['stem' => ..., 'img' => ..., 'lbl' => ...].
|
|
*/
|
|
function getFramePaths($eid, $fid) {
|
|
$base = getTrainingDataDir();
|
|
$stem = getFrameStem($eid, $fid);
|
|
return [
|
|
'stem' => $stem,
|
|
'img' => $base.'/images/all/'.$stem.'.jpg',
|
|
'lbl' => $base.'/labels/all/'.$stem.'.txt',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Rebuild data.yaml from remaining label files, removing unused classes.
|
|
* If no label files remain, removes data.yaml entirely.
|
|
*/
|
|
function rebuildDataYaml() {
|
|
$base = getTrainingDataDir();
|
|
if ($base === '') return;
|
|
$labelsDir = $base.'/labels/all';
|
|
$yamlFile = $base.'/data.yaml';
|
|
|
|
$currentLabels = getClassLabels();
|
|
if (empty($currentLabels)) {
|
|
if (file_exists($yamlFile)) unlink($yamlFile);
|
|
return;
|
|
}
|
|
|
|
// Scan all remaining label files for used class IDs
|
|
$usedIds = [];
|
|
$files = is_dir($labelsDir) ? glob($labelsDir.'/*.txt') : [];
|
|
foreach ($files as $file) {
|
|
foreach (file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) as $line) {
|
|
$parts = explode(' ', trim($line));
|
|
if (count($parts) >= 5) {
|
|
$usedIds[intval($parts[0])] = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (empty($usedIds)) {
|
|
// No annotations remain — remove data.yaml
|
|
if (file_exists($yamlFile)) unlink($yamlFile);
|
|
return;
|
|
}
|
|
|
|
// Keep only labels for class IDs still in use, re-index from 0
|
|
$newLabels = [];
|
|
$idMap = []; // old ID => new ID
|
|
foreach ($currentLabels as $oldId => $label) {
|
|
if (isset($usedIds[$oldId])) {
|
|
$idMap[$oldId] = count($newLabels);
|
|
$newLabels[] = $label;
|
|
}
|
|
}
|
|
|
|
// If IDs shifted, rewrite all label files with new IDs
|
|
if ($idMap !== array_flip(array_keys($usedIds)) || count($newLabels) < count($currentLabels)) {
|
|
$needsReindex = false;
|
|
foreach ($idMap as $old => $new) {
|
|
if ($old !== $new) { $needsReindex = true; break; }
|
|
}
|
|
if ($needsReindex) {
|
|
foreach ($files as $file) {
|
|
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
$out = [];
|
|
foreach ($lines as $line) {
|
|
$parts = explode(' ', trim($line));
|
|
if (count($parts) >= 5 && isset($idMap[intval($parts[0])])) {
|
|
$parts[0] = $idMap[intval($parts[0])];
|
|
$out[] = implode(' ', $parts);
|
|
}
|
|
}
|
|
file_put_contents($file, implode("\n", $out)."\n", LOCK_EX);
|
|
}
|
|
}
|
|
}
|
|
|
|
writeDataYaml($newLabels);
|
|
}
|
|
|
|
/**
|
|
* Write data.yaml in the training directory with the given labels.
|
|
*/
|
|
function writeDataYaml($labels) {
|
|
$base = getTrainingDataDir();
|
|
$yaml = "path: .\n";
|
|
$yaml .= "train: images/all\n";
|
|
$yaml .= "val: images/all\n";
|
|
$yaml .= "names:\n";
|
|
foreach ($labels as $i => $label) {
|
|
$yaml .= " $i: $label\n";
|
|
}
|
|
file_put_contents($base.'/data.yaml', $yaml, LOCK_EX);
|
|
ZM\Debug('Training: wrote data.yaml with '.count($labels).' classes: '.implode(', ', $labels));
|
|
}
|
|
|
|
/**
|
|
* Collect training dataset statistics:
|
|
* total images, total classes, images per class.
|
|
*/
|
|
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 = [
|
|
'total_images' => 0,
|
|
'total_classes' => 0,
|
|
'images_per_class' => [],
|
|
'class_labels' => $labels,
|
|
];
|
|
|
|
if (!is_dir($labelsDir)) return $stats;
|
|
|
|
$classCounts = array_fill(0, count($labels), 0);
|
|
|
|
$files = glob($labelsDir.'/*.txt');
|
|
$annotatedCount = 0;
|
|
|
|
$backgroundCount = 0;
|
|
foreach ($files as $file) {
|
|
$seenClasses = [];
|
|
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
if (empty($lines)) {
|
|
$backgroundCount++;
|
|
continue;
|
|
}
|
|
$annotatedCount++;
|
|
foreach ($lines as $line) {
|
|
$parts = explode(' ', trim($line));
|
|
if (count($parts) >= 5) {
|
|
$classId = intval($parts[0]);
|
|
if (!isset($seenClasses[$classId])) {
|
|
$seenClasses[$classId] = true;
|
|
if (isset($classCounts[$classId])) {
|
|
$classCounts[$classId]++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
$stats['total_images'] = $annotatedCount;
|
|
$stats['background_images'] = $backgroundCount;
|
|
|
|
foreach ($labels as $i => $label) {
|
|
$stats['images_per_class'][$label] = $classCounts[$i];
|
|
}
|
|
$stats['total_classes'] = count(array_filter($classCounts, function($c) { return $c > 0; }));
|
|
|
|
return $stats;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// ---- Consolidated permission and method checks ----
|
|
$readActions = ['load', 'load_saved', 'labels', 'status', 'browse', 'browse_objects', 'browse_file'];
|
|
$writeActions = ['save', 'delete', 'delete_all', 'browse_delete', 'detect'];
|
|
$postRequired = ['save', 'delete', 'delete_all', 'browse_delete', 'detect'];
|
|
|
|
if (in_array($action, $readActions) && !canView('Events')) {
|
|
ajaxError('Insufficient permissions for user '.$user->Username());
|
|
return;
|
|
}
|
|
if (in_array($action, $writeActions) && !canEdit('Events')) {
|
|
ajaxError('Insufficient permissions for user '.$user->Username());
|
|
return;
|
|
}
|
|
if (in_array($action, $postRequired) && $_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|
ajaxError('POST method required');
|
|
return;
|
|
}
|
|
|
|
switch ($action) {
|
|
|
|
case 'load':
|
|
// Load detection data for an event
|
|
if (empty($_REQUEST['eid'])) {
|
|
ajaxError('Event ID required');
|
|
break;
|
|
}
|
|
$eid = validCardinal($_REQUEST['eid']);
|
|
$Event = ZM\Event::find_one(['Id' => $eid]);
|
|
if (!$Event) {
|
|
ajaxError('Event not found');
|
|
break;
|
|
}
|
|
|
|
$eventPath = $Event->Path();
|
|
$objectsFile = $eventPath.'/objects.json';
|
|
$detectionData = null;
|
|
|
|
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
|
|
$defaultFrameId = null;
|
|
if ($detectionData && isset($detectionData['frame_id'])) {
|
|
$defaultFrameId = $detectionData['frame_id'];
|
|
} else if (file_exists($eventPath.'/alarm.jpg')) {
|
|
$defaultFrameId = 'alarm';
|
|
} else if (file_exists($eventPath.'/snapshot.jpg')) {
|
|
$defaultFrameId = 'snapshot';
|
|
}
|
|
|
|
// Check which special frames exist
|
|
$availableFrames = [];
|
|
foreach (['alarm', 'snapshot'] as $special) {
|
|
if (file_exists($eventPath.'/'.$special.'.jpg')) {
|
|
$availableFrames[] = $special;
|
|
}
|
|
}
|
|
|
|
// Also check if we already have a saved annotation for this event
|
|
$base = getTrainingDataDir();
|
|
$fid = $defaultFrameId ?: 'alarm';
|
|
$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 != '';
|
|
|
|
ajaxResponse([
|
|
'detectionData' => $detectionData,
|
|
'defaultFrameId' => $defaultFrameId,
|
|
'availableFrames' => $availableFrames,
|
|
'totalFrames' => $Event->Frames(),
|
|
'eventPath' => $Event->Relative_Path(),
|
|
'width' => $Event->Width(),
|
|
'height' => $Event->Height(),
|
|
'monitorId' => $Event->MonitorId(),
|
|
'hasSavedAnnotation' => $hasSavedAnnotation,
|
|
'hasDetectScript' => $hasDetectScript,
|
|
]);
|
|
break;
|
|
|
|
case 'load_saved':
|
|
// Load previously saved annotations for an event+frame from training data
|
|
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;
|
|
}
|
|
$paths = getFramePaths($eid, $fid);
|
|
if (!file_exists($paths['lbl'])) {
|
|
ajaxResponse(['annotations' => []]);
|
|
break;
|
|
}
|
|
$labels = getClassLabels();
|
|
// Need image dimensions to convert YOLO normalized back to pixels
|
|
$Event = ZM\Event::find_one(['Id' => $eid]);
|
|
if (!$Event) {
|
|
ajaxError('Event not found');
|
|
break;
|
|
}
|
|
$imgW = $Event->Width();
|
|
$imgH = $Event->Height();
|
|
$anns = [];
|
|
$lines = file($paths['lbl'], FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
foreach ($lines as $line) {
|
|
$parts = preg_split('/\s+/', trim($line));
|
|
if (count($parts) < 5) continue;
|
|
$classId = intval($parts[0]);
|
|
$cx = floatval($parts[1]);
|
|
$cy = floatval($parts[2]);
|
|
$bw = floatval($parts[3]);
|
|
$bh = floatval($parts[4]);
|
|
$label = isset($labels[$classId]) ? $labels[$classId] : 'class_'.$classId;
|
|
$anns[] = [
|
|
'x1' => round(($cx - $bw / 2) * $imgW),
|
|
'y1' => round(($cy - $bh / 2) * $imgH),
|
|
'x2' => round(($cx + $bw / 2) * $imgW),
|
|
'y2' => round(($cy + $bh / 2) * $imgH),
|
|
'label' => $label,
|
|
];
|
|
}
|
|
ajaxResponse(['annotations' => $anns]);
|
|
break;
|
|
|
|
case 'labels':
|
|
// Return current class label list
|
|
$labels = getClassLabels();
|
|
ajaxResponse(['labels' => $labels]);
|
|
break;
|
|
|
|
case 'status':
|
|
// Return training dataset statistics
|
|
ajaxResponse(['stats' => getTrainingStats()]);
|
|
break;
|
|
|
|
case 'browse':
|
|
// Return recursive directory tree of training folder
|
|
$base = getTrainingDataDir();
|
|
if ($base === '') {
|
|
ajaxResponse(['tree' => []]);
|
|
break;
|
|
}
|
|
|
|
$tree = buildTree($base, $base);
|
|
|
|
ajaxResponse([
|
|
'tree' => $tree,
|
|
]);
|
|
break;
|
|
|
|
case 'browse_objects':
|
|
// Return images grouped by class label for the virtual "Objects" folder
|
|
$base = getTrainingDataDir();
|
|
if ($base === '') {
|
|
ajaxResponse(['objects' => new stdClass()]);
|
|
break;
|
|
}
|
|
$labelsDir = $base.'/labels/all';
|
|
if (!is_dir($labelsDir)) {
|
|
ajaxResponse(['objects' => new stdClass()]);
|
|
break;
|
|
}
|
|
$labels = getClassLabels();
|
|
$objects = [];
|
|
$backgrounds = [];
|
|
foreach (glob($labelsDir.'/*.txt') as $file) {
|
|
$stem = pathinfo(basename($file), PATHINFO_FILENAME);
|
|
$imgPath = 'images/all/'.$stem.'.jpg';
|
|
if (!file_exists($base.'/'.$imgPath)) continue;
|
|
$lines = file($file, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
|
|
if (empty($lines)) {
|
|
$backgrounds[] = ['stem' => $stem, 'imgPath' => $imgPath];
|
|
continue;
|
|
}
|
|
$seenClasses = [];
|
|
foreach ($lines as $line) {
|
|
$parts = preg_split('/\s+/', trim($line));
|
|
if (count($parts) >= 5) {
|
|
$classId = intval($parts[0]);
|
|
if (!isset($seenClasses[$classId]) && isset($labels[$classId])) {
|
|
$seenClasses[$classId] = true;
|
|
$className = $labels[$classId];
|
|
if (!isset($objects[$className])) $objects[$className] = [];
|
|
$objects[$className][] = ['stem' => $stem, 'imgPath' => $imgPath];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ksort($objects);
|
|
ajaxResponse([
|
|
'objects' => empty($objects) ? new stdClass() : $objects,
|
|
'backgrounds' => $backgrounds,
|
|
]);
|
|
break;
|
|
|
|
case 'browse_file':
|
|
// Serve an individual file from the training directory
|
|
if (empty($_REQUEST['path'])) {
|
|
ajaxError('Path required');
|
|
break;
|
|
}
|
|
|
|
$fullPath = resolveTrainingPath($_REQUEST['path']);
|
|
if ($fullPath === false) {
|
|
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;
|
|
|
|
case 'save':
|
|
// Save annotation (image + YOLO label file)
|
|
if (empty($_REQUEST['eid']) || !isset($_REQUEST['fid'])) {
|
|
ajaxError('Event ID and Frame ID required');
|
|
break;
|
|
}
|
|
|
|
$eid = validCardinal($_REQUEST['eid']);
|
|
$fid = $_REQUEST['fid'];
|
|
|
|
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;
|
|
}
|
|
|
|
$paths = getFramePaths($eid, $fid);
|
|
|
|
// Copy the frame image to training dir
|
|
$eventPath = $Event->Path();
|
|
if (in_array($fid, ['alarm', 'snapshot'])) {
|
|
$srcImage = $eventPath.'/'.$fid.'.jpg';
|
|
} else {
|
|
$srcImage = $eventPath.'/'.sprintf('%0'.ZM_EVENT_IMAGE_DIGITS.'d', $fid).'-capture.jpg';
|
|
}
|
|
|
|
if (!file_exists($srcImage)) {
|
|
ZM\Warning('Training: source image not found: '.$srcImage);
|
|
ajaxError('Source frame image not found');
|
|
break;
|
|
}
|
|
|
|
ZM\Debug('Training: copying '.$srcImage.' to '.$paths['img']);
|
|
if (!copy($srcImage, $paths['img'])) {
|
|
ZM\Error('Training: failed to copy image from '.$srcImage.' to '.$paths['img']);
|
|
ajaxError('Failed to copy image');
|
|
break;
|
|
}
|
|
|
|
// Validate and sanitize annotation labels
|
|
foreach ($annotations as $ann) {
|
|
if (!isset($ann['label']) || !preg_match('/^[a-zA-Z0-9_-]+$/', $ann['label'])) {
|
|
ajaxError('Invalid label: labels must contain only letters, numbers, hyphens, and underscores');
|
|
break 2;
|
|
}
|
|
}
|
|
|
|
// Get current labels from data.yaml, add any new ones
|
|
$labels = getClassLabels();
|
|
foreach ($annotations as $ann) {
|
|
if (!in_array($ann['label'], $labels)) {
|
|
$labels[] = $ann['label'];
|
|
}
|
|
}
|
|
|
|
// Write YOLO label file (empty file = background/negative image)
|
|
$labelLines = [];
|
|
foreach ($annotations as $ann) {
|
|
$classId = array_search($ann['label'], $labels);
|
|
$x1 = floatval($ann['x1']);
|
|
$y1 = floatval($ann['y1']);
|
|
$x2 = floatval($ann['x2']);
|
|
$y2 = floatval($ann['y2']);
|
|
// Convert pixel coords [x1, y1, x2, y2] to YOLO normalized [cx, cy, w, h]
|
|
$cx = max(0.0, min(1.0, (($x1 + $x2) / 2) / $imgWidth));
|
|
$cy = max(0.0, min(1.0, (($y1 + $y2) / 2) / $imgHeight));
|
|
$w = max(0.0, min(1.0, ($x2 - $x1) / $imgWidth));
|
|
$h = max(0.0, min(1.0, ($y2 - $y1) / $imgHeight));
|
|
$labelLines[] = sprintf('%d %.6f %.6f %.6f %.6f', $classId, $cx, $cy, $w, $h);
|
|
}
|
|
file_put_contents($paths['lbl'], empty($labelLines) ? '' : implode("\n", $labelLines)."\n", LOCK_EX);
|
|
|
|
// Write data.yaml with the current label list (only if we have labels)
|
|
if (!empty($labels)) {
|
|
writeDataYaml($labels);
|
|
}
|
|
|
|
$savedType = empty($annotations) ? 'background' : count($annotations).' annotation(s)';
|
|
ZM\Debug('Training: saved '.$savedType.' for event '.$eid.' frame '.$fid);
|
|
|
|
$stats = getTrainingStats();
|
|
|
|
ajaxResponse([
|
|
'saved' => true,
|
|
'annotations_count' => count($annotations),
|
|
'stats' => $stats,
|
|
]);
|
|
break;
|
|
|
|
case 'delete':
|
|
// Remove a saved annotation
|
|
if (empty($_REQUEST['eid']) || !isset($_REQUEST['fid'])) {
|
|
ajaxError('Event ID and Frame ID required');
|
|
break;
|
|
}
|
|
|
|
$eid = validCardinal($_REQUEST['eid']);
|
|
$fid = $_REQUEST['fid'];
|
|
|
|
if (!validFrameId($fid)) {
|
|
ajaxError('Invalid frame ID');
|
|
break;
|
|
}
|
|
|
|
$paths = getFramePaths($eid, $fid);
|
|
|
|
$deleted = false;
|
|
if (file_exists($paths['img'])) { unlink($paths['img']); $deleted = true; }
|
|
if (file_exists($paths['lbl'])) { unlink($paths['lbl']); $deleted = true; }
|
|
|
|
if ($deleted) {
|
|
rebuildDataYaml();
|
|
ZM\Debug('Training: removed annotation for event '.$eid.' frame '.$fid);
|
|
}
|
|
|
|
ajaxResponse([
|
|
'deleted' => $deleted,
|
|
'stats' => getTrainingStats(),
|
|
]);
|
|
break;
|
|
|
|
case 'delete_all':
|
|
// Delete ALL training data (images, labels, data.yaml)
|
|
$base = getTrainingDataDir();
|
|
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++; }
|
|
}
|
|
}
|
|
}
|
|
$yamlFile = $base.'/data.yaml';
|
|
if (file_exists($yamlFile)) { unlink($yamlFile); $deleted++; }
|
|
|
|
ZM\Warning('Training: deleted ALL training data ('.$deleted.' files)');
|
|
ajaxResponse([
|
|
'deleted' => $deleted,
|
|
'stats' => getTrainingStats(),
|
|
]);
|
|
break;
|
|
|
|
case 'browse_delete':
|
|
// Delete an image/label pair by file path, then update data.yaml
|
|
if (empty($_REQUEST['path'])) {
|
|
ajaxError('Path required');
|
|
break;
|
|
}
|
|
|
|
$fullPath = resolveTrainingPath($_REQUEST['path']);
|
|
if ($fullPath === false) {
|
|
ajaxError('File not found or access denied');
|
|
break;
|
|
}
|
|
|
|
// Determine the stem and delete both image + label
|
|
$base = getTrainingDataDir();
|
|
$stem = pathinfo(basename($fullPath), PATHINFO_FILENAME);
|
|
$imgFile = $base.'/images/all/'.$stem.'.jpg';
|
|
$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'; }
|
|
|
|
rebuildDataYaml();
|
|
|
|
ajaxResponse([
|
|
'deleted' => $deletedFiles,
|
|
'stats' => getTrainingStats(),
|
|
]);
|
|
break;
|
|
|
|
case 'detect':
|
|
// Run object detection script on a frame image
|
|
if (!defined('ZM_TRAINING_DETECT_SCRIPT') || ZM_TRAINING_DETECT_SCRIPT == '') {
|
|
ajaxError('No detection script configured');
|
|
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;
|
|
}
|
|
|
|
$eventPath = $Event->Path();
|
|
if (in_array($fid, ['alarm', 'snapshot'])) {
|
|
$srcImage = $eventPath.'/'.$fid.'.jpg';
|
|
} else {
|
|
$srcImage = $eventPath.'/'.sprintf('%0'.ZM_EVENT_IMAGE_DIGITS.'d', $fid).'-capture.jpg';
|
|
}
|
|
|
|
if (!file_exists($srcImage)) {
|
|
ajaxError('Source frame image not found');
|
|
break;
|
|
}
|
|
|
|
// Split config into executable path and optional extra arguments
|
|
$scriptParts = preg_split('/\s+/', trim(ZM_TRAINING_DETECT_SCRIPT), 2);
|
|
$scriptPath = $scriptParts[0];
|
|
$scriptArgs = isset($scriptParts[1]) ? ' '.$scriptParts[1] : '';
|
|
|
|
if (!file_exists($scriptPath) || !is_executable($scriptPath)) {
|
|
ajaxError('Detection script not found or not executable: '.$scriptPath);
|
|
break;
|
|
}
|
|
|
|
// Copy to temp file so the script can read it.
|
|
// tempnam() creates the base file; rename it to add .jpg extension
|
|
// so the detection script receives a proper image filename.
|
|
$tmpBase = tempnam(sys_get_temp_dir(), 'zm_detect_');
|
|
$tmpFile = $tmpBase.'.jpg';
|
|
rename($tmpBase, $tmpFile);
|
|
if (!copy($srcImage, $tmpFile)) {
|
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
|
ajaxError('Failed to create temp file for detection');
|
|
break;
|
|
}
|
|
|
|
$monitorId = $Event->MonitorId();
|
|
$cmd = escapeshellarg($scriptPath).$scriptArgs.' -f '.escapeshellarg($tmpFile).' -m '.escapeshellarg($monitorId).' 2>&1';
|
|
ZM\Debug('Training: running detect command: '.$cmd);
|
|
exec($cmd, $outputLines, $exitCode);
|
|
$output = implode("\n", $outputLines);
|
|
if (file_exists($tmpFile)) unlink($tmpFile);
|
|
|
|
ZM\Debug('Training: detect script exit code='.$exitCode.' output length='.strlen($output));
|
|
if ($exitCode !== 0 && empty($output)) {
|
|
ZM\Warning('Training: detect script failed with exit code '.$exitCode.' for event '.$eid.' frame '.$fid);
|
|
ajaxError('Detection script failed (exit code '.$exitCode.')');
|
|
break;
|
|
}
|
|
|
|
// Parse output: "PREFIX detected:labels--SPLIT--{JSON}"
|
|
$detections = [];
|
|
if (strpos($output, '--SPLIT--') !== false) {
|
|
$parts = explode('--SPLIT--', $output, 2);
|
|
$json = json_decode(trim($parts[1]), true);
|
|
if ($json && isset($json['labels']) && isset($json['boxes'])) {
|
|
for ($i = 0; $i < count($json['labels']); $i++) {
|
|
$box = isset($json['boxes'][$i]) ? $json['boxes'][$i] : [0,0,0,0];
|
|
$conf = isset($json['confidences'][$i]) ? $json['confidences'][$i] : 0;
|
|
$detections[] = [
|
|
'label' => $json['labels'][$i],
|
|
'confidence' => $conf,
|
|
'bbox' => $box,
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
ZM\Debug('Training: detect found '.count($detections).' objects for event '.$eid.' frame '.$fid);
|
|
ajaxResponse([
|
|
'detections' => $detections,
|
|
]);
|
|
break;
|
|
|
|
}
|
|
ajaxError('Unrecognised action '.$action.' or insufficient permissions for user '.$user->Username());
|
|
?>
|