Improve registration fusion interaction

This commit is contained in:
Codex
2026-05-29 11:00:43 +08:00
parent 989303ec35
commit adb2f41213
2 changed files with 124 additions and 26 deletions

View File

@@ -996,6 +996,8 @@ def dicom_fusion_volume(
series_uid: str, series_uid: str,
center_index: int = 0, center_index: int = 0,
window: str = "soft", 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), radius: int = Query(default=96, ge=4, le=512),
range_start: int | None = None, range_start: int | None = None,
range_end: int | None = None, range_end: int | None = None,
@@ -1014,19 +1016,22 @@ def dicom_fusion_volume(
else: else:
start = max(0, center_index - radius) start = max(0, center_index - radius)
end = min(total - 1, 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: if end - start + 1 > max_frames:
step = int(np.ceil((end - start + 1) / max_frames)) step = int(np.ceil((end - start + 1) / max_frames))
indices = list(range(start, end + 1, step)) indices = list(range(start, end + 1, step))
else: else:
indices = list(range(start, end + 1)) 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) 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 = [] frames = []
frame_width = 0 frame_width = 0
frame_height = 0 frame_height = 0
for index in indices: 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")) frames.append("data:image/png;base64," + base64.b64encode(png).decode("ascii"))
if not frame_width: if not frame_width:
with Image.open(io.BytesIO(png)) as pil: with Image.open(io.BytesIO(png)) as pil:
@@ -1036,7 +1041,7 @@ def dicom_fusion_volume(
"indices": indices, "indices": indices,
"start": start, "start": start,
"end": end, "end": end,
"center": center_index, "center": focus_index,
"total": total, "total": total,
"width": frame_width, "width": frame_width,
"height": frame_height, "height": frame_height,

View File

@@ -85,6 +85,9 @@ const state = {
camera: null, camera: null,
scene: null, scene: null,
controls: null, controls: null,
fusionRoot: null,
viewPose: { rotateX: 0, rotateZ: 0, translateX: 0, translateY: 0, scale: 1 },
sceneDrag: null,
dicomGroup: null, dicomGroup: null,
dicomPlane: null, dicomPlane: null,
dicomPoints: null, dicomPoints: null,
@@ -985,10 +988,10 @@ function initScene() {
const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000); const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000);
camera.position.set(0, -8.5, 4.4); camera.position.set(0, -8.5, 4.4);
camera.up.set(0, 0, 1); camera.up.set(0, 0, 1);
camera.lookAt(0, 0, 0);
const controls = new OrbitControls(camera, renderer.domElement); const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enabled = false;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0); controls.target.set(0, 0, 0);
const ambient = new THREE.AmbientLight(0xffffff, 0.82); const ambient = new THREE.AmbientLight(0xffffff, 0.82);
@@ -1000,10 +1003,12 @@ function initScene() {
const modelGroup = new THREE.Group(); const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group();
const axisGroup = createSceneAxes(); const axisGroup = createSceneAxes();
const fusionRoot = new THREE.Group();
modelGroup.add(rawModelGroup); 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 resize = () => {
const rect = viewport.getBoundingClientRect(); const rect = viewport.getBoundingClientRect();
@@ -1019,10 +1024,11 @@ function initScene() {
const animate = () => { const animate = () => {
requestAnimationFrame(animate); requestAnimationFrame(animate);
controls.update(); applySceneViewPose();
renderer.render(scene, camera); renderer.render(scene, camera);
}; };
animate(); animate();
wireFusionSceneDrag(renderer.domElement);
} }
function createAxisLabel(text, color) { function createAxisLabel(text, color) {
@@ -1065,11 +1071,66 @@ function createSceneAxes() {
function initGeometryFallback() { function initGeometryFallback() {
if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return; if (state.dicomGroup && state.modelGroup && state.rawModelGroup) return;
const fusionRoot = new THREE.Group();
const dicomGroup = new THREE.Group(); const dicomGroup = new THREE.Group();
const modelGroup = new THREE.Group(); const modelGroup = new THREE.Group();
const rawModelGroup = new THREE.Group(); const rawModelGroup = new THREE.Group();
modelGroup.add(rawModelGroup); 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) { function clearGroup(group) {
@@ -1129,10 +1190,10 @@ function updateFusionMeta() {
function fusionDetailSettings() { function fusionDetailSettings() {
return { return {
low: { 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.46, sliceOpacity: 0.075, slicePlanes: 18, pointOpacity: 0.3, pointSize: 0.018, pointGrid: 88, pointThreshold: 22 }, medium: { planeOpacity: 0.92, sliceOpacity: 0.024, slicePlanes: 14, pointOpacity: 0.055, pointSize: 0.014, pointGrid: 88, pointThreshold: 34, textureSize: 512 },
high: { planeOpacity: 0.6, sliceOpacity: 0.1, slicePlanes: 26, pointOpacity: 0.42, pointSize: 0.021, pointGrid: 104, pointThreshold: 20 }, high: { planeOpacity: 1, sliceOpacity: 0.028, slicePlanes: 18, pointOpacity: 0.075, pointSize: 0.015, pointGrid: 104, pointThreshold: 32, textureSize: 768 },
}[state.fusionDetail] || { planeOpacity: 0.32, sliceOpacity: 0.05, slicePlanes: 12, pointOpacity: 0.18, pointSize: 0.014, pointGrid: 72, pointThreshold: 24 }; }[state.fusionDetail] || { planeOpacity: 0.82, sliceOpacity: 0.018, slicePlanes: 10, pointOpacity: 0.035, pointSize: 0.012, pointGrid: 72, pointThreshold: 36, textureSize: 384 };
} }
function modelDetailSettings() { function modelDetailSettings() {
@@ -1148,6 +1209,8 @@ function applyFusionDetail() {
const detail = fusionDetailSettings(); const detail = fusionDetailSettings();
if (state.dicomPlane?.material) { if (state.dicomPlane?.material) {
state.dicomPlane.material.opacity = detail.planeOpacity; state.dicomPlane.material.opacity = detail.planeOpacity;
state.dicomPlane.material.transparent = true;
state.dicomPlane.material.depthWrite = false;
state.dicomPlane.material.needsUpdate = true; state.dicomPlane.material.needsUpdate = true;
} }
if (state.dicomPoints?.material) { if (state.dicomPoints?.material) {
@@ -1212,6 +1275,8 @@ async function loadFusion() {
series_uid: selected.series_uid, series_uid: selected.series_uid,
center_index: String(state.sliceIndex), center_index: String(state.sliceIndex),
window: state.windowMode, window: state.windowMode,
detail: state.fusionDetail,
texture_size: String(fusionDetailSettings().textureSize),
range_start: String(state.sliceRangeStart), range_start: String(state.sliceRangeStart),
range_end: String(state.sliceRangeEnd), range_end: String(state.sliceRangeEnd),
}); });
@@ -1247,7 +1312,15 @@ async function loadFusion() {
async function loadTexture(url) { async function loadTexture(url) {
return new Promise((resolve, reject) => { 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 total = Math.max(1, Number(volume.total || 1));
const indices = volume.indices || []; const indices = volume.indices || [];
const frames = volume.frames || []; 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 = { state.volumeScene = {
width, width,
height, height,
@@ -1595,7 +1674,6 @@ async function buildDicomVolume(volume) {
const fusionDetail = fusionDetailSettings(); const fusionDetail = fusionDetailSettings();
if (centerFrame) { if (centerFrame) {
const texture = await loadTexture(centerFrame); const texture = await loadTexture(centerFrame);
texture.colorSpace = THREE.SRGBColorSpace;
const plane = new THREE.Mesh( const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height), new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({ new THREE.MeshBasicMaterial({
@@ -1604,9 +1682,11 @@ async function buildDicomVolume(volume) {
opacity: fusionDetail.planeOpacity, opacity: fusionDetail.planeOpacity,
side: THREE.DoubleSide, side: THREE.DoubleSide,
depthWrite: false, depthWrite: false,
depthTest: true,
}), }),
); );
plane.position.z = sliceToSceneZ(centerIndex) + 0.006; plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
plane.renderOrder = 20;
state.dicomGroup.add(plane); state.dicomGroup.add(plane);
state.dicomPlane = plane; state.dicomPlane = plane;
} }
@@ -1616,7 +1696,6 @@ async function buildDicomVolume(volume) {
for (let frameIndex = 0; frameIndex < frames.length; frameIndex += planeStep) { for (let frameIndex = 0; frameIndex < frames.length; frameIndex += planeStep) {
if (frameIndex === centerPosition) continue; if (frameIndex === centerPosition) continue;
const texture = await loadTexture(frames[frameIndex]); const texture = await loadTexture(frames[frameIndex]);
texture.colorSpace = THREE.SRGBColorSpace;
const plane = new THREE.Mesh( const plane = new THREE.Mesh(
new THREE.PlaneGeometry(width, height), new THREE.PlaneGeometry(width, height),
new THREE.MeshBasicMaterial({ new THREE.MeshBasicMaterial({
@@ -1629,6 +1708,7 @@ async function buildDicomVolume(volume) {
}), }),
); );
plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex)); plane.position.z = sliceToSceneZ(Number(indices[frameIndex] ?? centerIndex));
plane.renderOrder = 4;
state.dicomGroup.add(plane); state.dicomGroup.add(plane);
state.dicomSlicePlanes.push(plane); state.dicomSlicePlanes.push(plane);
} }
@@ -1667,6 +1747,7 @@ async function buildDicomVolume(volume) {
depthWrite: false, depthWrite: false,
}), }),
); );
points.renderOrder = 2;
state.dicomGroup.add(points); state.dicomGroup.add(points);
state.dicomPoints = points; state.dicomPoints = points;
} }
@@ -1809,15 +1890,17 @@ function applyPose(poseInput = state.pose) {
} }
function fitCamera() { function fitCamera() {
if (!state.camera || !state.controls) return; if (!state.camera) return;
applySceneViewPose();
const box = new THREE.Box3(); const box = new THREE.Box3();
if (state.dicomGroup?.visible !== false) box.expandByObject(state.dicomGroup); if (state.dicomGroup?.visible !== false) box.expandByObject(state.dicomGroup);
const modelBox = visibleModelBox(); const modelBox = visibleModelBox();
if (modelBox) box.union(modelBox); if (modelBox) box.union(modelBox);
if (box.isEmpty()) { if (box.isEmpty()) {
state.camera.position.set(0, -8.5, 4.4); state.camera.position.set(0, -8.5, 4.4);
state.controls.target.set(0, 0, 0); state.camera.lookAt(0, 0, 0);
state.controls.update(); state.controls?.target.set(0, 0, 0);
state.controls?.update();
return; return;
} }
const size = new THREE.Vector3(); const size = new THREE.Vector3();
@@ -1826,7 +1909,8 @@ function fitCamera() {
box.getCenter(center); box.getCenter(center);
const distance = Math.max(size.x, size.y, size.z, 1) * 1.8; 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.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) { if (state.axisGroup) {
const axisScale = Math.max(size.x, size.y, size.z, 1) / 5.5; const axisScale = Math.max(size.x, size.y, size.z, 1) / 5.5;
state.axisGroup.scale.setScalar(axisScale); state.axisGroup.scale.setScalar(axisScale);
@@ -1836,7 +1920,13 @@ function fitCamera() {
center.z - size.z * 0.42, 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) { function resetPose(kind) {
@@ -2185,7 +2275,7 @@ function wireEvents() {
$("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow); $("manualCacheRefreshBtn").addEventListener("click", refreshCacheNow);
$("relationBtn").addEventListener("click", () => openLinkedApp("relation")); $("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("fitBtn").addEventListener("click", fitCamera); $("fitBtn").addEventListener("click", resetSceneView);
$("saveBtn").addEventListener("click", () => saveRegistration()); $("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", () => markDirty("notes")); $("notes").addEventListener("input", () => markDirty("notes"));
@@ -2328,11 +2418,14 @@ function wireEvents() {
}); });
}); });
document.querySelectorAll("[data-fusion-detail]").forEach((button) => { document.querySelectorAll("[data-fusion-detail]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", async () => {
state.fusionDetail = button.dataset.fusionDetail || "low"; 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)); document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionDetail(); applyFusionDetail();
markDirty("display"); markDirty("display");
await loadFusion();
}); });
}); });
document.querySelectorAll("[data-model-detail]").forEach((button) => { document.querySelectorAll("[data-model-detail]").forEach((button) => {