KartikeyaM2007
Add browser-side ONNX inference
3d719da
Raw
History Blame Contribute Delete
14.5 kB
(function () {
const app = window.avtApp;
if (!app) return;
const MODEL_ROOT = "/static/models/browser/";
const DEFAULT_FPS = 25;
let manifest = null;
let featureSession = null;
let classifierSession = null;
let activeProvider = null;
function providers() {
return navigator.gpu ? ["webgpu", "wasm"] : ["wasm"];
}
function softmax2(a, b) {
const max = Math.max(a, b);
const ea = Math.exp(a - max);
const eb = Math.exp(b - max);
const total = ea + eb;
return [ea / total, eb / total];
}
async function loadManifest() {
if (manifest) return manifest;
const response = await fetch(`${MODEL_ROOT}manifest.json`);
if (!response.ok) throw new Error("Could not load browser model manifest");
manifest = await response.json();
return manifest;
}
async function loadModels() {
if (!window.ort) throw new Error("ONNX Runtime Web is not available");
const config = await loadManifest();
const selectedProviders = providers();
activeProvider = selectedProviders[0];
app.els.clientInferenceBtn.disabled = true;
app.els.clientInferenceStatus.textContent = `Loading browser models with ${activeProvider}...`;
app.appendTerminalLine(`[browser] loading ONNX models via ${selectedProviders.join(" -> ")}`);
try {
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/";
featureSession = featureSession || (await ort.InferenceSession.create(`${MODEL_ROOT}${config.models.videomaeFeatureExtractor}`, {
executionProviders: selectedProviders,
}));
app.appendTerminalLine("[browser] VideoMAE feature extractor ready");
classifierSession = classifierSession || (await ort.InferenceSession.create(`${MODEL_ROOT}${config.models.anomalyClassifier}`, {
executionProviders: selectedProviders,
}));
app.appendTerminalLine("[browser] anomaly classifier ready");
const test = new Float32Array(1 * config.maxFrames * config.featureDim);
const started = performance.now();
await classifierSession.run({ features: new ort.Tensor("float32", test, [1, config.maxFrames, config.featureDim]) });
const elapsed = performance.now() - started;
app.els.clientInferenceStatus.textContent = `Browser models loaded on ${activeProvider}. Classifier test ${elapsed.toFixed(1)} ms.`;
} finally {
app.els.clientInferenceBtn.disabled = false;
}
}
function videoReady(video) {
return new Promise((resolve, reject) => {
video.onloadedmetadata = () => resolve();
video.onerror = () => reject(new Error("Could not load video metadata"));
});
}
function seek(video, time) {
return new Promise((resolve, reject) => {
const done = () => {
video.removeEventListener("seeked", done);
resolve();
};
video.addEventListener("seeked", done, { once: true });
video.onerror = () => reject(new Error("Could not seek uploaded video"));
video.currentTime = Math.min(Math.max(time, 0), Math.max(video.duration - 0.02, 0));
});
}
async function captureFrame(video, canvas, time, size) {
await seek(video, time);
const ctx = canvas.getContext("2d", { willReadFrequently: true });
canvas.width = size;
canvas.height = size;
ctx.drawImage(video, 0, 0, size, size);
return ctx.getImageData(0, 0, size, size);
}
async function clipTensor(video, canvas, startTime, config) {
const size = config.imageSize;
const data = new Float32Array(1 * config.clipLen * 3 * size * size);
const frameStep = config.frameSkip / DEFAULT_FPS;
for (let frame = 0; frame < config.clipLen; frame += 1) {
const image = await captureFrame(video, canvas, startTime + frame * frameStep, size);
const pixels = image.data;
const frameOffset = frame * 3 * size * size;
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const pixelIndex = (y * size + x) * 4;
const outIndex = y * size + x;
data[frameOffset + outIndex] = (pixels[pixelIndex] / 255 - config.imageMean[0]) / config.imageStd[0];
data[frameOffset + size * size + outIndex] = (pixels[pixelIndex + 1] / 255 - config.imageMean[1]) / config.imageStd[1];
data[frameOffset + 2 * size * size + outIndex] = (pixels[pixelIndex + 2] / 255 - config.imageMean[2]) / config.imageStd[2];
}
}
}
return new ort.Tensor("float32", data, [1, config.clipLen, 3, size, size]);
}
function buildClipStarts(duration, config) {
const span = (config.clipLen * config.frameSkip) / DEFAULT_FPS;
const stride = config.clipStride / DEFAULT_FPS;
const starts = [];
for (let start = 0; start + span <= duration; start += stride) {
starts.push(start);
}
if (!starts.length) starts.push(0);
return starts;
}
async function extractFeatures(video, canvas, config) {
const starts = buildClipStarts(video.duration, config);
const features = [];
const started = performance.now();
for (let i = 0; i < starts.length; i += 1) {
app.setWorkflowStep("features", "running", `Browser feature ${i + 1}/${starts.length}`);
app.appendTerminalLine(`[browser-features] extracting feature ${i + 1}/${starts.length} t=${starts[i].toFixed(2)}s`);
const tensor = await clipTensor(video, canvas, starts[i], config);
const outputs = await featureSession.run({ pixel_values: tensor });
features.push(new Float32Array(outputs.features.data));
app.appendTerminalLine(`[browser-features] feature ${i + 1} ready shape=(768,)`);
await new Promise((resolve) => setTimeout(resolve, 0));
}
app.appendTerminalLine(`[browser-features] completed ${features.length} features in ${((performance.now() - started) / 1000).toFixed(2)}s`);
return { features, starts };
}
async function predictFeatureWindow(featureRows, threshold, config, label) {
const input = new Float32Array(1 * config.maxFrames * config.featureDim);
const rows = featureRows.length > config.maxFrames ? featureRows.slice(-config.maxFrames) : featureRows;
for (let row = 0; row < rows.length; row += 1) {
input.set(rows[row], row * config.featureDim);
}
const outputs = await classifierSession.run({
features: new ort.Tensor("float32", input, [1, config.maxFrames, config.featureDim]),
});
const logits = outputs.logits.data;
const [probNormal, probAnomaly] = softmax2(logits[0], logits[1]);
const prediction = probAnomaly >= threshold ? "ANOMALY" : "NORMAL";
app.appendTerminalLine(`[browser-calc] ${label}: normal=${probNormal.toFixed(4)} threat=${probAnomaly.toFixed(4)} => ${prediction}`);
return {
prob_normal: probNormal,
prob_anomaly: probAnomaly,
prediction,
confidence: Math.max(probNormal, probAnomaly),
};
}
async function scoreTimeline(features, starts, duration, threshold, config) {
const timeline = [];
const segmentClips = config.segmentClips;
const step = Math.max(1, Math.floor(segmentClips / 2));
for (let start = 0; start < Math.max(1, features.length - segmentClips + 1); start += step) {
const end = Math.min(start + segmentClips, features.length);
const result = await predictFeatureWindow(features.slice(start, end), threshold, config, `segment_${timeline.length + 1}`);
timeline.push({
start: Number(starts[start].toFixed(2)),
end: Number(Math.min(starts[end - 1] + (config.clipLen * config.frameSkip) / DEFAULT_FPS, duration).toFixed(2)),
prob_anomaly: result.prob_anomaly,
prediction: result.prediction,
});
}
return timeline;
}
function segmentUnionSeconds(segments) {
const intervals = segments.map((seg) => [Number(seg.start), Number(seg.end)]).sort((a, b) => a[0] - b[0]);
const merged = [];
for (const [start, end] of intervals) {
if (!merged.length || start > merged[merged.length - 1][1]) merged.push([start, end]);
else merged[merged.length - 1][1] = Math.max(merged[merged.length - 1][1], end);
}
return merged.reduce((sum, [start, end]) => sum + Math.max(0, end - start), 0);
}
function scoreAtTime(timeline, time) {
return (
timeline.find((segment) => Number(segment.start) <= time && time <= Number(segment.end)) ||
timeline.reduce((best, segment) => {
const current = Math.min(Math.abs(time - segment.start), Math.abs(time - segment.end));
const previous = Math.min(Math.abs(time - best.start), Math.abs(time - best.end));
return current < previous ? segment : best;
}, timeline[0])
);
}
async function frameSamples(video, timeline) {
if (!timeline.length) return [];
const canvas = document.createElement("canvas");
const samples = [];
const duration = video.duration;
const times = [0, duration * 0.25, duration * 0.5, duration * 0.75];
const peak = timeline.reduce((best, segment) => (segment.prob_anomaly > best.prob_anomaly ? segment : best), timeline[0]);
times.unshift((peak.start + peak.end) / 2);
const used = new Set();
for (const time of times) {
const key = time.toFixed(2);
if (used.has(key)) continue;
used.add(key);
await captureFrame(video, canvas, time, 360);
const score = scoreAtTime(timeline, time);
samples.push({
time: Number(time.toFixed(2)),
score: score.prob_anomaly,
prediction: score.prediction,
image: canvas.toDataURL("image/jpeg", 0.74),
});
if (samples.length >= 6) break;
}
return samples.sort((a, b) => a.time - b.time);
}
async function analyzeInBrowser() {
const file = app.els.videoFile.files[0];
if (!file) return;
app.startWorkflow(file);
app.els.uploadResult.textContent = "Starting browser-side analysis...";
app.els.timeline.innerHTML = "";
app.els.visualSummary.innerHTML = "";
app.els.analysisMetrics.innerHTML = "";
app.els.frameGallery.innerHTML = "";
const processingStarted = performance.now();
let url = null;
try {
const config = await loadManifest();
await loadModels();
app.appendTerminalLine("[browser] decoding video locally");
app.setWorkflowStep("frames", "running", "Reading browser video metadata");
url = URL.createObjectURL(file);
const video = document.createElement("video");
video.muted = true;
video.playsInline = true;
video.preload = "auto";
video.src = url;
await videoReady(video);
app.appendTerminalLine(`[browser-video] duration=${video.duration.toFixed(2)}s assumed_fps=${DEFAULT_FPS}`);
const canvas = document.createElement("canvas");
const featureStarted = performance.now();
const { features, starts } = await extractFeatures(video, canvas, config);
const featureSeconds = (performance.now() - featureStarted) / 1000;
app.setWorkflowStep("scoring", "running", "Scoring in browser");
const threshold = Number(app.els.thresholdInput.value);
const scoringStarted = performance.now();
const overall = await predictFeatureWindow(features, threshold, config, "overall");
const timeline = await scoreTimeline(features, starts, video.duration, threshold, config);
const scoringSeconds = (performance.now() - scoringStarted) / 1000;
const anomalySegments = timeline.filter((segment) => segment.prob_anomaly >= threshold);
const peakScore = Math.max(...timeline.map((segment) => segment.prob_anomaly), overall.prob_anomaly);
const peakSegment = timeline.reduce((best, segment) => (segment.prob_anomaly > best.prob_anomaly ? segment : best), timeline[0]);
const averageScore = timeline.reduce((sum, segment) => sum + segment.prob_anomaly, 0) / Math.max(timeline.length, 1);
const anomalySeconds = segmentUnionSeconds(anomalySegments);
const samples = await frameSamples(video, timeline);
const operationalScore = Math.max(overall.prob_anomaly, peakScore);
const operational = {
prob_anomaly: operationalScore,
prob_normal: 1 - operationalScore,
prediction: operationalScore >= threshold ? "ANOMALY" : "NORMAL",
confidence: Math.max(operationalScore, 1 - operationalScore),
basis: peakScore >= overall.prob_anomaly ? "peak_segment" : "whole_video",
};
const result = {
filename: file.name,
duration: video.duration,
fps: DEFAULT_FPS,
clips: features.length,
threshold,
overall,
operational,
timeline,
anomaly_segments: anomalySegments,
peak_score: peakScore,
peak_segment: peakSegment,
frame_samples: samples,
metrics: {
frames: Math.round(video.duration * DEFAULT_FPS),
fps: DEFAULT_FPS,
duration_seconds: Number(video.duration.toFixed(2)),
clips: features.length,
features: features.length,
feature_dim: config.featureDim,
timeline_segments: timeline.length,
anomaly_segments: anomalySegments.length,
anomaly_seconds: Number(anomalySeconds.toFixed(2)),
anomaly_coverage: anomalySeconds / Math.max(video.duration, 0.001),
average_score: averageScore,
peak_score: peakScore,
threshold,
processing_seconds: Number(((performance.now() - processingStarted) / 1000).toFixed(3)),
phase_times: {
read_video_seconds: 0,
feature_extraction_seconds: Number(featureSeconds.toFixed(3)),
scoring_seconds: Number(scoringSeconds.toFixed(3)),
},
},
};
app.appendTerminalLine(`[browser-done] completed prediction=${operational.prediction} threat=${operationalScore.toFixed(4)}`, "complete");
app.setWorkflowStep("completed", "complete", "Browser scoring completed");
app.completeWorkflow(result);
app.renderUpload(result);
} catch (err) {
app.appendTerminalLine(`[browser-error] ${err.message}`, "error");
app.failWorkflow(err.message);
app.els.uploadResult.textContent = err.message;
} finally {
if (url) URL.revokeObjectURL(url);
}
}
const browserAnalyzeBtn = document.getElementById("browserAnalyzeBtn");
if (browserAnalyzeBtn) {
browserAnalyzeBtn.addEventListener("click", analyzeInBrowser);
}
window.avtBrowserInference = {
analyzeInBrowser,
loadModels,
};
})();