[ci skip] sync tfstate and add frigate helper scripts
This commit is contained in:
parent
cd5261161b
commit
a926a5022c
2 changed files with 1003 additions and 0 deletions
698
scripts/frigate-bulk-classify.js
Normal file
698
scripts/frigate-bulk-classify.js
Normal file
|
|
@ -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} <img> 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 <select> which React intercepts) ---
|
||||
let selectedCategory = "";
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "bulk-classify-dropdown";
|
||||
|
||||
const dropdownBtn = document.createElement("div");
|
||||
dropdownBtn.className = "bulk-classify-dropdown-btn";
|
||||
dropdownBtn.textContent = "-- pick category --";
|
||||
|
||||
const dropdownMenu = document.createElement("div");
|
||||
dropdownMenu.className = "bulk-classify-dropdown-menu";
|
||||
|
||||
function buildMenuItems() {
|
||||
dropdownMenu.innerHTML = "";
|
||||
for (const cat of categories) {
|
||||
const item = document.createElement("div");
|
||||
item.className = "bulk-classify-dropdown-item";
|
||||
if (cat === selectedCategory) item.classList.add("active");
|
||||
item.textContent = cat;
|
||||
item.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
selectedCategory = cat;
|
||||
dropdownBtn.textContent = cat;
|
||||
dropdownMenu.classList.remove("open");
|
||||
buildMenuItems(); // refresh active state
|
||||
});
|
||||
dropdownMenu.appendChild(item);
|
||||
}
|
||||
}
|
||||
buildMenuItems();
|
||||
|
||||
dropdownBtn.addEventListener("mousedown", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dropdownMenu.classList.toggle("open");
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener("mousedown", (e) => {
|
||||
if (!dropdown.contains(e.target)) {
|
||||
dropdownMenu.classList.remove("open");
|
||||
}
|
||||
});
|
||||
|
||||
dropdown.appendChild(dropdownBtn);
|
||||
dropdown.appendChild(dropdownMenu);
|
||||
|
||||
// Allow typing a new category
|
||||
const newCatInput = document.createElement("input");
|
||||
newCatInput.type = "text";
|
||||
newCatInput.placeholder = "or type new...";
|
||||
newCatInput.style.cssText =
|
||||
"padding:6px 10px;border:1px solid #555;border-radius:6px;background:#313244;color:#cdd6f4;font-size:13px;width:120px;";
|
||||
|
||||
const categorizeBtn = document.createElement("button");
|
||||
categorizeBtn.className = "primary";
|
||||
categorizeBtn.textContent = "Categorize Selected";
|
||||
|
||||
const deleteBtn = document.createElement("button");
|
||||
deleteBtn.className = "danger";
|
||||
deleteBtn.textContent = "Delete Selected";
|
||||
|
||||
toolbar.append(
|
||||
countLabel,
|
||||
countText,
|
||||
sep1,
|
||||
selectAllBtn,
|
||||
deselectBtn,
|
||||
sep2,
|
||||
dropdown,
|
||||
newCatInput,
|
||||
categorizeBtn,
|
||||
deleteBtn,
|
||||
);
|
||||
|
||||
// Prevent events from bubbling out of toolbar to React's root handler
|
||||
for (const evt of ["click", "mousedown", "mouseup", "pointerdown", "pointerup", "focus", "blur"]) {
|
||||
toolbar.addEventListener(evt, (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
document.body.appendChild(toolbar);
|
||||
|
||||
function updateCount() {
|
||||
countLabel.textContent = selected.size;
|
||||
categorizeBtn.disabled = selected.size === 0;
|
||||
}
|
||||
|
||||
// --- Progress dialog ---
|
||||
function showProgress(title, total) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "bulk-classify-overlay";
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "bulk-classify-dialog";
|
||||
dialog.innerHTML = `
|
||||
<h3>${title}</h3>
|
||||
<div class="status">0 / ${total}</div>
|
||||
<div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>
|
||||
<div class="errors" style="color:#f38ba8;font-size:12px;margin-top:8px"></div>
|
||||
`;
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
return {
|
||||
update(current, errorMsg) {
|
||||
const pct = Math.round((current / total) * 100);
|
||||
dialog.querySelector(".status").textContent =
|
||||
`${current} / ${total}`;
|
||||
dialog.querySelector(".progress-fill").style.width = pct + "%";
|
||||
if (errorMsg) {
|
||||
dialog.querySelector(".errors").textContent += errorMsg + "\n";
|
||||
}
|
||||
},
|
||||
close() {
|
||||
overlay.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Categorize handler ---
|
||||
// POST /api/classification/{modelName}/dataset/categorize
|
||||
// body: { category: "...", training_file: "..." }
|
||||
categorizeBtn.addEventListener("click", async () => {
|
||||
const category = newCatInput.value.trim() || selectedCategory;
|
||||
if (!category) {
|
||||
alert("Select a category or type a new one.");
|
||||
return;
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
alert("No images selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(selected);
|
||||
if (
|
||||
!confirm(
|
||||
`Categorize ${files.length} image(s) as "${category}"?`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const progress = showProgress(
|
||||
`Categorizing as "${category}"`,
|
||||
files.length,
|
||||
);
|
||||
let errors = 0;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/classification/${encodeURIComponent(modelName)}/dataset/categorize`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: API_HEADERS,
|
||||
body: JSON.stringify({
|
||||
category: category,
|
||||
training_file: files[i],
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
progress.update(i + 1, `Failed: ${files[i]} - ${text}`);
|
||||
errors++;
|
||||
} else {
|
||||
progress.update(i + 1);
|
||||
}
|
||||
} catch (e) {
|
||||
progress.update(i + 1, `Error: ${files[i]} - ${e.message}`);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
progress.close();
|
||||
if (errors === 0) {
|
||||
selected.clear();
|
||||
updateCount();
|
||||
alert(
|
||||
`Done! ${files.length} image(s) categorized as "${category}".\nRefreshing the training view...`,
|
||||
);
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(
|
||||
`Completed with ${errors} error(s). Check console for details.`,
|
||||
);
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// --- Delete handler ---
|
||||
// POST /api/classification/{modelName}/train/delete
|
||||
// body: { ids: ["filename1", "filename2", ...] }
|
||||
deleteBtn.addEventListener("click", async () => {
|
||||
if (selected.size === 0) {
|
||||
alert("No images selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(selected);
|
||||
if (
|
||||
!confirm(
|
||||
`DELETE ${files.length} training image(s)? This cannot be undone.`,
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const progress = showProgress("Deleting training images", 1);
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/classification/${encodeURIComponent(modelName)}/train/delete`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: API_HEADERS,
|
||||
body: JSON.stringify({ ids: files }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text();
|
||||
progress.update(1, `Failed: ${text}`);
|
||||
} else {
|
||||
progress.update(1);
|
||||
}
|
||||
} catch (e) {
|
||||
progress.update(1, `Error: ${e.message}`);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
progress.close();
|
||||
selected.clear();
|
||||
updateCount();
|
||||
alert(`Deleted ${files.length} training image(s).\nRefreshing...`);
|
||||
window.location.reload();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// --- Initial injection + MutationObserver for dynamic loading ---
|
||||
injectCheckboxes();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
injectCheckboxes();
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
updateCount();
|
||||
console.log(
|
||||
`Bulk classifier active for model "${modelName}". ${categories.length} categories found: [${categories.join(", ")}]`,
|
||||
);
|
||||
})();
|
||||
305
scripts/frigate-inspect.mjs
Normal file
305
scripts/frigate-inspect.mjs
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
#!/usr/bin/env node
|
||||
// Frigate Classification Page Inspector
|
||||
// Phase 1: Fetch API data via HTTP to understand the data model
|
||||
// Phase 2: Fetch the classification page HTML and parse its DOM structure
|
||||
// No browser needed — uses plain HTTP requests.
|
||||
|
||||
import { spawn } from "child_process";
|
||||
import http from "http";
|
||||
|
||||
const KUBE_CONFIG = `${process.cwd()}/config`;
|
||||
const LOCAL_PORT = 15000;
|
||||
const FRIGATE_NS = "frigate";
|
||||
const FRIGATE_SVC = "svc/frigate";
|
||||
const FRIGATE_PORT = 80;
|
||||
const BASE_URL = `http://localhost:${LOCAL_PORT}`;
|
||||
|
||||
async function startPortForward() {
|
||||
console.log(
|
||||
`[port-forward] Starting: kubectl port-forward ${FRIGATE_SVC} ${LOCAL_PORT}:${FRIGATE_PORT} -n ${FRIGATE_NS}`,
|
||||
);
|
||||
const proc = spawn(
|
||||
"kubectl",
|
||||
[
|
||||
"--kubeconfig",
|
||||
KUBE_CONFIG,
|
||||
"port-forward",
|
||||
FRIGATE_SVC,
|
||||
`${LOCAL_PORT}:${FRIGATE_PORT}`,
|
||||
"-n",
|
||||
FRIGATE_NS,
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("Port-forward timed out")),
|
||||
15000,
|
||||
);
|
||||
proc.stdout.on("data", (data) => {
|
||||
if (data.toString().includes("Forwarding from")) {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
proc.stderr.on("data", (data) => {
|
||||
console.error(`[port-forward stderr] ${data.toString().trim()}`);
|
||||
});
|
||||
proc.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
proc.on("exit", (code) => {
|
||||
if (code !== null && code !== 0) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`port-forward exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
console.log("[port-forward] Ready");
|
||||
return proc;
|
||||
}
|
||||
|
||||
function httpGet(path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
http.get(url, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => (body += chunk));
|
||||
res.on("end", () =>
|
||||
resolve({ status: res.statusCode, body, headers: res.headers }),
|
||||
);
|
||||
}).on("error", (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let portForwardProc = null;
|
||||
|
||||
try {
|
||||
portForwardProc = await startPortForward();
|
||||
|
||||
// ================================================================
|
||||
// API INSPECTION
|
||||
// ================================================================
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("API INSPECTION");
|
||||
console.log("=".repeat(80));
|
||||
|
||||
// Get config to find model names
|
||||
const configResp = await httpGet("/api/config");
|
||||
let modelNames = [];
|
||||
if (configResp.status === 200) {
|
||||
try {
|
||||
const config = JSON.parse(configResp.body);
|
||||
// Custom classification models are under config.classification.custom
|
||||
const classificationModels = config.classification?.custom || {};
|
||||
modelNames = Object.keys(classificationModels);
|
||||
console.log(
|
||||
`\n[API] /api/config - Classification models: ${JSON.stringify(modelNames)}`,
|
||||
);
|
||||
console.log(
|
||||
`[API] Classification config:\n${JSON.stringify(config.classification, null, 2)}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(`[API] /api/config - Failed to parse: ${e.message}`);
|
||||
console.log(
|
||||
`[API] Raw (first 500): ${configResp.body.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`[API] /api/config - HTTP ${configResp.status}`);
|
||||
}
|
||||
|
||||
for (const model of modelNames) {
|
||||
console.log(`\n--- Model: ${model} ---`);
|
||||
const encodedModel = encodeURIComponent(model);
|
||||
|
||||
// Dataset endpoint
|
||||
const datasetResp = await httpGet(
|
||||
`/api/classification/${encodedModel}/dataset`,
|
||||
);
|
||||
if (datasetResp.status === 200) {
|
||||
try {
|
||||
const dataset = JSON.parse(datasetResp.body);
|
||||
// Dataset response: { categories: { catName: [files...] }, training_metadata: {...} }
|
||||
const cats = dataset.categories || dataset;
|
||||
const categories = Object.keys(cats);
|
||||
console.log(`[API] /api/classification/${model}/dataset`);
|
||||
console.log(` Categories: ${JSON.stringify(categories)}`);
|
||||
for (const cat of categories) {
|
||||
const items = Array.isArray(cats[cat]) ? cats[cat] : [];
|
||||
console.log(
|
||||
` "${cat}": ${items.length} items, first 3: ${JSON.stringify(items.slice(0, 3))}`,
|
||||
);
|
||||
}
|
||||
if (dataset.training_metadata) {
|
||||
console.log(` Training metadata: ${JSON.stringify(dataset.training_metadata, null, 2)}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(` Failed to parse dataset: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[API] /api/classification/${model}/dataset - HTTP ${datasetResp.status}: ${datasetResp.body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Train endpoint
|
||||
const trainResp = await httpGet(
|
||||
`/api/classification/${encodedModel}/train`,
|
||||
);
|
||||
if (trainResp.status === 200) {
|
||||
try {
|
||||
const train = JSON.parse(trainResp.body);
|
||||
const entries = Array.isArray(train) ? train : Object.entries(train);
|
||||
console.log(`[API] /api/classification/${model}/train`);
|
||||
console.log(
|
||||
` Type: ${Array.isArray(train) ? "array" : typeof train}, length/keys: ${Array.isArray(train) ? train.length : Object.keys(train).length}`,
|
||||
);
|
||||
console.log(
|
||||
` First 5 entries:\n${JSON.stringify(entries.slice(0, 5), null, 2)}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(` Failed to parse train: ${e.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[API] /api/classification/${model}/train - HTTP ${trainResp.status}: ${trainResp.body.slice(0, 200)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to get a thumbnail URL to understand the image src pattern
|
||||
if (trainResp.status === 200) {
|
||||
try {
|
||||
const train = JSON.parse(trainResp.body);
|
||||
const firstFile = Array.isArray(train) ? train[0] : null;
|
||||
if (firstFile) {
|
||||
// Try various thumbnail URL patterns
|
||||
const patterns = [
|
||||
`/api/classification/${encodedModel}/train/${firstFile}/thumbnail.jpg`,
|
||||
`/api/classification/${encodedModel}/train/${firstFile}`,
|
||||
`/clips/${encodedModel}/train/${firstFile}`,
|
||||
];
|
||||
for (const p of patterns) {
|
||||
const resp = await httpGet(p);
|
||||
console.log(
|
||||
` Thumbnail URL test: ${p} -> HTTP ${resp.status} (content-type: ${resp.headers["content-type"]}, size: ${resp.body.length})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// HTML/DOM INSPECTION
|
||||
// ================================================================
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("HTML / DOM INSPECTION");
|
||||
console.log("=".repeat(80));
|
||||
|
||||
// Fetch the main classification page HTML
|
||||
const classifPageResp = await httpGet("/classification");
|
||||
console.log(
|
||||
`\n[HTML] /classification - HTTP ${classifPageResp.status} (${classifPageResp.body.length} bytes)`,
|
||||
);
|
||||
|
||||
// This is likely a React SPA, so the HTML will be minimal. Let's check.
|
||||
const html = classifPageResp.body;
|
||||
console.log(`[HTML] First 2000 chars:\n${html.slice(0, 2000)}`);
|
||||
|
||||
// Check for any JS bundle references (to find source maps or component names)
|
||||
const scriptMatches = html.match(/<script[^>]*src="([^"]+)"[^>]*>/g) || [];
|
||||
console.log(`\n[HTML] Script tags: ${scriptMatches.length}`);
|
||||
for (const s of scriptMatches) {
|
||||
console.log(` ${s}`);
|
||||
}
|
||||
|
||||
// Fetch the main JS bundle to look for classification component code
|
||||
const jsMatch = html.match(/src="(\/assets\/[^"]+\.js)"/);
|
||||
if (jsMatch) {
|
||||
console.log(`\n[JS] Fetching main bundle: ${jsMatch[1]}`);
|
||||
const jsResp = await httpGet(jsMatch[1]);
|
||||
if (jsResp.status === 200) {
|
||||
const js = jsResp.body;
|
||||
console.log(`[JS] Bundle size: ${js.length} bytes`);
|
||||
|
||||
// Search for classification-related code patterns
|
||||
const searchTerms = [
|
||||
"classify image as",
|
||||
"Classify image as",
|
||||
"categorize",
|
||||
"/classification/",
|
||||
"dataset/categorize",
|
||||
"training_file",
|
||||
"train/delete",
|
||||
"ModelTraining",
|
||||
"classification",
|
||||
];
|
||||
for (const term of searchTerms) {
|
||||
const idx = js.indexOf(term);
|
||||
if (idx !== -1) {
|
||||
const context = js.slice(Math.max(0, idx - 200), idx + 200);
|
||||
console.log(`\n[JS] Found "${term}" at offset ${idx}:`);
|
||||
console.log(` ...${context}...`);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for the dropdown/select implementation
|
||||
const selectTerms = [
|
||||
"combobox",
|
||||
"listbox",
|
||||
"SelectTrigger",
|
||||
"SelectContent",
|
||||
"SelectItem",
|
||||
"Select>",
|
||||
"DropdownMenu",
|
||||
];
|
||||
for (const term of selectTerms) {
|
||||
const idx = js.indexOf(term);
|
||||
if (idx !== -1) {
|
||||
const context = js.slice(Math.max(0, idx - 150), idx + 150);
|
||||
console.log(`\n[JS] Found "${term}" at offset ${idx}:`);
|
||||
console.log(` ...${context}...`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if there are multiple JS chunks
|
||||
const allJsMatches =
|
||||
html.match(/src="(\/assets\/[^"]+\.js)"/g) || [];
|
||||
console.log(`\n[JS] All JS assets: ${allJsMatches.length}`);
|
||||
for (const m of allJsMatches) {
|
||||
const path = m.match(/src="([^"]+)"/)?.[1];
|
||||
if (path) console.log(` ${path}`);
|
||||
}
|
||||
|
||||
// Try to fetch the Frigate source for classification view from GitHub
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("FRIGATE VERSION");
|
||||
console.log("=".repeat(80));
|
||||
|
||||
const versionResp = await httpGet("/api/version");
|
||||
if (versionResp.status === 200) {
|
||||
console.log(`[API] Frigate version: ${versionResp.body}`);
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("INSPECTION COMPLETE");
|
||||
console.log("=".repeat(80));
|
||||
} catch (err) {
|
||||
console.error(`\n[ERROR] ${err.message}`);
|
||||
console.error(err.stack);
|
||||
} finally {
|
||||
if (portForwardProc) {
|
||||
console.log("\n[cleanup] Killing port-forward...");
|
||||
portForwardProc.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Loading…
Add table
Add a link
Reference in a new issue