File size: 6,958 Bytes
e31bb23
 
757768f
e31bb23
 
 
 
 
 
 
757768f
e31bb23
 
 
 
 
 
 
 
 
 
 
 
 
757768f
e31bb23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
757768f
e31bb23
 
 
 
 
 
 
 
 
 
 
 
 
757768f
e31bb23
 
 
 
 
 
 
 
 
 
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
93
94
95
96
97
98
// SPDX-License-Identifier: Apache-2.0
// 3D labeled segments in the CT volume view: every traced segment (winding mesh) that passes through the cube is
// drawn into a label volume (one byte per voxel, the segment's number) and colored over the MIP, so you can see
// which sheet in the raw scan belongs to which segment. Click a segment in the list to highlight it.
// Idea after the label overlay of ScrollSlabViewer (https://github.com/Paul-G2/ScrollSlabViewer, MIT); the code is
// original to Unroll.
const $ = (id) => document.getElementById(id);
const L = { on: false, active: 0, list: [], ms: 0, voxels: 0 };
const vol = () => window.unrollView?.ct?.volume;

// golden-angle hues, bright enough to read over a gray MIP; index 0 stays transparent
function palette(n) {
  const p = new Uint8Array(256 * 4);
  for (let i = 1; i < 256; i++) {
    const h = ((i - 1) * 137.508) % 360, s = 0.75, l = 0.58, k = (m) => (m + h / 30) % 12, a = s * Math.min(l, 1 - l);
    const f = (m) => l - a * Math.max(-1, Math.min(k(m) - 3, 9 - k(m), 1));
    p.set([f(0) * 255, f(8) * 255, f(4) * 255, 255].map(Math.round), i * 4);
  }
  return p;
}
const hex = (p, i) => '#' + [0, 1, 2].map((c) => p[i * 4 + c].toString(16).padStart(2, '0')).join('');
const PAL = palette();

// voxelize every winding grid inside the current cube. Grid cells are split finely enough that samples are
// under 0.7 voxels apart at the cube's detail level, then each sample marks its voxel and its 6 neighbors.
function build() {
  const V = vol(); const S = V?._internal?.st; const G = window.unrollView?.scanGeom?.();
  if (!S?.loaded || !S.start || !G?.all || !G.axis) return null;
  const t0 = performance.now(), N = S.size, f = 2 ** S.level, [z0, y0, x0] = S.start, c = G.axis.centre;
  const lab = new Uint8Array(N * N * N), list = [];
  const put = (x, y, z, id) => { const xi = Math.round(x), yi = Math.round(y), zi = Math.round(z);
    if (xi < 0 || yi < 0 || zi < 0 || xi >= N || yi >= N || zi >= N) return 0; const k = (zi * N + yi) * N + xi; if (lab[k]) return 0; lab[k] = id; return 1; };
  G.wins.forEach((w, wi) => {
    const id = Math.min(255, wi + 1), a = G.all.subarray(w.offset / 2, (w.offset + w.bytes) / 2), C = w.cols, R = w.rows;
    const P = (r, q) => { const k = (r * C + q) * 3; if (a[k] === -32768) return null; return [(a[k] + c[0]) / f - x0, (a[k + 1] + c[1]) / f - y0, (a[k + 2] + c[2]) / f - z0]; };
    let n = 0; const m = 2;
    for (let r = 0; r < R - 1; r++) for (let q = 0; q < C - 1; q++) {
      const p00 = P(r, q); if (!p00) continue;
      if (p00[0] < -64 || p00[1] < -64 || p00[2] < -64 || p00[0] > N + 64 || p00[1] > N + 64 || p00[2] > N + 64) continue;
      const p01 = P(r, q + 1) || p00, p10 = P(r + 1, q) || p00, p11 = P(r + 1, q + 1) || p01;
      const d = Math.max(Math.hypot(p01[0] - p00[0], p01[1] - p00[1], p01[2] - p00[2]), Math.hypot(p10[0] - p00[0], p10[1] - p00[1], p10[2] - p00[2]));
      const k = Math.min(64, Math.max(1, Math.ceil(d / 0.7)));
      for (let i = 0; i <= k; i++) for (let j = 0; j <= k; j++) {
        const u = j / k, v = i / k, x = [0, 1, 2].map((e) => (p00[e] * (1 - u) + p01[e] * u) * (1 - v) + (p10[e] * (1 - u) + p11[e] * u) * v);
        if (x[0] < -m || x[1] < -m || x[2] < -m || x[0] > N + m || x[1] > N + m || x[2] > N + m) continue;
        n += put(x[0], x[1], x[2], id); put(x[0] + 1, x[1], x[2], id); put(x[0] - 1, x[1], x[2], id);
        put(x[0], x[1] + 1, x[2], id); put(x[0], x[1] - 1, x[2], id); put(x[0], x[1], x[2] + 1, id); put(x[0], x[1], x[2] - 1, id);
      }
    }
    if (n) list.push({ id: w.id, label: id, color: hex(PAL, id), voxels: 0 });
  });
  const cnt = new Uint32Array(256); for (let i = 0; i < lab.length; i++) cnt[lab[i]]++;
  for (const x of list) x.voxels = cnt[x.label];
  L.ms = Math.round(performance.now() - t0); L.list = list; L.voxels = list.reduce((s, x) => s + x.voxels, 0);
  return lab;
}
function apply() {
  const V = vol(); if (!V) return;
  if (!L.on) { V._internal.setLabels(null); ui(); return; }
  const lab = build(); V._internal.setLabels(lab, PAL, L.active); ui();
}
function show(on = !L.on) { L.on = !!on; if (!L.on) L.active = 0; apply(); return state(); }
function highlight(id) {
  const hit = L.list.find((x) => x.id === id || x.label === +id);
  L.active = hit ? hit.label : 0; vol()?._internal?.setActive(L.active); ui(); return state();
}
function state() { return { on: L.on, active: L.list.find((x) => x.label === L.active)?.id || null, segments: L.list.map((x) => ({ ...x })), voxels: L.voxels, buildMs: L.ms }; }

