unroll / lib /ctlabels.js
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
6.96 kB
// 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();