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, 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, saving: false, fusionVersion: 0, volumeMeta: null, sceneReady: false, renderer: null, camera: null, scene: null, controls: null, dicomGroup: null, modelGroup: null, rawModelGroup: null, baseModelScale: 1, dicomSceneBox: null, resizeObserver: null, resizeScene: null, }; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } 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() { state.dirty = true; setSaveState("未保存", "warn"); } function resetDirty() { state.dirty = false; 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) { $("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 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 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 ` `; }) .join("") : `
没有符合条件的完整关联 CT
`; $("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 = `
未选择 CT
`; $("stlList").innerHTML = `
未选择 STL
`; $("modelRail").innerHTML = ""; renderDicomAnnotation(); clearFusion(); } async function confirmChange() { if (!state.dirty) return true; return window.confirm("当前配准参数还未保存,确定切换吗?"); } async function selectCase(ctNumber, algorithmModel = "未指定模型") { if (!ctNumber || !(await confirmChange())) return; setTopLoading(true); setFusionLoading(true, "正在读取 CT 关联数据"); try { const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" }); 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"; state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) }; 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(); resetDirty(); await loadFusion(); } catch (error) { $("fusionStatus").textContent = error.message; clearFusion(); } finally { setTopLoading(false); setFusionLoading(false); } } function pickDefaultSeries(series) { return [...series] .filter((item) => Number(item.count || 0) > 1) .sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[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 = [ `${statusText(state.registrationStatus)}`, ...bodyPartTags(row).map((tag) => `${escapeHtml(tag)}`), ]; $("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) : `
尚未选择配准序列
`; $("seriesList").innerHTML = state.series.length ? state.series .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid)) .join("") : `
未读取到 DICOM 序列
`; 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) => `${escapeHtml(label)}`).join(""); const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean); return ` `; } 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) => `${escapeHtml(model)}`).join(""); $("stlList").innerHTML = state.stlFiles.length ? state.stlFiles .map((file, index) => { const checked = state.selectedStlIds.has(Number(file.id)); return ` `; }) .join("") : `
当前算法模型没有 STL 文件
`; $("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) => `
${item.key.startsWith("rotate") ? `
` : ""}
`, ).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); $("sliceSlider").max = Math.max(0, count - 1); $("sliceSlider").value = Math.min(state.sliceIndex, Math.max(0, count - 1)); $("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 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) => `${escapeHtml(tag)}`).join("") : `未标注`; } function updateDicomPreview() { const selected = currentSeries(); if (!state.activeCase || !selected) { $("dicomPreview").removeAttribute("src"); renderDicomAnnotation(); 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"); $("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 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; $("fusionMeta").textContent = "DICOM - · STL -"; $("fusionStatus").textContent = "等待选择 DICOM 与 STL"; updateSliceControl(); } 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) { $("fusionStatus").textContent = "当前浏览器未能启用 WebGL,无法显示三维融合视图"; return; } 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; applyFusionMode(); fitCamera(); $("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`; $("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 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 = 5.2 / maxPhysical; const width = (physical.width || 1) * sceneScale; const height = (physical.height || 1) * sceneScale; const depth = (physical.depth || 1) * sceneScale; const centerIndex = Number(volume.center || 0); const indices = volume.indices || []; const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0; const textures = await Promise.all((volume.frames || []).map((frame) => loadTexture(frame))); textures.forEach((texture, index) => { texture.colorSpace = THREE.SRGBColorSpace; const material = new THREE.MeshBasicMaterial({ map: texture, transparent: true, opacity: index === centerPosition ? 0.78 : 0.14, side: THREE.DoubleSide, depthWrite: false, }); const plane = new THREE.Mesh(new THREE.PlaneGeometry(width, height), material); const z = (Number(indices[index] ?? centerIndex) - centerIndex) * Number(volume.spacing?.slice || 1) * sceneScale; plane.position.z = z; state.dicomGroup.add(plane); }); const box = new THREE.BoxGeometry(width, height, Math.max(depth, 0.02)); const edges = new THREE.EdgesGeometry(box); const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 })); state.dicomGroup.add(lines); state.baseModelScale = sceneScale; state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup); } async function buildStlModels(volume) { clearGroup(state.rawModelGroup); const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id))); if (!files.length) { applyPose(); return; } const loader = new STLLoader(); const token = encodeURIComponent(state.token); 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; state.rawModelGroup.add(mesh); } 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(); } 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 * (pose.flipX ? -1 : 1), scale * (pose.flipY ? -1 : 1), scale * (pose.flipZ ? -1 : 1)); } 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 }; } else if (kind === "flip") { state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false }; } else { state.pose = { ...DEFAULT_POSE }; } renderPoseControls(); applyPose(); markDirty(); } 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")); $("suggestBoneBtn").addEventListener("click", suggestBoneSelection); $("autoCoarseBtn").addEventListener("click", runAutoCoarse); $("autoFineBtn").addEventListener("click", runAutoFine); $("dicomPreview").addEventListener("load", () => $("dicomLoading").classList.add("hidden")); $("dicomPreview").addEventListener("error", () => $("dicomLoading").classList.add("hidden")); 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()); } wireEvents(); if (state.token) { bootstrap(); } else { loginVisible(true); }