iridescent / app /engine.js
ipsitashukla's picture
Screening thresholds at 90% specificity; strongest finding first
7aad4ce verified
Raw History Blame Contribute Delete
44.3 kB
// On-device analysis engine: runs the chestcam models in the browser with ONNX Runtime Web.
// Produces exactly the result object the (former) server API returned, so the UI is unchanged.
//
// window.XAI_ENGINE.models -> model list for the picker
// await window.XAI_ENGINE.init(onStatus) -> picks WebGPU or WebAssembly
// await window.XAI_ENGINE.analyze(file, {modelId, methods, quality}, onProgress)
//
// Faithful to the Python pipeline:
// * preprocessing: grayscale -> area resize to 256 (like cv2.INTER_AREA) -> uint8 ->
// bilinear 256->224 (like torch grid_sample, align_corners=False) -> ImageNet normalisation
// * prediction: mean of original + horizontally flipped logits (test-time augmentation)
// * Grad-CAM: exact, from the exported graph's per-class CAM (see deploy/export_onnx.py), then
// ReLU + min-max + bilinear resize, as pytorch-grad-cam does
// * LIME: SLIC superpixels, segment-mean masking, cosine kernel (width 0.25), weighted ridge
// * SHAP: KernelSHAP over superpixels with a blurred-image reference (forward passes only;
// the report's gradient-based SHAP needs backpropagation, which the browser runtime lacks)
(function () {
"use strict";
const ORT_VERSION = "1.30.0";
const ORT_CDN = `https://cdn.jsdelivr.net/npm/onnxruntime-web@${ORT_VERSION}/dist/`;
// models live next to this script (app/models/), wherever the page itself is
const SCRIPT_BASE = document.currentScript ? new URL(".", document.currentScript.src).href : document.baseURI;
const LOCAL_MODELS = new URL("models/", SCRIPT_BASE).href;
const MODEL_BASES = [((window.XAI_CONFIG || {}).modelBase || "").trim(), LOCAL_MODELS].filter(Boolean);
// fetch a model file from the first source that has it (remote repo, then the local folder)
async function fetchModelFile(name, opts = {}) {
let last;
for (const base of MODEL_BASES) {
try {
const r = await fetch(new URL(name, base).href, opts);
if (r.ok) return r;
last = new Error(`HTTP ${r.status} for ${name}`);
} catch (e) { last = e; }
}
throw new Error(`Could not download ${name} (${last && last.message}). Check your connection and try again`);
}
const S = 224, CACHE = 256, DISPLAY = 448;
const MEAN = [0.485, 0.456, 0.406], STD = [0.229, 0.224, 0.225];
// samples per quality: LIME perturbations, SHAP coalitions, superpixels for SHAP
const QUALITY = { fast: { lime: 100, shap: 100, shapSeg: 40 }, balanced: { lime: 200, shap: 200, shapSeg: 50 },
thorough: { lime: 500, shap: 400, shapSeg: 60 } };
const BATCH = 8;
const INFERNO = hexToBytes("00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4");
const DISCLAIMER = "AI decision support only - not a diagnosis. Findings must be confirmed by a qualified radiologist.";
const MODELS = [
{ id: "chest-xray-14", name: "Chest screening", short: "Chest screening", modality: "xray", status: "active", placeholder: false,
file: "nih14.onnx", multilabel: true,
description: "Screens adult frontal chest X-rays for 14 conditions (NIH ChestX-ray14 model)." },
{ id: "chest-xray-pneumonia", name: "Paediatric pneumonia", short: "Pneumonia (child)", modality: "xray", status: "active", placeholder: false,
file: "pneumonia.onnx", classes: ["NORMAL", "PNEUMONIA"], multilabel: false,
description: "Pneumonia vs normal, for chest X-rays of children aged 1–5." },
];
let meta = null, backend = null;
const sessions = {};
function hexToBytes(h) { const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16); return a; }
const sleep0 = () => new Promise((r) => setTimeout(r, 0));
function loadScript(src) {
return new Promise((res, rej) => { const s = document.createElement("script"); s.src = src; s.onload = res; s.onerror = () => rej(new Error(`Could not load ${src}`)); document.head.appendChild(s); });
}
async function init(onStatus = () => {}) {
if (backend) return backend;
onStatus("Loading the on-device engine…");
if (!window.ort) await loadScript(`${ORT_CDN}ort.webgpu.min.js`);
ort.env.wasm.wasmPaths = ORT_CDN;
ort.env.wasm.numThreads = self.crossOriginIsolated ? Math.min(4, navigator.hardwareConcurrency || 2) : 1;
meta = await (await fetchModelFile("meta.json", { cache: "no-cache" })).json();
MODELS.find((m) => m.id === "chest-xray-14").classes = meta.nih14.classes;
let gpu = false;
let forceCpu = false; try { forceCpu = localStorage.getItem("compute") === "cpu"; } catch {}
if (!forceCpu) { try { gpu = !!(navigator.gpu && (await navigator.gpu.requestAdapter())); } catch { gpu = false; } }
backend = gpu ? "webgpu" : "wasm";
return backend;
}
// Some phone GPUs compute DenseNet-121 inaccurately (reduced precision / driver bugs), which changes
// the result. Every GPU session is therefore verified on a fixed test pattern against reference
// outputs computed in PyTorch/ONNX Runtime (meta.golden); on mismatch we fall back to WebAssembly,
// which reproduces the reference numbers exactly.
let gpuCheck = null; // {ok, maxDiff, model}
function goldenInput() {
const img = new Float32Array(S * S);
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++)
img[y * S + x] = 0.5 + 0.35 * Math.sin(x / 7) * Math.cos(y / 11) + 0.1 * Math.sin((x + y) / 23);
return img;
}
async function verify(sess, key) {
const ref = meta.golden && meta.golden[key];
if (!ref) return { ok: true, maxDiff: null };
const out = await sess.run({ input: toTensor([goldenInput()]) });
const got = Array.from(out.logits.data);
const maxDiff = Math.max(...ref.map((v, i) => Math.abs(v - got[i])));
return { ok: Number.isFinite(maxDiff) && maxDiff <= (meta.golden.tolerance || 0.05), maxDiff };
}
async function session(model, onStatus = () => {}) {
if (sessions[model.id]) return sessions[model.id];
const key = model.id === "chest-xray-14" ? "nih14" : "pneumonia";
onStatus(`Downloading ${model.name} (about ${Math.round(meta[key].size_mb)} MB, first time only)…`);
const buf = await (await fetchModelFile(model.file)).arrayBuffer();
onStatus("Preparing the model…");
let sess = null;
if (backend === "webgpu") {
try {
sess = await ort.InferenceSession.create(buf, { executionProviders: ["webgpu"], graphOptimizationLevel: "all" });
onStatus("Checking that this device's GPU computes accurately…");
const chk = await verify(sess, key);
gpuCheck = { ...chk, model: model.id };
if (!chk.ok) { try { await sess.release(); } catch {} sess = null; }
} catch (e) { gpuCheck = { ok: false, maxDiff: null, error: String(e), model: model.id }; sess = null; }
if (!sess) backend = "wasm";
}
if (!sess) sess = await ort.InferenceSession.create(buf, { executionProviders: ["wasm"], graphOptimizationLevel: "all" });
sessions[model.id] = sess;
return sess;
}
// ------------------------------------------------------------------ preprocessing
async function decode(file) {
const name = (file.name || "").toLowerCase();
if (name.endsWith(".dcm") || name.endsWith(".dicom")) throw new Error("DICOM files aren't supported on-device yet. Please export the scan as PNG or JPEG");
const bmp = await createImageBitmap(file, { imageOrientation: "from-image" });
const c = new OffscreenCanvas(bmp.width, bmp.height);
const ctx = c.getContext("2d");
ctx.drawImage(bmp, 0, 0);
const { data } = ctx.getImageData(0, 0, bmp.width, bmp.height);
const gray = new Float32Array(bmp.width * bmp.height);
for (let i = 0, j = 0; i < gray.length; i++, j += 4) gray[i] = 0.299 * data[j] + 0.587 * data[j + 1] + 0.114 * data[j + 2];
return { gray, w: bmp.width, h: bmp.height, bitmap: bmp };
}
// 1-D resampling weights for cv2.INTER_AREA (downscale) or bilinear (upscale)
function areaWeights(src, dst) {
const scale = src / dst, out = [];
for (let i = 0; i < dst; i++) {
if (scale >= 1) {
const a = i * scale, b = a + scale, taps = [];
for (let k = Math.floor(a); k < Math.ceil(b) && k < src; k++) {
const w = Math.min(k + 1, b) - Math.max(k, a);
if (w > 1e-9) taps.push([k, w / scale]);
}
out.push(taps);
} else {
const x = Math.min(Math.max((i + 0.5) * scale - 0.5, 0), src - 1), k = Math.floor(x), f = x - k;
out.push(k + 1 < src ? [[k, 1 - f], [k + 1, f]] : [[k, 1]]);
}
}
return out;
}
function resizeArea(img, w, h, dw, dh) {
const wx = areaWeights(w, dw), wy = areaWeights(h, dh);
const tmp = new Float32Array(dw * h);
for (let y = 0; y < h; y++) for (let x = 0; x < dw; x++) {
let s = 0; for (const [k, wt] of wx[x]) s += img[y * w + k] * wt; tmp[y * dw + x] = s;
}
const out = new Float32Array(dw * dh);
for (let y = 0; y < dh; y++) for (let x = 0; x < dw; x++) {
let s = 0; for (const [k, wt] of wy[y]) s += tmp[k * dw + x] * wt; out[y * dw + x] = s;
}
return out;
}
// grid_sample(bilinear, align_corners=False), CACHE -> S, on values already scaled to [0, 1]
function toDisplay(u8) {
const out = new Float32Array(S * S), r = CACHE / S;
for (let y = 0; y < S; y++) {
const sy = (y + 0.5) * r - 0.5, y0 = Math.floor(sy), fy = sy - y0;
for (let x = 0; x < S; x++) {
const sx = (x + 0.5) * r - 0.5, x0 = Math.floor(sx), fx = sx - x0;
const g = (yy, xx) => (yy < 0 || xx < 0 || yy >= CACHE || xx >= CACHE ? 0 : u8[yy * CACHE + xx] / 255);
out[y * S + x] = (1 - fy) * ((1 - fx) * g(y0, x0) + fx * g(y0, x0 + 1)) + fy * ((1 - fx) * g(y0 + 1, x0) + fx * g(y0 + 1, x0 + 1));
}
}
return out;
}
// [n images of S*S in 0..1] -> normalised NCHW float32 (grayscale replicated to 3 channels)
function toTensor(imgs, flip = false) {
const n = imgs.length, data = new Float32Array(n * 3 * S * S);
imgs.forEach((im, b) => {
for (let c = 0; c < 3; c++) {
const off = (b * 3 + c) * S * S, m = MEAN[c], sd = STD[c];
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++)
data[off + y * S + x] = (im[y * S + (flip ? S - 1 - x : x)] - m) / sd;
}
});
return new ort.Tensor("float32", data, [n, 3, S, S]);
}
// ------------------------------------------------------------------ inference helpers
const softmax = (z) => { const m = Math.max(...z), e = z.map((v) => Math.exp(v - m)), s = e.reduce((a, b) => a + b, 0); return e.map((v) => v / s); };
const sigmoid = (z) => z.map((v) => 1 / (1 + Math.exp(-v)));
// The 14-finding model was trained with weighted BCE (pos_weight w_c), which inflates each finding's
// logit by log(w_c). Subtracting it gives prior-corrected probabilities comparable across findings.
const priorShift = () => (meta && meta.nih14.pos_weight ? meta.nih14.pos_weight.map(Math.log) : null);
const correct = (z) => { const s = priorShift(); return s ? z.map((v, i) => v - s[i]) : z; };
async function run(sess, imgs) {
const out = await sess.run({ input: toTensor(imgs) });
return { logits: out.logits.data, cams: out.cams.data, k: out.logits.dims[1] };
}
// probability of `target` for many perturbed images, in batches, with progress
async function probsFor(sess, imgs, target, multilabel, onEach) {
const probs = new Float32Array(imgs.length);
for (let i = 0; i < imgs.length; i += BATCH) {
const batch = imgs.slice(i, i + BATCH);
const { logits, k } = await run(sess, batch);
for (let b = 0; b < batch.length; b++) {
const z = Array.from(logits.slice(b * k, (b + 1) * k));
probs[i + b] = (multilabel ? sigmoid(correct(z)) : softmax(z))[target];
}
onEach(Math.min(i + BATCH, imgs.length), imgs.length);
await sleep0();
}
return probs;
}
// ------------------------------------------------------------------ Grad-CAM
function gradcam(cams, k, target) {
let c = Array.from(cams.slice(target * 49, target * 49 + 49)).map((v) => Math.max(v, 0));
const mn = Math.min(...c); c = c.map((v) => v - mn);
const mx = Math.max(...c); c = c.map((v) => v / (1e-7 + mx));
const out = new Float32Array(S * S), r = 7 / S;
for (let y = 0; y < S; y++) {
const sy = Math.min(Math.max((y + 0.5) * r - 0.5, 0), 6), y0 = Math.floor(sy), y1 = Math.min(y0 + 1, 6), fy = sy - y0;
for (let x = 0; x < S; x++) {
const sx = Math.min(Math.max((x + 0.5) * r - 0.5, 0), 6), x0 = Math.floor(sx), x1 = Math.min(x0 + 1, 6), fx = sx - x0;
out[y * S + x] = (1 - fy) * ((1 - fx) * c[y0 * 7 + x0] + fx * c[y0 * 7 + x1]) + fy * ((1 - fx) * c[y1 * 7 + x0] + fx * c[y1 * 7 + x1]);
}
}
return positive(out);
}
function positive(m) { const o = m.map((v) => Math.max(v, 0)); let mx = 0; for (const v of o) if (v > mx) mx = v; return mx > 0 ? o.map((v) => v / mx) : o; }
// ------------------------------------------------------------------ superpixels (SLIC)
function gaussBlur(img, sigma) {
const r = Math.ceil(3 * sigma), k = []; let s = 0;
for (let i = -r; i <= r; i++) { const v = Math.exp(-(i * i) / (2 * sigma * sigma)); k.push(v); s += v; }
const kn = k.map((v) => v / s), tmp = new Float32Array(S * S), out = new Float32Array(S * S);
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) { let a = 0; for (let i = -r; i <= r; i++) a += img[y * S + Math.min(S - 1, Math.max(0, x + i))] * kn[i + r]; tmp[y * S + x] = a; }
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) { let a = 0; for (let i = -r; i <= r; i++) a += tmp[Math.min(S - 1, Math.max(0, y + i)) * S + x] * kn[i + r]; out[y * S + x] = a; }
return out;
}
function slic(img01, nSeg, compactness = 10) {
// skimage-style: Lab lightness of the (grey) image, Gaussian sigma 1, spatial weight compactness/step
const L = gaussBlur(img01, 1).map((g) => { const lin = g <= 0.04045 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4); const f = lin > 0.008856 ? Math.cbrt(lin) : 7.787 * lin + 16 / 116; return 116 * f - 16; });
const step = Math.sqrt((S * S) / nSeg), centers = [];
for (let y = step / 2; y < S; y += step) for (let x = step / 2; x < S; x += step) centers.push([y, x, L[Math.floor(y) * S + Math.floor(x)]]);
const label = new Int32Array(S * S).fill(-1), dist = new Float32Array(S * S);
const ratio = (compactness / step) ** 2;
for (let it = 0; it < 10; it++) {
dist.fill(Infinity);
centers.forEach(([cy, cx, cl], ci) => {
for (let y = Math.max(0, Math.floor(cy - step)); y < Math.min(S, Math.ceil(cy + step)); y++)
for (let x = Math.max(0, Math.floor(cx - step)); x < Math.min(S, Math.ceil(cx + step)); x++) {
const d = (L[y * S + x] - cl) ** 2 + ratio * ((y - cy) ** 2 + (x - cx) ** 2);
if (d < dist[y * S + x]) { dist[y * S + x] = d; label[y * S + x] = ci; }
}
});
const acc = centers.map(() => [0, 0, 0, 0]);
for (let i = 0; i < S * S; i++) { const a = acc[label[i]]; a[0] += Math.floor(i / S); a[1] += i % S; a[2] += L[i]; a[3]++; }
acc.forEach((a, ci) => { if (a[3]) centers[ci] = [a[0] / a[3], a[1] / a[3], a[2] / a[3]]; });
}
return connectivity(label, step);
}
// split disconnected pieces, merge tiny ones into a neighbour, relabel 0..n-1
function connectivity(label, step) {
const minSize = Math.max(1, Math.floor(0.25 * step * step)), comp = new Int32Array(S * S).fill(-1), sizes = [];
let n = 0;
for (let i = 0; i < S * S; i++) {
if (comp[i] >= 0) continue;
const stack = [i]; comp[i] = n; let size = 0;
while (stack.length) {
const p = stack.pop(); size++;
const y = Math.floor(p / S), x = p % S;
for (const [dy, dx] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const yy = y + dy, xx = x + dx; if (yy < 0 || xx < 0 || yy >= S || xx >= S) continue;
const q = yy * S + xx; if (comp[q] < 0 && label[q] === label[p]) { comp[q] = n; stack.push(q); }
}
}
sizes.push(size); n++;
}
// union-find: merging can never create a cycle (the old "point at a neighbour" scheme could loop forever)
const parent = sizes.map((_, i) => i), total = sizes.slice();
const root = (c) => { while (parent[c] !== c) { parent[c] = parent[parent[c]]; c = parent[c]; } return c; };
for (let i = 0; i < S * S; i++) {
const c = root(comp[i]); if (total[c] >= minSize) continue;
const y = Math.floor(i / S), x = i % S;
for (const [dy, dx] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) {
const yy = y + dy, xx = x + dx; if (yy < 0 || xx < 0 || yy >= S || xx >= S) continue;
const o = root(comp[yy * S + xx]);
if (o !== c) { parent[c] = o; total[o] += total[c]; break; }
}
}
const map = new Map(), out = new Int32Array(S * S);
for (let i = 0; i < S * S; i++) { const r = root(comp[i]); if (!map.has(r)) map.set(r, map.size); out[i] = map.get(r); }
return { seg: out, n: map.size };
}
function segmentMeans(img, seg, n) {
const sum = new Float64Array(n), cnt = new Float64Array(n);
for (let i = 0; i < img.length; i++) { sum[seg[i]] += img[i]; cnt[seg[i]]++; }
return Array.from(sum, (s, i) => (cnt[i] ? s / cnt[i] : 0));
}
function compose(img, seg, keep, fill) {
const out = new Float32Array(S * S);
for (let i = 0; i < out.length; i++) out[i] = keep[seg[i]] ? img[i] : (typeof fill === "function" ? fill(i) : fill[seg[i]]);
return out;
}
// ------------------------------------------------------------------ linear algebra
function solve(A, b) { // Gaussian elimination with partial pivoting (A is n x n, modified)
const n = b.length;
for (let i = 0; i < n; i++) {
let p = i; for (let r = i + 1; r < n; r++) if (Math.abs(A[r][i]) > Math.abs(A[p][i])) p = r;
[A[i], A[p]] = [A[p], A[i]]; [b[i], b[p]] = [b[p], b[i]];
const d = A[i][i] || 1e-12;
for (let r = i + 1; r < n; r++) { const f = A[r][i] / d; if (!f) continue; for (let c = i; c < n; c++) A[r][c] -= f * A[i][c]; b[r] -= f * b[i]; }
}
const x = new Array(n).fill(0);
for (let i = n - 1; i >= 0; i--) { let s = b[i]; for (let c = i + 1; c < n; c++) s -= A[i][c] * x[c]; x[i] = s / (A[i][i] || 1e-12); }
return x;
}
// weighted ridge with intercept (like sklearn Ridge(alpha, fit_intercept=True) + sample_weight)
function ridge(X, y, w, alpha) {
const n = X.length, p = X[0].length, sw = w.reduce((a, b) => a + b, 0);
const xm = new Array(p).fill(0); let ym = 0;
for (let i = 0; i < n; i++) { ym += w[i] * y[i]; for (let j = 0; j < p; j++) xm[j] += w[i] * X[i][j]; }
ym /= sw; for (let j = 0; j < p; j++) xm[j] /= sw;
const A = Array.from({ length: p }, (_, j) => { const r = new Array(p).fill(0); r[j] = alpha; return r; }), bb = new Array(p).fill(0);
for (let i = 0; i < n; i++) {
const xc = X[i].map((v, j) => v - xm[j]), yc = y[i] - ym;
for (let j = 0; j < p; j++) { if (!xc[j]) continue; bb[j] += w[i] * xc[j] * yc; for (let k = 0; k < p; k++) A[j][k] += w[i] * xc[j] * xc[k]; }
}
const coef = solve(A, bb), b0 = ym - coef.reduce((a, c, j) => a + c * xm[j], 0);
let ssr = 0, sst = 0;
for (let i = 0; i < n; i++) { const pr = b0 + coef.reduce((a, c, j) => a + c * X[i][j], 0); ssr += w[i] * (y[i] - pr) ** 2; sst += w[i] * (y[i] - ym) ** 2; }
return { coef, r2: sst > 0 ? 1 - ssr / sst : 0 };
}
function rng(seed) { let s = seed >>> 0; return () => ((s = (s * 1664525 + 1013904223) >>> 0) / 4294967296); }
// ------------------------------------------------------------------ LIME
async function lime(sess, img, target, multilabel, nSamples, onProgress) {
const { seg, n } = slic(img, 100);
const means = segmentMeans(img, seg, n), rand = rng(42);
const rows = [new Array(n).fill(1)];
for (let i = 1; i < nSamples; i++) rows.push(Array.from({ length: n }, () => (rand() < 0.5 ? 1 : 0)));
const probs = await probsFor(sess, rows.map((r) => compose(img, seg, r, means)), target, multilabel, (d, t) => onProgress("lime", d, t));
const w = rows.map((r) => { const on = r.reduce((a, b) => a + b, 0); const d = on ? 1 - on / (Math.sqrt(on) * Math.sqrt(n)) : 1; return Math.sqrt(Math.exp(-(d * d) / (0.25 * 0.25))); });
const { coef, r2 } = ridge(rows, Array.from(probs), w, 1);
return { map: Float32Array.from(seg, (s) => coef[s]), seg, coef, r2 };
}
// ------------------------------------------------------------------ KernelSHAP
async function kernelShap(sess, img, target, multilabel, nSamples, nSeg, onProgress) {
const { seg, n } = slic(img, nSeg), blurred = gaussBlur(img, 8), rand = rng(7);
const fill = (i) => blurred[i];
const coalitions = [new Array(n).fill(0), new Array(n).fill(1)];
const sizeW = []; for (let s = 1; s < n; s++) sizeW.push((n - 1) / (s * (n - s)));
const tot = sizeW.reduce((a, b) => a + b, 0);
while (coalitions.length < nSamples) {
let u = rand() * tot, s = 1; for (; s < n - 1 && u > sizeW[s - 1]; s++) u -= sizeW[s - 1];
const idx = Array.from({ length: n }, (_, i) => i); for (let i = n - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); [idx[i], idx[j]] = [idx[j], idx[i]]; }
const z = new Array(n).fill(0); idx.slice(0, s).forEach((i) => (z[i] = 1));
coalitions.push(z, z.map((v) => 1 - v)); // paired sampling
}
const f = await probsFor(sess, coalitions.map((z) => compose(img, seg, z, fill)), target, multilabel, (d, t) => onProgress("shap", d, t));
const f0 = f[0], fx = f[1], delta = fx - f0;
// efficiency constraint sum(phi) = f(x) - f(reference): eliminate the last feature
const X = [], y = [];
for (let i = 2; i < coalitions.length; i++) {
const z = coalitions[i]; X.push(z.slice(0, n - 1).map((v) => v - z[n - 1])); y.push(f[i] - f0 - z[n - 1] * delta);
}
const { coef } = ridge(X, y, new Array(X.length).fill(1), 1e-3);
const phi = [...coef, delta - coef.reduce((a, b) => a + b, 0)];
return { map: Float32Array.from(seg, (s) => phi[s]), seg, phi };
}
// ------------------------------------------------------------------ overlays
function upsample(m, from = S, to = DISPLAY) {
const out = new Float32Array(to * to), r = from / to;
for (let y = 0; y < to; y++) { const sy = Math.min(Math.max((y + 0.5) * r - 0.5, 0), from - 1), y0 = Math.floor(sy), y1 = Math.min(y0 + 1, from - 1), fy = sy - y0;
for (let x = 0; x < to; x++) { const sx = Math.min(Math.max((x + 0.5) * r - 0.5, 0), from - 1), x0 = Math.floor(sx), x1 = Math.min(x0 + 1, from - 1), fx = sx - x0;
out[y * to + x] = (1 - fy) * ((1 - fx) * m[y0 * from + x0] + fx * m[y0 * from + x1]) + fy * ((1 - fx) * m[y1 * from + x0] + fx * m[y1 * from + x1]); } }
return out;
}
function nearest(seg, from = S, to = DISPLAY) { const out = new Int32Array(to * to), r = from / to; for (let y = 0; y < to; y++) for (let x = 0; x < to; x++) out[y * to + x] = seg[Math.floor(y * r) * from + Math.floor(x * r)]; return out; }
function toPNG(rgba) {
const c = new OffscreenCanvas(DISPLAY, DISPLAY), ctx = c.getContext("2d");
ctx.putImageData(new ImageData(rgba, DISPLAY, DISPLAY), 0, 0);
return c.convertToBlob({ type: "image/png" }).then((b) => new Promise((res) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.readAsDataURL(b); }));
}
function heatLayer(sal) {
const s = upsample(sal), out = new Uint8ClampedArray(DISPLAY * DISPLAY * 4);
for (let i = 0; i < s.length; i++) { const v = Math.min(1, Math.max(0, s[i])), k = Math.round(v * 255) * 3;
out[i * 4] = INFERNO[k]; out[i * 4 + 1] = INFERNO[k + 1]; out[i * 4 + 2] = INFERNO[k + 2]; out[i * 4 + 3] = Math.min(1, v * 1.3) * 235; }
return out;
}
function limeLayer(coef, seg, top = 8) {
const ids = coef.map((c, i) => [c, i]), forIds = new Set(ids.filter(([c]) => c > 0).sort((a, b) => b[0] - a[0]).slice(0, top).map(([, i]) => i));
const against = new Set(ids.filter(([c]) => c < 0).sort((a, b) => a[0] - b[0]).slice(0, top).map(([, i]) => i));
const sg = nearest(seg), out = new Uint8ClampedArray(DISPLAY * DISPLAY * 4);
for (let i = 0; i < sg.length; i++) {
const s = sg[i], shown = forIds.has(s) || against.has(s);
if (forIds.has(s)) out.set([230, 57, 70, 120], i * 4); else if (against.has(s)) out.set([56, 132, 244, 120], i * 4);
if (shown) { const y = Math.floor(i / DISPLAY), x = i % DISPLAY;
if ((x + 1 < DISPLAY && sg[i + 1] !== s) || (y + 1 < DISPLAY && sg[i + DISPLAY] !== s) || (x > 0 && sg[i - 1] !== s) || (y > 0 && sg[i - DISPLAY] !== s)) out.set([255, 255, 255, 230], i * 4); }
}
return out;
}
function shapLayer(map) {
const sm = gaussBlur(map, 4); let scale = 0; const abs = Array.from(sm, Math.abs).sort((a, b) => a - b); scale = abs[Math.floor(abs.length * 0.99)] || 1;
const v = upsample(sm.map((x) => Math.max(-1, Math.min(1, x / scale)))), out = new Uint8ClampedArray(DISPLAY * DISPLAY * 4);
for (let i = 0; i < v.length; i++) { const strength = Math.min(1, Math.max(0, (Math.abs(v[i]) - 0.2) / 0.8));
out.set(v[i] > 0 ? [230, 57, 70] : [56, 132, 244], i * 4); out[i * 4 + 3] = Math.pow(strength, 0.7) * 225; }
return out;
}
// ------------------------------------------------------------------ reasoning (port of chestcam/api.py)
// Without a lung segmentation on the device, zones are geometric boxes over the typical lung fields
// (image left = patient right on a frontal film); this is stated in the caveats.
function zones() {
const out = {}, rows = [0.12, 0.37, 0.62, 0.88].map((f) => Math.round(f * S));
const sides = { right: [0.08, 0.49], left: [0.51, 0.92] }, names = ["upper", "middle", "lower"];
for (const [side, [a, b]] of Object.entries(sides)) names.forEach((z, k) => (out[`${side} ${z}`] = [rows[k], rows[k + 1], Math.round(a * S), Math.round(b * S)]));
return out;
}
const boxSum = (m, [y0, y1, x0, x1]) => { let s = 0; for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) s += m[y * S + x]; return s; };
const topMask = (m, frac = 0.15) => { const k = Math.max(1, Math.floor(frac * m.length)); const thr = Float32Array.from(m).sort()[m.length - k]; return m.map((v) => (v >= thr ? 1 : 0)); };
function reasoning({ label, conf, probs, sal, limeR2, shapPhi, isPositive, calibrated, modelName, pPos = null, thr = null }) {
const g = sal.gradcam || Object.values(sal)[0];
const Z = zones(), inside = Object.values(Z).reduce((a, b) => a + boxSum(g, b), 0);
const regions = {}; for (const [z, b] of Object.entries(Z)) regions[z] = inside > 0 ? +(boxSum(g, b) / inside).toFixed(4) : 0;
const ranked = Object.entries(regions).sort((a, b) => b[1] - a[1]), main = ranked.filter(([, v]) => v >= 0.2).map(([z]) => z);
const mainZones = main.length ? main : [ranked[0][0]];
const flat = Float32Array.from(g).sort().reverse(), total = flat.reduce((a, b) => a + b, 0);
let acc = 0, idx = 0; while (idx < flat.length && acc < 0.5 * total) acc += flat[idx++];
const focality = total > 0 ? idx / flat.length : 1, spread = focality < 0.12 ? "focal" : focality < 0.25 ? "moderately localised" : "diffuse";
const bh = Math.floor(0.1 * S); let innerS = 0; for (let y = bh; y < S - bh; y++) for (let x = bh; x < S - bh; x++) innerS += g[y * S + x];
const border = total > 0 ? 1 - innerS / total : 0;
const names = Object.keys(sal), pairs = {};
for (let i = 0; i < names.length; i++) for (let j = i + 1; j < names.length; j++) {
const a = topMask(sal[names[i]]), b = topMask(sal[names[j]]); let inter = 0, uni = 0;
for (let k = 0; k < a.length; k++) { inter += a[k] & b[k]; uni += a[k] | b[k]; }
pairs[`${names[i]}~${names[j]}`] = +(inter / Math.max(uni, 1)).toFixed(4);
}
const pv = Object.values(pairs), meanIou = pv.length ? pv.reduce((a, b) => a + b, 0) / pv.length : null;
const level = meanIou === null ? null : meanIou >= 0.35 ? "strong" : meanIou >= 0.15 ? "partial" : "weak";
const sp = Object.values(probs).sort((a, b) => b - a), margin = sp[0] - (sp[1] || 0);
// Binary model: certainty = how far P(pneumonia) is from the decision threshold (not the raw probability)
// relative distance: how far towards 0 (below the threshold) or towards 1 (above it)
const rel = pPos !== null && thr !== null ? (pPos >= thr ? (pPos - thr) / (1 - thr) : (thr - pPos) / thr) : null;
const tier = rel !== null
? (rel >= 0.75 ? "high" : rel >= 0.4 ? "moderate" : "low")
: conf >= 0.9 ? "high" : conf >= 0.7 ? "moderate" : "low";
let shapBalance = null;
if (shapPhi) { const pos = shapPhi.filter((v) => v > 0).reduce((a, b) => a + b, 0), neg = -shapPhi.filter((v) => v < 0).reduce((a, b) => a + b, 0); shapBalance = { for: +(pos / Math.max(pos + neg, 1e-9)).toFixed(4), against: +(neg / Math.max(pos + neg, 1e-9)).toFixed(4) }; }
const where = mainZones.length === 1 ? mainZones[0] : mainZones.slice(0, -1).join(", ") + " and " + mainZones.at(-1);
const findings = [`The evidence is concentrated in the **${where}** zone${mainZones.length > 1 ? "s" : ""}.`,
`The attention pattern is **${spread}** (half of the evidence lies in ${Math.round(100 * focality)}% of the image).`];
const caveats = [];
if (level) { findings.push(`The explanation methods show **${level}** agreement (mean overlap ${meanIou.toFixed(2)}).`); if (level === "weak") caveats.push("The explanation methods disagree; no single map should be read as the reason."); }
if (border > 0.3) caveats.push(`${Math.round(100 * border)}% of the attention is on the image border (text markers or film edges): possible shortcut.`);
if (limeR2 !== null && limeR2 < 0.2) caveats.push(`LIME's local fit is poor (R² = ${limeR2.toFixed(2)}); its map is less reliable for this scan.`);
if (tier === "low" && isPositive !== null) caveats.push("Low certainty: the probability is close to the decision threshold.");
caveats.push("Lung zones are approximate on the device (fixed boxes over the typical lung fields, no lung segmentation).");
caveats.push(DISCLAIMER);
let summary;
if (isPositive === null) {
const top = Object.entries(probs).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([c, v]) => `${c} ${Math.round(100 * v)}%`).join(", ");
summary = conf < 0.5
? `No finding is clearly indicated. The highest screening scores are ${top}. These are screening scores, not diagnoses.`
: `Highest screening score: **${label}** (${Math.round(100 * conf)}%). Next: ${top.split(", ").slice(1).join(", ")}. These are screening scores, not diagnoses.`;
caveats.unshift("This model was trained on adult X-rays (NIH ChestX-ray14).");
} else {
const p = Math.round(100 * pPos), t = Math.round(100 * thr);
summary = isPositive
? (tier === "low" ? `Pneumonia probability is **${p}%**, just above the model's decision threshold of ${t}% (chosen on validation data): flagged as **possible pneumonia**, low certainty.`
: `The model finds signs of **pneumonia** (probability ${p}%, decision threshold ${t}%), ${tier} certainty.`)
: (tier === "low" ? `Pneumonia probability is **${p}%**, just below the model's decision threshold of ${t}%: **not flagged**, but low certainty.`
: `The model finds **no signs of pneumonia** (probability ${p}%, decision threshold ${t}%), ${tier} certainty.`);
caveats.unshift("This model was trained on children's X-rays (ages 1–5) and is not validated on adults. For adult X-rays, use the 14 findings model.");
}
return { summary, findings, caveats, metrics: { confidence_tier: tier, margin: +margin.toFixed(4), regions, focality: +focality.toFixed(4), spread,
in_zone_fraction: total > 0 ? +(inside / total).toFixed(4) : null, main_zones: mainZones,
border_share: +border.toFixed(4), agreement: { pairs, mean_iou: meanIou, level }, shap_balance: shapBalance, lime_r2: limeR2 } };
}
// ------------------------------------------------------------------ clinician-facing summary
// Plain-language result, checks and model facts. Everything is derived from the analysis numbers
// and the measured model card in meta.json; the UI only lays it out.
function clinical({ model, probs, label, conf, isPositive, thr, pPos, reason, methodsRun }) {
const mt = reason.metrics, tier = mt.confidence_tier;
const zonesText = mt.main_zones || [];
const where = zonesText.length ? (zonesText.length === 1 ? zonesText[0] : zonesText.slice(0, -1).join(", ") + " and " + zonesText.at(-1)) : null;
const checks = [];
const add = (state, title, detail) => checks.push({ state, title, detail });
if (mt.in_zone_fraction != null)
add(mt.in_zone_fraction >= 0.6 ? "ok" : mt.in_zone_fraction >= 0.45 ? "note" : "caution",
mt.in_zone_fraction >= 0.6 ? "Attention is inside the chest" : "Attention is partly outside the chest",
`${Math.round(100 * mt.in_zone_fraction)}% of the attention falls inside the chest region. This device uses fixed chest-region boxes, not a lung segmentation, so the heart and mediastinum count as inside.`);
if (mt.border_share != null)
add(mt.border_share <= 0.3 ? "ok" : "caution",
mt.border_share <= 0.3 ? "No reliance on image edges or text markers" : "Attention on image edges or text markers",
mt.border_share <= 0.3 ? `${Math.round(100 * mt.border_share)}% of the attention is near the film border (caution above 30%).`
: `${Math.round(100 * mt.border_share)}% of the attention is near the film border, where labels such as "AP" or "R" sit. Review the result critically.`);
const ag = mt.agreement;
if (ag && ag.level)
add(ag.level === "strong" ? "ok" : ag.level === "partial" ? "note" : "caution",
ag.level === "strong" ? "Explanation methods agree" : ag.level === "partial" ? "Explanation methods partly agree" : "Explanation methods disagree",
ag.level === "weak" ? "Grad-CAM, LIME and SHAP highlight different regions, so no single map should be read as the reason."
: "Grad-CAM, LIME and SHAP highlight overlapping regions.");
if (isPositive !== null && pPos !== null)
add(tier === "low" ? "caution" : "ok", tier === "low" ? "Close to the decision threshold" : "Clear of the decision threshold",
`Pneumonia probability ${Math.round(100 * pPos)}%; the model flags pneumonia at ${Math.round(100 * thr)}% or more.`);
let headline, status, sentence;
const card = model.multilabel ? meta.nih14.card : meta.pneumonia.card;
if (isPositive === null) {
const ranked = Object.entries(probs).sort((a, b) => b[1] - a[1]);
const T0 = meta.nih14.thresholds; // per-finding validation thresholds (display names use spaces)
const T = T0 && Object.keys(T0).length ? T0 : null;
const thrOf = (name) => (T ? T[name.replace(/ /g, "_")] ?? null : null);
// flagged conditions, strongest first: how far each score is above its own threshold
const flagged = T ? ranked.filter(([c, v]) => thrOf(c) !== null && v >= thrOf(c)).sort((a, b) => b[1] / thrOf(b[0]) - a[1] / thrOf(a[0])) : [];
if (T) {
status = flagged.length ? "finding" : "none";
headline = flagged.length ? (flagged.length === 1 ? flagged[0][0] : `${flagged[0][0]} + ${flagged.length - 1} more`) : "No finding above threshold";
sentence = flagged.length
? `Above its validated threshold (90% specificity): ${flagged.map(([c, v]) => `${c.toLowerCase()} (${Math.round(100 * v)}%)`).join(", ")}.`
: "None of the 14 conditions reaches its validated threshold (set at 90% specificity).";
} else {
status = "none";
headline = `Most likely: ${label}`;
sentence = `${label} has the highest screening score (${Math.round(100 * conf)}%). Per-condition thresholds are not available yet, so no condition is flagged.`;
}
const top = (flagged.length ? flagged : ranked).slice(0, 1).map(([c]) => c);
return { headline, status, sentence, where, checks,
scores: ranked.slice(0, 5).map(([c, v]) => ({ name: c, p: v, threshold: thrOf(c), flagged: thrOf(c) !== null && v >= thrOf(c), auroc: card && card.auroc[c] })),
model: { name: "About the chest screening model", facts: card ? `Trained on ${card.train_images.toLocaleString()} adult chest X-rays (NIH Clinical Center, ChestX-ray14). Mean test AUROC ${card.mean_auroc.toFixed(2)} across 14 conditions on ${card.test_images.toLocaleString()} unseen images.${T ? " Thresholds chosen on NIH validation data." : ""}` : "" },
advice: where && flagged.length ? `Review the ${where} zone${(mt.main_zones || []).length > 1 ? "s" : ""} on the original film.` : null };
}
status = isPositive ? (tier === "low" ? "possible" : "finding") : (tier === "low" ? "borderline" : "negative");
headline = { finding: "Pneumonia likely", possible: "Possible pneumonia", borderline: "Pneumonia unlikely (borderline)", negative: "No pneumonia detected" }[status];
sentence = `Pneumonia probability ${Math.round(100 * pPos)}% (threshold ${Math.round(100 * thr)}%).`;
return { headline, status, sentence, where, checks, probability: pPos, threshold: thr, certainty: tier,
model: { name: "About the paediatric pneumonia model", facts: card ? `Trained on ${card.train_images.toLocaleString()} chest X-rays of ${card.population}. On ${card.test_images} unseen test images: sensitivity ${(100 * card.sensitivity).toFixed(1)}%, specificity ${(100 * card.specificity).toFixed(1)}% (mean of ${card.runs} training runs). Not validated on adults.` : "" },
advice: where && status !== "negative" ? `Review the ${where} zone${(mt.main_zones || []).length > 1 ? "s" : ""} on the original film.` : null };
}
// ------------------------------------------------------------------ main entry
async function analyze(file, { modelId, methods, quality }, onProgress = () => {}) {
const t0 = performance.now(), timings = {}, T = (k, t) => (timings[k] = +((performance.now() - t) / 1000).toFixed(2));
await init((s) => onProgress("status", 0, 0, s));
const model = MODELS.find((m) => m.id === modelId && m.status === "active");
if (!model) throw new Error("This model is not available");
const sess = await session(model, (s) => onProgress("status", 0, 0, s));
const q = QUALITY[quality] || QUALITY.fast, wanted = methods.length ? methods : ["gradcam"];
let t = performance.now();
onProgress("prepare", 0, 1);
const dec = await decode(file);
const u8 = resizeArea(dec.gray, dec.w, dec.h, CACHE, CACHE).map((v) => Math.min(255, Math.max(0, Math.round(v))));
const img = toDisplay(u8);
onProgress("predict", 0, 1);
const orig = await run(sess, [img]);
const flipped = await sess.run({ input: toTensor([img], true) });
const k = orig.k, zmean = Array.from({ length: k }, (_, c) => (orig.logits[c] + flipped.logits.data[c]) / 2);
const pr = model.multilabel ? sigmoid(correct(zmean)) : softmax(zmean);
let target, label, conf, isPositive, thr, probs = {};
const classes = model.multilabel ? meta.nih14.classes.map((c) => c.replace(/_/g, " ")) : model.classes;
classes.forEach((c, i) => (probs[c] = +pr[i].toFixed(4)));
if (model.multilabel) {
target = pr.indexOf(Math.max(...pr)); label = classes[target]; conf = pr[target]; isPositive = null; // explain the top-scoring finding
thr = (meta.nih14.thresholds || {})[meta.nih14.classes[target]] ?? null;
} else {
thr = meta.pneumonia.threshold; isPositive = pr[1] >= thr; target = isPositive ? 1 : 0; label = classes[target]; conf = pr[target];
}
T("predict", t);
const sal = {}, layers = {}; let limeR2 = null, shapPhi = null;
if (wanted.includes("gradcam")) {
t = performance.now(); onProgress("gradcam", 0, 1);
const g = gradcam(orig.cams, k, target); sal.gradcam = g; layers.gradcam = { layer: await toPNG(heatLayer(g)) }; T("gradcam", t);
}
if (wanted.includes("lime")) {
t = performance.now();
const L = await lime(sess, img, target, model.multilabel, q.lime, onProgress);
limeR2 = L.r2; sal.lime = positive(L.map); layers.lime = { layer: await toPNG(limeLayer(L.coef, L.seg)) }; T("lime", t);
}
if (wanted.includes("shap")) {
t = performance.now();
const K = await kernelShap(sess, img, target, model.multilabel, q.shap, q.shapSeg, onProgress);
shapPhi = K.phi; sal.shap = positive(K.map); layers.shap = { layer: await toPNG(shapLayer(K.map)) }; T("shap", t);
}
onProgress("explain", 0, 1);
const baseC = new OffscreenCanvas(DISPLAY, DISPLAY), bctx = baseC.getContext("2d");
bctx.filter = "grayscale(1)"; bctx.drawImage(dec.bitmap, 0, 0, DISPLAY, DISPLAY);
const base = await new Promise((res) => baseC.convertToBlob({ type: "image/png" }).then((b) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.readAsDataURL(b); }));
const reason = reasoning({ label, conf, probs, sal, limeR2, shapPhi, isPositive, calibrated: thr !== null, modelName: model.name,
pPos: model.multilabel ? null : pr[1], thr: model.multilabel ? null : thr });
if (thr !== null) reason.metrics.threshold = +thr.toFixed(4);
const clin = clinical({ model, probs, label, conf, isPositive, thr, pPos: model.multilabel ? null : pr[1], reason, methodsRun: wanted });
timings.total = +((performance.now() - t0) / 1000).toFixed(2);
window.XAI_ENGINE._last = { gradcam: sal.gradcam || null, input: img }; // intermediates for self-tests
return {
model: { id: model.id, name: model.name, modality: model.modality, status: model.status, description: model.description, placeholder: false },
prediction: { label, index: target, confidence: +conf.toFixed(4), probabilities: probs, threshold: thr === null ? null : +thr.toFixed(4) },
image: { base, original_size: [dec.w, dec.h], input_size: S },
explanations: layers, reasoning: reason, clinical: clin, timings,
settings: { engine: `on-device (${backend}${gpuCheck && !gpuCheck.ok ? ", GPU failed accuracy check" : ""})`, lime_samples: wanted.includes("lime") ? q.lime : null, shap_samples: wanted.includes("shap") ? q.shap : null },
};
}
window.XAI_ENGINE = { models: MODELS, init, analyze, get backend() { return backend; }, get gpuCheck() { return gpuCheck; }, _test: { resizeArea, toDisplay, toTensor, gradcam, slic } };
})();