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");

View File

@@ -101,13 +101,10 @@
</div>
<section class="tool-pane active" data-tool-pane="series">
<div class="tool-section-title">
<span>融合显示</span>
</div>
<div class="display-segmented" id="fusionDetailControls">
<button class="active" data-fusion-detail="low" type="button">DICOM 低</button>
<button data-fusion-detail="low" type="button">DICOM 低</button>
<button data-fusion-detail="medium" type="button">DICOM 中</button>
<button data-fusion-detail="high" type="button">DICOM 高</button>
<button class="active" data-fusion-detail="high" type="button">DICOM 高</button>
</div>
<div class="tool-section-title">
<span>检查序列</span>
@@ -117,16 +114,16 @@
</section>
<section class="tool-pane" data-tool-pane="models">
<div class="tool-section-title">
<span>模型可视</span>
<em id="stlCount">0</em>
</div>
<div class="display-segmented" id="modelDetailControls">
<button class="active" data-model-detail="standard" type="button">标准</button>
<button data-model-detail="fine" type="button">精细</button>
<button data-model-detail="ultra" type="button">超精细</button>
<button data-model-detail="solid" type="button">实体</button>
</div>
<div class="tool-section-title compact-title">
<span>STL</span>
<em id="stlCount">0</em>
</div>
<div id="modelRail" class="chip-rail"></div>
<div id="stlList" class="stl-list compact-list"></div>
</section>
@@ -153,7 +150,57 @@
<em id="autoState">未运行</em>
</div>
<div class="auto-box compact-auto">
<button id="openAutoModalBtn" class="wide-action" type="button">打开自动调整区域</button>
<button id="openAutoModalBtn" class="wide-action" type="button">自动调整设置</button>
<div id="autoSettingsPanel" class="auto-settings-panel">
<div class="auto-options modal-options">
<label><input id="autoX" type="checkbox" checked /> X 方向</label>
<label><input id="autoY" type="checkbox" checked /> Y 方向</label>
<label><input id="autoZ" type="checkbox" checked /> Z 方向</label>
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
</div>
<div class="auto-config-grid">
<section>
<div class="modal-section-head">
<strong>STL 参考区域</strong>
<button id="suggestBoneBtn" type="button">建议选择</button>
</div>
<div id="autoBoneOptions" class="bone-option-list"></div>
</section>
<section>
<div class="modal-section-head">
<strong>采样切片</strong>
<button id="autoDefaultsBtn" type="button">默认数量</button>
</div>
<div class="modal-slider">
<input id="autoSampleSlices" type="range" min="3" max="60" value="9" />
<input id="autoSampleSlicesNumber" type="number" min="3" max="60" value="9" />
</div>
</section>
</div>
<div class="auto-weight-grid">
<label>命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label>
<label>区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label>
<label>移动惩罚<input id="autoMovePenalty" type="number" step="0.01" value="0" /></label>
<label>缩放惩罚<input id="autoScalePenalty" type="number" step="0.01" value="0" /></label>
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="100" value="30" /></label>
<label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
</div>
<div class="pose-actions modal-actions">
<button id="autoCoarseBtn" type="button">自动粗配准</button>
<button id="autoFineBtn" type="button">自动微调</button>
</div>
<div id="autoPoseResult" class="auto-pose-result">
<span>平移 X 0</span>
<span>平移 Y 0</span>
<span>平移 Z 0</span>
<span>缩放 1</span>
</div>
<div class="pose-actions modal-actions">
<button id="savePoseBtn" type="button">保存位姿</button>
<button id="saveIterateBtn" type="button">保存并迭代</button>
<button id="restorePoseBtn" type="button">退回位姿</button>
</div>
</div>
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
</div>
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
@@ -168,8 +215,10 @@
<button data-fusion-mode="fusion" class="active">融合结果</button>
<button data-fusion-mode="model">单独模型</button>
</div>
<button id="saveBtn" class="primary-btn">保存配准</button>
<button id="statusBtn" class="state-btn">标为已配准</button>
<div class="fusion-save-actions">
<button id="saveBtn" class="primary-btn">保存配准</button>
<button id="statusBtn" class="state-btn">标为已配准</button>
</div>
</div>
<div class="fusion-control-row">
<div class="segmented">
@@ -267,71 +316,6 @@
</section>
</main>
<div id="autoModal" class="modal-shell hidden">
<div class="modal-panel">
<div class="modal-head">
<div>
<h2>位姿自动调整</h2>
<p>选择参与自动配准的调整方向</p>
</div>
<button id="closeAutoModalBtn" class="ghost-btn" type="button">关闭</button>
</div>
<div class="auto-options modal-options">
<label><input id="autoX" type="checkbox" checked /> X 方向</label>
<label><input id="autoY" type="checkbox" checked /> Y 方向</label>
<label><input id="autoZ" type="checkbox" checked /> Z 方向</label>
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
</div>
<div class="auto-config-grid">
<section>
<div class="modal-section-head">
<strong>骨骼区域</strong>
<button id="suggestBoneBtn" type="button">建议选择</button>
</div>
<div id="autoBoneOptions" class="bone-option-list">
<label><input type="checkbox" value="vertebra" checked /> 椎骨</label>
<label><input type="checkbox" value="rib" checked /> 肋骨</label>
<label><input type="checkbox" value="sternum" /> 胸骨</label>
<label><input type="checkbox" value="pelvis" /> 骨盆</label>
</div>
</section>
<section>
<div class="modal-section-head">
<strong>采样切片</strong>
<button id="autoDefaultsBtn" type="button">默认数量</button>
</div>
<div class="modal-slider">
<input id="autoSampleSlices" type="range" min="3" max="60" value="9" />
<input id="autoSampleSlicesNumber" type="number" min="3" max="60" value="9" />
</div>
</section>
</div>
<div class="auto-weight-grid">
<label>骨窗命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label>
<label>非骨区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label>
<label>移动惩罚<input id="autoMovePenalty" type="number" step="0.01" value="0" /></label>
<label>缩放惩罚<input id="autoScalePenalty" type="number" step="0.01" value="0" /></label>
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="20" value="6" /></label>
<label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
</div>
<div class="pose-actions modal-actions">
<button id="autoCoarseBtn" type="button">自动粗配准</button>
<button id="autoFineBtn" type="button">自动微调</button>
</div>
<div id="autoPoseResult" class="auto-pose-result">
<span>平移 X 0</span>
<span>平移 Y 0</span>
<span>平移 Z 0</span>
<span>缩放 1</span>
</div>
<div class="pose-actions modal-actions">
<button id="savePoseBtn" type="button">保存位姿</button>
<button id="saveIterateBtn" type="button">保存并迭代</button>
<button id="restorePoseBtn" type="button">退回位姿</button>
</div>
</div>
</div>
<script type="module" src="/static/app.js"></script>
</body>
</html>

