Tighten registration controls and fusion display

This commit is contained in:
Codex
2026-05-29 02:28:07 +08:00
parent 559266d5e6
commit dbf52d147a
3 changed files with 330 additions and 131 deletions

View File

@@ -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() {
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
$("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 = `<div class="empty-state compact-empty">No STL</div>`;
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 `
<label title="${title}" class="${likely ? "likely" : ""}">
<input type="checkbox" value="${id}" ${checked} />
<span>${escapeHtml(stlDisplayName(file))}</span>
${likely ? `<em>priority</em>` : ""}
</label>
`;
}).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");