Improve auto pose refinement controls

This commit is contained in:
Codex
2026-05-30 10:52:11 +08:00
parent bdd0db2a4c
commit c1fbdd8bac
3 changed files with 179 additions and 47 deletions

View File

@@ -99,6 +99,8 @@ const state = {
dicomPreviewTimer: 0,
autoResult: null,
lastAutoPose: null,
autoDeltaBaseline: null,
autoFineRunning: false,
dirty: false,
dirtyReason: "",
savedPoseSignature: "",
@@ -223,6 +225,7 @@ function markDirty(reason = "pose") {
state.dirty = true;
state.dirtyReason = reason;
setSaveState("未保存", "warn");
if (reason === "pose") updateAutoPoseResult();
}
function poseSignature(pose = state.pose) {
@@ -243,6 +246,7 @@ function resetDirty() {
state.dirtyReason = "";
state.savedPoseSignature = poseSignature(state.pose);
setSaveState("已保存", "ok");
resetAutoDeltaBaseline();
}
async function api(path, options = {}) {
@@ -530,8 +534,7 @@ function modelPoseMatrix() {
return state.modelGroup.matrix.clone();
}
function visibleModelBox() {
const records = visibleModelRecords();
function visibleModelBox(records = visibleModelRecords()) {
if (!records.length) return null;
const box = new THREE.Box3();
const matrix = modelPoseMatrix();
@@ -543,6 +546,14 @@ function visibleModelBox() {
return box;
}
function autoReferenceRecords(settings = null) {
const visibleRecords = visibleModelRecords();
const referenceIds = new Set((settings?.referenceIds || []).map((id) => Number(id)));
if (!referenceIds.size) return visibleRecords;
const referenceRecords = visibleRecords.filter((record) => referenceIds.has(Number(record.file?.id)));
return referenceRecords.length ? referenceRecords : visibleRecords;
}
function bodyPartTags(row) {
const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : [];
const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : [];
@@ -637,6 +648,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "ultra";
state.autoResult = null;
state.lastAutoPose = null;
state.autoDeltaBaseline = null;
state.sliceIndex = 0;
$("notes").value = registration.notes || "";
@@ -2460,6 +2472,9 @@ function readAutoSettings() {
const value = Number($(id)?.value);
return Number.isFinite(value) ? value : fallback;
};
const referenceIds = [...document.querySelectorAll("#autoBoneOptions input:checked")]
.map((input) => Number(input.value))
.filter(Number.isFinite);
return {
adjustable: {
translateX: $("autoX")?.checked,
@@ -2467,6 +2482,7 @@ function readAutoSettings() {
translateZ: $("autoZ")?.checked,
scale: $("autoScale")?.checked,
},
referenceIds,
sampleSlices: Math.max(3, Math.min(60, Math.round(numericValue("autoSampleSlicesNumber", 9)))),
boneReward: numericValue("autoBoneReward", 1),
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
@@ -2479,6 +2495,9 @@ function readAutoSettings() {
function poseDelta(from, to) {
return {
rotateX: Number(((to.rotateX || 0) - (from.rotateX || 0)).toFixed(3)),
rotateY: Number(((to.rotateY || 0) - (from.rotateY || 0)).toFixed(3)),
rotateZ: Number(((to.rotateZ || 0) - (from.rotateZ || 0)).toFixed(3)),
translateX: Number(((to.translateX || 0) - (from.translateX || 0)).toFixed(3)),
translateY: Number(((to.translateY || 0) - (from.translateY || 0)).toFixed(3)),
translateZ: Number(((to.translateZ || 0) - (from.translateZ || 0)).toFixed(3)),
@@ -2502,11 +2521,14 @@ function autoPoseResultItem(label, deltaText, currentText, changed = true) {
`;
}
function updateAutoPoseResult(before = state.lastAutoPose?.before || state.pose, after = state.pose) {
function updateAutoPoseResult(before = state.autoDeltaBaseline || state.lastAutoPose?.before || state.pose, after = state.pose) {
const delta = poseDelta(before, after);
const el = $("autoPoseResult");
if (!el) return;
el.innerHTML = [
autoPoseResultItem("旋转 X", `Δ ${signedValue(delta.rotateX, 1)}°`, `当前 ${Number(after.rotateX || 0).toFixed(1)}°`, Math.abs(delta.rotateX) > 0.0005),
autoPoseResultItem("旋转 Y", `Δ ${signedValue(delta.rotateY, 1)}°`, `当前 ${Number(after.rotateY || 0).toFixed(1)}°`, Math.abs(delta.rotateY) > 0.0005),
autoPoseResultItem("旋转 Z", `Δ ${signedValue(delta.rotateZ, 1)}°`, `当前 ${Number(after.rotateZ || 0).toFixed(1)}°`, Math.abs(delta.rotateZ) > 0.0005),
autoPoseResultItem("平移 X", `Δ ${signedValue(delta.translateX)}`, `当前 ${Number(after.translateX || 0).toFixed(3)}`, Math.abs(delta.translateX) > 0.0005),
autoPoseResultItem("平移 Y", `Δ ${signedValue(delta.translateY)}`, `当前 ${Number(after.translateY || 0).toFixed(3)}`, Math.abs(delta.translateY) > 0.0005),
autoPoseResultItem("平移 Z", `Δ ${signedValue(delta.translateZ)}`, `当前 ${Number(after.translateZ || 0).toFixed(3)}`, Math.abs(delta.translateZ) > 0.0005),
@@ -2514,6 +2536,31 @@ function updateAutoPoseResult(before = state.lastAutoPose?.before || state.pose,
].join("");
}
function resetAutoDeltaBaseline() {
state.autoDeltaBaseline = { ...state.pose };
updateAutoPoseResult(state.pose, state.pose);
}
function setAutoProgress(visible, value = 0, text = "") {
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;
});
}
function waitForUiFrame() {
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
}
function restoreAutoPose() {
if (!state.lastAutoPose?.before) {
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
@@ -2523,7 +2570,7 @@ function restoreAutoPose() {
renderPoseControls();
applyPose();
fitCamera();
updateAutoPoseResult(state.lastAutoPose.after, state.pose);
resetAutoDeltaBaseline();
$("autoResult").textContent = "已退回到自动调整前的位姿。";
markDirty();
}
@@ -2531,9 +2578,11 @@ function restoreAutoPose() {
function candidateScore(pose, settings = null) {
if (!state.dicomSceneBox || !state.modelGroup || !visibleModelRecords().length) return -Infinity;
const config = settings || readAutoSettings();
const records = autoReferenceRecords(config);
if (!records.length) return -Infinity;
const current = { ...state.pose };
applyPose(pose);
const modelBox = visibleModelBox();
const modelBox = visibleModelBox(records);
applyPose(current);
if (!modelBox) return -Infinity;
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
@@ -2556,7 +2605,8 @@ function candidateScore(pose, settings = null) {
}
function estimateCoarsePose(settings) {
const modelBox = visibleModelBox();
const records = autoReferenceRecords(settings);
const modelBox = visibleModelBox(records);
if (!modelBox || !state.dicomSceneBox) return null;
const adjustable = settings.adjustable || {};
const dicomSize = new THREE.Vector3();
@@ -2577,7 +2627,7 @@ function estimateCoarsePose(settings) {
}
const previous = { ...state.pose };
applyPose(candidate);
const nextBox = visibleModelBox();
const nextBox = visibleModelBox(records);
applyPose(previous);
if (!nextBox) return candidate;
const modelCenter = new THREE.Vector3();
@@ -2594,6 +2644,8 @@ function runAutoCoarse() {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
if (state.autoFineRunning) return;
setAutoProgress(false);
const before = { ...state.pose };
const settings = readAutoSettings();
const estimated = estimateCoarsePose(settings);
@@ -2608,6 +2660,7 @@ function runAutoCoarse() {
fitCamera();
state.autoResult = { score: candidateScore(state.pose, settings), pose: { ...state.pose }, evaluated: 1, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
state.autoDeltaBaseline = { ...before };
setAutoState("粗配准完成", "ok");
const delta = poseDelta(before, state.pose);
$("autoResult").textContent = `已按 DICOM/STL 盒体估算中心与缩放ΔX ${signedValue(delta.translateX)}ΔY ${signedValue(delta.translateY)}ΔZ ${signedValue(delta.translateZ)},缩放 ×${delta.scale.toFixed(3)}score ${state.autoResult.score.toFixed(4)}`;
@@ -2615,49 +2668,81 @@ function runAutoCoarse() {
markDirty();
}
function runAutoFine() {
async function runAutoFine() {
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
setAutoState("运行中", "warn");
$("autoResult").textContent = "正在进行几何重叠微调...";
if (state.autoFineRunning) return;
const before = { ...state.pose };
const settings = readAutoSettings();
const adjustable = settings.adjustable;
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
let evaluated = 1;
const rounds = Array.from({ length: settings.iterations }, (_, index) => {
const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1));
return { move: 0.34 * t, scale: 0.12 * t };
});
for (const round of rounds) {
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 pose = { ...best.pose, [key]: key === "scale" ? Math.max(0.2, Math.min(3, best.pose.scale + delta)) : best.pose[key] + delta };
const score = candidateScore(pose, settings);
state.autoFineRunning = true;
setAutoControlsBusy(true);
setAutoState("运行中", "warn");
setAutoProgress(true, 2, "2%");
if (state.windowMode !== "bone") setWindowMode("bone", true);
$("autoResult").textContent = "已切换骨窗显示,正在按参考 STL 与 DICOM 三维盒体重叠做微调...";
await waitForUiFrame();
try {
let best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
let evaluated = 1;
const coarsePose = estimateCoarsePose(settings);
if (coarsePose) {
const coarseScore = candidateScore(coarsePose, settings);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
if (coarseScore > best.score) best = { pose: coarsePose, score: coarseScore, mode: "粗配准种子" };
}
const rounds = Array.from({ length: settings.iterations }, (_, index) => {
const t = Math.max(0.18, 1 - index / Math.max(settings.iterations, 1));
return { move: 0.34 * t, scale: 0.12 * t };
});
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 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)) };
const score = candidateScore(pose, settings);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
}
const progress = 8 + ((index + 1) / Math.max(rounds.length, 1)) * 88;
setAutoProgress(true, progress, `${Math.round(progress)}%`);
await waitForUiFrame();
}
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated, settings: { ...settings, scoring: "reference-stl-box-overlap", window: "bone-display" } };
state.lastAutoPose = { before, after: { ...state.pose } };
state.autoDeltaBaseline = { ...before };
renderPoseControls();
applyPose();
fitCamera();
const changed = poseSignature(best.pose) !== poseSignature(before);
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 盒体评分,若图像上仍错位,需要先粗配准或手动调整旋转后再微调。`;
updateAutoPoseResult(before, state.pose);
setAutoProgress(true, 100, "100%");
markDirty();
} catch (error) {
setAutoState("微调失败", "error");
$("autoResult").textContent = error.message || "自动微调失败。";
setAutoProgress(false);
} finally {
state.autoFineRunning = false;
setAutoControlsBusy(false);
}
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
renderPoseControls();
applyPose();
fitCamera();
setAutoState("微调完成", "ok");
$("autoResult").textContent = `最佳候选:${best.mode}score ${best.score.toFixed(4)};评估 ${evaluated} 个候选;采样 ${settings.sampleSlices} 张。`;
updateAutoPoseResult(before, state.pose);
markDirty();
}
async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return;
if (!state.activeCase || !currentSeries() || state.saving) return false;
state.saving = true;
$("saveBtn").disabled = true;
$("statusBtn").disabled = true;
@@ -2722,8 +2807,10 @@ async function saveRegistration(nextStatus = null) {
renderCases();
renderSeries();
renderActiveHeader();
return true;
} catch (error) {
setSaveState(error.message, "error");
return false;
} finally {
state.saving = false;
$("saveBtn").disabled = false;
@@ -2822,8 +2909,10 @@ function wireEvents() {
$("autoFineBtn").addEventListener("click", runAutoFine);
$("savePoseBtn").addEventListener("click", () => saveRegistration());
$("saveIterateBtn").addEventListener("click", async () => {
await saveRegistration();
runAutoFine();
const saved = await saveRegistration();
if (!saved) return;
await runAutoFine();
resetAutoDeltaBaseline();
});
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
const syncSampleSlices = (source) => {

View File

@@ -236,23 +236,30 @@
<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">
<div class="pose-actions modal-actions auto-run-actions">
<button id="autoCoarseBtn" type="button">自动粗配准</button>
<button id="autoFineBtn" type="button">自动微调</button>
</div>
<div class="pose-actions modal-actions save-pose-actions">
<button id="savePoseBtn" type="button">保存位姿</button>
<button id="saveIterateBtn" type="button">保存并迭代</button>
<button id="restorePoseBtn" type="button">退回位姿</button>
</div>
<div id="autoPoseResult" class="auto-pose-result">
<span>旋转 X 0</span>
<span>旋转 Y 0</span>
<span>旋转 Z 0</span>
<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 id="autoProgress" class="auto-progress hidden">
<div><i id="autoProgressFill"></i></div>
<span id="autoProgressText">0%</span>
</div>
</div>
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
</section>

View File

@@ -1872,6 +1872,42 @@ input[type="range"] {
padding: 8px;
}
.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);
border-radius: 8px;
background: rgba(7, 10, 15, 0.72);
padding: 7px 8px;
}
.auto-progress div {
height: 7px;
overflow: hidden;
border-radius: 99px;
background: rgba(148, 163, 184, 0.18);
}
.auto-progress i {
display: block;
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #22d3ee, #3b82f6);
transition: width 160ms ease;
}
.auto-progress span {
color: #c7d2fe;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
font-weight: 950;
text-align: right;
}
.compact-title {
min-height: 28px;
margin-top: 4px;
@@ -2076,7 +2112,7 @@ input[type="range"] {
}
.tool-pane .auto-pose-result {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.compact-empty {
@@ -2084,7 +2120,7 @@ input[type="range"] {
}
.auto-pose-result span {
min-height: 52px;
min-height: 50px;
display: grid;
grid-template-rows: auto auto auto;
align-items: center;