diff --git a/DICOM_and_UPP配准/app.py b/DICOM_and_UPP配准/app.py index 8cf502f..8155803 100644 --- a/DICOM_and_UPP配准/app.py +++ b/DICOM_and_UPP配准/app.py @@ -996,6 +996,8 @@ def dicom_fusion_volume( series_uid: str, center_index: int = 0, window: str = "soft", + detail: str = "high", + texture_size: int | None = Query(default=None, ge=128, le=1024), radius: int = Query(default=96, ge=4, le=512), range_start: int | None = None, range_end: int | None = None, @@ -1014,19 +1016,22 @@ def dicom_fusion_volume( else: start = max(0, center_index - radius) end = min(total - 1, center_index + radius) - max_frames = 72 + max_frames = {"low": 48, "medium": 64, "high": 80}.get(str(detail).lower(), 64) if end - start + 1 > max_frames: step = int(np.ceil((end - start + 1) / max_frames)) indices = list(range(start, end + 1, step)) else: indices = list(range(start, end + 1)) - sample_ds = datasets[center_index] + focus_index = min(max(center_index, start), end) + indices = sorted({*indices, start, end, focus_index}) + sample_ds = datasets[focus_index] center, width = window_values(sample_ds, window) + max_texture_size = int(texture_size or {"low": 384, "medium": 512, "high": 768}.get(str(detail).lower(), 512)) frames = [] frame_width = 0 frame_height = 0 for index in indices: - png = render_array(stack[index], center, width, max_size=256, spacing=(stack_data["row_spacing"], stack_data["col_spacing"])) + png = render_array(stack[index], center, width, max_size=max_texture_size, spacing=(stack_data["row_spacing"], stack_data["col_spacing"])) frames.append("data:image/png;base64," + base64.b64encode(png).decode("ascii")) if not frame_width: with Image.open(io.BytesIO(png)) as pil: @@ -1036,7 +1041,7 @@ def dicom_fusion_volume( "indices": indices, "start": start, "end": end, - "center": center_index, + "center": focus_index, "total": total, "width": frame_width, "height": frame_height, diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index b0f2edf..4d24707 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -85,6 +85,9 @@ const state = { camera: null, scene: null, controls: null, + fusionRoot: null, + viewPose: { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 }, + sceneDrag: null, dicomGroup: null, dicomPlane: null, dicomPoints: null, @@ -985,10 +988,10 @@ function initScene() { const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000); camera.position.set(0, -8.5, 4.4); camera.up.set(0, 0, 1); + camera.lookAt(0, 0, 0); const controls = new OrbitControls(camera, renderer.domElement); - controls.enableDamping = true; - controls.dampingFactor = 0.08; + controls.enabled = false; controls.target.set(0, 0, 0); const ambient = new THREE.AmbientLight(0xffffff, 0.82); @@ -1000,10 +1003,12 @@ function initScene() { const modelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group(); const axisGroup = createSceneAxes(); + const fusionRoot = new THREE.Group(); modelGroup.add(rawModelGroup); - scene.add(dicomGroup, modelGroup, axisGroup); + fusionRoot.add(dicomGroup, modelGroup, axisGroup); + scene.add(fusionRoot); - Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, axisGroup, sceneReady: true }); + Object.assign(state, { renderer, camera, scene, controls, fusionRoot, dicomGroup, modelGroup, rawModelGroup, axisGroup, sceneReady: true }); const resize = () => { const rect = viewport.getBoundingClientRect(); @@ -1019,10 +1024,11 @@ function initScene() { const animate = () => { requestAnimationFrame(animate); - controls.update(); + applySceneViewPose(); renderer.render(scene, camera); }; animate(); + wireFusionSceneDrag(renderer.domElement); } function createAxisLabel(text, color) { @@ -1065,11 +1071,66 @@ function createSceneAxes() { function initGeometryFallback() { if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return; + const fusionRoot = new THREE.Group(); const dicomGroup = new THREE.Group(); const modelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group(); modelGroup.add(rawModelGroup); - Object.assign(state, { dicomGroup, modelGroup, rawModelGroup }); + fusionRoot.add(dicomGroup, modelGroup); + Object.assign(state, { fusionRoot, dicomGroup, modelGroup, rawModelGroup }); +} + +function applySceneViewPose() { + if (!state.fusionRoot) return; + const pose = state.viewPose || {}; + state.fusionRoot.rotation.set(Number(pose.rotateX) || 0, 0, Number(pose.rotateZ) || 0); + state.fusionRoot.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, 0); + state.fusionRoot.scale.setScalar(Math.max(0.2, Number(pose.scale) || 1)); + state.fusionRoot.updateMatrixWorld(true); +} + +function wireFusionSceneDrag(canvas) { + if (!canvas) return; + canvas.style.touchAction = "none"; + canvas.addEventListener("contextmenu", (event) => event.preventDefault()); + canvas.addEventListener("pointerdown", (event) => { + event.preventDefault(); + state.sceneDrag = { + pointerId: event.pointerId, + mode: event.button === 2 || event.shiftKey ? "pan" : "rotate", + startX: event.clientX, + startY: event.clientY, + pose: { ...state.viewPose }, + }; + canvas.setPointerCapture?.(event.pointerId); + }); + canvas.addEventListener("pointermove", (event) => { + const drag = state.sceneDrag; + if (!drag || drag.pointerId !== event.pointerId) return; + event.preventDefault(); + const dx = event.clientX - drag.startX; + const dy = event.clientY - drag.startY; + if (drag.mode === "pan") { + state.viewPose.translateX = drag.pose.translateX + dx * 0.006; + state.viewPose.translateY = drag.pose.translateY - dy * 0.006; + } else { + state.viewPose.rotateZ = drag.pose.rotateZ + dx * 0.008; + state.viewPose.rotateX = drag.pose.rotateX + dy * 0.008; + } + applySceneViewPose(); + }); + const stop = (event) => { + if (state.sceneDrag?.pointerId !== event.pointerId) return; + state.sceneDrag = null; + canvas.releasePointerCapture?.(event.pointerId); + }; + canvas.addEventListener("pointerup", stop); + canvas.addEventListener("pointercancel", stop); + canvas.addEventListener("wheel", (event) => { + event.preventDefault(); + state.viewPose.scale = Math.max(0.35, Math.min(3, state.viewPose.scale - event.deltaY * 0.001)); + applySceneViewPose(); + }, { passive: false }); } function clearGroup(group) { @@ -1129,10 +1190,10 @@ function updateFusionMeta() { function fusionDetailSettings() { return { - 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 }; + low: { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }, + medium: { planeOpacity: 0.92, sliceOpacity: 0.024, slicePlanes: 14, pointOpacity: 0.055, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 512 }, + high: { planeOpacity: 1, sliceOpacity: 0.028, slicePlanes: 18, pointOpacity: 0.075, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 768 }, + }[state.fusionDetail] || { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 }; } function modelDetailSettings() { @@ -1148,6 +1209,8 @@ function applyFusionDetail() { const detail = fusionDetailSettings(); if (state.dicomPlane?.material) { state.dicomPlane.material.opacity = detail.planeOpacity; + state.dicomPlane.material.transparent = true; + state.dicomPlane.material.depthWrite = false; state.dicomPlane.material.needsUpdate = true; } if (state.dicomPoints?.material) { @@ -1212,6 +1275,8 @@ async function loadFusion() { series_uid: selected.series_uid, center_index: String(state.sliceIndex), window: state.windowMode, + detail: state.fusionDetail, + texture_size: String(fusionDetailSettings().textureSize), range_start: String(state.sliceRangeStart), range_end: String(state.sliceRangeEnd), }); @@ -1247,7 +1312,15 @@ async function loadFusion() { async function loadTexture(url) { return new Promise((resolve, reject) => { - new THREE.TextureLoader().load(url, resolve, undefined, reject); + new THREE.TextureLoader().load(url, (texture) => { + texture.colorSpace = THREE.SRGBColorSpace; + texture.generateMipmaps = false; + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.anisotropy = Math.min(4, state.renderer?.capabilities?.getMaxAnisotropy?.() || 1); + texture.needsUpdate = true; + resolve(texture); + }, undefined, reject); }); } @@ -1558,7 +1631,13 @@ async function buildDicomVolume(volume) { const total = Math.max(1, Number(volume.total || 1)); const indices = volume.indices || []; const frames = volume.frames || []; - const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0; + const centerPosition = indices.length + ? indices.reduce((bestIndex, item, index) => { + const bestDistance = Math.abs(Number(indices[bestIndex] ?? centerIndex) - centerIndex); + const distance = Math.abs(Number(item ?? centerIndex) - centerIndex); + return distance < bestDistance ? index : bestIndex; + }, 0) + : 0; state.volumeScene = { width, height, @@ -1595,7 +1674,6 @@ async function buildDicomVolume(volume) { const fusionDetail = fusionDetailSettings(); if (centerFrame) { const texture = await loadTexture(centerFrame); - texture.colorSpace = THREE.SRGBColorSpace; const plane = new THREE.Mesh( new THREE.PlaneGeometry(width, height), new THREE.MeshBasicMaterial({ @@ -1604,9 +1682,11 @@ async function buildDicomVolume(volume) { opacity: fusionDetail.planeOpacity, side: THREE.DoubleSide, depthWrite: false, + depthTest: true, }), ); plane.position.z = sliceToSceneZ(centerIndex) + 0.006; + plane.renderOrder = 20; state.dicomGroup.add(plane); state.dicomPlane = plane; } @@ -1616,7 +1696,6 @@ async function buildDicomVolume(volume) { 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({ @@ -1629,6 +1708,7 @@ async function buildDicomVolume(volume) { }), ); plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex)); + plane.renderOrder = 4; state.dicomGroup.add(plane); state.dicomSlicePlanes.push(plane); } @@ -1667,6 +1747,7 @@ async function buildDicomVolume(volume) { depthWrite: false, }), ); + points.renderOrder = 2; state.dicomGroup.add(points); state.dicomPoints = points; } @@ -1809,15 +1890,17 @@ function applyPose(poseInput = state.pose) { } function fitCamera() { - if (!state.camera || !state.controls) return; + if (!state.camera) return; + applySceneViewPose(); const box = new THREE.Box3(); if (state.dicomGroup?.visible !== false) box.expandByObject(state.dicomGroup); const modelBox = visibleModelBox(); if (modelBox) box.union(modelBox); if (box.isEmpty()) { state.camera.position.set(0, -8.5, 4.4); - state.controls.target.set(0, 0, 0); - state.controls.update(); + state.camera.lookAt(0, 0, 0); + state.controls?.target.set(0, 0, 0); + state.controls?.update(); return; } const size = new THREE.Vector3(); @@ -1826,7 +1909,8 @@ function fitCamera() { 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.camera.lookAt(center); + 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); @@ -1836,7 +1920,13 @@ function fitCamera() { center.z - size.z * 0.42, ); } - state.controls.update(); + state.controls?.update(); +} + +function resetSceneView() { + state.viewPose = { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 }; + applySceneViewPose(); + fitCamera(); } function resetPose(kind) { @@ -2185,7 +2275,7 @@ function wireEvents() { $("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow); $("relationBtn").addEventListener("click", () => openLinkedApp("relation")); $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); - $("fitBtn").addEventListener("click", fitCamera); + $("fitBtn").addEventListener("click", resetSceneView); $("saveBtn").addEventListener("click", () => saveRegistration()); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("notes").addEventListener("input", () => markDirty("notes")); @@ -2328,11 +2418,14 @@ function wireEvents() { }); }); document.querySelectorAll("[data-fusion-detail]").forEach((button) => { - button.addEventListener("click", () => { - state.fusionDetail = button.dataset.fusionDetail || "low"; + button.addEventListener("click", async () => { + const nextDetail = button.dataset.fusionDetail || "low"; + if (nextDetail === state.fusionDetail) return; + state.fusionDetail = nextDetail; document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button)); applyFusionDetail(); markDirty("display"); + await loadFusion(); }); }); document.querySelectorAll("[data-model-detail]").forEach((button) => {