From dbf52d147a5a58aa028b3d5894f0e27054a012e3 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 29 May 2026 02:28:07 +0800 Subject: [PATCH] Tighten registration controls and fusion display --- DICOM_and_UPP配准/static/app.js | 249 ++++++++++++++++++++++------ DICOM_and_UPP配准/static/index.html | 138 +++++++-------- DICOM_and_UPP配准/static/styles.css | 74 ++++++++- 3 files changed, 330 insertions(+), 131 deletions(-) diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index b176082..b0a7a12 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -29,13 +29,13 @@ const DEFAULT_POSE = { }; 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" }, + { key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" }, + { key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" }, + { key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 0.1, decimals: 1, suffix: "°" }, + { key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" }, + { key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" }, + { key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.001, decimals: 3, suffix: "" }, + { key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001, decimals: 3, suffix: "x" }, ]; const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a]; @@ -61,7 +61,7 @@ const state = { pose: { ...DEFAULT_POSE }, windowMode: "default", fusionMode: "fusion", - fusionDetail: "low", + fusionDetail: "high", modelDetail: "standard", mappingMode: "result", sliceIndex: 0, @@ -84,8 +84,10 @@ const state = { dicomGroup: null, dicomPlane: null, dicomPoints: null, + dicomSlicePlanes: [], modelGroup: null, rawModelGroup: null, + axisGroup: null, modelMeshes: [], stlSignature: "", baseModelScale: 1, @@ -95,6 +97,8 @@ const state = { mappingDrawFrame: 0, resizeObserver: null, resizeScene: null, + nudgeTimer: null, + nudgeDelayTimer: null, }; function escapeHtml(value) { @@ -595,10 +599,6 @@ function renderStl() { : `
当前算法模型没有 STL 文件
`; $("stlList").querySelectorAll("input[data-stl]").forEach((input) => { input.addEventListener("change", async () => { - if (state.dirty && !(await confirmChange())) { - input.checked = !input.checked; - return; - } const id = Number(input.dataset.stl); if (input.checked) state.selectedStlIds.add(id); else state.selectedStlIds.delete(id); @@ -607,6 +607,52 @@ function renderStl() { await loadFusion(); }); }); + renderAutoBoneOptions(); +} + +function stlLikelyScore(file) { + const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase(); + const priority = [ + ["rib", 95], + ["vertebra", 94], + ["spine", 92], + ["sternum", 90], + ["pelvis", 82], + ["skull", 78], + ["bone", 72], + ]; + return priority.reduce((score, [key, value]) => textValue.includes(key) ? Math.max(score, value) : score, 0); +} + +function stlDisplayName(file) { + return String(file.segment_name || file.family || file.file_name || `stl_${file.id || ""}`).replace(/\\.stl$/i, ""); +} + +function renderAutoBoneOptions() { + const container = $("autoBoneOptions"); + if (!container) return; + if (!state.stlFiles.length) { + container.innerHTML = `
No STL
`; + return; + } + const rows = [...state.stlFiles].sort((a, b) => { + const diff = stlLikelyScore(b) - stlLikelyScore(a); + if (diff) return diff; + return stlDisplayName(a).localeCompare(stlDisplayName(b)); + }); + container.innerHTML = rows.map((file) => { + const id = Number(file.id); + const likely = stlLikelyScore(file) > 0; + const checked = likely ? "checked" : ""; + const title = escapeHtml(`${file.file_name || ""} ${file.family || ""}`.trim()); + return ` + + `; + }).join(""); } function cssColor(index) { @@ -637,9 +683,10 @@ function buildPoseControls() { const key = row.dataset.pose; const range = row.querySelector("input[type='range']"); const number = row.querySelector("input[type='number']"); + const option = POSE_CONTROLS.find((item) => item.key === key); const sync = (value, source) => { const numeric = Number(value); - state.pose[key] = Number.isFinite(numeric) ? numeric : DEFAULT_POSE[key]; + state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : DEFAULT_POSE[key]; if (source !== range) range.value = state.pose[key]; if (source !== number) number.value = state.pose[key]; applyPose(); @@ -648,9 +695,7 @@ function buildPoseControls() { 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-nudge]").forEach((button) => wireHoldNudge(button)); document.querySelectorAll("[data-flip]").forEach((button) => { button.addEventListener("click", () => { const key = button.dataset.flip; @@ -662,12 +707,49 @@ function buildPoseControls() { }); } +function wireHoldNudge(button) { + const fixedDelta = Number(button.dataset.delta || 0); + if (Math.abs(fixedDelta) >= 10) { + button.addEventListener("click", () => nudgePose(button.dataset.nudge, fixedDelta)); + return; + } + const start = (event) => { + if (event.button !== undefined && event.button !== 0) return; + event.preventDefault(); + const key = button.dataset.nudge; + const delta = Number(button.dataset.delta || 0); + nudgePose(key, delta); + clearNudgeTimers(); + state.nudgeDelayTimer = window.setTimeout(() => { + state.nudgeTimer = window.setInterval(() => nudgePose(key, delta), 54); + }, 260); + button.setPointerCapture?.(event.pointerId); + }; + const stop = (event) => { + clearNudgeTimers(); + if (event?.pointerId !== undefined && button.hasPointerCapture?.(event.pointerId)) { + button.releasePointerCapture(event.pointerId); + } + }; + button.addEventListener("pointerdown", start); + button.addEventListener("pointerup", stop); + button.addEventListener("pointercancel", stop); + button.addEventListener("pointerleave", stop); +} + +function clearNudgeTimers() { + window.clearTimeout(state.nudgeDelayTimer); + window.clearInterval(state.nudgeTimer); + state.nudgeDelayTimer = null; + state.nudgeTimer = null; +} + 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)); + state.pose[key] = Number(next.toFixed(option?.decimals ?? 3)); renderPoseControls(); applyPose(); markDirty(); @@ -679,7 +761,7 @@ function renderPoseControls() { 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); + row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(item.decimals ?? 3); }); document.querySelectorAll("[data-flip]").forEach((button) => { button.classList.toggle("active", Boolean(state.pose[button.dataset.flip])); @@ -772,10 +854,11 @@ function initScene() { const dicomGroup = new THREE.Group(); const modelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group(); + const axisGroup = createSceneAxes(); modelGroup.add(rawModelGroup); - scene.add(dicomGroup, modelGroup); + scene.add(dicomGroup, modelGroup, axisGroup); - Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, sceneReady: true }); + Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, axisGroup, sceneReady: true }); const resize = () => { const rect = viewport.getBoundingClientRect(); @@ -797,6 +880,44 @@ function initScene() { animate(); } +function createAxisLabel(text, color) { + const canvas = document.createElement("canvas"); + canvas.width = 96; + canvas.height = 64; + const context = canvas.getContext("2d"); + context.clearRect(0, 0, canvas.width, canvas.height); + context.fillStyle = color; + context.font = "900 42px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(text, 48, 34); + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: texture, transparent: true, depthTest: false })); + sprite.scale.set(0.28, 0.18, 1); + return sprite; +} + +function createSceneAxes() { + const group = new THREE.Group(); + const length = 0.78; + const headLength = 0.16; + const headWidth = 0.08; + const axes = [ + { label: "X", color: 0xf87171, css: "#f87171", dir: new THREE.Vector3(1, 0, 0) }, + { label: "Y", color: 0x4ade80, css: "#4ade80", dir: new THREE.Vector3(0, 1, 0) }, + { label: "Z", color: 0x60a5fa, css: "#60a5fa", dir: new THREE.Vector3(0, 0, 1) }, + ]; + axes.forEach((axis) => { + const arrow = new THREE.ArrowHelper(axis.dir, new THREE.Vector3(0, 0, 0), length, axis.color, headLength, headWidth); + const label = createAxisLabel(axis.label, axis.css); + label.position.copy(axis.dir.clone().multiplyScalar(length + 0.16)); + group.add(arrow, label); + }); + group.visible = true; + return group; +} + function initGeometryFallback() { if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return; const dicomGroup = new THREE.Group(); @@ -829,6 +950,7 @@ function clearFusion() { state.modelMeshes = []; state.dicomPlane = null; state.dicomPoints = null; + state.dicomSlicePlanes = []; $("fusionMeta").textContent = "DICOM - · STL -"; $("fusionStatus").textContent = "等待选择 DICOM 与 STL"; updateSliceControl(); @@ -845,10 +967,10 @@ function applyFusionMode() { function fusionDetailSettings() { return { - low: { planeOpacity: 0.24, pointOpacity: 0.18, pointSize: 0.014 }, - medium: { planeOpacity: 0.38, pointOpacity: 0.3, pointSize: 0.018 }, - high: { planeOpacity: 0.52, pointOpacity: 0.44, pointSize: 0.022 }, - }[state.fusionDetail] || { planeOpacity: 0.24, pointOpacity: 0.18, pointSize: 0.014 }; + low: { planeOpacity: 0.32, sliceOpacity: 0.05, slicePlanes: 12, pointOpacity: 0.18, pointSize: 0.014, pointGrid: 72, pointThreshold: 24 }, + medium: { planeOpacity: 0.46, sliceOpacity: 0.075, slicePlanes: 18, pointOpacity: 0.3, pointSize: 0.018, pointGrid: 88, pointThreshold: 22 }, + high: { planeOpacity: 0.6, sliceOpacity: 0.1, slicePlanes: 26, pointOpacity: 0.42, pointSize: 0.021, pointGrid: 104, pointThreshold: 20 }, + }[state.fusionDetail] || { planeOpacity: 0.32, sliceOpacity: 0.05, slicePlanes: 12, pointOpacity: 0.18, pointSize: 0.014, pointGrid: 72, pointThreshold: 24 }; } function modelDetailSettings() { @@ -871,6 +993,11 @@ function applyFusionDetail() { state.dicomPoints.material.size = detail.pointSize; state.dicomPoints.material.needsUpdate = true; } + state.dicomSlicePlanes.forEach((plane) => { + if (!plane.material) return; + plane.material.opacity = detail.sliceOpacity; + plane.material.needsUpdate = true; + }); } function applyModelDetail() { @@ -1245,6 +1372,7 @@ async function buildDicomVolume(volume) { clearGroup(state.dicomGroup); state.dicomPlane = null; state.dicomPoints = null; + state.dicomSlicePlanes = []; 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; @@ -1307,18 +1435,41 @@ async function buildDicomVolume(volume) { state.dicomPlane = plane; } + if (frames.length > 1) { + const planeStep = Math.max(1, Math.ceil(frames.length / Math.max(1, fusionDetail.slicePlanes))); + for (let frameIndex = 0; frameIndex < frames.length; frameIndex += planeStep) { + if (frameIndex === centerPosition) continue; + const texture = await loadTexture(frames[frameIndex]); + texture.colorSpace = THREE.SRGBColorSpace; + const plane = new THREE.Mesh( + new THREE.PlaneGeometry(width, height), + new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + opacity: fusionDetail.sliceOpacity, + side: THREE.DoubleSide, + depthWrite: false, + depthTest: true, + }), + ); + plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex)); + state.dicomGroup.add(plane); + state.dicomSlicePlanes.push(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 stride = Math.max(2, Math.round(Math.max(image.width, image.height) / fusionDetail.pointGrid)); 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; + if (lum < fusionDetail.pointThreshold) 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); @@ -1438,6 +1589,15 @@ function fitCamera() { 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); + if (state.axisGroup) { + const axisScale = Math.max(size.x, size.y, size.z, 1) / 5.5; + state.axisGroup.scale.setScalar(axisScale); + state.axisGroup.position.set( + center.x - size.x * 0.46, + center.y - size.y * 0.46, + center.z - size.z * 0.42, + ); + } state.controls.update(); } @@ -1481,23 +1641,15 @@ function stretchAxis(axis) { } function suggestBoneSelection() { - const selectedBoneKeys = [...document.querySelectorAll("#autoBoneOptions input:checked")].map((input) => input.value); - const keyMap = { - vertebra: ["vertebra", "spine", "椎", "脊柱"], - rib: ["rib", "肋"], - sternum: ["sternum", "胸骨"], - pelvis: ["pelvis", "髂", "骨盆"], - }; - const boneKeys = (selectedBoneKeys.length ? selectedBoneKeys : ["vertebra", "rib"]) - .flatMap((key) => keyMap[key] || [key]) - .concat(["bone", "骨"]); - 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)); - }); + const checkedIds = [...document.querySelectorAll("#autoBoneOptions input:checked")] + .map((input) => Number(input.value)) + .filter(Number.isFinite); + const next = new Set(checkedIds); if (!next.size) { - state.stlFiles.slice(0, Math.min(6, state.stlFiles.length)).forEach((file) => next.add(Number(file.id))); + [...state.stlFiles] + .sort((a, b) => stlLikelyScore(b) - stlLikelyScore(a)) + .slice(0, Math.min(6, state.stlFiles.length)) + .forEach((file) => next.add(Number(file.id))); } state.selectedStlIds = next; renderStl(); @@ -1522,7 +1674,7 @@ function readAutoSettings() { outsidePenalty: numericValue("autoOutsidePenalty", 0.1), movePenalty: numericValue("autoMovePenalty", 0), scalePenalty: numericValue("autoScalePenalty", 0), - iterations: Math.max(1, Math.min(20, Math.round(numericValue("autoIterations", 6)))), + iterations: Math.max(1, Math.min(100, Math.round(numericValue("autoIterations", 30)))), candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))), }; } @@ -1758,10 +1910,6 @@ function setMappingMode(mode) { scheduleMappingDraw(); } -function setAutoModalVisible(visible) { - $("autoModal").classList.toggle("hidden", !visible); -} - function wireEvents() { $("loginForm").addEventListener("submit", login); $("logoutBtn").addEventListener("click", () => logout()); @@ -1790,10 +1938,8 @@ function wireEvents() { $("stretchXBtn").addEventListener("click", () => stretchAxis("x")); $("stretchYBtn").addEventListener("click", () => stretchAxis("y")); $("stretchZBtn").addEventListener("click", () => stretchAxis("z")); - $("openAutoModalBtn").addEventListener("click", () => setAutoModalVisible(true)); - $("closeAutoModalBtn").addEventListener("click", () => setAutoModalVisible(false)); - $("autoModal").addEventListener("click", (event) => { - if (event.target === $("autoModal")) setAutoModalVisible(false); + $("openAutoModalBtn").addEventListener("click", () => { + $("autoSettingsPanel").scrollIntoView({ block: "nearest", behavior: "smooth" }); }); $("suggestBoneBtn").addEventListener("click", suggestBoneSelection); $("autoCoarseBtn").addEventListener("click", runAutoCoarse); @@ -1816,6 +1962,7 @@ function wireEvents() { $("autoDefaultsBtn").addEventListener("click", () => { $("autoSampleSlices").value = 9; $("autoSampleSlicesNumber").value = 9; + $("autoIterations").value = 30; }); $("dicomPreview").addEventListener("load", () => { $("dicomLoading").classList.add("hidden"); diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index 15f0b4f..6cf8dc6 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -101,13 +101,10 @@
-
- 融合显示 -
- + - +
检查序列 @@ -117,16 +114,16 @@
-
- 模型可视 - 0 -
+
+ STL + 0 +
@@ -153,7 +150,57 @@ 未运行
- + +
+ +
+
+ +
+
+
+ + +
+
+
+ + + + + + +
+ +
+ 平移 X 0 + 平移 Y 0 + 平移 Z 0 + 缩放 1 +
+ +
选择 DICOM 序列和 STL 后可运行。
@@ -168,8 +215,10 @@ - - +
+ + +
@@ -267,71 +316,6 @@ - - diff --git a/DICOM_and_UPP配准/static/styles.css b/DICOM_and_UPP配准/static/styles.css index ac20817..9b1ffb6 100644 --- a/DICOM_and_UPP配准/static/styles.css +++ b/DICOM_and_UPP配准/static/styles.css @@ -730,6 +730,13 @@ button { justify-content: space-between; } +.fusion-save-actions { + display: flex; + align-items: center; + gap: 10px; + margin-left: auto; +} + .fusion-control-row, .mapping-toolbar { justify-content: space-between; @@ -771,12 +778,16 @@ button { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; - margin: 0 0 10px; + margin: 0 0 12px; border-radius: 12px; background: rgba(148, 163, 184, 0.08); padding: 6px; } +.tool-pane > .display-segmented:first-child { + margin-top: 4px; +} + #modelDetailControls { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -1471,6 +1482,11 @@ input[type="range"] { gap: 8px; } +.auto-settings-panel { + display: grid; + gap: 10px; +} + .wide-action { width: 100%; min-height: 34px; @@ -1508,6 +1524,12 @@ input[type="range"] { padding: 8px; } +.compact-title { + min-height: 28px; + margin-top: 4px; + background: rgba(15, 22, 32, 0.64); +} + .empty-state { padding: 22px; color: var(--muted); @@ -1571,6 +1593,19 @@ input[type="range"] { margin-top: 12px; } +.tool-pane .auto-config-grid, +.tool-pane .auto-weight-grid { + grid-template-columns: 1fr; +} + +.tool-pane .auto-config-grid { + margin-top: 0; +} + +.tool-pane .auto-weight-grid { + gap: 8px; +} + .auto-config-grid section, .auto-weight-grid { border: 1px solid rgba(148, 163, 184, 0.18); @@ -1602,9 +1637,9 @@ input[type="range"] { } .bone-option-list { - max-height: 156px; + max-height: 220px; display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: 1fr; gap: 8px; overflow: auto; } @@ -1624,6 +1659,31 @@ input[type="range"] { padding: 0 10px; } +.bone-option-list label { + min-width: 0; + justify-content: flex-start; +} + +.bone-option-list label span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bone-option-list label em { + margin-left: auto; + color: #9debf4; + font-size: 10px; + font-style: normal; + text-transform: uppercase; +} + +.bone-option-list label.likely { + border-color: rgba(25, 214, 195, 0.36); + background: rgba(20, 184, 166, 0.1); +} + .modal-slider { display: grid; grid-template-columns: minmax(0, 1fr) 86px; @@ -1667,6 +1727,14 @@ input[type="range"] { margin-top: 12px; } +.tool-pane .auto-pose-result { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.compact-empty { + padding: 12px; +} + .auto-pose-result span { min-height: 36px; display: inline-flex;