unroll / lib /ctwl.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
5.93 kB
// 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', `
<label class="ui-row" for="ctwlWin"><span>Window (contrast range) <output id="ctwlWinOut" class="dim"></output></span><input type="range" id="ctwlWin" min="0.02" max="1" step="0.01" data-action="ct.window" aria-label="X-ray window: the brightness range shown, narrower is more contrast"></label>
<label class="ui-row" for="ctwlLev"><span>Level (center brightness) <output id="ctwlLevOut" class="dim"></output></span><input type="range" id="ctwlLev" min="0" max="1" step="0.01" data-action="ct.level" aria-label="X-ray level: the brightness at the middle of the window"></label>
<div class="ui-grid"><button type="button" class="icon ui-btn" id="ctwlAuto" data-action="ct.auto" aria-pressed="false" title="Auto contrast: stretch the 1st to 99th percentile of the X-ray brightness (Settings sets the percentiles)"><span class="ui-lbl">Auto contrast</span></button>
<button type="button" class="icon ui-btn" id="ctwlReset" data-action="ct.reset" title="Show the full X-ray brightness range"><span class="ui-lbl">Full range</span></button></div>`);
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();