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 @@