View File

@@ -730,6 +730,13 @@ button {
justify-content: space-between;
}
.fusion-save-actions {
display: flex;
align-items: center;
gap: 10px;
margin-left: auto;
}
.fusion-control-row,
.mapping-toolbar {
justify-content: space-between;
@@ -771,12 +778,16 @@ button {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 0 0 10px;
margin: 0 0 12px;
border-radius: 12px;
background: rgba(148, 163, 184, 0.08);
padding: 6px;
}
.tool-pane > .display-segmented:first-child {
margin-top: 4px;
}
#modelDetailControls {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1471,6 +1482,11 @@ input[type="range"] {
gap: 8px;
}
.auto-settings-panel {
display: grid;
gap: 10px;
}
.wide-action {
width: 100%;
min-height: 34px;
@@ -1508,6 +1524,12 @@ input[type="range"] {
padding: 8px;
}
.compact-title {
min-height: 28px;
margin-top: 4px;
background: rgba(15, 22, 32, 0.64);
}
.empty-state {
padding: 22px;
color: var(--muted);
@@ -1571,6 +1593,19 @@ input[type="range"] {
margin-top: 12px;
}
.tool-pane .auto-config-grid,
.tool-pane .auto-weight-grid {
grid-template-columns: 1fr;
}
.tool-pane .auto-config-grid {
margin-top: 0;
}
.tool-pane .auto-weight-grid {
gap: 8px;
}
.auto-config-grid section,
.auto-weight-grid {
border: 1px solid rgba(148, 163, 184, 0.18);
@@ -1602,9 +1637,9 @@ input[type="range"] {
}
.bone-option-list {
max-height: 156px;
max-height: 220px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: 1fr;
gap: 8px;
overflow: auto;
}
@@ -1624,6 +1659,31 @@ input[type="range"] {
padding: 0 10px;
}
.bone-option-list label {
min-width: 0;
justify-content: flex-start;
}
.bone-option-list label span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bone-option-list label em {
margin-left: auto;
color: #9debf4;
font-size: 10px;
font-style: normal;
text-transform: uppercase;
}
.bone-option-list label.likely {
border-color: rgba(25, 214, 195, 0.36);
background: rgba(20, 184, 166, 0.1);
}
.modal-slider {
display: grid;
grid-template-columns: minmax(0, 1fr) 86px;
@@ -1667,6 +1727,14 @@ input[type="range"] {
margin-top: 12px;
}
.tool-pane .auto-pose-result {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.compact-empty {
padding: 12px;
}
.auto-pose-result span {
min-height: 36px;
display: inline-flex;