From 7af9c48e88a2360a2fff68d42cca77bf23736f29 Mon Sep 17 00:00:00 2001 From: bronsdonayden Date: Tue, 14 Jul 2026 14:28:19 -0400 Subject: [PATCH 1/2] Minor fixes to pixelExplorer to make that work better and faster, moved stats out of the main boostlet area, should hopefully work now. --- examples/PixelExplorer.js | 34 ++++++++++++----- examples/roiStats.js | 75 +++++++++++++++++++++++++------------ src/boostlet.js | 15 +------- src/stats.js | 79 --------------------------------------- 4 files changed, 78 insertions(+), 125 deletions(-) delete mode 100644 src/stats.js diff --git a/examples/PixelExplorer.js b/examples/PixelExplorer.js index 634f82fe..4c5049d0 100644 --- a/examples/PixelExplorer.js +++ b/examples/PixelExplorer.js @@ -1,4 +1,4 @@ -CATEGORY = "Visualization" +const CATEGORY = "Visualization" if (typeof window._PixelExplorerLoaded === 'undefined') { window._PixelExplorerLoaded = true; @@ -12,9 +12,12 @@ script.onload = function() { /// wait for nv and volumes before calling init // calling init too early means niivue won't be detected yet var poll = setInterval(function() { - if (window.nv && nv.volumes && nv.volumes.length > 0) { + try { + Boostlet.init(); clearInterval(poll); run(); + } catch(e) { + // framework not ready yet keep polling } }, 300); }; @@ -23,8 +26,6 @@ document.head.appendChild(script); const HALF = 4; function run() { - Boostlet.init(); - if (Boostlet.framework.name !== 'niivue') { alert('Only niivue is supported right now :('); return; @@ -42,7 +43,8 @@ function setup(nv) { const existingOnLocationChange = nv.onLocationChange; // hook into location change — fires on every crosshair move / slice change - nv.onLocationChange = function(loc) { + // fire once immediately so the panel is populated on load without requiring a click + const triggerUpdate = function(loc) { if (existingOnLocationChange) existingOnLocationChange(loc); if (!nv.volumes || !nv.volumes.length) return; @@ -64,6 +66,18 @@ function setup(nv) { if (mc) drawMatrix(data, dims, mc); } }; + + nv.onLocationChange = triggerUpdate; + + // synthesize a loc object from current crosshair state so the panel fills immediately + const pos = nv.scene.crosshairPos; + const dims = nv.volumes[0].dims; + const initialVox = [ + Math.round(pos[0] * dims[1]), + Math.round(pos[1] * dims[2]), + Math.round(pos[2] * dims[3]) + ]; + triggerUpdate({ vox: initialVox }); } // render voxel values as a labeled grayscale grid @@ -121,14 +135,16 @@ function plot() { let container = window.document.createElement('div'); container.id = 'PixelExplorerDiv'; - container.style.cssText = 'position:fixed; top:10px; left:10px; z-index:1000; cursor:move;'; + container.style.cssText = 'position:fixed; top:10px; left:10px; z-index:2147483647; cursor:move; border:2px solid #fff; box-shadow:0 0 0 1px #000; border-radius:3px; overflow:hidden; line-height:0;'; window.document.body.appendChild(container); + const size = Math.floor(400 / (HALF * 2 + 1)) * (HALF * 2 + 1); + const mc = document.createElement('canvas'); mc.id = 'PixelExplorerCanvas'; - mc.width = 400; - mc.height = 400; - mc.style.cssText = 'display:block; width:400px; height:400px;'; + mc.width = size; + mc.height = size; + mc.style.cssText = `display:block; width:${size}px; height:${size}px;`; container.appendChild(mc); // drag logic diff --git a/examples/roiStats.js b/examples/roiStats.js index c90eadf1..7507ffd0 100644 --- a/examples/roiStats.js +++ b/examples/roiStats.js @@ -1,33 +1,36 @@ +const CATEGORY = "Statistics"; + +if (typeof window._RoiStatsLoaded === 'undefined') { +window._RoiStatsLoaded = true; + (function() { -const CATEGORY = "Statistics"; +const NUMPY_TS_URL = 'https://cdn.jsdelivr.net/npm/numpy-ts@1.3.0/dist/numpy-ts.browser.js'; -// inject boostlet, but wait for nv and volumes before calling init -// calling init too early means niivue won't be detected yet -const script = document.createElement("script"); -script.type = "text/javascript"; -script.src = "https://boostlet.org/dist/boostlet.min.js"; +// half width of the roi in voxels so the full neighborhood is (2*HALF+1)^2 +const HALF = 4; + +const script = document.createElement('script'); +script.type = 'text/javascript'; +script.src = 'https://boostlet.org/dist/boostlet.min.js'; script.onload = function() { var poll = setInterval(function() { - if (window.nv && nv.volumes && nv.volumes.length > 0) { + try { + Boostlet.init(); clearInterval(poll); run(); + } catch(e) { + // framework not ready yet keep polling } }, 300); }; document.head.appendChild(script); -// half-width of the roi in voxels, so the full neighborhood is (2*HALF+1)^2 -const HALF = 4; - -async function run() { - Boostlet.init(); - +function run() { if (Boostlet.framework.name !== 'niivue') { alert('Only niivue is supported right now :('); return; } - const nv = Boostlet.framework.instance; setup(nv); } @@ -35,13 +38,12 @@ async function run() { async function setup(nv) { plot(); - // load numpy-ts via stats wrapper - const stats = await Boostlet.stats(); + // load numpy-ts directly no boostlet wrapper needed + const np = await import(NUMPY_TS_URL); - // save whatever handler was already on nv so we can chain it const existingOnLocationChange = nv.onLocationChange; - nv.onLocationChange = async function(loc) { + const triggerUpdate = async function(loc) { if (existingOnLocationChange) existingOnLocationChange(loc); if (!nv.volumes || !nv.volumes.length) return; @@ -50,8 +52,6 @@ async function setup(nv) { const start = [v[0] - HALF, v[1] - HALF, v[2] - HALF]; const end = [v[0] + HALF, v[1] + HALF, v[2] + HALF]; - // collapse the axis perpendicular to the current slice to a single voxel - // 2 = sagittal (x axis), 1 = coronal (y axis), default axial (z axis) const fixedAxis = nv.opts.sliceType === 2 ? 0 : nv.opts.sliceType === 1 ? 1 : 2; start[fixedAxis] = end[fixedAxis] = Math.round((start[fixedAxis] + end[fixedAxis]) / 2); @@ -59,9 +59,20 @@ async function setup(nv) { const { data } = Boostlet.get_subvolume(start, end); if (!data || data.length === 0) return; - const result = stats.all(data, nv.volumes[0]); + const result = computeStats(np, data, nv.volumes[0]); updatePanel(result); }; + + nv.onLocationChange = triggerUpdate; + + // fire immediately so panel populates on load + const pos = nv.scene.crosshairPos; + const dims = nv.volumes[0].dims; + triggerUpdate({ vox: [ + Math.round(pos[0] * dims[1]), + Math.round(pos[1] * dims[2]), + Math.round(pos[2] * dims[3]) + ]}); } // write computed stats into the panel dom elements @@ -74,7 +85,7 @@ function updatePanel(s) { document.getElementById('roi-p75').textContent = s.p75.toFixed(2); } -// create and inject the stats panel, remove any existing one first +// create and inject the stats panel remove any existing one first function plot() { const existing = document.getElementById('RoiStatsDiv'); if (existing) existing.remove(); @@ -82,7 +93,7 @@ function plot() { const div = document.createElement('div'); div.id = 'RoiStatsDiv'; div.style.cssText = ` - position: fixed; top: 10px; right: 10px; z-index: 1000; + position: fixed; top: 10px; right: 10px; z-index: 2147483647; background: rgba(0,0,0,0.85); color: #fff; font-family: monospace; font-size: 13px; padding: 12px 16px; border-radius: 6px; @@ -123,4 +134,22 @@ function plot() { }); } +// inline stat functions using numpy-ts directly +// apply nifti slope/intercept then compute all stats in one pass +function computeStats(np, data, volume) { + const slope = (volume && volume.hdr.scl_slope) || 1; + const inter = (volume && volume.hdr.scl_inter) || 0; + const a = np.array(Array.from(data), 'float32').multiply(slope).add(inter); + const scalar = v => typeof v === 'number' ? v : v.tolist ? v.tolist() : Number(v); + return { + mean : scalar(np.mean(a)), + std : scalar(np.std(a)), + min : scalar(np.min(a)), + max : scalar(np.max(a)), + p25 : scalar(np.percentile(a, 25)), + p75 : scalar(np.percentile(a, 75)), + }; +} + })(); +} diff --git a/src/boostlet.js b/src/boostlet.js index b9be5774..caf431b1 100644 --- a/src/boostlet.js +++ b/src/boostlet.js @@ -1,6 +1,4 @@ import {Util} from './util.js'; -import {Framework} from './framework.js'; -import {loadStats} from './stats.js'; export class Boostlet { @@ -34,7 +32,7 @@ export class Boostlet { if (this.framework) { - console.log('Found', this.framework, '!') + console.log('Found', this.framework.name, '!') } else { @@ -145,15 +143,4 @@ export class Boostlet { } - - - // const stats = await Boostlet.stats(); - // const result = stats.all(subvolumeData); - // gives you { mean, std, min, max, p25, p75 } - async stats() { - - return loadStats(); - - } - } diff --git a/src/stats.js b/src/stats.js deleted file mode 100644 index a8f61e91..00000000 --- a/src/stats.js +++ /dev/null @@ -1,79 +0,0 @@ -// stats.js — numpy-ts powered statistics for Boostlet -// Lazy-loads numpy-ts once on first call, then reuses the instance. -// -// Usage from a boostlet: -// const stats = await Boostlet.stats(); -// const result = stats.all(data, nv.volumes[0]); - -const NUMPY_TS_URL = 'https://esm.run/numpy-ts'; - -let _np = null; - -// only load numpy-ts once, cache it in _np -async function _load() { - if (!_np) { - _np = await import(NUMPY_TS_URL); - } - return _np; -} - -export async function loadStats() { - const np = await _load(); - - return { - - // convert typed array to a numpy-ts array so we can run ops on it - _wrap(data) { - return np.array(Array.from(data), 'float32'); - }, - - // apply nifti slope/intercept: display = raw * slope + inter - _applyScaling(data, volume) { - if (!volume) return this._wrap(data); - const slope = volume.hdr.scl_slope || 1; - const inter = volume.hdr.scl_inter || 0; - const a = this._wrap(data); - return a.multiply(slope).add(inter); - }, - - // numpy-ts returns array objects not plain numbers, unwrap to a scalar - _scalar(val) { - return typeof val === 'number' ? val : val.tolist ? val.tolist() : Number(val); - }, - - mean(data, volume) { - return this._scalar(np.mean(this._applyScaling(data, volume))); - }, - - std(data, volume) { - return this._scalar(np.std(this._applyScaling(data, volume))); - }, - - min(data, volume) { - return this._scalar(np.min(this._applyScaling(data, volume))); - }, - - max(data, volume) { - return this._scalar(np.max(this._applyScaling(data, volume))); - }, - - percentile(data, p, volume) { - return this._scalar(np.percentile(this._applyScaling(data, volume), p)); - }, - - // returns all stats in one pass - // pass volume as second arg to apply slope/intercept - all(data, volume) { - const a = this._applyScaling(data, volume); - return { - mean : this._scalar(np.mean(a)), - std : this._scalar(np.std(a)), - min : this._scalar(np.min(a)), - max : this._scalar(np.max(a)), - p25 : this._scalar(np.percentile(a, 25)), - p75 : this._scalar(np.percentile(a, 75)), - }; - } - - }; -} From 48129e3bf228bed0d0145baeae21208d43f3020d Mon Sep 17 00:00:00 2001 From: bronsdonayden Date: Tue, 14 Jul 2026 18:35:27 -0400 Subject: [PATCH 2/2] Update PixelExplorer.js --- examples/PixelExplorer.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/PixelExplorer.js b/examples/PixelExplorer.js index 4c5049d0..94339dc7 100644 --- a/examples/PixelExplorer.js +++ b/examples/PixelExplorer.js @@ -69,7 +69,7 @@ function setup(nv) { nv.onLocationChange = triggerUpdate; - // synthesize a loc object from current crosshair state so the panel fills immediately + // make a loc object from current crosshair state so the panel fills immediately const pos = nv.scene.crosshairPos; const dims = nv.volumes[0].dims; const initialVox = [