fire-detection / detector.js
cforge42's picture
Add Innomium Ember static fire detection Space.
9282073
Raw
History Blame Contribute Delete
21.1 kB
import * as ort from "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.min.mjs";
const CLASS_NAMES = ["fire", "smoke", "fire extinguisher"];
const CLS_REMAP = [0, 2, 1];
const CLASS_COLORS = {
fire: "#EF4444",
smoke: "#64748B",
"fire extinguisher": "#DC2626",
};
const CONFIG = {
confThres: [0.15, 0.3, 0.23],
bonus: [0.02, 0.1, 0.1],
iouThres: 0.55,
crossIouThresh: 0.8,
maxDet: 150,
useTta: false,
minBoxArea: 14 * 14,
minSide: 8,
maxAspectRatio: 8.0,
maxBoxAreaRatio: 0.95,
smokeMergeOverlap: 0.8,
fireMergeOverlap: 1.01,
fireSuppressOverlap: 0.88,
fireColorFilterMaxConf: 0.45,
fireExtColorFilterMaxConf: 0.4,
colorFilterMinSaturation: 0.06,
};
function safeDim(value, fallback) {
return Number.isInteger(value) && value > 0 ? value : fallback;
}
function remapClassId(clsId) {
return CLS_REMAP[clsId] ?? clsId;
}
function clipBoxes(boxes, imageW, imageH) {
for (const box of boxes) {
box[0] = Math.min(Math.max(box[0], 0), imageW - 1);
box[1] = Math.min(Math.max(box[1], 0), imageH - 1);
box[2] = Math.min(Math.max(box[2], 0), imageW - 1);
box[3] = Math.min(Math.max(box[3], 0), imageH - 1);
}
return boxes;
}
function hardNms(boxes, scores, iouThresh) {
const n = boxes.length;
if (n === 0) return [];
const order = scores
.map((score, index) => [score, index])
.sort((a, b) => b[0] - a[0])
.map((pair) => pair[1]);
const keep = [];
while (order.length > 0) {
const i = order.shift();
keep.push(i);
if (order.length === 0) break;
const rest = [];
const boxI = boxes[i];
const areaI = Math.max(0, boxI[2] - boxI[0]) * Math.max(0, boxI[3] - boxI[1]);
for (const j of order) {
const boxJ = boxes[j];
const xx1 = Math.max(boxI[0], boxJ[0]);
const yy1 = Math.max(boxI[1], boxJ[1]);
const xx2 = Math.min(boxI[2], boxJ[2]);
const yy2 = Math.min(boxI[3], boxJ[3]);
const inter = Math.max(0, xx2 - xx1) * Math.max(0, yy2 - yy1);
const areaJ = Math.max(0, boxJ[2] - boxJ[0]) * Math.max(0, boxJ[3] - boxJ[1]);
const iou = inter / (areaI + areaJ - inter + 1e-7);
if (iou <= iouThresh) rest.push(j);
}
order.length = 0;
order.push(...rest);
}
return keep;
}
function perClassHardNms(boxes, scores, clsIds, iouThresh) {
if (boxes.length === 0) return [];
const classes = [...new Set(clsIds)];
const allKeep = [];
for (const cls of classes) {
const indices = clsIds.map((id, i) => (id === cls ? i : -1)).filter((i) => i >= 0);
const classBoxes = indices.map((i) => boxes[i]);
const classScores = indices.map((i) => scores[i]);
const keep = hardNms(classBoxes, classScores, iouThresh);
allKeep.push(...keep.map((k) => indices[k]));
}
allKeep.sort((a, b) => a - b);
return allKeep;
}
function confFilterMask(scores, clsIds) {
const keep = scores.map((score, i) => score >= CONFIG.confThres[clsIds[i]]);
const classes = [...new Set(clsIds)];
for (const c of classes) {
if (CONFIG.bonus[c] <= 0) continue;
const indices = clsIds.map((id, i) => (id === c ? i : -1)).filter((i) => i >= 0);
if (indices.some((i) => keep[i])) continue;
let top = indices[0];
for (const i of indices) {
if (scores[i] > scores[top]) top = i;
}
if (scores[top] >= CONFIG.confThres[c] - CONFIG.bonus[c]) keep[top] = true;
}
return keep;
}
function filterSaneBoxes(boxes, scores, clsIds, origW, origH) {
const imageArea = origW * origH;
const keptBoxes = [];
const keptScores = [];
const keptCls = [];
for (let i = 0; i < boxes.length; i += 1) {
const [x1, y1, x2, y2] = boxes[i];
const bw = x2 - x1;
const bh = y2 - y1;
if (bw <= 0 || bh <= 0) continue;
if (bw < CONFIG.minSide || bh < CONFIG.minSide) continue;
const area = bw * bh;
if (area < CONFIG.minBoxArea) continue;
if (area > CONFIG.maxBoxAreaRatio * imageArea) continue;
const ar = Math.max(bw / Math.max(bh, 1e-6), bh / Math.max(bw, 1e-6));
if (ar > CONFIG.maxAspectRatio) continue;
keptBoxes.push(boxes[i]);
keptScores.push(scores[i]);
keptCls.push(clsIds[i]);
}
return { boxes: keptBoxes, scores: keptScores, clsIds: keptCls };
}
function crossClassDedup(boxes, scores, clsIds, iouThresh) {
const n = boxes.length;
if (n <= 1) return { boxes, scores, clsIds };
const areas = boxes.map(([x1, y1, x2, y2]) => Math.max(0, x2 - x1) * Math.max(0, y2 - y1));
const margins = scores.map((s, i) => s - CONFIG.confThres[clsIds[i]]);
const order = [...Array(n).keys()].sort((a, b) => {
if (margins[b] !== margins[a]) return margins[b] - margins[a];
return areas[b] - areas[a];
});
const suppressed = new Array(n).fill(false);
const keep = [];
for (const i of order) {
if (suppressed[i]) continue;
keep.push(i);
const bi = boxes[i];
const areaI = Math.max(1e-7, (bi[2] - bi[0]) * (bi[3] - bi[1]));
for (let j = 0; j < n; j += 1) {
if (j === i || suppressed[j]) continue;
const bj = boxes[j];
const xx1 = Math.max(bi[0], bj[0]);
const yy1 = Math.max(bi[1], bj[1]);
const xx2 = Math.min(bi[2], bj[2]);
const yy2 = Math.min(bi[3], bj[3]);
const inter = Math.max(0, xx2 - xx1) * Math.max(0, yy2 - yy1);
const iou = inter / (areaI + areas[j] - inter + 1e-7);
if (iou > iouThresh) suppressed[j] = true;
}
}
return {
boxes: keep.map((i) => boxes[i]),
scores: keep.map((i) => scores[i]),
clsIds: keep.map((i) => clsIds[i]),
};
}
function mergeClassBoxes(boxes, scores, clsIds, targetCls, overlap) {
if (overlap > 1.0) return { boxes, scores, clsIds };
const idx = clsIds.map((id, i) => (id === targetCls ? i : -1)).filter((i) => i >= 0);
if (idx.length <= 1) return { boxes, scores, clsIds };
let sb = idx.map((i) => [...boxes[i]]);
let ss = idx.map((i) => scores[i]);
let mergedAny = true;
while (mergedAny && sb.length > 1) {
mergedAny = false;
for (let i = 0; i < sb.length; i += 1) {
for (let j = i + 1; j < sb.length; j += 1) {
const a = sb[i];
const b = sb[j];
const ix1 = Math.max(a[0], b[0]);
const iy1 = Math.max(a[1], b[1]);
const ix2 = Math.min(a[2], b[2]);
const iy2 = Math.min(a[3], b[3]);
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
const areaA = Math.max(0, a[2] - a[0]) * Math.max(0, a[3] - a[1]);
const areaB = Math.max(0, b[2] - b[0]) * Math.max(0, b[3] - b[1]);
if (inter / (Math.min(areaA, areaB) + 1e-7) >= overlap) {
sb[i] = [Math.min(a[0], b[0]), Math.min(a[1], b[1]), Math.max(a[2], b[2]), Math.max(a[3], b[3])];
ss[i] = Math.max(ss[i], ss[j]);
sb.splice(j, 1);
ss.splice(j, 1);
mergedAny = true;
break;
}
}
if (mergedAny) break;
}
}
const other = [];
const otherScores = [];
const otherCls = [];
for (let i = 0; i < boxes.length; i += 1) {
if (clsIds[i] !== targetCls) {
other.push(boxes[i]);
otherScores.push(scores[i]);
otherCls.push(clsIds[i]);
}
}
return {
boxes: [...other, ...sb],
scores: [...otherScores, ...ss],
clsIds: [...otherCls, ...new Array(sb.length).fill(targetCls)],
};
}
function suppressContainedLowerConf(boxes, scores, clsIds, targetCls, overlap) {
if (overlap > 1.0) return { boxes, scores, clsIds };
const idx = clsIds.map((id, i) => (id === targetCls ? i : -1)).filter((i) => i >= 0);
if (idx.length <= 1) return { boxes, scores, clsIds };
const order = [...idx].sort((a, b) => scores[b] - scores[a]);
const remove = new Set();
for (let a = 0; a < order.length; a += 1) {
const i = order[a];
if (remove.has(i)) continue;
const bi = boxes[i];
const areaI = Math.max(1e-7, (bi[2] - bi[0]) * (bi[3] - bi[1]));
for (let b = a + 1; b < order.length; b += 1) {
const j = order[b];
if (remove.has(j)) continue;
const bj = boxes[j];
const ix1 = Math.max(bi[0], bj[0]);
const iy1 = Math.max(bi[1], bj[1]);
const ix2 = Math.min(bi[2], bj[2]);
const iy2 = Math.min(bi[3], bj[3]);
const inter = Math.max(0, ix2 - ix1) * Math.max(0, iy2 - iy1);
if (inter <= 0) continue;
const areaJ = Math.max(1e-7, (bj[2] - bj[0]) * (bj[3] - bj[1]));
if (inter / (Math.min(areaI, areaJ) + 1e-7) >= overlap) remove.add(j);
}
}
if (remove.size === 0) return { boxes, scores, clsIds };
const keep = boxes.map((_, i) => !remove.has(i));
return {
boxes: boxes.filter((_, i) => keep[i]),
scores: scores.filter((_, i) => keep[i]),
clsIds: clsIds.filter((_, i) => keep[i]),
};
}
function mergeSameClassBoxes(boxes, scores, clsIds) {
let state = mergeClassBoxes(boxes, scores, clsIds, CLASS_NAMES.indexOf("smoke"), CONFIG.smokeMergeOverlap);
state = mergeClassBoxes(state.boxes, state.scores, state.clsIds, CLASS_NAMES.indexOf("fire"), CONFIG.fireMergeOverlap);
state = suppressContainedLowerConf(state.boxes, state.scores, state.clsIds, CLASS_NAMES.indexOf("fire"), CONFIG.fireSuppressOverlap);
return state;
}
function perViewPipeline(boxes, scores, clsIds) {
if (boxes.length > 1) {
const keep = perClassHardNms(boxes, scores, clsIds, CONFIG.iouThres);
boxes = keep.map((i) => boxes[i]);
scores = keep.map((i) => scores[i]);
clsIds = keep.map((i) => clsIds[i]);
}
if (scores.length > CONFIG.maxDet) {
const order = scores.map((s, i) => [s, i]).sort((a, b) => b[0] - a[0]).slice(0, CONFIG.maxDet).map((p) => p[1]);
boxes = order.map((i) => boxes[i]);
scores = order.map((i) => scores[i]);
clsIds = order.map((i) => clsIds[i]);
}
if (boxes.length > 1) {
const deduped = crossClassDedup(boxes, scores, clsIds, CONFIG.crossIouThresh);
boxes = deduped.boxes;
scores = deduped.scores;
clsIds = deduped.clsIds;
}
if (boxes.length > 1) {
const merged = mergeSameClassBoxes(boxes, scores, clsIds);
boxes = merged.boxes;
scores = merged.scores;
clsIds = merged.clsIds;
}
return { boxes, scores, clsIds };
}
function toBoundingBoxes(boxes, scores, clsIds) {
const out = [];
for (let i = 0; i < boxes.length; i += 1) {
const [x1, y1, x2, y2] = boxes[i];
if (x2 <= x1 || y2 <= y1) continue;
out.push({
x1: Math.floor(x1),
y1: Math.floor(y1),
x2: Math.ceil(x2),
y2: Math.ceil(y2),
cls_id: clsIds[i],
conf: scores[i],
});
}
return out;
}
function letterboxCanvas(image, newW, newH) {
const srcW = image.width;
const srcH = image.height;
const ratio = Math.min(newW / srcW, newH / srcH);
const resizedW = Math.round(srcW * ratio);
const resizedH = Math.round(srcH * ratio);
const resized = document.createElement("canvas");
resized.width = resizedW;
resized.height = resizedH;
resized.getContext("2d").drawImage(image, 0, 0, resizedW, resizedH);
const padW = (newW - resizedW) / 2;
const padH = (newH - resizedH) / 2;
const canvas = document.createElement("canvas");
canvas.width = newW;
canvas.height = newH;
const ctx = canvas.getContext("2d");
ctx.fillStyle = "rgb(114,114,114)";
ctx.fillRect(0, 0, newW, newH);
ctx.drawImage(resized, padW, padH);
return { canvas, ratio, pad: [padW, padH] };
}
function imageToTensor(canvas, inputW, inputH) {
const { data } = canvas.getContext("2d").getImageData(0, 0, inputW, inputH);
const tensor = new Float32Array(1 * 3 * inputH * inputW);
const plane = inputH * inputW;
for (let y = 0; y < inputH; y += 1) {
for (let x = 0; x < inputW; x += 1) {
const i = (y * inputW + x) * 4;
const offset = y * inputW + x;
tensor[offset] = data[i] / 255;
tensor[plane + offset] = data[i + 1] / 255;
tensor[2 * plane + offset] = data[i + 2] / 255;
}
}
return tensor;
}
function transpose(matrix) {
const rows = matrix.length;
const cols = matrix[0].length;
const out = Array.from({ length: cols }, () => new Array(rows));
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
out[c][r] = matrix[r][c];
}
}
return out;
}
function tensorToNestedArray(tensor) {
const data = Array.from(tensor.data);
const dims = tensor.dims;
if (dims.length === 2) {
const [rows, cols] = dims;
const out = [];
for (let r = 0; r < rows; r += 1) out.push(data.slice(r * cols, (r + 1) * cols));
return out;
}
if (dims.length === 3) {
const [batch, dim1, dim2] = dims;
const out = [];
const stride = dim1 * dim2;
for (let b = 0; b < batch; b += 1) {
const batchData = data.slice(b * stride, (b + 1) * stride);
const rows = [];
for (let r = 0; r < dim1; r += 1) rows.push(batchData.slice(r * dim2, (r + 1) * dim2));
out.push(rows);
}
return out;
}
throw new Error(`Unsupported output tensor shape: ${dims.join("x")}`);
}
function decodeFinalDets(preds, ratio, pad, origW, origH) {
let rows = preds;
if (preds.length === 1 && Array.isArray(preds[0]) && Array.isArray(preds[0][0])) {
rows = preds[0];
}
const rawScores = [];
const rawCls = [];
for (const row of rows) {
if (row.length < 6) continue;
rawScores.push(row[4]);
rawCls.push(remapClassId(Math.round(row[5])));
}
const mask = confFilterMask(rawScores, rawCls);
let boxes = [];
let scores = [];
let clsIds = [];
for (let i = 0; i < rows.length; i += 1) {
if (!mask[i] || rows[i].length < 6) continue;
boxes.push(rows[i].slice(0, 4));
scores.push(rows[i][4]);
clsIds.push(rawCls[i]);
}
if (boxes.length === 0) return [];
const [padW, padH] = pad;
boxes = boxes.map(([x1, y1, x2, y2]) => [
(x1 - padW) / ratio,
(y1 - padH) / ratio,
(x2 - padW) / ratio,
(y2 - padH) / ratio,
]);
clipBoxes(boxes, origW, origH);
let filtered = filterSaneBoxes(boxes, scores, clsIds, origW, origH);
if (filtered.boxes.length === 0) return [];
const piped = perViewPipeline(filtered.boxes, filtered.scores, filtered.clsIds);
return toBoundingBoxes(piped.boxes, piped.scores, piped.clsIds);
}
function decodeRawYolo(preds, ratio, pad, origW, origH) {
let rows = preds[0];
if (rows.length <= 16 && rows[0].length > rows.length) rows = transpose(rows);
const rawScores = [];
const rawCls = [];
const boxesXywh = [];
for (const row of rows) {
if (row.length < 5) continue;
const tail = row.slice(4);
let score;
let clsId;
if (tail.length === 1) {
score = tail[0];
clsId = 0;
} else {
clsId = tail.indexOf(Math.max(...tail));
score = tail[clsId];
}
clsId = remapClassId(clsId);
rawScores.push(score);
rawCls.push(clsId);
boxesXywh.push(row.slice(0, 4));
}
const mask = confFilterMask(rawScores, rawCls);
let boxes = [];
let scores = [];
let clsIds = [];
for (let i = 0; i < boxesXywh.length; i += 1) {
if (!mask[i]) continue;
const [x, y, w, h] = boxesXywh[i];
boxes.push([x - w / 2, y - h / 2, x + w / 2, y + h / 2]);
scores.push(rawScores[i]);
clsIds.push(rawCls[i]);
}
if (boxes.length === 0) return [];
const [padW, padH] = pad;
boxes = boxes.map(([x1, y1, x2, y2]) => [
(x1 - padW) / ratio,
(y1 - padH) / ratio,
(x2 - padW) / ratio,
(y2 - padH) / ratio,
]);
clipBoxes(boxes, origW, origH);
let filtered = filterSaneBoxes(boxes, scores, clsIds, origW, origH);
if (filtered.boxes.length === 0) return [];
const piped = perViewPipeline(filtered.boxes, filtered.scores, filtered.clsIds);
return toBoundingBoxes(piped.boxes, piped.scores, piped.clsIds);
}
function postprocess(output, ratio, pad, origW, origH) {
const preds = tensorToNestedArray(output);
if (output.dims.length === 2 && output.dims[1] >= 6) {
return decodeFinalDets(preds, ratio, pad, origW, origH);
}
if (output.dims.length === 3 && output.dims[0] === 1 && output.dims[2] >= 6) {
return decodeFinalDets(preds, ratio, pad, origW, origH);
}
return decodeRawYolo(preds, ratio, pad, origW, origH);
}
function getRoiData(image, box) {
const canvas = document.createElement("canvas");
const w = image.width;
const h = image.height;
const x1 = Math.max(0, Math.floor(box.x1));
const y1 = Math.max(0, Math.floor(box.y1));
const x2 = Math.min(w, Math.ceil(box.x2));
const y2 = Math.min(h, Math.ceil(box.y2));
if (x2 <= x1 || y2 <= y1) return null;
canvas.width = x2 - x1;
canvas.height = y2 - y1;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, x1, y1, x2 - x1, y2 - y1, 0, 0, x2 - x1, y2 - y1);
return ctx.getImageData(0, 0, x2 - x1, y2 - y1).data;
}
function roiIsNearGrayscale(data) {
let satSum = 0;
const pixels = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const mx = Math.max(r, g, b);
const mn = Math.min(r, g, b);
satSum += (mx - mn) / (mx + 1e-6);
}
return satSum / pixels < CONFIG.colorFilterMinSaturation;
}
function passesFireColor(data) {
let meanR = 0;
let meanG = 0;
let maxRgb = 0;
let brightCount = 0;
let warmCount = 0;
const pixels = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
meanR += r;
meanG += g;
maxRgb = Math.max(maxRgb, r, g, b);
if (Math.max(r, g, b) >= 150) brightCount += 1;
if (r > g + 10 && r > b + 10) warmCount += 1;
}
meanR /= pixels;
meanG /= pixels;
const brightFrac = brightCount / pixels;
const warmFrac = warmCount / pixels;
if (maxRgb >= 200 && brightFrac >= 0.01) return true;
if (warmFrac >= 0.05 && (maxRgb >= 120 || meanR >= 120 || warmFrac >= 0.15)) return true;
if (brightFrac >= 0.12 && meanR - meanG >= 2) return true;
return false;
}
function passesFireExtColor(data) {
let redDom = 0;
let meanR = 0;
let meanG = 0;
const pixels = data.length / 4;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
meanR += r;
meanG += g;
if (r > g + 10 && r > b + 10) redDom += 1;
}
meanR /= pixels;
meanG /= pixels;
if (redDom / pixels >= 0.03) return true;
return meanR - meanG >= 0 && meanR >= 50;
}
function filterLowConfByColor(image, boxes) {
const clsFire = CLASS_NAMES.indexOf("fire");
const clsExt = CLASS_NAMES.indexOf("fire extinguisher");
const out = [];
for (const box of boxes) {
const checkFire = box.cls_id === clsFire && box.conf <= CONFIG.fireColorFilterMaxConf;
const checkExt = box.cls_id === clsExt && box.conf <= CONFIG.fireExtColorFilterMaxConf;
if (!checkFire && !checkExt) {
out.push(box);
continue;
}
const data = getRoiData(image, box);
if (!data || roiIsNearGrayscale(data)) {
out.push(box);
continue;
}
if (checkFire && !passesFireColor(data)) continue;
if (checkExt && !passesFireExtColor(data)) continue;
out.push(box);
}
return out;
}
export class FireDetector {
constructor() {
this.session = null;
this.inputName = null;
this.outputNames = null;
this.inputHeight = 1280;
this.inputWidth = 1280;
}
async load(modelUrl) {
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/";
this.session = await ort.InferenceSession.create(modelUrl, {
executionProviders: ["wasm"],
});
this.inputName = this.session.inputNames[0];
this.outputNames = this.session.outputNames;
const shape = this.session.inputs.get(this.inputName).dims;
this.inputHeight = safeDim(shape[2], 1280);
this.inputWidth = safeDim(shape[3], 1280);
}
async predictSingle(image) {
const origW = image.width;
const origH = image.height;
const { canvas, ratio, pad } = letterboxCanvas(image, this.inputWidth, this.inputHeight);
const tensorData = imageToTensor(canvas, this.inputWidth, this.inputHeight);
const inputTensor = new ort.Tensor("float32", tensorData, [1, 3, this.inputHeight, this.inputWidth]);
const outputs = await this.session.run({ [this.inputName]: inputTensor });
return postprocess(outputs[this.outputNames[0]], ratio, pad, origW, origH);
}
async predictImage(image) {
const boxes = await this.predictSingle(image);
return filterLowConfByColor(image, boxes);
}
}
export function className(clsId) {
return CLASS_NAMES[clsId] ?? "unknown";
}
export function drawBoxes(ctx, boxes) {
ctx.lineWidth = 2;
ctx.font = "600 12px Inter, system-ui, sans-serif";
for (const box of boxes) {
const label = CLASS_NAMES[box.cls_id] ?? "unknown";
const color = CLASS_COLORS[label] ?? "#EF4444";
const w = box.x2 - box.x1;
const h = box.y2 - box.y1;
ctx.strokeStyle = color;
ctx.strokeRect(box.x1, box.y1, w, h);
const text = `${label.toUpperCase()} ${(box.conf * 100).toFixed(1)}%`;
const textWidth = ctx.measureText(text).width;
const y = Math.max(box.y1, 18);
ctx.fillStyle = color;
ctx.fillRect(box.x1, y - 18, textWidth + 10, 18);
ctx.fillStyle = "#ffffff";
ctx.fillText(text, box.x1 + 5, y - 4);
}
}