// 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) => `${text}`; 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 = `
`; 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) => ``).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();