diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index 8658e17..225caa5 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -65,6 +65,12 @@ const AUTO_PROGRESS_STEPS = [ { id: "score", label: "评分" }, { id: "apply", label: "应用" }, ]; +const STL_DISPLAY_MODES = ["hidden", "solid", "translucent"]; +const STL_DISPLAY_LABELS = { + hidden: "取消显示", + solid: "实体显示", + translucent: "半透明", +}; const state = { token: localStorage.getItem("dicom_upp_registration_token") || "", @@ -92,6 +98,8 @@ const state = { registrationSeriesUid: "", stlFiles: [], selectedStlIds: new Set(), + stlDisplayModes: {}, + collapsedStlFamilies: new Set(), algorithmModel: "", registrationStatus: "unregistered", pose: { ...DEFAULT_POSE }, @@ -109,6 +117,7 @@ const state = { autoDeltaBaseline: null, autoFineRunning: false, poseControlsLocked: false, + collapsedPoseSections: { manual: false, auto: false }, dirty: false, dirtyReason: "", savedPoseSignature: "", @@ -533,7 +542,7 @@ function stlSelectionSignature() { } function visibleModelRecords() { - return state.modelMeshes.filter((record) => state.selectedStlIds.has(Number(record.file?.id)) && record.mesh?.visible !== false); + return state.modelMeshes.filter((record) => stlMode(record.file) !== "hidden" && record.mesh?.visible !== false); } function modelPoseMatrix() { @@ -609,6 +618,8 @@ function clearDetail() { state.series = []; state.stlFiles = []; state.selectedStlIds = new Set(); + state.stlDisplayModes = {}; + state.collapsedStlFamilies = new Set(); state.selectedSeriesUid = ""; state.registrationSeriesUid = ""; $("activeTitle").textContent = "未选择 CT"; @@ -662,6 +673,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { 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.stlDisplayModes = initStlDisplayModes(state.stlFiles, state.selectedStlIds, registration.module_styles || {}); + state.collapsedStlFamilies = new Set(); const defaultSeries = pickDefaultSeries(state.series); const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid); const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries; @@ -728,6 +741,101 @@ function pickDefaultStlIds(files) { return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id))); } +function normalizedStlId(fileOrId) { + const id = typeof fileOrId === "object" ? Number(fileOrId?.id) : Number(fileOrId); + return Number.isFinite(id) ? id : null; +} + +function validStlDisplayMode(mode) { + return STL_DISPLAY_MODES.includes(mode) ? mode : ""; +} + +function stlMode(fileOrId) { + const id = normalizedStlId(fileOrId); + if (id == null || !state.selectedStlIds.has(id)) return "hidden"; + return validStlDisplayMode(state.stlDisplayModes[id]) || "translucent"; +} + +function stlFamilyLabel(file) { + return String(file.family || "未分类"); +} + +function stlFileIndex(file) { + const id = normalizedStlId(file); + const index = state.stlFiles.findIndex((item) => normalizedStlId(item) === id); + return index >= 0 ? index : 0; +} + +function stlColor(file, fallbackIndex = 0) { + const index = stlFileIndex(file); + return cssColor(index >= 0 ? index : fallbackIndex); +} + +function syncStlDisplayModesForSelection(defaultMode = "translucent") { + const next = {}; + state.stlFiles.forEach((file) => { + const id = normalizedStlId(file); + if (id == null || !state.selectedStlIds.has(id)) return; + next[id] = validStlDisplayMode(state.stlDisplayModes[id]) || defaultMode; + }); + state.stlDisplayModes = next; +} + +function initStlDisplayModes(files, selectedIds, moduleStyles = {}) { + const modes = {}; + files.forEach((file) => { + const id = normalizedStlId(file); + if (id == null || !selectedIds.has(id)) return; + const style = moduleStyles?.[file.file_name] || moduleStyles?.[file.segment_name] || {}; + const savedMode = validStlDisplayMode(style.mode); + if (savedMode && savedMode !== "hidden") { + modes[id] = savedMode; + return; + } + modes[id] = Number(style.opacity) >= 0.95 ? "solid" : "translucent"; + }); + return modes; +} + +async function setStlMode(fileOrId, mode) { + const id = normalizedStlId(fileOrId); + const nextMode = validStlDisplayMode(mode); + if (id == null || !nextMode) return; + if (nextMode === "hidden") { + state.selectedStlIds.delete(id); + delete state.stlDisplayModes[id]; + } else { + state.selectedStlIds.add(id); + state.stlDisplayModes[id] = nextMode; + } + await applyStlSelectionChange(); +} + +async function cycleStlMode(file) { + const next = { + hidden: "solid", + solid: "translucent", + translucent: "hidden", + }[stlMode(file)] || "translucent"; + await setStlMode(file, next); +} + +async function setAllStlMode(mode) { + const nextMode = validStlDisplayMode(mode); + if (!state.stlFiles.length || !nextMode) return; + if (nextMode === "hidden") { + state.selectedStlIds = new Set(); + state.stlDisplayModes = {}; + } else { + state.selectedStlIds = new Set(state.stlFiles.map((file) => normalizedStlId(file)).filter((id) => id != null)); + state.stlDisplayModes = {}; + state.selectedStlIds.forEach((id) => { + state.stlDisplayModes[id] = nextMode; + }); + } + await applyStlSelectionChange(); +} + function currentSeries() { return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null; } @@ -829,56 +937,91 @@ async function selectSeries(uid, options = {}) { } function renderStl() { - $("stlCount").textContent = state.stlFiles.length; + $("stlCount").textContent = state.stlFiles.length ? `${state.selectedStlIds.size}/${state.stlFiles.length}` : "0"; 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); - await applyStlSelectionChange(); + if (!state.stlFiles.length) { + $("stlList").innerHTML = `
当前算法模型没有 STL 文件
`; + renderAutoBoneOptions(); + return; + } + const grouped = new Map(); + state.stlFiles.forEach((file, index) => { + const family = stlFamilyLabel(file); + if (!grouped.has(family)) grouped.set(family, []); + grouped.get(family).push({ file, index }); + }); + $("stlList").innerHTML = [...grouped.entries()].map(([family, rows]) => { + const visibleCount = rows.filter(({ file }) => stlMode(file) !== "hidden").length; + const collapsed = state.collapsedStlFamilies.has(family); + return ` +
+ +
+ ${rows.map(({ file, index }) => { + const id = normalizedStlId(file); + const mode = stlMode(file); + const color = stlColor(file, index); + return ` + + `; + }).join("")} +
+
+ `; + }).join(""); + $("stlList").querySelectorAll("[data-family-toggle]").forEach((button) => { + button.addEventListener("click", () => { + const family = button.dataset.familyToggle; + if (state.collapsedStlFamilies.has(family)) state.collapsedStlFamilies.delete(family); + else state.collapsedStlFamilies.add(family); + renderStl(); + }); + }); + $("stlList").querySelectorAll("[data-stl-mode]").forEach((button) => { + button.addEventListener("click", async () => { + const id = Number(button.dataset.stlMode); + const file = state.stlFiles.find((item) => normalizedStlId(item) === id); + if (file) await cycleStlMode(file); }); }); renderAutoBoneOptions(); } async function selectAllStl() { - if (!state.stlFiles.length) return; - state.selectedStlIds = new Set(state.stlFiles.map((file) => Number(file.id)).filter(Number.isFinite)); - await applyStlSelectionChange(); + await setAllStlMode("translucent"); } async function invertStlSelection() { if (!state.stlFiles.length) return; - const next = new Set(); + const nextSelected = new Set(); + const nextModes = {}; state.stlFiles.forEach((file) => { - const id = Number(file.id); - if (Number.isFinite(id) && !state.selectedStlIds.has(id)) next.add(id); + const id = normalizedStlId(file); + if (id != null && !state.selectedStlIds.has(id)) { + nextSelected.add(id); + nextModes[id] = "translucent"; + } }); - state.selectedStlIds = next; + state.selectedStlIds = nextSelected; + state.stlDisplayModes = nextModes; await applyStlSelectionChange(); } async function applyStlSelectionChange() { markDirty("stl"); + syncStlDisplayModesForSelection(); renderStl(); const loadedIds = new Set(state.modelMeshes.map((record) => Number(record.file?.id)).filter(Number.isFinite)); const needsLoad = [...state.selectedStlIds].some((id) => !loadedIds.has(Number(id))); @@ -1090,6 +1233,15 @@ function updateStretchButtons() { }); } +function renderPoseSectionCollapse() { + const manualCollapsed = Boolean(state.collapsedPoseSections.manual); + const autoCollapsed = Boolean(state.collapsedPoseSections.auto); + $("poseManualBody")?.classList.toggle("collapsed", manualCollapsed); + $("poseAutoBody")?.classList.toggle("collapsed", autoCollapsed); + $("poseManualToggle")?.classList.toggle("collapsed", manualCollapsed); + $("poseAutoToggle")?.classList.toggle("collapsed", autoCollapsed); +} + function applyPoseLockState() { const locked = Boolean(state.poseControlsLocked); const selectors = [ @@ -1099,6 +1251,7 @@ function applyPoseLockState() { "#resetRotationBtn", "#resetTransformBtn", "#resetFlipBtn", + "#openAutoModalBtn", "#autoSettingsPanel input", "#autoSettingsPanel button", "#savePoseBtn", @@ -1518,9 +1671,11 @@ function applyModelDetail() { state.modelMeshes.forEach((record) => { const material = record.mesh.material; if (!material) return; - material.opacity = detail.opacity; - material.transparent = detail.opacity < 1; - material.depthWrite = detail.depthWrite; + const mode = stlMode(record.file); + const opacity = mode === "solid" ? 1 : Math.min(0.52, detail.opacity); + material.opacity = opacity; + material.transparent = opacity < 1; + material.depthWrite = mode === "solid" ? true : false; material.wireframe = detail.wireframe; material.metalness = detail.metalness; material.roughness = detail.roughness; @@ -1530,9 +1685,8 @@ function applyModelDetail() { } function applyStlVisibility() { - const selected = state.selectedStlIds; state.modelMeshes.forEach((record) => { - record.mesh.visible = selected.has(Number(record.file?.id)); + record.mesh.visible = stlMode(record.file) !== "hidden"; }); if (state.modelBoundsFrame) state.modelBoundsFrame.visible = state.modelMeshes.some((record) => record.mesh.visible); if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model"; @@ -2325,8 +2479,9 @@ async function buildStlModels(volume, onProgress = null) { const geometry = await loader.loadAsync(`/api/stl/file?${params.toString()}`); geometry.computeVertexNormals(); geometry.computeBoundingBox(); + const color = stlColor(file, index); const material = new THREE.MeshStandardMaterial({ - color: STL_COLORS[index % STL_COLORS.length], + color, metalness: detail.metalness, roughness: detail.roughness, transparent: true, @@ -2338,9 +2493,9 @@ async function buildStlModels(volume, onProgress = null) { const mesh = new THREE.Mesh(geometry, material); mesh.name = file.segment_name || file.file_name; mesh.userData.file = file; - mesh.userData.color = cssColor(index); + mesh.userData.color = color; state.rawModelGroup.add(mesh); - const record = { mesh, file, color: cssColor(Number(file.id) || index), index: Number(file.id) || index }; + const record = { mesh, file, color, index }; state.modelMeshes.push(record); freshRecords.push(record); onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100); @@ -2385,6 +2540,7 @@ async function buildStlModels(volume, onProgress = null) { updateBaseModelScaleFromBounds(); if (size.x || size.y || size.z) addModelBoundsFrame(size); applyStlVisibility(); + applyModelDetail(); applyPose(); scheduleMappingDraw(); onProgress?.(100); @@ -2524,6 +2680,39 @@ function readAutoSettings() { }; } +function resetAutoSettings() { + if (state.poseControlsLocked) return; + const checkedDefaults = { + autoX: true, + autoY: true, + autoZ: true, + autoScale: true, + }; + Object.entries(checkedDefaults).forEach(([id, value]) => { + const input = $(id); + if (input) input.checked = value; + }); + const valueDefaults = { + autoSampleSlices: 9, + autoSampleSlicesNumber: 9, + autoBoneReward: 1, + autoOutsidePenalty: 0.1, + autoMovePenalty: 0, + autoScalePenalty: 0, + autoIterations: 30, + autoCandidates: 36, + }; + Object.entries(valueDefaults).forEach(([id, value]) => { + const input = $(id); + if (input) input.value = value; + }); + renderAutoBoneOptions(); + state.autoResult = null; + setAutoProgress(false); + setAutoState("已恢复", "ok"); + $("autoResult").textContent = "迭代参数已恢复初始设置。"; +} + function poseDelta(from, to) { return { rotateX: Number(((to.rotateX || 0) - (from.rotateX || 0)).toFixed(3)), @@ -2999,7 +3188,15 @@ async function saveRegistration(nextStatus = null) { algorithm_model: file.algorithm_model, })), transform: state.pose, - module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: modelDetailSettings().opacity, detail: state.modelDetail }])), + module_styles: Object.fromEntries(selectedFiles.map((file, index) => { + const mode = stlMode(file); + return [file.file_name, { + color: stlColor(file, index), + opacity: mode === "solid" ? 1 : 0.52, + detail: state.modelDetail, + mode, + }]; + })), dicom_reference: { series_uid: selectedSeries.series_uid, series_number: selectedSeries.series_number, @@ -3128,17 +3325,24 @@ function wireEvents() { $("saveBtn").addEventListener("click", () => saveRegistration()); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("notes").addEventListener("input", () => markDirty("notes")); - $("selectAllStlBtn").addEventListener("click", selectAllStl); - $("invertStlBtn").addEventListener("click", invertStlSelection); + $("hideAllStlBtn").addEventListener("click", () => setAllStlMode("hidden")); + $("solidAllStlBtn").addEventListener("click", () => setAllStlMode("solid")); + $("translucentAllStlBtn").addEventListener("click", () => setAllStlMode("translucent")); $("resetRotationBtn").addEventListener("click", () => resetPose("rotation")); $("resetTransformBtn").addEventListener("click", () => resetPose("transform")); $("resetFlipBtn").addEventListener("click", () => resetPose("flip")); + $("poseManualToggle").addEventListener("click", () => { + state.collapsedPoseSections.manual = !state.collapsedPoseSections.manual; + renderPoseSectionCollapse(); + }); + $("poseAutoToggle").addEventListener("click", () => { + state.collapsedPoseSections.auto = !state.collapsedPoseSections.auto; + renderPoseSectionCollapse(); + }); $("stretchXBtn").addEventListener("click", () => stretchAxis("x")); $("stretchYBtn").addEventListener("click", () => stretchAxis("y")); $("stretchZBtn").addEventListener("click", () => stretchAxis("z")); - $("openAutoModalBtn").addEventListener("click", () => { - $("autoSettingsPanel").scrollIntoView({ block: "nearest", behavior: "smooth" }); - }); + $("openAutoModalBtn").addEventListener("click", resetAutoSettings); $("suggestBoneBtn").addEventListener("click", suggestBoneSelection); $("autoCoarseBtn")?.addEventListener("click", runAutoCoarse); $("autoFineBtn")?.addEventListener("click", runAutoFine); @@ -3308,6 +3512,7 @@ function wireEvents() { } wireEvents(); +renderPoseSectionCollapse(); if (state.token) { bootstrap(); diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index a237bb3..01ac339 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -169,9 +169,10 @@
STL -
- - +
+ + +
0
@@ -182,26 +183,30 @@
位姿手动调整 + 未保存
-
- - - +
+
+ + + +
+
+ + + +
+
-
- - - -
-
位姿自动调整 + 未运行
-
- +
+
+
迭代参数
@@ -236,9 +242,7 @@
-
- 下方为配准操作区:保存当前位姿、保存并迭代自动微调,或退回上次自动调整前位姿。 -
+
迭代操作区