Improve registration iteration feedback

This commit is contained in:
Codex
2026-05-30 14:17:55 +08:00
parent 40e18ed45d
commit 30f73937aa
3 changed files with 390 additions and 60 deletions

View File

@@ -58,6 +58,13 @@ const POSE_SIGNATURE_KEYS = [
"flipY",
"flipZ",
];
const AUTO_PROGRESS_STEPS = [
{ id: "save", label: "保存" },
{ id: "bone", label: "骨窗" },
{ id: "sample", label: "采样" },
{ id: "score", label: "评分" },
{ id: "apply", label: "应用" },
];
const state = {
token: localStorage.getItem("dicom_upp_registration_token") || "",
@@ -101,6 +108,7 @@ const state = {
lastAutoPose: null,
autoDeltaBaseline: null,
autoFineRunning: false,
poseControlsLocked: false,
dirty: false,
dirtyReason: "",
savedPoseSignature: "",
@@ -967,6 +975,7 @@ function buildPoseControls() {
const number = row.querySelector("input[type='number']");
const option = POSE_CONTROLS.find((item) => item.key === key);
const sync = (value, source) => {
if (state.poseControlsLocked) return;
const numeric = Number(value);
state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : DEFAULT_POSE[key];
if (source !== range) range.value = state.pose[key];
@@ -980,6 +989,7 @@ function buildPoseControls() {
document.querySelectorAll("[data-nudge]").forEach((button) => wireHoldNudge(button));
document.querySelectorAll("[data-flip]").forEach((button) => {
button.addEventListener("click", () => {
if (state.poseControlsLocked) return;
const key = button.dataset.flip;
state.pose[key] = !state.pose[key];
renderPoseControls();
@@ -988,6 +998,7 @@ function buildPoseControls() {
});
});
updateAutoPoseResult(state.pose, state.pose);
applyPoseLockState();
}
function wireHoldNudge(button) {
@@ -997,6 +1008,7 @@ function wireHoldNudge(button) {
return;
}
const start = (event) => {
if (state.poseControlsLocked || button.disabled) return;
if (event.button !== undefined && event.button !== 0) return;
event.preventDefault();
const key = button.dataset.nudge;
@@ -1028,6 +1040,7 @@ function clearNudgeTimers() {
}
function nudgePose(key, delta) {
if (state.poseControlsLocked) return;
if (!key || !Number.isFinite(delta)) return;
const option = POSE_CONTROLS.find((item) => item.key === key);
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
@@ -1051,6 +1064,7 @@ function renderPoseControls() {
});
renderActiveHeader();
updateStretchButtons();
applyPoseLockState();
}
function isRightAngleRotation(value) {
@@ -1063,17 +1077,55 @@ function canStretchByAxis() {
}
function updateStretchButtons() {
const enabled = canStretchByAxis();
const enabled = canStretchByAxis() && !state.poseControlsLocked;
["x", "y", "z"].forEach((axis) => {
const button = $(`stretch${axis.toUpperCase()}Btn`);
if (!button) return;
button.disabled = !enabled;
button.title = enabled
? `${axis.toUpperCase()} 方向自动等比例拉伸模型`
: "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可用";
: state.poseControlsLocked
? "自动迭代运行中,暂不可调整位姿"
: "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可用";
});
}
function applyPoseLockState() {
const locked = Boolean(state.poseControlsLocked);
const selectors = [
"#poseGrid input",
"#poseGrid button",
"[data-flip]",
"#resetRotationBtn",
"#resetTransformBtn",
"#resetFlipBtn",
"#autoSettingsPanel input",
"#autoSettingsPanel button",
"#savePoseBtn",
"#saveIterateBtn",
"#restorePoseBtn",
"#saveBtn",
"#statusBtn",
"#autoCoarseBtn",
"#autoFineBtn",
];
selectors.forEach((selector) => {
document.querySelectorAll(selector).forEach((element) => {
element.disabled = locked;
});
});
document.querySelectorAll(".pose-control, .flip-row, .pose-actions, .auto-settings-panel").forEach((element) => {
element.classList.toggle("locked", locked);
});
updateStretchButtons();
}
function setPoseEditingLocked(locked) {
state.poseControlsLocked = Boolean(locked);
if (locked) clearNudgeTimers();
applyPoseLockState();
}
function updateSliceControl() {
const selected = currentSeries();
const count = Number(selected?.count || 0);
@@ -2364,7 +2416,7 @@ async function buildStlModels(volume, onProgress = null) {
onProgress?.(100);
}
function applyPose(poseInput = state.pose) {
function applyPose(poseInput = state.pose, options = {}) {
if (!state.modelGroup) return;
const pose = { ...DEFAULT_POSE, ...poseInput };
state.modelGroup.rotation.set(
@@ -2380,7 +2432,7 @@ function applyPose(poseInput = state.pose) {
scale * (pose.flipZ ? -1 : 1),
);
state.modelGroup.updateMatrixWorld(true);
scheduleMappingDraw();
if (options.scheduleMapping !== false) scheduleMappingDraw();
}
function fitCamera() {
@@ -2402,6 +2454,7 @@ function resetSceneView() {
}
function resetPose(kind) {
if (state.poseControlsLocked) return;
if (kind === "rotation") {
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
} else if (kind === "transform") {
@@ -2417,6 +2470,10 @@ function resetPose(kind) {
}
function stretchAxis(axis) {
if (state.poseControlsLocked) {
$("autoResult").textContent = "自动迭代运行中,暂不可调整位姿。";
return;
}
if (!canStretchByAxis()) {
const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。";
setAutoState("XYZ 拉伸不可用", "warn");
@@ -2541,27 +2598,165 @@ function resetAutoDeltaBaseline() {
updateAutoPoseResult(state.pose, state.pose);
}
function setAutoProgress(visible, value = 0, text = "") {
function renderAutoProgressSteps(activeStep = "") {
const stepsEl = $("autoProgressSteps");
if (!stepsEl) return;
const activeIndex = AUTO_PROGRESS_STEPS.findIndex((step) => step.id === activeStep);
stepsEl.innerHTML = AUTO_PROGRESS_STEPS.map((step, index) => {
const cls = index < activeIndex ? "done" : step.id === activeStep ? "active" : "";
return `<span class="${cls}">${escapeHtml(step.label)}</span>`;
}).join("");
}
function setAutoProgress(visible, value = 0, title = "", detail = "", activeStep = "") {
const wrap = $("autoProgress");
if (!wrap) return;
wrap.classList.toggle("hidden", !visible);
const safeValue = Math.max(0, Math.min(100, Number(value) || 0));
$("autoProgressFill").style.width = `${safeValue}%`;
$("autoProgressText").textContent = text || `${Math.round(safeValue)}%`;
}
function setAutoControlsBusy(busy) {
["autoCoarseBtn", "autoFineBtn", "saveIterateBtn"].forEach((id) => {
const button = $(id);
if (button) button.disabled = busy;
});
$("autoProgressText").textContent = `${Math.round(safeValue)}%`;
$("autoProgressTitle").textContent = title || "自动微调处理中";
$("autoProgressDetail").textContent = detail || "正在准备自动微调任务。";
renderAutoProgressSteps(activeStep);
}
function waitForUiFrame() {
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
}
function autoSettingsForStorage(settings) {
const { imageContext, ...rest } = settings || {};
return rest;
}
function chooseAutoVolumeEntries(settings) {
const volume = state.volumeMeta;
const frames = volume?.frames || [];
const indices = volume?.indices || [];
if (!frames.length || !indices.length) return [];
const entries = frames.map((frame, index) => ({ frame, slice: Number(indices[index] ?? 0) }));
const limit = Math.max(3, Math.min(entries.length, Number(settings.sampleSlices || 9)));
if (entries.length <= limit) return entries;
const selected = new Map();
for (let index = 0; index < limit; index += 1) {
const entryIndex = Math.round((index * (entries.length - 1)) / Math.max(limit - 1, 1));
selected.set(entryIndex, entries[entryIndex]);
}
return [...selected.values()].sort((left, right) => left.slice - right.slice);
}
function collectAutoSamplePoints(settings, maxPoints = 5200) {
const records = autoReferenceRecords(settings);
const totalVertices = records.reduce((sum, record) => sum + (record.mesh.geometry.attributes.position?.count || 0), 0);
if (!totalVertices) return [];
const stride = Math.max(1, Math.ceil(totalVertices / maxPoints));
const samples = [];
records.forEach((record) => {
const attribute = record.mesh.geometry.attributes.position;
if (!attribute) return;
for (let index = 0; index < attribute.count; index += stride) {
samples.push({
x: attribute.getX(index),
y: attribute.getY(index),
z: attribute.getZ(index),
});
}
});
return samples;
}
async function prepareAutoFineImageContext(settings) {
if (!state.volumeMeta || !state.volumeScene) return null;
const entries = chooseAutoVolumeEntries(settings);
const samples = collectAutoSamplePoints(settings);
if (!entries.length || !samples.length) return null;
const frames = [];
for (const [index, entry] of entries.entries()) {
const progress = 20 + (index / Math.max(entries.length, 1)) * 16;
setAutoProgress(true, progress, "读取骨窗采样切片", `正在读取第 ${index + 1}/${entries.length} 张骨窗切片,切片 ${entry.slice + 1}`, "bone");
await waitForUiFrame();
frames.push({ ...entry, image: await loadImageData(entry.frame, 320) });
}
const sortedSlices = frames.map((entry) => entry.slice).sort((left, right) => left - right);
const gaps = sortedSlices.slice(1).map((slice, index) => Math.abs(slice - sortedSlices[index]));
const sliceTolerance = Math.max(1, Math.ceil((gaps.length ? Math.max(...gaps) : 1) / 2));
return {
frames,
samples,
sliceTolerance,
volumeScene: { ...state.volumeScene },
};
}
function scorePoseAgainstBoneImages(pose, context, settings) {
if (!context?.frames?.length || !context.samples?.length || !context.volumeScene) return NaN;
const current = { ...state.pose };
applyPose(pose, { scheduleMapping: false });
const matrix = modelPoseMatrix();
applyPose(current, { scheduleMapping: false });
const volume = context.volumeScene;
const point = new THREE.Vector3();
let hitReward = 0;
let missPenalty = 0;
let contributors = 0;
for (const sample of context.samples) {
point.set(sample.x, sample.y, sample.z).applyMatrix4(matrix);
if (
point.x < -volume.width / 2 || point.x > volume.width / 2
|| point.y < -volume.height / 2 || point.y > volume.height / 2
|| point.z < -volume.depth / 2 || point.z > volume.depth / 2
) {
missPenalty += 0.85;
contributors += 1;
continue;
}
const slice = volume.total <= 1 ? 0 : ((point.z + volume.depth / 2) / Math.max(volume.depth, 0.001)) * (volume.total - 1);
let nearest = context.frames[0];
let nearestDistance = Infinity;
for (const frame of context.frames) {
const distance = Math.abs(frame.slice - slice);
if (distance < nearestDistance) {
nearest = frame;
nearestDistance = distance;
}
}
if (nearestDistance > context.sliceTolerance) {
missPenalty += 0.28;
contributors += 1;
continue;
}
const image = nearest.image;
const x = Math.round(((point.x + volume.width / 2) / Math.max(volume.width, 0.001)) * (image.width - 1));
const y = Math.round(((volume.height / 2 - point.y) / Math.max(volume.height, 0.001)) * (image.height - 1));
if (x < 0 || x >= image.width || y < 0 || y >= image.height) {
missPenalty += 0.75;
contributors += 1;
continue;
}
const lum = image.data[(y * image.width + x) * 4] || 0;
if (lum >= 170) hitReward += 1;
else if (lum >= 115) hitReward += 0.45;
else if (lum >= 80) hitReward += 0.16;
else missPenalty += 0.9 - lum / 255;
contributors += 1;
}
const safeContributors = Math.max(contributors, 1);
const basePose = settings.basePose || state.autoDeltaBaseline || state.pose;
const movement = Math.sqrt(
((pose.translateX || 0) - (basePose.translateX || 0)) ** 2
+ ((pose.translateY || 0) - (basePose.translateY || 0)) ** 2
+ ((pose.translateZ || 0) - (basePose.translateZ || 0)) ** 2,
);
const movementPenalty = movement * Number(settings.movePenalty || 0);
const scalePenalty = Math.abs((pose.scale || 1) - (basePose.scale || 1)) * Number(settings.scalePenalty || 0);
return (hitReward / safeContributors) * Number(settings.boneReward || 1)
- (missPenalty / safeContributors) * (0.25 + Number(settings.outsidePenalty || 0))
- movementPenalty
- scalePenalty;
}
function restoreAutoPose() {
if (state.poseControlsLocked) return;
if (!state.lastAutoPose?.before) {
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
return;
@@ -2581,9 +2776,9 @@ function candidateScore(pose, settings = null) {
const records = autoReferenceRecords(config);
if (!records.length) return -Infinity;
const current = { ...state.pose };
applyPose(pose);
applyPose(pose, { scheduleMapping: false });
const modelBox = visibleModelBox(records);
applyPose(current);
applyPose(current, { scheduleMapping: false });
if (!modelBox) return -Infinity;
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
const overlapSize = new THREE.Vector3();
@@ -2601,7 +2796,12 @@ function candidateScore(pose, settings = null) {
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
const scalePenalty = Math.abs((pose.scale || 1) - 1) * (0.03 + Number(config.scalePenalty || 0));
const movePenalty = (Math.abs(pose.translateX || 0) + Math.abs(pose.translateY || 0) + Math.abs(pose.translateZ || 0)) * Number(config.movePenalty || 0);
return (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01;
const geometryScore = (overlapVolume / modelVolume) * Number(config.boneReward || 1) - centerPenalty - scalePenalty - movePenalty - Number(config.outsidePenalty || 0) * 0.01;
if (config.imageContext) {
const imageScore = scorePoseAgainstBoneImages(pose, config.imageContext, config);
if (Number.isFinite(imageScore)) return imageScore + geometryScore * 0.12;
}
return geometryScore;
}
function estimateCoarsePose(settings) {
@@ -2626,9 +2826,9 @@ function estimateCoarsePose(settings) {
}
}
const previous = { ...state.pose };
applyPose(candidate);
applyPose(candidate, { scheduleMapping: false });
const nextBox = visibleModelBox(records);
applyPose(previous);
applyPose(previous, { scheduleMapping: false });
if (!nextBox) return candidate;
const modelCenter = new THREE.Vector3();
nextBox.getCenter(modelCenter);
@@ -2640,6 +2840,7 @@ function estimateCoarsePose(settings) {
}
function runAutoCoarse() {
if (state.poseControlsLocked) return;
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
@@ -2668,23 +2869,35 @@ function runAutoCoarse() {
markDirty();
}
async function runAutoFine() {
async function runAutoFine(options = {}) {
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
if (state.autoFineRunning) return;
if (state.autoFineRunning && !options.lockAlreadyHeld) return;
const before = { ...state.pose };
const settings = readAutoSettings();
const settings = { ...readAutoSettings(), basePose: before };
const adjustable = settings.adjustable;
const lockAlreadyHeld = Boolean(options.lockAlreadyHeld);
state.autoFineRunning = true;
setAutoControlsBusy(true);
if (!lockAlreadyHeld) setPoseEditingLocked(true);
setAutoState("运行中", "warn");
setAutoProgress(true, 2, "2%");
if (state.windowMode !== "bone") setWindowMode("bone", true);
$("autoResult").textContent = "已切换骨窗显示,正在按参考 STL 与 DICOM 三维盒体重叠做微调...";
setAutoProgress(true, options.fromSaveIterate ? 12 : 4, "锁定位姿参数", "自动微调运行中位姿输入、XYZ 拉伸和迭代按钮已临时锁定。", options.fromSaveIterate ? "save" : "bone");
$("autoResult").textContent = "正在准备自动微调:锁定位姿控件并切换骨窗采样。";
await waitForUiFrame();
try {
setAutoProgress(true, 14, "加载骨窗参考", "读取骨窗 DICOM 切片范围,并重建三维参考显示。", "bone");
if (state.windowMode !== "bone") await setWindowMode("bone", true);
await waitForUiFrame();
setAutoProgress(true, 22, "采样参考 STL", "正在从勾选的参考 STL 和骨窗切片中构建评分样本。", "sample");
settings.imageContext = await prepareAutoFineImageContext(settings);
const imageContextText = settings.imageContext
? `已采样 ${settings.imageContext.samples.length} 个 STL 点、${settings.imageContext.frames.length} 张骨窗切片。`
: "未能建立骨窗像素样本,将退回 DICOM/STL 三维盒体评分。";
setAutoProgress(true, 38, "建立候选队列", imageContextText, "score");
await waitForUiFrame();
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
let evaluated = 1;
const coarsePose = estimateCoarsePose(settings);
@@ -2699,24 +2912,51 @@ async function runAutoFine() {
});
for (const [index, round] of rounds.entries()) {
const candidates = [];
if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]);
if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]);
if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]);
if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]);
for (const [key, delta] of candidates.slice(0, settings.candidates)) {
const baseValue = Number(best.pose[key] ?? DEFAULT_POSE[key]);
const enabledKeys = [];
if (adjustable.translateX) enabledKeys.push("translateX");
if (adjustable.translateY) enabledKeys.push("translateY");
if (adjustable.translateZ) enabledKeys.push("translateZ");
if (adjustable.scale) enabledKeys.push("scale");
const stepForKey = (key, ratio = 1) => (key === "scale" ? round.scale : round.move) * ratio;
const poseWithDelta = (sourcePose, key, delta) => {
const baseValue = Number(sourcePose[key] ?? DEFAULT_POSE[key]);
const nextValue = key === "scale" ? Math.max(0.2, Math.min(3, baseValue + delta)) : baseValue + delta;
const pose = { ...best.pose, [key]: Number(nextValue.toFixed(key === "scale" ? 3 : 4)) };
return { ...sourcePose, [key]: Number(nextValue.toFixed(key === "scale" ? 3 : 4)) };
};
enabledKeys.forEach((key) => {
candidates.push({ pose: poseWithDelta(best.pose, key, stepForKey(key)), mode: `${key} +` });
candidates.push({ pose: poseWithDelta(best.pose, key, -stepForKey(key)), mode: `${key} -` });
});
for (let left = 0; left < enabledKeys.length; left += 1) {
for (let right = left + 1; right < enabledKeys.length; right += 1) {
[-1, 1].forEach((leftSign) => {
[-1, 1].forEach((rightSign) => {
const leftKey = enabledKeys[left];
const rightKey = enabledKeys[right];
const pose = poseWithDelta(
poseWithDelta(best.pose, leftKey, stepForKey(leftKey, 0.65) * leftSign),
rightKey,
stepForKey(rightKey, 0.65) * rightSign,
);
candidates.push({ pose, mode: `${leftKey}/${rightKey} 联合` });
});
});
}
}
for (const candidate of candidates.slice(0, settings.candidates)) {
const pose = candidate.pose;
const score = candidateScore(pose, settings);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
if (score > best.score) best = { pose, score, mode: candidate.mode };
}
const progress = 8 + ((index + 1) / Math.max(rounds.length, 1)) * 88;
setAutoProgress(true, progress, `${Math.round(progress)}%`);
const progress = 40 + ((index + 1) / Math.max(rounds.length, 1)) * 48;
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
setAutoProgress(true, progress, `候选评分 ${index + 1}/${rounds.length}`, `本轮评估 ${candidates.length} 个候选,累计 ${evaluated} 个;当前最佳 ${best.mode}score ${scoreText}`, "score");
await waitForUiFrame();
}
setAutoProgress(true, 94, "应用最佳位姿", "正在把本轮最高分候选写回位姿控件和融合视图。", "apply");
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated, settings: { ...settings, scoring: "reference-stl-box-overlap", window: "bone-display" } };
state.autoResult = { ...best, evaluated, settings: { ...autoSettingsForStorage(settings), scoring: settings.imageContext ? "bone-window-stl-samples" : "reference-stl-box-overlap", window: "bone" } };
state.lastAutoPose = { before, after: { ...state.pose } };
state.autoDeltaBaseline = { ...before };
renderPoseControls();
@@ -2726,10 +2966,10 @@ async function runAutoFine() {
setAutoState(changed ? "微调完成" : "微调无变化", changed ? "ok" : "warn");
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
$("autoResult").textContent = changed
? `最佳候选:${best.mode}score ${scoreText};评估 ${evaluated} 个候选;骨窗显示 + 参考 STL 盒体评分。`
: `已完成评估但最佳仍为初始位姿score ${scoreText};评估 ${evaluated} 个候选。当前微调使用骨窗显示和参考 STL 盒体评分,若图像上仍错位,需要先粗配准或手动调整旋转后再微调。`;
? `最佳候选:${best.mode}score ${scoreText};评估 ${evaluated} 个候选;${settings.imageContext ? "骨窗像素采样 + STL 参考评分" : "参考 STL 盒体评分"}`
: `已完成评估但最佳仍为初始位姿score ${scoreText};评估 ${evaluated} 个候选。说明本轮候选没有比当前位姿更高分;若图像上仍错位,通常需要先调整大角度旋转或粗配准后再微调。`;
updateAutoPoseResult(before, state.pose);
setAutoProgress(true, 100, "100%");
setAutoProgress(true, 100, changed ? "微调完成" : "未找到更优候选", changed ? `已应用 ${best.mode}` : "候选均未超过当前位姿,因此没有强行移动模型。", "apply");
markDirty();
} catch (error) {
setAutoState("微调失败", "error");
@@ -2737,10 +2977,27 @@ async function runAutoFine() {
setAutoProgress(false);
} finally {
state.autoFineRunning = false;
setAutoControlsBusy(false);
setPoseEditingLocked(false);
}
}
async function saveAndIteratePose() {
if (state.autoFineRunning || state.poseControlsLocked) return;
state.autoFineRunning = true;
setPoseEditingLocked(true);
setAutoState("保存中", "warn");
setAutoProgress(true, 4, "保存当前位姿", "先保存当前人工位姿,并以保存后的位姿作为本轮自动微调起点。", "save");
const saved = await saveRegistration();
if (!saved) {
state.autoFineRunning = false;
setPoseEditingLocked(false);
setAutoProgress(false);
return;
}
state.autoFineRunning = false;
await runAutoFine({ lockAlreadyHeld: true, fromSaveIterate: true });
}
async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return false;
state.saving = true;
@@ -2813,8 +3070,12 @@ async function saveRegistration(nextStatus = null) {
return false;
} finally {
state.saving = false;
$("saveBtn").disabled = false;
$("statusBtn").disabled = false;
if (state.poseControlsLocked) {
applyPoseLockState();
} else {
$("saveBtn").disabled = false;
$("statusBtn").disabled = false;
}
}
}
@@ -2853,7 +3114,7 @@ function setWindowMode(mode, reload = true) {
state.windowMode = mode || "default";
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode));
document.querySelectorAll("[data-map-window]").forEach((item) => item.classList.toggle("active", item.dataset.mapWindow === state.windowMode));
if (reload) loadFusion();
return reload ? loadFusion() : Promise.resolve();
}
function setMappingMode(mode) {
@@ -2908,12 +3169,7 @@ function wireEvents() {
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine);
$("savePoseBtn").addEventListener("click", () => saveRegistration());
$("saveIterateBtn").addEventListener("click", async () => {
const saved = await saveRegistration();
if (!saved) return;
await runAutoFine();
resetAutoDeltaBaseline();
});
$("saveIterateBtn").addEventListener("click", saveAndIteratePose);
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
const syncSampleSlices = (source) => {
const range = $("autoSampleSlices");

View File

@@ -257,8 +257,13 @@
</div>
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
<div id="autoProgress" class="auto-progress hidden">
<div><i id="autoProgressFill"></i></div>
<span id="autoProgressText">0%</span>
<div class="auto-progress-head">
<b id="autoProgressTitle">准备迭代</b>
<span id="autoProgressText">0%</span>
</div>
<div class="auto-progress-track"><i id="autoProgressFill"></i></div>
<div id="autoProgressSteps" class="auto-progress-steps"></div>
<p id="autoProgressDetail">等待自动微调任务。</p>
</div>
</div>
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>

View File

@@ -1874,24 +1874,39 @@ input[type="range"] {
.auto-progress {
display: grid;
grid-template-columns: minmax(0, 1fr) 42px;
align-items: center;
gap: 10px;
min-height: 28px;
border: 1px solid rgba(59, 130, 246, 0.3);
gap: 8px;
min-height: 92px;
border: 1px solid rgba(59, 130, 246, 0.34);
border-radius: 8px;
background: rgba(7, 10, 15, 0.72);
padding: 7px 8px;
padding: 9px 10px;
}
.auto-progress div {
.auto-progress-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.auto-progress-head b {
min-width: 0;
overflow: hidden;
color: #dbeafe;
font-size: 12px;
font-weight: 950;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-progress-track {
height: 7px;
overflow: hidden;
border-radius: 99px;
background: rgba(148, 163, 184, 0.18);
}
.auto-progress i {
.auto-progress-track i {
display: block;
width: 0%;
height: 100%;
@@ -1900,7 +1915,7 @@ input[type="range"] {
transition: width 160ms ease;
}
.auto-progress span {
.auto-progress-head span {
color: #c7d2fe;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
@@ -1908,6 +1923,60 @@ input[type="range"] {
text-align: right;
}
.auto-progress-steps {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 4px;
}
.auto-progress-steps span {
min-width: 0;
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 7px;
background: rgba(15, 23, 42, 0.62);
color: #8ea3bb;
font-size: 10px;
font-weight: 900;
line-height: 1.2;
padding: 5px 3px;
text-align: center;
}
.auto-progress-steps span.active {
border-color: rgba(34, 211, 238, 0.68);
background: rgba(8, 145, 178, 0.2);
color: #cffafe;
}
.auto-progress-steps span.done {
border-color: rgba(52, 211, 153, 0.42);
background: rgba(20, 184, 166, 0.12);
color: #a7f3d0;
}
.auto-progress p {
margin: 0;
color: #9fb3c8;
font-size: 11px;
line-height: 1.45;
}
.pose-control.locked,
.flip-row.locked,
.pose-actions.locked,
.auto-settings-panel.locked {
opacity: 0.58;
}
.pose-control button:disabled,
.pose-control input:disabled,
.pose-actions button:disabled,
.flip-row button:disabled,
.auto-box button:disabled,
.auto-box input:disabled {
cursor: not-allowed;
}
.compact-title {
min-height: 28px;
margin-top: 4px;