const term = (id, text) => `<a class="ui-term" href="#g-${id}" data-term="${id}" data-testid="ctlab-term-${id}" data-api="ui.glossary('${id}')">${text}</a>`;
function panel() {
  const body = document.querySelector('#ctVol .cv-body'); if (!body || $('cvLab')) return !!body;
  const d = document.createElement('div'); d.id = 'cvLab'; d.className = 'cv-lab';
  d.innerHTML = `<label for="cvLabOn"><span>Labeled ${term('segment', 'segments')}</span><input type="checkbox" id="cvLabOn" data-testid="ctvol-labels" data-api="ct.labels(on)" aria-label="Color the traced segments inside the cube"></label>
<div class="cv-row" id="cvLabList" role="list" aria-label="Segments in the cube"></div>
<p class="cv-stat" id="cvLabStat"></p>`;
  body.append(d);
  const st = document.createElement('style'); st.textContent = '#cvLabList .chip i{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px;vertical-align:-1px}#cvLabList .chip[aria-pressed=true]{outline:2px solid #fff}';
  document.head.append(st);
  $('cvLabOn').onchange = (e) => show(e.target.checked);
  $('cvLabList').onclick = (e) => { const b = e.target.closest('button[data-seg]'); if (b) highlight(L.active && b.dataset.seg === state().active ? null : b.dataset.seg); };
  ui(); return true;
}
function ui() {
  if (!$('cvLab')) return;
  $('cvLabOn').checked = L.on;
  $('cvLabList').innerHTML = L.on ? L.list.map((x) => `<button type="button" class="chip" role="listitem" data-seg="${x.id}" data-testid="ctvol-seg-${x.id}" data-api="ct.highlight('${x.id}')" aria-pressed="${x.label === L.active}" aria-label="Highlight segment ${x.id}"><i style="background:${x.color}"></i>${x.id}</button>`).join('') : '';
  $('cvLabStat').textContent = !L.on ? '' : L.list.length ? `${L.list.length} segment${L.list.length > 1 ? 's' : ''} in the cube, ${L.voxels.toLocaleString('en')} labeled voxels (${L.ms} ms). Click one to highlight it.` : 'No traced segment passes through this cube.';
}

function init() {
  const V = vol(); if (!V?._internal) return setTimeout(init, 200);
  V.labels = { show, state, highlight, colors: () => L.list.map((x) => [x.id, x.color]) };
  addEventListener('unroll:ctvolume', (e) => { if (e.detail.type === 'open') { V._internal.buildPanel(); panel(); L.active = 0; } if (e.detail.type === 'loaded') apply(); });
  const q = new URLSearchParams(location.search);
  if (q.get('ctlabels') === '1') L.on = true;
}
init();