Files
PACS/DICOM_and_UPP配准/static/app.js

1647 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as THREE from "three";
import { STLLoader } from "./vendor/STLLoader.js";
import { OrbitControls } from "./vendor/OrbitControls.js";
const $ = (id) => document.getElementById(id);
const BODY_LABELS = {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
lower_abdomen: "下腹部",
pelvis: "盆腔",
};
const DEFAULT_POSE = {
rotateX: 0,
rotateY: 0,
rotateZ: 0,
translateX: 0,
translateY: 0,
translateZ: 0,
scale: 1,
scaleX: 1,
scaleY: 1,
scaleZ: 1,
flipX: false,
flipY: false,
flipZ: false,
};
const POSE_CONTROLS = [
{ key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1, suffix: "°" },
{ key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.01, suffix: "" },
{ key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.01, suffix: "x" },
];
const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a];
const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "",
user: null,
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
statusFilter: "",
partFilter: "",
search: "",
caseCollapsed: false,
activeToolTab: "series",
cases: [],
activeCase: null,
series: [],
selectedSeriesUid: "",
stlFiles: [],
selectedStlIds: new Set(),
algorithmModel: "",
registrationStatus: "unregistered",
pose: { ...DEFAULT_POSE },
windowMode: "default",
fusionMode: "fusion",
sliceIndex: 0,
dicomPreviewTimer: 0,
autoResult: null,
dirty: false,
dirtyReason: "",
saving: false,
fusionVersion: 0,
volumeMeta: null,
volumeScene: null,
sceneReady: false,
renderer: null,
camera: null,
scene: null,
controls: null,
dicomGroup: null,
modelGroup: null,
rawModelGroup: null,
modelMeshes: [],
baseModelScale: 1,
dicomSceneBox: null,
mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 },
mappingDrag: null,
mappingDrawFrame: 0,
resizeObserver: null,
resizeScene: null,
};
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function cls(value) {
return value ? "active" : "";
}
function setTopLoading(visible) {
$("topLoading").classList.toggle("hidden", !visible);
}
function setFusionLoading(visible, text = "正在构建融合视图") {
$("fusionLoading").classList.toggle("hidden", !visible);
$("fusionLoading").querySelector("span").textContent = text;
}
function setSaveState(text, tone = "") {
const el = $("saveState");
el.textContent = text;
el.className = tone;
}
function setAutoState(text, tone = "") {
const el = $("autoState");
if (!el) return;
el.textContent = text;
el.className = tone;
}
function markDirty(reason = "pose") {
state.dirty = true;
state.dirtyReason = reason;
setSaveState("未保存", "warn");
}
function resetDirty() {
state.dirty = false;
state.dirtyReason = "";
setSaveState("已保存", "ok");
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
if (state.token) headers.set("Authorization", `Bearer ${state.token}`);
if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...options, headers });
if (response.status === 401) {
logout(false);
throw new Error("登录已过期");
}
if (!response.ok) {
let message = response.statusText;
try {
const payload = await response.json();
message = payload.detail || message;
} catch {
message = await response.text();
}
throw new Error(message || "请求失败");
}
const type = response.headers.get("content-type") || "";
return type.includes("application/json") ? response.json() : response.text();
}
function loginVisible(visible) {
$("loginOverlay").classList.toggle("hidden", !visible);
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const payload = await api("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
state.token = payload.token;
state.user = payload;
localStorage.setItem("dicom_upp_registration_token", state.token);
loginVisible(false);
await bootstrap();
} catch (error) {
$("loginError").textContent = error.message;
}
}
function logout(clearToken = true) {
if (clearToken) localStorage.removeItem("dicom_upp_registration_token");
state.token = "";
state.user = null;
loginVisible(true);
$("userBadge").textContent = "未登录";
}
async function bootstrap() {
setTopLoading(true);
try {
const me = await api("/api/auth/me");
state.user = me;
$("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`;
loginVisible(false);
try {
initScene();
} catch (error) {
initGeometryFallback();
$("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`;
console.warn(error);
}
buildPoseControls();
await loadStatus();
await loadCases();
} catch (error) {
console.warn(error);
if (!state.token) logout(false);
else {
$("dbStatus").textContent = error.message || "启动失败";
$("dbStatus").className = "status-pill offline";
loginVisible(false);
}
} finally {
setTopLoading(false);
}
}
async function loadStatus() {
try {
const payload = await api("/api/status");
state.links = payload.links || state.links;
const db = payload.database || {};
$("dbStatus").textContent = db.ok ? `数据库已连接 · ${payload.counts?.complete_cases || 0}` : "数据库异常";
$("dbStatus").className = `status-pill ${db.ok ? "online" : "offline"}`;
} catch (error) {
$("dbStatus").textContent = "数据库异常";
$("dbStatus").className = "status-pill offline";
}
}
async function loadCases(selectFirst = true) {
setTopLoading(true);
try {
const params = new URLSearchParams();
if (state.search) params.set("q", state.search);
if (state.statusFilter) params.set("status", state.statusFilter);
if (state.partFilter) params.set("body_part", state.partFilter);
const rows = await api(`/api/cases?${params.toString()}`);
state.cases = rows;
renderCases();
if (selectFirst && !state.activeCase && rows.length) {
await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型");
} else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) {
state.activeCase = null;
clearDetail();
}
} finally {
setTopLoading(false);
}
}
function sameCase(a, b) {
return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型");
}
function sameCtNumber(a, b) {
return normalize(a) === normalize(b);
}
function normalize(value) {
return String(value || "").trim().toUpperCase();
}
function fmtDateTime(date, time) {
const d = String(date || "").replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3");
const t = fmtTime(time);
return [d, t].filter(Boolean).join(" ");
}
function fmtTime(value) {
const raw = String(value || "").replace(/[^\d.]/g, "");
if (!raw) return "";
const main = raw.split(".")[0].padEnd(6, "0").slice(0, 6);
return `${main.slice(0, 2)}:${main.slice(2, 4)}:${main.slice(4, 6)}`;
}
function statusText(value) {
return value === "registered" ? "已配准" : "未配准";
}
function seriesLabels(item) {
return Array.isArray(item?.annotation_labels) ? item.annotation_labels.map((label) => String(label || "")) : [];
}
function isSkippedSeries(item) {
return seriesLabels(item).some((label) => label.includes("略过") || label.includes("不采用"));
}
function hasLabel(item, matcher) {
return seriesLabels(item).some((label) => matcher(String(label)));
}
function isUpperAbdomenSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部"));
}
function isPortalPhaseSeries(item) {
return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期")));
}
function bodyPartTags(row) {
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
if (labels.length) return labels;
return keys.map((key) => BODY_LABELS[key]).filter(Boolean);
}
function renderCases() {
$("caseCount").textContent = state.cases.length;
const active = state.activeCase;
$("caseList").innerHTML = state.cases.length
? state.cases
.map((row) => {
const tags = bodyPartTags(row).slice(0, 3);
const status = row.registration_status || "unregistered";
const selected = active && sameCase(row, active);
const title = escapeHtml(row.ct_number || row.ct_key);
const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-");
const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
return `
<button class="case-card ${selected ? "active" : ""}" data-ct="${escapeHtml(row.ct_number)}" data-algorithm="${escapeHtml(row.algorithm_model || "未指定模型")}" title="${title} ${patient}">
<div class="card-line">
<strong>${title}</strong>
<em class="tag ${status === "registered" ? "green" : "amber"}">${statusText(status)}</em>
</div>
<span>${patient} · ${escapeHtml(row.patient_id || row.patient_id_masked || "")}</span>
<small>${escapeHtml(meta)}</small>
<div class="mini-tags">
<i>${escapeHtml(row.algorithm_model || "未指定模型")}</i>
${tags.map((tag) => `<i>${escapeHtml(tag)}</i>`).join("")}
</div>
</button>
`;
})
.join("")
: `<div class="empty-state">没有符合条件的完整关联 CT</div>`;
$("caseList").querySelectorAll(".case-card").forEach((button) => {
button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型"));
});
}
function clearDetail() {
state.series = [];
state.stlFiles = [];
state.selectedStlIds = new Set();
state.selectedSeriesUid = "";
$("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = "";
$("patientSummary").textContent = "未选择 CT";
$("summaryCt").textContent = "-";
$("summaryPatientId").textContent = "-";
$("summaryStudyTime").textContent = "-";
$("summaryBodyPart").textContent = "-";
$("matchedSeries").innerHTML = "";
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
$("modelRail").innerHTML = "";
renderDicomAnnotation();
clearFusion();
}
async function confirmChange() {
if (!state.dirty) return true;
return window.confirm("当前配准参数还未保存,确定切换吗?");
}
async function selectCase(ctNumber, algorithmModel = "未指定模型") {
if (!ctNumber) return;
const normalizedAlgorithm = algorithmModel || "未指定模型";
const switchingModelOnly = state.activeCase
&& sameCtNumber(state.activeCase.ct_number, ctNumber)
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
const previousPose = { ...state.pose };
if (!switchingModelOnly && !(await confirmChange())) return;
setTopLoading(true);
setFusionLoading(true, "正在读取 CT 关联数据");
try {
const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm });
const [detail, seriesPayload, stlPayload, registration] = await Promise.all([
api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`),
api(`/api/cases/${encodeURIComponent(ctNumber)}/series`),
api(`/api/cases/${encodeURIComponent(ctNumber)}/stl?${params.toString()}`),
api(`/api/registrations/${encodeURIComponent(ctNumber)}?${params.toString()}`),
]);
state.activeCase = detail;
state.algorithmModel = detail.algorithm_model || algorithmModel || "未指定模型";
state.series = seriesPayload.series || [];
state.stlFiles = stlPayload.files || [];
state.registrationStatus = registration.registration_status || "unregistered";
const savedTransform = registration.transform || {};
const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.autoResult = null;
state.sliceIndex = 0;
$("notes").value = registration.notes || "";
const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite));
state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles);
state.selectedSeriesUid = registration.series_instance_uid || pickDefaultSeries(state.series)?.series_uid || "";
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
renderCases();
renderActiveHeader();
renderSeries();
renderStl();
renderPoseControls();
if (keepPreviousPose) markDirty("model");
else resetDirty();
await loadFusion();
} catch (error) {
$("fusionStatus").textContent = error.message;
clearFusion();
} finally {
setTopLoading(false);
setFusionLoading(false);
}
}
function pickDefaultSeries(series) {
const candidates = [...series].filter((item) => Number(item.count || 0) > 1);
const usable = candidates.filter((item) => !isSkippedSeries(item));
const pool = usable.length ? usable : candidates;
const algorithm = String(state.algorithmModel || "").toLowerCase();
const rank = (item) => {
let score = Number(item.count || 0) / 10000;
if (algorithm.includes("肝胆") || algorithm.includes("liver") || algorithm.includes("bile")) {
if (isPortalPhaseSeries(item)) score += 900;
else if (isUpperAbdomenSeries(item)) score += 720;
} else if (isUpperAbdomenSeries(item)) {
score += 120;
}
if (isSkippedSeries(item)) score -= 1000;
return score;
};
return [...pool]
.sort((a, b) => {
const diff = rank(b) - rank(a);
if (Math.abs(diff) > 1e-6) return diff;
return Number(a.series_number || 999999) - Number(b.series_number || 999999);
})[0] || series[0] || null;
}
function pickDefaultStlIds(files) {
const priority = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen", "vertebrae"];
const ranked = [...files].sort((a, b) => {
const af = String(a.family || a.segment_name || a.file_name || "").toLowerCase();
const bf = String(b.family || b.segment_name || b.file_name || "").toLowerCase();
const ai = priority.findIndex((key) => af === key || af.includes(key));
const bi = priority.findIndex((key) => bf === key || bf.includes(key));
const ar = ai < 0 ? 999 : ai;
const br = bi < 0 ? 999 : bi;
if (ar !== br) return ar - br;
return Number(a.id || 0) - Number(b.id || 0);
});
return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id)));
}
function currentSeries() {
return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null;
}
function renderActiveHeader() {
const row = state.activeCase;
if (!row) return;
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
const tags = [
`<span class="tag ${row.registration_status === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
...bodyPartTags(row).map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
];
$("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
$("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered");
$("patientSummary").textContent = `${row.patient_name || row.upp_patient_name || "-"} ${row.patient_sex || ""}`.trim();
$("summaryCt").textContent = row.ct_number || "-";
$("summaryPatientId").textContent = row.patient_id || "-";
$("summaryStudyTime").textContent = fmtDateTime(row.study_date, row.study_time) || "-";
$("summaryBodyPart").textContent = bodyPartTags(row).join("、") || row.study_description || "-";
}
function renderSeries() {
$("seriesCount").textContent = state.series.length;
const activeSeries = currentSeries();
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
$("seriesList").innerHTML = state.series.length
? state.series
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
.join("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
document.querySelectorAll(".series-card[data-series]").forEach((button) => {
button.addEventListener("click", () => selectSeries(button.dataset.series));
});
updateSliceControl();
renderDicomAnnotation();
}
function seriesCardHtml(item, active = false) {
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
const skipped = isSkippedSeries(item);
return `
<button class="series-card ${active ? "active" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
<div class="mini-tags">
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
${labels || `<i>未标注</i>`}
</div>
</button>
`;
}
async function selectSeries(uid) {
if (!uid || uid === state.selectedSeriesUid) return;
if (!(await confirmChange())) return;
state.selectedSeriesUid = uid;
const selected = currentSeries();
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
markDirty();
renderSeries();
renderDicomAnnotation();
await loadFusion();
}
function renderStl() {
$("stlCount").textContent = state.stlFiles.length;
const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))];
$("modelRail").innerHTML = models.map((model) => `<span class="chip active">${escapeHtml(model)}</span>`).join("");
$("stlList").innerHTML = state.stlFiles.length
? state.stlFiles
.map((file, index) => {
const checked = state.selectedStlIds.has(Number(file.id));
return `
<label class="stl-row ${checked ? "active" : ""}" title="${escapeHtml(file.file_name)}">
<input type="checkbox" data-stl="${Number(file.id)}" ${checked ? "checked" : ""} />
<span>
<strong>${escapeHtml(file.segment_name || file.file_name)}</strong>
<small>${escapeHtml(file.family || "未分类")} · ${escapeHtml(file.category || "")} · ${formatBytes(file.size_bytes)}</small>
<small style="color:${cssColor(index)}">${escapeHtml(file.file_name)}</small>
</span>
</label>
`;
})
.join("")
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
input.addEventListener("change", async () => {
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
markDirty();
renderStl();
await loadFusion();
});
});
}
function cssColor(index) {
return `#${STL_COLORS[index % STL_COLORS.length].toString(16).padStart(6, "0")}`;
}
function formatBytes(value) {
const n = Number(value || 0);
if (!n) return "大小未知";
if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
return `${(n / 1024).toFixed(1)} KB`;
}
function buildPoseControls() {
$("poseGrid").innerHTML = POSE_CONTROLS.map(
(item) => `
<div class="pose-control" data-pose="${item.key}">
<label>${item.label}</label>
<button type="button" data-nudge="${item.key}" data-delta="${-item.step}">-</button>
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
<button type="button" data-nudge="${item.key}" data-delta="${item.step}">+</button>
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
${item.key.startsWith("rotate") ? `<div class="quick-row"><button type="button" data-nudge="${item.key}" data-delta="-90">-90°</button><button type="button" data-nudge="${item.key}" data-delta="90">+90°</button></div>` : ""}
</div>
`,
).join("");
$("poseGrid").querySelectorAll(".pose-control").forEach((row) => {
const key = row.dataset.pose;
const range = row.querySelector("input[type='range']");
const number = row.querySelector("input[type='number']");
const sync = (value, source) => {
const numeric = Number(value);
state.pose[key] = Number.isFinite(numeric) ? numeric : DEFAULT_POSE[key];
if (source !== range) range.value = state.pose[key];
if (source !== number) number.value = state.pose[key];
applyPose();
markDirty();
};
range.addEventListener("input", () => sync(range.value, range));
number.addEventListener("input", () => sync(number.value, number));
});
document.querySelectorAll("[data-nudge]").forEach((button) => {
button.addEventListener("click", () => nudgePose(button.dataset.nudge, Number(button.dataset.delta || 0)));
});
document.querySelectorAll("[data-flip]").forEach((button) => {
button.addEventListener("click", () => {
const key = button.dataset.flip;
state.pose[key] = !state.pose[key];
renderPoseControls();
applyPose();
markDirty();
});
});
}
function nudgePose(key, delta) {
if (!key || !Number.isFinite(delta)) return;
const option = POSE_CONTROLS.find((item) => item.key === key);
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
state.pose[key] = Number(next.toFixed(3));
renderPoseControls();
applyPose();
markDirty();
}
function renderPoseControls() {
POSE_CONTROLS.forEach((item) => {
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
if (!row) return;
const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]);
row.querySelector("input[type='range']").value = value;
row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(2);
});
document.querySelectorAll("[data-flip]").forEach((button) => {
button.classList.toggle("active", Boolean(state.pose[button.dataset.flip]));
});
renderActiveHeader();
}
function updateSliceControl() {
const selected = currentSeries();
const count = Number(selected?.count || 0);
const max = Math.max(0, count - 1);
state.sliceIndex = Math.max(0, Math.min(state.sliceIndex, max));
$("sliceSlider").max = max;
$("sliceSlider").value = state.sliceIndex;
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
$("mappingSliceSlider").max = max;
$("mappingSliceSlider").value = max - state.sliceIndex;
$("mappingSliceLabel").textContent = count ? `${state.sliceIndex + 1} / ${count}` : "0 / 0";
}
function renderDicomAnnotation() {
const row = state.activeCase;
const selected = currentSeries();
const count = Number(selected?.count || 0);
$("dicomPanelMeta").textContent = selected ? `${selected.description || "未命名序列"} · ${count}` : "当前序列切片";
$("dicomInfoLeft").textContent = row && selected
? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}`
: "未选择序列";
$("dicomInfoRight").textContent = selected
? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}`
: "";
$("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-";
const tags = selected?.annotation_labels?.length ? selected.annotation_labels : bodyPartTags(row || {});
$("annotationTags").innerHTML = tags.length
? tags.map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")
: `<span>未标注</span>`;
}
function updateDicomPreview() {
const selected = currentSeries();
if (!state.activeCase || !selected) {
$("dicomPreview").removeAttribute("src");
renderDicomAnnotation();
resetMappingCanvas("未选择 DICOM 序列");
return;
}
window.clearTimeout(state.dicomPreviewTimer);
state.dicomPreviewTimer = window.setTimeout(() => {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
series_uid: selected.series_uid,
index: String(state.sliceIndex),
window: state.windowMode,
access_token: state.token,
});
$("dicomLoading").classList.remove("hidden");
resetMappingCanvas("正在加载 DICOM Base Layer");
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
renderDicomAnnotation();
}, 80);
}
function initScene() {
if (state.sceneReady) return;
const viewport = $("fusionViewport");
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setClearColor(0x000000, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
viewport.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000);
camera.position.set(0, -8.5, 4.4);
camera.up.set(0, 0, 1);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.82);
const directional = new THREE.DirectionalLight(0xffffff, 1.8);
directional.position.set(3.5, -4, 6);
scene.add(ambient, directional);
const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup);
scene.add(dicomGroup, modelGroup);
Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, sceneReady: true });
const resize = () => {
const rect = viewport.getBoundingClientRect();
if (!rect.width || !rect.height) return;
renderer.setSize(rect.width, rect.height, false);
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
};
state.resizeScene = resize;
state.resizeObserver = new ResizeObserver(resize);
state.resizeObserver.observe(viewport);
resize();
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
}
function initGeometryFallback() {
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup);
Object.assign(state, { dicomGroup, modelGroup, rawModelGroup });
}
function clearGroup(group) {
if (!group) return;
while (group.children.length) {
const child = group.children.pop();
child.traverse?.((node) => {
node.geometry?.dispose?.();
if (node.material) {
if (Array.isArray(node.material)) node.material.forEach((material) => material.dispose?.());
else node.material.dispose?.();
}
node.material?.map?.dispose?.();
});
}
}
function clearFusion() {
clearGroup(state.dicomGroup);
clearGroup(state.rawModelGroup);
state.volumeMeta = null;
state.volumeScene = null;
state.modelMeshes = [];
$("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateSliceControl();
resetMappingCanvas();
}
function applyFusionMode() {
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
if (state.modelGroup) state.modelGroup.visible = true;
$("fusionMeta").textContent = state.fusionMode === "model"
? `单独模型 · STL ${state.selectedStlIds.size}`
: $("fusionMeta").textContent;
}
async function loadFusion() {
const version = ++state.fusionVersion;
const selected = currentSeries();
if (!state.sceneReady) initGeometryFallback();
if (!state.activeCase || !selected) {
clearFusion();
return;
}
setFusionLoading(true, "正在按需加载 DICOM 切片与 STL");
updateSliceControl();
try {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
series_uid: selected.series_uid,
center_index: String(state.sliceIndex),
window: state.windowMode,
radius: "24",
});
const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`);
if (version !== state.fusionVersion) return;
state.volumeMeta = volume;
await buildDicomVolume(volume);
if (version !== state.fusionVersion) return;
await buildStlModels(volume);
if (version !== state.fusionVersion) return;
if (state.sceneReady) {
applyFusionMode();
fitCamera();
}
$("fusionStatus").textContent = state.sceneReady
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}`
: `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`;
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size}`;
renderDicomAnnotation();
updateDicomPreview();
} catch (error) {
clearFusion();
$("fusionStatus").textContent = error.message;
} finally {
if (version === state.fusionVersion) setFusionLoading(false);
}
}
async function loadTexture(url) {
return new Promise((resolve, reject) => {
new THREE.TextureLoader().load(url, resolve, undefined, reject);
});
}
async function loadImageData(url, maxSize = 256) {
const image = await new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = url;
});
const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height, 1));
const width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
const height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d", { willReadFrequently: true });
context.drawImage(image, 0, 0, width, height);
return { width, height, data: context.getImageData(0, 0, width, height).data };
}
function sliceToSceneZ(sliceIndex, volumeScene = state.volumeScene) {
if (!volumeScene) return 0;
return volumeScene.total <= 1
? 0
: -volumeScene.depth / 2 + (volumeScene.depth * Math.max(0, Math.min(sliceIndex, volumeScene.total - 1))) / (volumeScene.total - 1);
}
function parseCssColor(color) {
const value = String(color || "#19d6c3").replace("#", "");
const hex = value.length === 3 ? value.split("").map((char) => char + char).join("") : value.padStart(6, "0").slice(0, 6);
const intValue = Number.parseInt(hex, 16);
return {
r: (intValue >> 16) & 255,
g: (intValue >> 8) & 255,
b: intValue & 255,
};
}
function resetMappingCanvas(message = "当前切片暂无可见构件") {
const canvas = $("mappingCanvas");
const context = canvas?.getContext("2d");
if (context) context.clearRect(0, 0, canvas.width || 1, canvas.height || 1);
$("mappingStats").textContent = "0/0 构件 · 0 边 · 0 px";
$("mappingLegend").innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`;
}
function scheduleMappingDraw() {
window.cancelAnimationFrame(state.mappingDrawFrame);
state.mappingDrawFrame = window.requestAnimationFrame(() => drawMappingView());
}
function applyMappingTransform() {
const layer = $("mappingLayer");
if (!layer) return;
const { scale, offsetX, offsetY } = state.mappingViewport;
layer.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(${scale})`;
}
function intersectEdgeWithZ(a, b, targetZ) {
const da = a.z - targetZ;
const db = b.z - targetZ;
if (Math.abs(da) < 1e-5 && Math.abs(db) < 1e-5) return [a.clone(), b.clone()];
if (Math.abs(da) < 1e-5) return [a.clone()];
if (Math.abs(db) < 1e-5) return [b.clone()];
if ((da > 0 && db > 0) || (da < 0 && db < 0)) return [];
const t = da / (da - db);
return [new THREE.Vector3(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
targetZ,
)];
}
function uniquePlanePoints(points) {
const unique = [];
points.forEach((point) => {
if (!unique.some((item) => item.distanceToSquared(point) < 1e-8)) unique.push(point);
});
return unique;
}
function intersectTriangleWithZ(a, b, c, targetZ) {
const points = uniquePlanePoints([
...intersectEdgeWithZ(a, b, targetZ),
...intersectEdgeWithZ(b, c, targetZ),
...intersectEdgeWithZ(c, a, targetZ),
]);
if (points.length < 2) return null;
return { a: points[0], b: points[1] };
}
function addSegmentIntersectionsToRows(rows, width, height, segment) {
const a = segment.a;
const b = segment.b;
if (!Number.isFinite(a.x) || !Number.isFinite(a.y) || !Number.isFinite(b.x) || !Number.isFinite(b.y)) return;
const minY = Math.max(0, Math.floor(Math.min(a.y, b.y)));
const maxY = Math.min(height - 1, Math.ceil(Math.max(a.y, b.y)));
const deltaY = b.y - a.y;
if (Math.abs(deltaY) < 1e-6) {
const row = Math.max(0, Math.min(height - 1, Math.round(a.y)));
rows[row].push(Math.max(0, Math.min(width - 1, a.x)));
rows[row].push(Math.max(0, Math.min(width - 1, b.x)));
return;
}
for (let row = minY; row <= maxY; row += 1) {
const sampleY = row + 0.5;
const t = (sampleY - a.y) / deltaY;
if (t < 0 || t > 1) continue;
const x = a.x + (b.x - a.x) * t;
if (Number.isFinite(x)) rows[row].push(x);
}
}
function fillSegmentsAsSolidMask(context, width, height, segments, color, opacity = 0.72) {
if (!segments.length) return 0;
const rgb = parseCssColor(color);
const maskCanvas = document.createElement("canvas");
maskCanvas.width = width;
maskCanvas.height = height;
const maskContext = maskCanvas.getContext("2d");
const imageData = maskContext.createImageData(width, height);
const rows = Array.from({ length: height }, () => []);
const alpha = Math.round(Math.max(0.12, Math.min(opacity, 1)) * 190);
let filled = 0;
segments.forEach((segment) => addSegmentIntersectionsToRows(rows, width, height, segment));
rows.forEach((intersections, y) => {
if (intersections.length < 2) return;
intersections.sort((a, b) => a - b);
const cleaned = [];
intersections.forEach((x) => {
const previous = cleaned[cleaned.length - 1];
if (previous === undefined || Math.abs(previous - x) > 0.35) cleaned.push(x);
});
for (let index = 0; index + 1 < cleaned.length; index += 2) {
const startX = Math.max(0, Math.min(width - 1, Math.ceil(cleaned[index])));
const endX = Math.max(0, Math.min(width - 1, Math.floor(cleaned[index + 1])));
if (endX < startX) continue;
for (let x = startX; x <= endX; x += 1) {
const offset = (y * width + x) * 4;
imageData.data[offset] = rgb.r;
imageData.data[offset + 1] = rgb.g;
imageData.data[offset + 2] = rgb.b;
imageData.data[offset + 3] = alpha;
filled += 1;
}
}
});
maskContext.putImageData(imageData, 0, 0);
context.drawImage(maskCanvas, 0, 0);
context.save();
context.globalAlpha = Math.max(0.18, Math.min(opacity, 1));
context.strokeStyle = color;
context.lineWidth = Math.max(0.8, Math.max(width, height) * 0.0012);
context.lineCap = "round";
context.lineJoin = "round";
context.beginPath();
segments.forEach((segment) => {
context.moveTo(segment.a.x, segment.a.y);
context.lineTo(segment.b.x, segment.b.y);
});
context.stroke();
context.restore();
return filled;
}
function drawMappingView() {
const image = $("dicomPreview");
const canvas = $("mappingCanvas");
const volumeScene = state.volumeScene;
if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) {
resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
return;
}
const width = image.naturalWidth;
const height = image.naturalHeight;
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const context = canvas.getContext("2d");
context.clearRect(0, 0, width, height);
const targetZ = sliceToSceneZ(state.sliceIndex, volumeScene);
const mapPoint = (point) => ({
x: ((point.x + volumeScene.width / 2) / volumeScene.width) * width,
y: (0.5 - point.y / volumeScene.height) * height,
});
state.modelGroup?.updateMatrixWorld(true);
let activeModules = 0;
let segmentCount = 0;
let filledPixels = 0;
const modules = [];
const tempA = new THREE.Vector3();
const tempB = new THREE.Vector3();
const tempC = new THREE.Vector3();
state.modelMeshes.forEach((record, index) => {
const position = record.mesh.geometry.attributes.position;
if (!position) return;
const triangleCount = Math.floor(position.count / 3);
const step = Math.max(1, Math.ceil(triangleCount / 70000));
const segments = [];
for (let tri = 0; tri < triangleCount; tri += step) {
tempA.fromBufferAttribute(position, tri * 3);
tempB.fromBufferAttribute(position, tri * 3 + 1);
tempC.fromBufferAttribute(position, tri * 3 + 2);
record.mesh.localToWorld(tempA);
record.mesh.localToWorld(tempB);
record.mesh.localToWorld(tempC);
const segment = intersectTriangleWithZ(tempA, tempB, tempC, targetZ);
if (!segment) continue;
const mapped = { a: mapPoint(segment.a), b: mapPoint(segment.b) };
if (
mapped.a.x > -width && mapped.a.x < width * 2
&& mapped.b.x > -width && mapped.b.x < width * 2
&& mapped.a.y > -height && mapped.a.y < height * 2
&& mapped.b.y > -height && mapped.b.y < height * 2
) {
segments.push(mapped);
}
}
const pixels = fillSegmentsAsSolidMask(context, width, height, segments, record.color, 0.66);
if (segments.length || pixels) {
activeModules += 1;
segmentCount += segments.length;
filledPixels += pixels;
modules.push({
id: index + 1,
name: record.file.segment_name || record.file.file_name || `STL ${index + 1}`,
color: record.color,
segments: segments.length,
pixels,
});
}
});
$("mappingStats").textContent = `${activeModules}/${state.modelMeshes.length} 构件 · ${segmentCount} 边 · ${filledPixels} px`;
$("mappingLegend").innerHTML = modules.length
? modules.map((item) => `
<div class="legend-item" title="${escapeHtml(item.name)}">
<span class="legend-color" style="background:${escapeHtml(item.color)}"></span>
<span class="legend-name">${escapeHtml(item.name)}</span>
<span class="legend-meta">ID ${item.id}</span>
<span></span>
<span class="legend-meta">${item.segments} 边</span>
<span class="legend-meta">${item.pixels} px</span>
</div>
`).join("")
: `<div class="empty-state">当前切片暂无可见构件</div>`;
}
async function buildDicomVolume(volume) {
clearGroup(state.dicomGroup);
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = 4.8 / maxPhysical;
const width = (physical.width || 1) * sceneScale;
const height = (physical.height || 1) * sceneScale;
const depth = Math.max((physical.depth || 1) * sceneScale, 0.18);
const centerIndex = Number(volume.center || 0);
const total = Math.max(1, Number(volume.total || 1));
const indices = volume.indices || [];
const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0;
state.volumeScene = {
width,
height,
depth,
total,
sceneScale,
rowSpacing: Number(volume.spacing?.row || 1),
columnSpacing: Number(volume.spacing?.column || 1),
sliceSpacing: Number(volume.spacing?.slice || 1),
};
const boxMesh = new THREE.Mesh(
new THREE.BoxGeometry(width, height, depth),
new THREE.MeshBasicMaterial({ color: 0x020617, transparent: true, opacity: 0.045, depthWrite: false }),
);
state.dicomGroup.add(boxMesh);
const edges = new THREE.LineSegments(
new THREE.EdgesGeometry(boxMesh.geometry),
new THREE.LineBasicMaterial({ color: 0x38bdf8, transparent: true, opacity: 0.46 }),
);
state.dicomGroup.add(edges);
const localStart = Number(volume.start ?? indices[0] ?? centerIndex);
const localEnd = Number(volume.end ?? indices[indices.length - 1] ?? centerIndex);
const rangeDepth = Math.max(0.015, Math.abs(sliceToSceneZ(localEnd) - sliceToSceneZ(localStart)));
const rangeBox = new THREE.LineSegments(
new THREE.EdgesGeometry(new THREE.BoxGeometry(width * 0.94, height * 0.94, rangeDepth)),
new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.56 }),
);
rangeBox.position.z = (sliceToSceneZ(localStart) + sliceToSceneZ(localEnd)) / 2;
state.dicomGroup.add(rangeBox);
const centerFrame = (volume.frames || [])[centerPosition];
if (centerFrame) {
const texture = await loadTexture(centerFrame);
texture.colorSpace = THREE.SRGBColorSpace;
const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.42,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
state.dicomGroup.add(plane);
}
const pointPositions = [];
const pointColors = [];
const frames = volume.frames || [];
for (const [index, frame] of frames.entries()) {
const image = await loadImageData(frame, 180);
const stride = Math.max(3, Math.round(Math.max(image.width, image.height) / 72));
const z = sliceToSceneZ(Number(indices[index] ?? centerIndex));
for (let y = 0; y < image.height; y += stride) {
for (let x = 0; x < image.width; x += stride) {
const offset = (y * image.width + x) * 4;
const lum = image.data[offset];
if (lum < 12) continue;
pointPositions.push((x / Math.max(image.width - 1, 1) - 0.5) * width);
pointPositions.push((0.5 - y / Math.max(image.height - 1, 1)) * height);
pointPositions.push(z);
const v = 0.16 + (lum / 255) * 0.72;
pointColors.push(v * 0.92, v, Math.min(1, v * 1.08));
}
}
}
if (pointPositions.length) {
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.Float32BufferAttribute(pointPositions, 3));
geometry.setAttribute("color", new THREE.Float32BufferAttribute(pointColors, 3));
const points = new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: 0.018,
vertexColors: true,
transparent: true,
opacity: 0.34,
depthWrite: false,
}),
);
state.dicomGroup.add(points);
}
state.baseModelScale = sceneScale;
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
}
async function buildStlModels(volume) {
clearGroup(state.rawModelGroup);
state.modelMeshes = [];
const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
if (!files.length) {
applyPose();
resetMappingCanvas("当前没有可见 STL 构件");
return;
}
const loader = new STLLoader();
for (const [index, file] of files.entries()) {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
file_id: String(file.id),
algorithm_model: state.algorithmModel,
access_token: state.token,
});
const geometry = await loader.loadAsync(`/api/stl/file?${params.toString()}`);
geometry.computeVertexNormals();
geometry.computeBoundingBox();
const material = new THREE.MeshStandardMaterial({
color: STL_COLORS[index % STL_COLORS.length],
metalness: 0.08,
roughness: 0.5,
transparent: true,
opacity: 0.58,
side: THREE.DoubleSide,
depthWrite: false,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = file.segment_name || file.file_name;
mesh.userData.file = file;
mesh.userData.color = cssColor(index);
state.rawModelGroup.add(mesh);
state.modelMeshes.push({ mesh, file, color: cssColor(index), index });
}
const bbox = new THREE.Box3().setFromObject(state.rawModelGroup);
const center = new THREE.Vector3();
bbox.getCenter(center);
state.rawModelGroup.position.set(-center.x, -center.y, -center.z);
applyPose();
scheduleMappingDraw();
}
function applyPose(poseInput = state.pose) {
if (!state.modelGroup) return;
const pose = { ...DEFAULT_POSE, ...poseInput };
state.modelGroup.rotation.set(
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateZ) || 0),
);
state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0);
const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale;
state.modelGroup.scale.set(
scale * Math.max(0.001, Number(pose.scaleX) || 1) * (pose.flipX ? -1 : 1),
scale * Math.max(0.001, Number(pose.scaleY) || 1) * (pose.flipY ? -1 : 1),
scale * Math.max(0.001, Number(pose.scaleZ) || 1) * (pose.flipZ ? -1 : 1),
);
state.modelGroup.updateMatrixWorld(true);
scheduleMappingDraw();
}
function fitCamera() {
if (!state.camera || !state.controls) return;
const box = new THREE.Box3();
box.expandByObject(state.dicomGroup);
box.expandByObject(state.modelGroup);
const size = new THREE.Vector3();
const center = new THREE.Vector3();
box.getSize(size);
box.getCenter(center);
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8;
state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45);
state.controls.target.copy(center);
state.controls.update();
}
function resetPose(kind) {
if (kind === "rotation") {
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
} else if (kind === "transform") {
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1, scaleX: 1, scaleY: 1, scaleZ: 1 };
} else if (kind === "flip") {
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
} else {
state.pose = { ...DEFAULT_POSE };
}
renderPoseControls();
applyPose();
markDirty();
}
function stretchAxis(axis) {
if (!state.rawModelGroup?.children.length || !state.dicomSceneBox) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
const axisKey = { x: "scaleX", y: "scaleY", z: "scaleZ" }[axis];
if (!axisKey) return;
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
const dicomSize = new THREE.Vector3();
const modelSize = new THREE.Vector3();
state.dicomSceneBox.getSize(dicomSize);
modelBox.getSize(modelSize);
const currentAxisScale = Math.max(0.001, Number(state.pose[axisKey]) || 1);
const targetSize = Math.max(0.001, dicomSize[axis]);
const currentSize = Math.max(0.001, modelSize[axis]);
const next = Math.max(0.2, Math.min(4, currentAxisScale * (targetSize / currentSize)));
state.pose[axisKey] = Number(next.toFixed(3));
applyPose();
markDirty("pose");
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
}
function suggestBoneSelection() {
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
const next = new Set();
state.stlFiles.forEach((file) => {
const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
if (boneKeys.some((key) => haystack.includes(key))) next.add(Number(file.id));
});
if (!next.size) {
state.stlFiles.slice(0, Math.min(6, state.stlFiles.length)).forEach((file) => next.add(Number(file.id)));
}
state.selectedStlIds = next;
renderStl();
markDirty();
loadFusion();
}
function candidateScore(pose) {
if (!state.dicomSceneBox || !state.modelGroup || !state.rawModelGroup?.children.length) return -Infinity;
const current = { ...state.pose };
applyPose(pose);
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
applyPose(current);
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
const overlapSize = new THREE.Vector3();
overlap.getSize(overlapSize);
const modelSize = new THREE.Vector3();
const dicomSize = new THREE.Vector3();
modelBox.getSize(modelSize);
state.dicomSceneBox.getSize(dicomSize);
const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z);
const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001);
const modelCenter = new THREE.Vector3();
const dicomCenter = new THREE.Vector3();
modelBox.getCenter(modelCenter);
state.dicomSceneBox.getCenter(dicomCenter);
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
const scalePenalty = Math.abs((pose.scale || 1) - 1) * 0.03;
return overlapVolume / modelVolume - centerPenalty - scalePenalty;
}
function runAutoCoarse() {
if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
renderPoseControls();
applyPose();
fitCamera();
state.autoResult = { score: candidateScore(state.pose), pose: { ...state.pose }, evaluated: 1 };
setAutoState("粗配准完成", "ok");
$("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放score ${state.autoResult.score.toFixed(4)}`;
markDirty();
}
function runAutoFine() {
if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
setAutoState("运行中", "warn");
$("autoResult").textContent = "正在进行几何重叠微调...";
const adjustable = {
translateX: $("autoX").checked,
translateY: $("autoY").checked,
translateZ: $("autoZ").checked,
scale: $("autoScale").checked,
};
let best = { pose: { ...state.pose }, score: candidateScore(state.pose), mode: "初始位姿" };
let evaluated = 1;
const rounds = [
{ move: 0.34, scale: 0.12 },
{ move: 0.18, scale: 0.06 },
{ move: 0.08, scale: 0.025 },
{ move: 0.035, scale: 0.01 },
];
for (const round of rounds) {
const candidates = [];
if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]);
if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]);
if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]);
if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]);
for (const [key, delta] of candidates) {
const pose = { ...best.pose, [key]: key === "scale" ? Math.max(0.2, Math.min(3, best.pose.scale + delta)) : best.pose[key] + delta };
const score = candidateScore(pose);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
}
}
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated };
renderPoseControls();
applyPose();
fitCamera();
setAutoState("微调完成", "ok");
$("autoResult").textContent = `最佳候选:${best.mode}score ${best.score.toFixed(4)};评估 ${evaluated} 个候选。`;
markDirty();
}
async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return;
state.saving = true;
$("saveBtn").disabled = true;
$("statusBtn").disabled = true;
setSaveState("保存中", "warn");
const selectedSeries = currentSeries();
const status = nextStatus || state.registrationStatus;
const selectedFiles = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id)));
try {
await api("/api/registrations", {
method: "POST",
body: JSON.stringify({
ct_number: state.activeCase.ct_number,
algorithm_model: state.algorithmModel,
registration_status: status,
series_instance_uid: selectedSeries.series_uid,
series_description: selectedSeries.description || "",
selected_stl_files: selectedFiles.map((file) => ({
id: file.id,
file_name: file.file_name,
segment_name: file.segment_name,
family: file.family,
category: file.category,
algorithm_model: file.algorithm_model,
})),
transform: state.pose,
module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: 0.58 }])),
dicom_reference: {
series_uid: selectedSeries.series_uid,
series_number: selectedSeries.series_number,
description: selectedSeries.description,
slice_index: state.sliceIndex,
slice_count: selectedSeries.count,
window: state.windowMode,
volume: state.volumeMeta?.physicalSize || null,
},
model_reference: {
algorithm_model: state.algorithmModel,
selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
fusion_mode: state.fusionMode,
auto_result: state.autoResult,
},
notes: $("notes").value,
}),
});
state.registrationStatus = status;
if (state.activeCase) state.activeCase.registration_status = status;
const existing = state.cases.find((row) => sameCase(row, state.activeCase));
if (existing) existing.registration_status = status;
resetDirty();
renderCases();
renderActiveHeader();
} catch (error) {
setSaveState(error.message, "error");
} finally {
state.saving = false;
$("saveBtn").disabled = false;
$("statusBtn").disabled = false;
}
}
function sameHostUrl(raw, targetPort) {
try {
const url = new URL(raw);
const here = new URL(window.location.href);
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) url.hostname = here.hostname;
if (targetPort) url.port = String(targetPort);
return url.toString();
} catch {
const here = new URL(window.location.href);
here.port = String(targetPort || here.port);
here.pathname = "/";
here.search = "";
return here.toString();
}
}
function openLinkedApp(kind) {
const ct = state.activeCase?.ct_number || "";
const model = state.algorithmModel || "";
if (kind === "viewer") {
const url = new URL(sameHostUrl(state.links.pacs_viewer_url, 8107));
if (ct) url.searchParams.set("ct_number", ct);
window.open(url.toString(), "_blank");
} else {
const url = new URL(sameHostUrl(state.links.relation_viewer_url, 8108));
if (ct) url.searchParams.set("ct_number", ct);
if (model) url.searchParams.set("algorithm_model", model);
window.open(url.toString(), "_blank");
}
}
function wireEvents() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout());
$("caseToggleBtn").addEventListener("click", () => {
state.caseCollapsed = !state.caseCollapsed;
document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed);
$("caseToggleBtn").classList.toggle("active", state.caseCollapsed);
window.setTimeout(() => {
state.resizeScene?.();
fitCamera();
}, 230);
});
$("refreshBtn").addEventListener("click", async () => {
await loadStatus();
await loadCases(false);
});
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("openViewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("reloadFusionBtn").addEventListener("click", () => loadFusion());
$("fitBtn").addEventListener("click", fitCamera);
$("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", markDirty);
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
$("stretchXBtn").addEventListener("click", () => stretchAxis("x"));
$("stretchYBtn").addEventListener("click", () => stretchAxis("y"));
$("stretchZBtn").addEventListener("click", () => stretchAxis("z"));
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine);
$("dicomPreview").addEventListener("load", () => {
$("dicomLoading").classList.add("hidden");
scheduleMappingDraw();
});
$("dicomPreview").addEventListener("error", () => {
$("dicomLoading").classList.add("hidden");
resetMappingCanvas("DICOM Base Layer 加载失败");
});
$("mappingResetBtn").addEventListener("click", () => {
state.mappingViewport = { scale: 1, offsetX: 0, offsetY: 0 };
applyMappingTransform();
});
$("mappingViewport").addEventListener("wheel", (event) => {
event.preventDefault();
const factor = event.deltaY > 0 ? 0.9 : 1.1;
state.mappingViewport.scale = Math.max(0.45, Math.min(6, state.mappingViewport.scale * factor));
applyMappingTransform();
}, { passive: false });
$("mappingViewport").addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
state.mappingDrag = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
offsetX: state.mappingViewport.offsetX,
offsetY: state.mappingViewport.offsetY,
};
$("mappingViewport").classList.add("dragging");
$("mappingViewport").setPointerCapture(event.pointerId);
});
$("mappingViewport").addEventListener("pointermove", (event) => {
const drag = state.mappingDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.mappingViewport.offsetX = drag.offsetX + event.clientX - drag.startX;
state.mappingViewport.offsetY = drag.offsetY + event.clientY - drag.startY;
applyMappingTransform();
});
const stopMappingDrag = (event) => {
const drag = state.mappingDrag;
if (!drag || drag.pointerId !== event.pointerId) return;
state.mappingDrag = null;
$("mappingViewport").classList.remove("dragging");
if ($("mappingViewport").hasPointerCapture(event.pointerId)) $("mappingViewport").releasePointerCapture(event.pointerId);
};
$("mappingViewport").addEventListener("pointerup", stopMappingDrag);
$("mappingViewport").addEventListener("pointercancel", stopMappingDrag);
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
button.addEventListener("click", () => {
state.activeToolTab = button.dataset.toolTab || "series";
document.querySelectorAll("[data-tool-tab]").forEach((item) => item.classList.toggle("active", item === button));
document.querySelectorAll("[data-tool-pane]").forEach((pane) => pane.classList.toggle("active", pane.dataset.toolPane === state.activeToolTab));
});
});
$("caseSearch").addEventListener("input", () => {
state.search = $("caseSearch").value.trim();
window.clearTimeout(state.searchTimer);
state.searchTimer = window.setTimeout(() => loadCases(false), 240);
});
document.querySelectorAll(".filter[data-status]").forEach((button) => {
button.addEventListener("click", async () => {
state.statusFilter = button.dataset.status || "";
document.querySelectorAll(".filter[data-status]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll(".filter[data-part]").forEach((button) => {
button.addEventListener("click", async () => {
state.partFilter = button.dataset.part || "";
document.querySelectorAll(".filter[data-part]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", async () => {
state.windowMode = button.dataset.window || "default";
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button));
await loadFusion();
});
});
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
button.addEventListener("click", () => {
state.fusionMode = button.dataset.fusionMode || "fusion";
document.querySelectorAll("[data-fusion-mode]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionMode();
fitCamera();
});
});
$("sliceSlider").addEventListener("input", () => {
state.sliceIndex = Number($("sliceSlider").value || 0);
updateSliceControl();
renderDicomAnnotation();
updateDicomPreview();
});
$("sliceSlider").addEventListener("change", () => loadFusion());
$("mappingSliceSlider").addEventListener("input", () => {
const selected = currentSeries();
const max = Math.max(0, Number(selected?.count || 0) - 1);
state.sliceIndex = max - Number($("mappingSliceSlider").value || 0);
updateSliceControl();
renderDicomAnnotation();
updateDicomPreview();
});
$("mappingSliceSlider").addEventListener("change", () => loadFusion());
}
wireEvents();
if (state.token) {
bootstrap();
} else {
loginVisible(true);
}