Improve auto pose refinement controls
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user