File size: 5,926 Bytes
ce6b88b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757768f
ce6b88b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// 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();