Improve registration iteration feedback
This commit is contained in:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user