// SPDX-License-Identifier: Apache-2.0 // X-ray window/level for the surface layer, with percentile auto-contrast. // window = high - low, level = (low + high) / 2, both on the 0..1 scale of the CT atlas. // Auto-contrast takes the given percentiles of the atlas histogram, ignoring the empty (black) pixels. const $ = (id) => document.getElementById(id); const st = { low: 0, high: 1, auto: null }; let U = null; // the shader uniform (THREE.Vector2) from app.js function setLowHigh(low, high, src = 'api') { low = Math.max(0, Math.min(1, +low)); high = Math.max(0, Math.min(1, +high)); if (!(high > low)) high = Math.min(1, low + 0.001); st.low = low; st.high = high; if (U) U.value.set(low, high); sync(src); return state(); } const setWindow = (width, level) => { st.auto = null; const w = Math.max(0.001, Math.min(1, +width)), l = +level; return setLowHigh(l - w / 2, l + w / 2, 'api'); }; function state() { return { low: +st.low.toFixed(4), high: +st.high.toFixed(4), window: +(st.high - st.low).toFixed(4), level: +((st.high + st.low) / 2).toFixed(4), auto: st.auto }; } // histogram of the CT atlas (256 bins), drawn into a small canvas once let HIST = null; function histogram() { if (HIST) return HIST; const tex = window.unrollView?.gfx?.ctTexture?.(); const img = tex?.image; if (!img) return null; const W = 512, H = Math.max(1, Math.round(W * (img.height / img.width))); const c = document.createElement('canvas'); c.width = W; c.height = H; const x = c.getContext('2d', { willReadFrequently: true }); x.drawImage(img, 0, 0, W, H); const d = x.getImageData(0, 0, W, H).data, h = new Uint32Array(256); for (let i = 0; i < d.length; i += 4) if (d[i] > 3) h[d[i]]++; // 0..3 is the empty atlas background return (HIST = h); } function percentiles(h, pl, ph) { let n = 0; for (const v of h) n += v; if (!n) return [0, 1]; const at = (p) => { const k = n * p / 100; let s = 0; for (let i = 0; i < 256; i++) { s += h[i]; if (s >= k) return i / 255; } return 1; }; return [at(pl), at(ph)]; } function auto(pLow, pHigh) { pLow = +(pLow ?? setting('ctAutoLow', 1)); pHigh = +(pHigh ?? setting('ctAutoHigh', 99)); const h = histogram(); if (!h) return null; const [lo, hi] = percentiles(h, pLow, pHigh); st.auto = { pLow, pHigh }; setLowHigh(lo, hi, 'auto'); st.auto = { pLow, pHigh }; return state(); } function reset() { st.auto = null; return setLowHigh(0, 1, 'reset'); } function setting(k, d) { try { return window.unrollView.settings.get(k); } catch { return d; } } // ---------------------------------------------------------------- panel group function sync() { const w = $('ctwlWin'), l = $('ctwlLev'); if (w) { w.value = (st.high - st.low).toFixed(3); $('ctwlWinOut').textContent = Math.round((st.high - st.low) * 100) + '%'; } if (l) { l.value = ((st.high + st.low) / 2).toFixed(3); $('ctwlLevOut').textContent = Math.round((st.high + st.low) * 50) + '%'; } $('ctwlAuto')?.setAttribute('aria-pressed', !!st.auto); } function buildGroup(P) { if ($('uiCT')) return; const g = P.group('X-ray contrast', 'ui-ct'); g.id = 'uiCT'; g.insertAdjacentHTML('beforeend', `
`); P.body.append(g); const onWL = () => { st.auto = null; setWindow($('ctwlWin').value, $('ctwlLev').value); }; $('ctwlWin').addEventListener('input', onWL); $('ctwlLev').addEventListener('input', onWL); $('ctwlAuto').onclick = () => auto(); $('ctwlReset').onclick = () => reset(); P.addAction?.('ct.auto', { id: 'ctwlAuto', label: 'X-ray auto contrast (percentiles from Settings)', run: (a) => auto(...(Array.isArray(a) ? a : [])) }); P.addAction?.('ct.reset', { id: 'ctwlReset', label: 'X-ray full brightness range', run: () => reset() }); sync(); P.measure?.(); } function init() { const v = window.unrollView; if (!v?.gfx?.ctWindow) return setTimeout(init, 100); U = v.gfx.ctWindow; const api = { setWindow, setLowHigh, auto, reset, state, histogram: () => Array.from(histogram() || []), percentiles: (a, b) => percentiles(histogram(), a, b) }; v.ct = Object.assign(v.ct || {}, api); if (window.unrollPanel) buildGroup(window.unrollPanel); else addEventListener('unroll:panel', (e) => buildGroup(e.detail), { once: true }); // URL: ?ctwin=0.5&ctlevel=0.4 or ?ctauto=1 (or 2,98 for other percentiles); Settings: auto contrast on load const q = new URLSearchParams(location.search); const go = () => { if (q.has('ctwin') || q.has('ctlevel')) setWindow(q.get('ctwin') ?? 1, q.get('ctlevel') ?? 0.5); else if (q.has('ctauto')) { const [a, b] = q.get('ctauto').split(',').map(Number); auto(b ? a : undefined, b || undefined); } else if (setting('ctAuto', false)) auto(); }; const wait = () => (v.gfx.ctTexture()?.image ? go() : setTimeout(wait, 200)); wait(); addEventListener('unroll:settings', (e) => { if (e.detail.ctAuto && !st.auto) auto(); else if (st.auto) auto(); }); } init();