From a926a5022ccc4f6c6d42d5f6e8cdf6b3f6d7d971 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Thu, 12 Feb 2026 23:11:23 +0000 Subject: [PATCH] [ci skip] sync tfstate and add frigate helper scripts --- scripts/frigate-bulk-classify.js | 698 +++++++++++++++++++++++++++++++ scripts/frigate-inspect.mjs | 305 ++++++++++++++ 2 files changed, 1003 insertions(+) create mode 100644 scripts/frigate-bulk-classify.js create mode 100644 scripts/frigate-inspect.mjs diff --git a/scripts/frigate-bulk-classify.js b/scripts/frigate-bulk-classify.js new file mode 100644 index 00000000..32b594e5 --- /dev/null +++ b/scripts/frigate-bulk-classify.js @@ -0,0 +1,698 @@ +// Frigate Bulk Classification Labeler +// Paste this into the browser console on the Frigate /classification page +// while viewing a model's training images. +// +// Image URL pattern: /clips/{modelName}/train/{filename} +// Categorize API: POST /api/classification/{modelName}/dataset/categorize +// body: { category: "...", training_file: "..." } +// Delete API: POST /api/classification/{modelName}/train/delete +// body: { ids: ["..."] } +// Dataset API: GET /api/classification/{modelName}/dataset +// returns: { categories: { catName: [files...] }, training_metadata: {...} } + +(async () => { + "use strict"; + + // --- Configuration --- + const API_BASE = window.location.origin + "/api"; + const TOOLBAR_ID = "bulk-classify-toolbar"; + // Frigate's axios instance sends these headers on every request. + // X-CSRF-TOKEN is required for state-modifying (POST/PUT/DELETE) requests. + const API_HEADERS = { + "Content-Type": "application/json", + "X-CSRF-TOKEN": "1", + "X-CACHE-BYPASS": "1", + }; + + // Abort if already injected + if (document.getElementById(TOOLBAR_ID)) { + console.log("Bulk classifier already active. Refresh page to re-inject."); + return; + } + + // --- Extract model name from page --- + // Training images use src="/clips/{modelName}/train/{filename}" + let modelName = null; + + // Method 1: Extract from training image src on the page + for (const img of document.querySelectorAll("img")) { + const src = img.getAttribute("src") || ""; + const m = src.match(/\/clips\/([^/]+)\/train\//); + if (m) { modelName = decodeURIComponent(m[1]); break; } + } + + // Method 2: List all custom models from config and let the user pick + if (!modelName) { + try { + const resp = await fetch(`${API_BASE}/config`); + const config = await resp.json(); + // Custom classification models are under config.classification.custom + const models = Object.keys(config.classification?.custom || {}); + if (models.length === 1) { + modelName = models[0]; + } else if (models.length > 1) { + modelName = prompt( + `Multiple classification models found. Enter the model name:\n\n${models.join(", ")}`, + ); + } + } catch (_) {} + } + + if (!modelName) { + alert( + "Could not detect model name.\nMake sure you are on the /classification page with training images visible.", + ); + return; + } + + console.log(`[bulk-classify] Detected model: "${modelName}"`); + + // --- Fetch categories from the dataset API --- + let categories = []; + try { + const resp = await fetch(`${API_BASE}/classification/${encodeURIComponent(modelName)}/dataset`); + const data = await resp.json(); + // Dataset response: { categories: { catName: [files...] }, training_metadata: {...} } + categories = Object.keys(data.categories || data); + } catch (e) { + console.error("Failed to fetch categories:", e); + } + + // Deduplicate + categories = [...new Set(categories)]; + console.log("[bulk-classify] Categories:", categories); + + // --- Fetch all training filenames and build event groups --- + // Frigate groups training images by eventId (first two segments of the filename). + // Filename format: {timestamp}-{randomId}-{timestamp2}-{label}-{score}.webp + // EventId = "{timestamp}-{randomId}" + let allTrainFiles = []; + const eventGroups = {}; // eventId -> [filename, ...] + + function parseEventId(filename) { + const base = filename.replace(/\.webp$/, ""); + const parts = base.split("-"); + if (parts.length >= 2) return `${parts[0]}-${parts[1]}`; + return filename; // fallback: treat as its own group + } + + try { + const resp = await fetch( + `${API_BASE}/classification/${encodeURIComponent(modelName)}/train`, + { headers: API_HEADERS }, + ); + allTrainFiles = await resp.json(); + for (const f of allTrainFiles) { + const eid = parseEventId(f); + if (!eventGroups[eid]) eventGroups[eid] = []; + eventGroups[eid].push(f); + } + console.log( + `[bulk-classify] Loaded ${allTrainFiles.length} training files in ${Object.keys(eventGroups).length} event groups.`, + ); + } catch (e) { + console.error("[bulk-classify] Failed to fetch training files:", e); + } + + // Get all filenames in the same event group as the given filename + function getGroupFiles(filename) { + const eid = parseEventId(filename); + return eventGroups[eid] || [filename]; + } + + // --- State --- + const selected = new Set(); + + // --- Inject styles --- + const style = document.createElement("style"); + style.textContent = ` + #${TOOLBAR_ID} { + position: fixed; + bottom: 20px; + left: 50%; + transform: translateX(-50%); + z-index: 99999; + background: #1e1e2e; + border: 1px solid #444; + border-radius: 12px; + padding: 12px 20px; + display: flex; + align-items: center; + gap: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.5); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 14px; + color: #cdd6f4; + } + #${TOOLBAR_ID} button { + padding: 6px 14px; + border: 1px solid #555; + border-radius: 6px; + background: #313244; + color: #cdd6f4; + cursor: pointer; + font-size: 13px; + white-space: nowrap; + } + #${TOOLBAR_ID} button:hover { + background: #45475a; + } + #${TOOLBAR_ID} button.primary { + background: #89b4fa; + color: #1e1e2e; + border-color: #89b4fa; + font-weight: 600; + } + #${TOOLBAR_ID} button.primary:hover { + background: #74c7ec; + } + #${TOOLBAR_ID} button.primary:disabled { + opacity: 0.5; + cursor: not-allowed; + } + #${TOOLBAR_ID} button.danger { + background: #f38ba8; + color: #1e1e2e; + border-color: #f38ba8; + font-weight: 600; + } + #${TOOLBAR_ID} button.danger:hover { + background: #eba0ac; + } + .bulk-classify-dropdown { + position: relative; + display: inline-block; + } + .bulk-classify-dropdown-btn { + padding: 6px 14px; + border: 1px solid #555; + border-radius: 6px; + background: #313244; + color: #cdd6f4; + cursor: pointer; + font-size: 13px; + white-space: nowrap; + min-width: 140px; + text-align: left; + } + .bulk-classify-dropdown-btn::after { + content: " ▾"; + float: right; + margin-left: 8px; + } + .bulk-classify-dropdown-menu { + display: none; + position: absolute; + bottom: 100%; + left: 0; + margin-bottom: 4px; + background: #313244; + border: 1px solid #555; + border-radius: 6px; + max-height: 250px; + overflow-y: auto; + min-width: 180px; + box-shadow: 0 -4px 16px rgba(0,0,0,0.4); + z-index: 100000; + } + .bulk-classify-dropdown-menu.open { + display: block; + } + .bulk-classify-dropdown-item { + padding: 8px 14px; + cursor: pointer; + font-size: 13px; + color: #cdd6f4; + white-space: nowrap; + } + .bulk-classify-dropdown-item:hover { + background: #45475a; + } + .bulk-classify-dropdown-item.active { + background: #89b4fa; + color: #1e1e2e; + } + #${TOOLBAR_ID} .count { + font-weight: 600; + min-width: 30px; + text-align: center; + } + #${TOOLBAR_ID} .separator { + width: 1px; + height: 24px; + background: #555; + } + #${TOOLBAR_ID} .progress { + font-size: 12px; + color: #a6adc8; + } + .bulk-classify-checkbox { + position: absolute; + top: 6px; + left: 6px; + z-index: 9999; + width: 22px; + height: 22px; + cursor: pointer; + accent-color: #89b4fa; + pointer-events: auto; + } + .bulk-classify-selected { + outline: 3px solid #89b4fa !important; + outline-offset: -3px; + } + .bulk-classify-overlay { + position: fixed; + inset: 0; + z-index: 99998; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + } + .bulk-classify-dialog { + background: #1e1e2e; + border: 1px solid #444; + border-radius: 12px; + padding: 24px; + min-width: 350px; + color: #cdd6f4; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + .bulk-classify-dialog h3 { + margin: 0 0 16px; + font-size: 16px; + } + .bulk-classify-dialog .progress-bar { + width: 100%; + height: 8px; + background: #313244; + border-radius: 4px; + overflow: hidden; + margin: 12px 0; + } + .bulk-classify-dialog .progress-fill { + height: 100%; + background: #89b4fa; + transition: width 0.2s; + } + .bulk-classify-dialog .status { + font-size: 13px; + color: #a6adc8; + } + `; + document.head.appendChild(style); + + // --- Helper: find all training image cards --- + function getImageCards() { + // Training images use src="/clips/{modelName}/train/{filename}" + // Filenames are like: 1770573871.602803-in4y00-1770573889.027752-none-1.0.webp + const pattern = /\/clips\/[^/]+\/train\/([^/?#]+)/; + const imgs = document.querySelectorAll("img"); + const cards = []; + const seen = new Set(); + for (const img of imgs) { + const src = img.getAttribute("src") || ""; + const match = src.match(pattern); + if (match && !seen.has(match[1])) { + seen.add(match[1]); + // Walk up to find the card container (Frigate uses aspect-square divs) + let card = + img.closest("[class*='aspect-']") || + img.closest("[class*='card']") || + img.parentElement?.parentElement || + img.parentElement; + // Resolve the full group of filenames for this card + const groupFiles = getGroupFiles(match[1]); + cards.push({ element: card, filename: match[1], img, groupFiles }); + } + } + return cards; + } + + // --- Debug: log what images we found --- + const debugImgs = document.querySelectorAll("img"); + const debugSrcs = Array.from(debugImgs) + .map((i) => i.getAttribute("src")) + .filter(Boolean); + console.log( + `[bulk-classify] Found ${debugSrcs.length} elements. Sample srcs:`, + debugSrcs.slice(0, 5), + ); + const initialCards = getImageCards(); + console.log( + `[bulk-classify] Matched ${initialCards.length} training image cards.`, + ); + + // --- Add checkboxes to all cards --- + function injectCheckboxes() { + const cards = getImageCards(); + for (const { element, filename, groupFiles } of cards) { + if (element.querySelector(".bulk-classify-checkbox")) continue; + + // Ensure relative positioning for absolute checkbox + element.style.position = "relative"; + + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.className = "bulk-classify-checkbox"; + cb.dataset.filename = filename; + cb.checked = selected.has(filename); + + // Show group count badge next to checkbox if group has >1 image + let badge = null; + if (groupFiles.length > 1) { + badge = document.createElement("span"); + badge.className = "bulk-classify-badge"; + badge.textContent = groupFiles.length; + badge.style.cssText = + "position:absolute;top:6px;left:32px;z-index:9999;background:#89b4fa;color:#1e1e2e;" + + "font-size:11px;font-weight:700;padding:1px 5px;border-radius:8px;pointer-events:none;"; + } + + cb.addEventListener("change", (e) => { + e.stopPropagation(); + if (cb.checked) { + // Select ALL files in this event group + for (const f of groupFiles) selected.add(f); + element.classList.add("bulk-classify-selected"); + } else { + for (const f of groupFiles) selected.delete(f); + element.classList.remove("bulk-classify-selected"); + } + updateCount(); + }); + + // Also allow clicking the image to toggle + element.addEventListener("click", (e) => { + // Don't intercept if clicking the checkbox itself or a button + if ( + e.target === cb || + e.target.closest("button") || + e.target.closest("a") + ) + return; + e.preventDefault(); + e.stopPropagation(); + cb.checked = !cb.checked; + cb.dispatchEvent(new Event("change")); + }); + + element.prepend(cb); + if (badge) element.appendChild(badge); + } + } + + // --- Toolbar --- + const toolbar = document.createElement("div"); + toolbar.id = TOOLBAR_ID; + + const countLabel = document.createElement("span"); + countLabel.className = "count"; + countLabel.textContent = "0"; + + const countText = document.createElement("span"); + countText.textContent = "selected"; + + const sep1 = document.createElement("div"); + sep1.className = "separator"; + + const selectAllBtn = document.createElement("button"); + selectAllBtn.textContent = "Select All"; + selectAllBtn.addEventListener("click", () => { + const cards = getImageCards(); + for (const { element, groupFiles } of cards) { + for (const f of groupFiles) selected.add(f); + element.classList.add("bulk-classify-selected"); + const cb = element.querySelector(".bulk-classify-checkbox"); + if (cb) cb.checked = true; + } + updateCount(); + }); + + const deselectBtn = document.createElement("button"); + deselectBtn.textContent = "Deselect All"; + deselectBtn.addEventListener("click", () => { + const cards = getImageCards(); + for (const { element, groupFiles } of cards) { + for (const f of groupFiles) selected.delete(f); + element.classList.remove("bulk-classify-selected"); + const cb = element.querySelector(".bulk-classify-checkbox"); + if (cb) cb.checked = false; + } + updateCount(); + }); + + const sep2 = document.createElement("div"); + sep2.className = "separator"; + + // --- Custom dropdown (replaces native