--- a +++ b/main.js @@ -0,0 +1,384 @@ +import { Niivue } from "@niivue/niivue"; +import { runInference } from "./brainchop-mainthread.js"; +import { inferenceModelsList, brainChopOpts } from "./brainchop-parameters.js"; +import { isChrome, localSystemDetails } from "./brainchop-diagnostics.js"; +import MyWorker from "./brainchop-webworker.js?worker"; + +async function main() { + dragMode.onchange = async function () { + nv1.opts.dragMode = this.selectedIndex; + }; + drawDrop.onchange = async function () { + if (nv1.volumes.length < 2) { + window.alert("No segmentation open (use the Segmentation pull down)"); + drawDrop.selectedIndex = -1; + return; + } + if (!nv1.drawBitmap) { + window.alert("No drawing (hint: use the Draw pull down to select a pen)"); + drawDrop.selectedIndex = -1; + return; + } + const mode = parseInt(this.value); + if (mode === 0) { + nv1.drawUndo(); + drawDrop.selectedIndex = -1; + return; + } + let img = nv1.volumes[1].img; + let draw = await nv1.saveImage({ filename: "", isSaveDrawing: true }); + const niiHdrBytes = 352; + const nvox = draw.length; + if (mode === 1) { + //append + for (let i = 0; i < nvox; i++) if (draw[niiHdrBytes + i] > 0) img[i] = 1; + } + if (mode === 2) { + //delete + for (let i = 0; i < nvox; i++) if (draw[niiHdrBytes + i] > 0) img[i] = 0; + } + nv1.closeDrawing(); + nv1.updateGLVolume(); + nv1.setDrawingEnabled(false); + penDrop.selectedIndex = -1; + drawDrop.selectedIndex = -1; + }; + penDrop.onchange = async function () { + const mode = parseInt(this.value); + nv1.setDrawingEnabled(mode >= 0); + if (mode >= 0) nv1.setPenValue(mode & 7, mode > 7); + }; + aboutBtn.onclick = function () { + window.alert( + "Drag and drop NIfTI images. Use pulldown menu to choose brainchop model", + ); + }; + diagnosticsBtn.onclick = function () { + if (diagnosticsString.length < 1) { + window.alert( + "No diagnostic string generated: run a model to create diagnostics", + ); + return; + } + missingLabelStatus = missingLabelStatus.slice(0, -2); + if (missingLabelStatus !== "") { + if (diagnosticsString.includes('Status: OK')) { + diagnosticsString = diagnosticsString.replace('Status: OK', `Status: ${missingLabelStatus}`); + } + } + missingLabelStatus = "" + navigator.clipboard.writeText(diagnosticsString); + window.alert("Diagnostics copied to clipboard\n" + diagnosticsString); + }; + opacitySlider0.oninput = function () { + nv1.setOpacity(0, opacitySlider0.value / 255); + nv1.updateGLVolume(); + }; + opacitySlider1.oninput = function () { + nv1.setOpacity(1, opacitySlider1.value / 255); + }; + async function ensureConformed() { + const nii = nv1.volumes[0]; + let isConformed = + nii.dims[1] === 256 && nii.dims[2] === 256 && nii.dims[3] === 256; + if ( + nii.permRAS[0] !== -1 || + nii.permRAS[1] !== 3 || + nii.permRAS[2] !== -2 + ) { + isConformed = false; + } + if (isConformed) { + return; + } + const nii2 = await nv1.conform(nii, false); + await nv1.removeVolume(nv1.volumes[0]); + await nv1.addVolume(nii2); + } + async function closeAllOverlays() { + while (nv1.volumes.length > 1) { + await nv1.removeVolume(nv1.volumes[1]); + } + } + modelSelect.onchange = async function () { + if (this.selectedIndex < 0) { + modelSelect.selectedIndex = 11; + } + await closeAllOverlays(); + await ensureConformed(); + const model = inferenceModelsList[this.selectedIndex]; + const opts = brainChopOpts; + // opts.rootURL should be the url without the query string + const urlParams = new URL(window.location.href); + // remove the query string + opts.rootURL = urlParams.origin + urlParams.pathname; + const isLocalhost = Boolean( + window.location.hostname === "localhost" || + // [::1] is the IPv6 localhost address. + window.location.hostname === "[::1]" || + // 127.0.0.1/8 is considered localhost for IPv4. + window.location.hostname.match( + /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, + ), + ); + if (isLocalhost) { + opts.rootURL = location.protocol + "//" + location.host; + } + if (workerCheck.checked) { + if (typeof chopWorker !== "undefined") { + console.log( + "Unable to start new segmentation: previous call has not completed", + ); + return; + } + chopWorker = await new MyWorker({ type: "module" }); + const hdr = { + datatypeCode: nv1.volumes[0].hdr.datatypeCode, + dims: nv1.volumes[0].hdr.dims, + }; + const msg = { + opts, + modelEntry: model, + niftiHeader: hdr, + niftiImage: nv1.volumes[0].img, + }; + chopWorker.postMessage(msg); + chopWorker.onmessage = function (event) { + const cmd = event.data.cmd; + if (cmd === "ui") { + if (event.data.modalMessage !== "") { + chopWorker.terminate(); + chopWorker = undefined; + } + callbackUI( + event.data.message, + event.data.progressFrac, + event.data.modalMessage, + event.data.statData, + ); + } + if (cmd === "img") { + chopWorker.terminate(); + chopWorker = undefined; + callbackImg(event.data.img, event.data.opts, event.data.modelEntry); + } + }; + } else { + runInference( + opts, + model, + nv1.volumes[0].hdr, + nv1.volumes[0].img, + callbackImg, + callbackUI, + ); + } + }; + saveImgBtn.onclick = function () { + nv1.volumes[1].saveToDisk("segmentaion.nii.gz"); + }; + saveSceneBtn.onclick = function () { + nv1.saveDocument("brainchop.nvd"); + }; + workerCheck.onchange = function () { + modelSelect.onchange(); + }; + clipCheck.onchange = function () { + if (clipCheck.checked) { + nv1.setClipPlane([0, 0, 90]); + } else { + nv1.setClipPlane([2, 0, 90]); + } + }; + function doLoadImage() { + opacitySlider0.oninput(); + } + async function fetchJSON(fnm) { + const response = await fetch(fnm); + const js = await response.json(); + return js; + } + async function getUniqueValuesAndCounts(uint8Array) { + // Use a Map to count occurrences + const countsMap = new Map(); + + for (let i = 0; i < uint8Array.length; i++) { + const value = uint8Array[i]; + if (countsMap.has(value)) { + countsMap.set(value, countsMap.get(value) + 1); + } else { + countsMap.set(value, 1); + } + } + + // Convert the Map to an array of objects + const result = Array.from(countsMap, ([value, count]) => ({ + value, + count, + })); + + return result; + } + async function createLabeledCounts(uniqueValuesAndCounts, labelStrings) { + if (uniqueValuesAndCounts.length !== labelStrings.length) { + missingLabelStatus = "Failed to Predict Labels - " + console.error( + "Mismatch in lengths: uniqueValuesAndCounts has", + uniqueValuesAndCounts.length, + "items, but labelStrings has", + labelStrings.length, + "items.", + ); + } + + return labelStrings.map((label, index) => { + // Find the entry matching the current label index + const entry = uniqueValuesAndCounts.find(item => item.value === index); + + // If an entry is found, append the count value with 'mm3', otherwise show 'Missing' + const countText = entry ? `${entry.count} mm3` : "Missing"; + + countText === "Missing" + ? missingLabelStatus += `${label}, ` : null; + + return `${label} ${countText}`; + }); + } + async function callbackImg(img, opts, modelEntry) { + closeAllOverlays(); + const overlayVolume = await nv1.volumes[0].clone(); + overlayVolume.zeroImage(); + overlayVolume.hdr.scl_inter = 0; + overlayVolume.hdr.scl_slope = 1; + overlayVolume.img = new Uint8Array(img); + const roiVolumes = await getUniqueValuesAndCounts(overlayVolume.img); + console.log(roiVolumes); + if (modelEntry.colormapPath) { + const cmap = await fetchJSON(modelEntry.colormapPath); + const newLabels = await createLabeledCounts(roiVolumes, cmap["labels"]); + console.log(newLabels); + overlayVolume.setColormapLabel({ + R: cmap["R"], + G: cmap["G"], + B: cmap["B"], + labels: newLabels, + }); + // n.b. most models create indexed labels, but those without colormap mask scalar input + overlayVolume.hdr.intent_code = 1002; // NIFTI_INTENT_LABEL + } else { + let colormap = opts.atlasSelectedColorTable.toLowerCase(); + const cmaps = nv1.colormaps(); + if (!cmaps.includes(colormap)) { + colormap = "actc"; + } + overlayVolume.colormap = colormap; + } + overlayVolume.opacity = opacitySlider1.value / 255; + await nv1.addVolume(overlayVolume); + } + async function reportTelemetry(statData) { + if (typeof statData === "string" || statData instanceof String) { + function strToArray(str) { + const list = JSON.parse(str); + const array = []; + for (const key in list) { + array[key] = list[key]; + } + return array; + } + statData = strToArray(statData); + } + statData = await localSystemDetails(statData, nv1.gl); + diagnosticsString = + ":: Diagnostics can help resolve issues https://github.com/neuroneural/brainchop/issues ::\n"; + for (const key in statData) { + diagnosticsString += key + ": " + statData[key] + "\n"; + } + } + function callbackUI( + message = "", + progressFrac = -1, + modalMessage = "", + statData = [], + ) { + if (message !== "") { + console.log(message); + document.getElementById("location").innerHTML = message; + } + if (isNaN(progressFrac)) { + // memory issue + memstatus.style.color = "red"; + memstatus.innerHTML = "Memory Issue"; + } else if (progressFrac >= 0) { + modelProgress.value = progressFrac * modelProgress.max; + } + if (modalMessage !== "") { + window.alert(modalMessage); + } + if (Object.keys(statData).length > 0) { + reportTelemetry(statData); + } + } + function handleLocationChange(data) { + document.getElementById("location").innerHTML = data.string + .split(" ") + .map((value) => `<p style="font-size: 14px;margin:0px;">${value}</p>`) + .join(""); + } + const defaults = { + backColor: [0.4, 0.4, 0.4, 1], + show3Dcrosshair: true, + onLocationChange: handleLocationChange, + }; + let diagnosticsString = ""; + let missingLabelStatus = "" + let chopWorker; + const nv1 = new Niivue(defaults); + nv1.attachToCanvas(gl1); + nv1.opts.dragMode = nv1.dragModes.pan; + nv1.opts.multiplanarForceRender = true; + nv1.opts.yoke3Dto2DZoom = true; + nv1.opts.crosshairGap = 11; + nv1.setInterpolation(true); + await nv1.loadVolumes([{ url: "./t1_crop.nii.gz" }]); + for (let i = 0; i < inferenceModelsList.length; i++) { + const option = document.createElement("option"); + option.text = inferenceModelsList[i].modelName; + option.value = inferenceModelsList[i].id.toString(); + modelSelect.appendChild(option); + } + nv1.onImageLoaded = doLoadImage; + modelSelect.selectedIndex = -1; + drawDrop.selectedIndex = -1; + workerCheck.checked = await isChrome(); // TODO: Safari does not yet support WebGL TFJS webworkers, test FireFox + // uncomment next two lines to automatically run segmentation when web page is loaded + // modelSelect.selectedIndex = 11 + // modelSelect.onchange() + + // get the query string parameter model. + // if set, select the model from the dropdown list and call the modelSelect.onchange() function + const urlParams = new URLSearchParams(window.location.search); + const modelParam = urlParams.get("model"); + if (modelParam) { + // make sure the model index is a number + modelSelect.selectedIndex = Number(modelParam); + modelSelect.onchange(); + } +} + +async function updateStarCount() { + try { + const response = await fetch( + `https://api.github.com/repos/neuroneural/brainchop`, + ); + const data = await response.json(); + document.getElementById("star-count").textContent = data.stargazers_count; + } catch (error) { + console.error("Error fetching star count:", error); + } +} +(async function () { + await main(); + updateStarCount(); +})(); +