Download lib/ctlabels.js from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 6.96 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/ctlabels.js
- Command line
-
hf download hf://spaces/SharpMeow/unroll/lib/ctlabels.js
-
curl -L -o ctlabels.js https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/lib/ctlabels.js
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(); | |