Improve registration fusion interaction
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user