| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| const MODULE_ID = "cine-view-classifier"; |
| const MODULE_VERSION = "0.1.0"; |
| const WEIGHT_FILE = "no_other_view_yolo.onnx"; |
| const WEIGHT_URL = |
| "https://huggingface.co/JerryX/cmr-annotator-cine-view-classifier/resolve/main/checkpoints/no_other_view_yolo.onnx"; |
| const WEIGHT_SHA256 = |
| "6b6916e54b306655d56f892d1e1484acaf3b4e94a39cf410dccbaaa52cdb55fc"; |
|
|
| const CLASS_NAMES = ["4ch", "lvot", "sas", "vla"]; |
| const STRIDE = 32; |
|
|
| let sessionPromise = null; |
|
|
| function loadSession(host) { |
| if (!sessionPromise) { |
| sessionPromise = (async () => { |
| host.meta.log.info("fetching view-classifier weights..."); |
| const buf = await host.weights.fetchCachedWeight(MODULE_ID, MODULE_VERSION, { |
| file: WEIGHT_FILE, |
| url: WEIGHT_URL, |
| sha256: WEIGHT_SHA256, |
| }); |
| host.meta.log.info("creating ONNX session..."); |
| return host.runtime.createOnnxSession(buf); |
| })().catch((err) => { |
| sessionPromise = null; |
| throw err; |
| }); |
| } |
| return sessionPromise; |
| } |
|
|
| function normalizeSlice(slice) { |
| const { pixels, width, height, bitsAllocated, pixelRepresentation } = slice; |
| const n = width * height; |
| const out = new Float32Array(n); |
| let min = Infinity; |
| let max = -Infinity; |
| if (bitsAllocated === 16 && pixelRepresentation === 1) { |
| const p = pixels; |
| for (let i = 0; i < n; i++) { |
| const v = p[i]; |
| if (v < min) min = v; |
| if (v > max) max = v; |
| } |
| const range = max - min || 1; |
| for (let i = 0; i < n; i++) out[i] = (p[i] - min) / range; |
| } else { |
| const p = pixels; |
| for (let i = 0; i < n; i++) { |
| const v = p[i]; |
| if (v < min) min = v; |
| if (v > max) max = v; |
| } |
| const range = max - min || 1; |
| for (let i = 0; i < n; i++) out[i] = (p[i] - min) / range; |
| } |
| return out; |
| } |
|
|
| function circularPadToStride(src, srcW, srcH, stride) { |
| const newW = Math.ceil(srcW / stride) * stride; |
| const newH = Math.ceil(srcH / stride) * stride; |
| if (newW === srcW && newH === srcH) { |
| return { data: src, width: srcW, height: srcH }; |
| } |
| const out = new Float32Array(newW * newH); |
| for (let y = 0; y < newH; y++) { |
| const sy = y < srcH ? y : y % srcH; |
| for (let x = 0; x < newW; x++) { |
| const sx = x < srcW ? x : x % srcW; |
| out[y * newW + x] = src[sy * srcW + sx]; |
| } |
| } |
| return { data: out, width: newW, height: newH }; |
| } |
|
|
| function buildInputTensor(plane2d, w, h) { |
| const planeSize = w * h; |
| const tensor = new Float32Array(3 * planeSize); |
| tensor.set(plane2d, 0); |
| tensor.set(plane2d, planeSize); |
| tensor.set(plane2d, 2 * planeSize); |
| return tensor; |
| } |
|
|
| function softmax(logits) { |
| let max = -Infinity; |
| for (let i = 0; i < logits.length; i++) if (logits[i] > max) max = logits[i]; |
| const exps = new Float32Array(logits.length); |
| let sum = 0; |
| for (let i = 0; i < logits.length; i++) { |
| exps[i] = Math.exp(logits[i] - max); |
| sum += exps[i]; |
| } |
| for (let i = 0; i < logits.length; i++) exps[i] /= sum || 1; |
| return exps; |
| } |
|
|
| async function classifySlice(host, seriesUid, sliceIdx) { |
| const slice = await host.data.getSlice(seriesUid, sliceIdx); |
| const norm = normalizeSlice(slice); |
| const padded = circularPadToStride(norm, slice.width, slice.height, STRIDE); |
| const tensorData = buildInputTensor(padded.data, padded.width, padded.height); |
|
|
| const session = await loadSession(host); |
| const inputName = session.inputNames[0]; |
| const outputs = await session.run({ |
| [inputName]: { |
| data: tensorData, |
| dims: [1, 3, padded.height, padded.width], |
| }, |
| }); |
| const out0 = outputs[session.outputNames[0]]; |
| let probs = out0.data; |
| |
| |
| let sum = 0; |
| for (let i = 0; i < probs.length; i++) sum += probs[i]; |
| if (Math.abs(sum - 1) > 0.01) probs = softmax(probs); |
|
|
| let bestIdx = 0; |
| for (let i = 1; i < probs.length; i++) { |
| if (probs[i] > probs[bestIdx]) bestIdx = i; |
| } |
| return { |
| label: CLASS_NAMES[bestIdx] ?? `class-${bestIdx}`, |
| confidence: probs[bestIdx], |
| probabilities: Array.from(probs).map((p, i) => ({ |
| label: CLASS_NAMES[i] ?? `class-${i}`, |
| probability: p, |
| })), |
| }; |
| } |
|
|
| export default function register(host) { |
| |
| |
| |
| let active = null; |
| const offSlice = host.events.on("slice-changed", (payload) => { |
| active = payload; |
| }); |
|
|
| const offRibbon = host.ui.registerRibbon({ |
| id: "classify-view", |
| label: "Classify View", |
| onClick: async (ctx) => { |
| const series = active ?? null; |
| if (!series) { |
| ctx.host.ui.showToast( |
| "Click a slice in a CINE viewport first, then try Classify View", |
| "info", |
| ); |
| return; |
| } |
| ctx.host.ui.showToast( |
| `Classifying slice ${series.sliceIdx}…`, |
| "info", |
| ); |
| try { |
| const result = await classifySlice(ctx.host, series.seriesUid, series.sliceIdx); |
| const pct = Math.round(result.confidence * 100); |
| ctx.host.ui.showToast( |
| `View: ${result.label.toUpperCase()} (${pct}% confidence)`, |
| "success", |
| ); |
| ctx.host.meta.log.info( |
| `classified slice ${series.sliceIdx}: ${result.label} (${pct}%)`, |
| result.probabilities, |
| ); |
| } catch (err) { |
| const msg = err instanceof Error ? err.message : String(err); |
| ctx.host.ui.showToast(`Classification failed: ${msg}`, "error"); |
| ctx.host.meta.log.error("classification failed", err); |
| } |
| }, |
| }); |
|
|
| host.meta.log.info("cine-view-classifier registered"); |
|
|
| return { |
| host, |
| dispose() { |
| offRibbon(); |
| offSlice(); |
| }, |
| }; |
| } |
|
|