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. // Use ZoneMinder's configured temp dir (ZM_DIR_TEMP) rather than the PHP // system temp, so the file stays inside ZM's controlled/cleaned tree. $tmpBase = tempnam(ZM_DIR_TEMP, '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()); ?>