Improve registration iteration feedback
This commit is contained in:
@@ -58,6 +58,13 @@ const POSE_SIGNATURE_KEYS = [
|
|||||||
"flipY",
|
"flipY",
|
||||||
"flipZ",
|
"flipZ",
|
||||||
];
|
];
|
||||||
|
const AUTO_PROGRESS_STEPS = [
|
||||||
|
{ id: "save", label: "保存" },
|
||||||
|
{ id: "bone", label: "骨窗" },
|
||||||
|
{ id: "sample", label: "采样" },
|
||||||
|
{ id: "score", label: "评分" },
|
||||||
|
{ id: "apply", label: "应用" },
|
||||||
|
];
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
token: localStorage.getItem("dicom_upp_registration_token") || "",
|
token: localStorage.getItem("dicom_upp_registration_token") || "",
|
||||||
@@ -101,6 +108,7 @@ const state = {
|
|||||||
lastAutoPose: null,
|
lastAutoPose: null,
|
||||||
autoDeltaBaseline: null,
|
autoDeltaBaseline: null,
|
||||||
autoFineRunning: false,
|
autoFineRunning: false,
|
||||||
|
poseControlsLocked: false,
|
||||||
dirty: false,
|
dirty: false,
|
||||||
dirtyReason: "",
|
dirtyReason: "",
|
||||||
savedPoseSignature: "",
|
savedPoseSignature: "",
|
||||||
@@ -967,6 +975,7 @@ function buildPoseControls() {
|
|||||||
const number = row.querySelector("input[type='number']");
|
const number = row.querySelector("input[type='number']");
|
||||||
const option = POSE_CONTROLS.find((item) => item.key === key);
|
const option = POSE_CONTROLS.find((item) => item.key === key);
|
||||||
const sync = (value, source) => {
|
const sync = (value, source) => {
|
||||||
|
if (state.poseControlsLocked) return;
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
state.pose[key] = Number.isFinite(numeric) ? Number(numeric.toFixed(option?.decimals ?? 3)) : 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 !== range) range.value = state.pose[key];
|
||||||
@@ -980,6 +989,7 @@ function buildPoseControls() {
|
|||||||
document.querySelectorAll("[data-nudge]").forEach((button) => wireHoldNudge(button));
|
document.querySelectorAll("[data-nudge]").forEach((button) => wireHoldNudge(button));
|
||||||
document.querySelectorAll("[data-flip]").forEach((button) => {
|
document.querySelectorAll("[data-flip]").forEach((button) => {
|
||||||
button.addEventListener("click", () => {
|
button.addEventListener("click", () => {
|
||||||
|
if (state.poseControlsLocked) return;
|
||||||
const key = button.dataset.flip;
|
const key = button.dataset.flip;
|
||||||
state.pose[key] = !state.pose[key];
|
state.pose[key] = !state.pose[key];
|
||||||
renderPoseControls();
|
renderPoseControls();
|
||||||
@@ -988,6 +998,7 @@ function buildPoseControls() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
updateAutoPoseResult(state.pose, state.pose);
|
updateAutoPoseResult(state.pose, state.pose);
|
||||||
|
applyPoseLockState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function wireHoldNudge(button) {
|
function wireHoldNudge(button) {
|
||||||
@@ -997,6 +1008,7 @@ function wireHoldNudge(button) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const start = (event) => {
|
const start = (event) => {
|
||||||
|
if (state.poseControlsLocked || button.disabled) return;
|
||||||
if (event.button !== undefined && event.button !== 0) return;
|
if (event.button !== undefined && event.button !== 0) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const key = button.dataset.nudge;
|
const key = button.dataset.nudge;
|
||||||
@@ -1028,6 +1040,7 @@ function clearNudgeTimers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function nudgePose(key, delta) {
|
function nudgePose(key, delta) {
|
||||||
|
if (state.poseControlsLocked) return;
|
||||||
if (!key || !Number.isFinite(delta)) return;
|
if (!key || !Number.isFinite(delta)) return;
|
||||||
const option = POSE_CONTROLS.find((item) => item.key === key);
|
const option = POSE_CONTROLS.find((item) => item.key === key);
|
||||||
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
|
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
|
||||||
@@ -1051,6 +1064,7 @@ function renderPoseControls() {
|
|||||||
});
|
});
|
||||||
renderActiveHeader();
|
renderActiveHeader();
|
||||||
updateStretchButtons();
|
updateStretchButtons();
|
||||||
|
applyPoseLockState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRightAngleRotation(value) {
|
function isRightAngleRotation(value) {
|
||||||
@@ -1063,17 +1077,55 @@ function canStretchByAxis() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateStretchButtons() {
|
function updateStretchButtons() {
|
||||||
const enabled = canStretchByAxis();
|
const enabled = canStretchByAxis() && !state.poseControlsLocked;
|
||||||
["x", "y", "z"].forEach((axis) => {
|
["x", "y", "z"].forEach((axis) => {
|
||||||
const button = $(`stretch${axis.toUpperCase()}Btn`);
|
const button = $(`stretch${axis.toUpperCase()}Btn`);
|
||||||
if (!button) return;
|
if (!button) return;
|
||||||
button.disabled = !enabled;
|
button.disabled = !enabled;
|
||||||
button.title = enabled
|
button.title = enabled
|
||||||
? `按 ${axis.toUpperCase()} 方向自动等比例拉伸模型`
|
? `按 ${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() {
|
function updateSliceControl() {
|
||||||
const selected = currentSeries();
|
const selected = currentSeries();
|
||||||
const count = Number(selected?.count || 0);
|
const count = Number(selected?.count || 0);
|
||||||
@@ -2364,7 +2416,7 @@ async function buildStlModels(volume, onProgress = null) {
|
|||||||
onProgress?.(100);
|
onProgress?.(100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyPose(poseInput = state.pose) {
|
function applyPose(poseInput = state.pose, options = {}) {
|
||||||
if (!state.modelGroup) return;
|
if (!state.modelGroup) return;
|
||||||
const pose = { ...DEFAULT_POSE, ...poseInput };
|
const pose = { ...DEFAULT_POSE, ...poseInput };
|
||||||
state.modelGroup.rotation.set(
|
state.modelGroup.rotation.set(
|
||||||
@@ -2380,7 +2432,7 @@ function applyPose(poseInput = state.pose) {
|
|||||||
scale * (pose.flipZ ? -1 : 1),
|
scale * (pose.flipZ ? -1 : 1),
|
||||||
);
|
);
|
||||||
state.modelGroup.updateMatrixWorld(true);
|
state.modelGroup.updateMatrixWorld(true);
|
||||||
scheduleMappingDraw();
|
if (options.scheduleMapping !== false) scheduleMappingDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
function fitCamera() {
|
function fitCamera() {
|
||||||
@@ -2402,6 +2454,7 @@ function resetSceneView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetPose(kind) {
|
function resetPose(kind) {
|
||||||
|
if (state.poseControlsLocked) return;
|
||||||
if (kind === "rotation") {
|
if (kind === "rotation") {
|
||||||
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
|
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
|
||||||
} else if (kind === "transform") {
|
} else if (kind === "transform") {
|
||||||
@@ -2417,6 +2470,10 @@ function resetPose(kind) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stretchAxis(axis) {
|
function stretchAxis(axis) {
|
||||||
|
if (state.poseControlsLocked) {
|
||||||
|
$("autoResult").textContent = "自动迭代运行中,暂不可调整位姿。";
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!canStretchByAxis()) {
|
if (!canStretchByAxis()) {
|
||||||
const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。";
|
const message = "仅当旋转 X/Y/Z 均为 0 或 90° 的倍数时可使用 XYZ 拉伸。";
|
||||||
setAutoState("XYZ 拉伸不可用", "warn");
|
setAutoState("XYZ 拉伸不可用", "warn");
|
||||||
@@ -2541,27 +2598,165 @@ function resetAutoDeltaBaseline() {
|
|||||||
updateAutoPoseResult(state.pose, state.pose);
|
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");
|
const wrap = $("autoProgress");
|
||||||
if (!wrap) return;
|
if (!wrap) return;
|
||||||
wrap.classList.toggle("hidden", !visible);
|
wrap.classList.toggle("hidden", !visible);
|
||||||
const safeValue = Math.max(0, Math.min(100, Number(value) || 0));
|
const safeValue = Math.max(0, Math.min(100, Number(value) || 0));
|
||||||
$("autoProgressFill").style.width = `${safeValue}%`;
|
$("autoProgressFill").style.width = `${safeValue}%`;
|
||||||
$("autoProgressText").textContent = text || `${Math.round(safeValue)}%`;
|
$("autoProgressText").textContent = `${Math.round(safeValue)}%`;
|
||||||
}
|
$("autoProgressTitle").textContent = title || "自动微调处理中";
|
||||||
|
$("autoProgressDetail").textContent = detail || "正在准备自动微调任务。";
|
||||||
function setAutoControlsBusy(busy) {
|
renderAutoProgressSteps(activeStep);
|
||||||
["autoCoarseBtn", "autoFineBtn", "saveIterateBtn"].forEach((id) => {
|
|
||||||
const button = $(id);
|
|
||||||
if (button) button.disabled = busy;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function waitForUiFrame() {
|
function waitForUiFrame() {
|
||||||
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()));
|
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() {
|
function restoreAutoPose() {
|
||||||
|
if (state.poseControlsLocked) return;
|
||||||
if (!state.lastAutoPose?.before) {
|
if (!state.lastAutoPose?.before) {
|
||||||
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
|
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
|
||||||
return;
|
return;
|
||||||
@@ -2581,9 +2776,9 @@ function candidateScore(pose, settings = null) {
|
|||||||
const records = autoReferenceRecords(config);
|
const records = autoReferenceRecords(config);
|
||||||
if (!records.length) return -Infinity;
|
if (!records.length) return -Infinity;
|
||||||
const current = { ...state.pose };
|
const current = { ...state.pose };
|
||||||
applyPose(pose);
|
applyPose(pose, { scheduleMapping: false });
|
||||||
const modelBox = visibleModelBox(records);
|
const modelBox = visibleModelBox(records);
|
||||||
applyPose(current);
|
applyPose(current, { scheduleMapping: false });
|
||||||
if (!modelBox) return -Infinity;
|
if (!modelBox) return -Infinity;
|
||||||
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
|
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
|
||||||
const overlapSize = new THREE.Vector3();
|
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 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 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);
|
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) {
|
function estimateCoarsePose(settings) {
|
||||||
@@ -2626,9 +2826,9 @@ function estimateCoarsePose(settings) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const previous = { ...state.pose };
|
const previous = { ...state.pose };
|
||||||
applyPose(candidate);
|
applyPose(candidate, { scheduleMapping: false });
|
||||||
const nextBox = visibleModelBox(records);
|
const nextBox = visibleModelBox(records);
|
||||||
applyPose(previous);
|
applyPose(previous, { scheduleMapping: false });
|
||||||
if (!nextBox) return candidate;
|
if (!nextBox) return candidate;
|
||||||
const modelCenter = new THREE.Vector3();
|
const modelCenter = new THREE.Vector3();
|
||||||
nextBox.getCenter(modelCenter);
|
nextBox.getCenter(modelCenter);
|
||||||
@@ -2640,6 +2840,7 @@ function estimateCoarsePose(settings) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function runAutoCoarse() {
|
function runAutoCoarse() {
|
||||||
|
if (state.poseControlsLocked) return;
|
||||||
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
|
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
|
||||||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||||
return;
|
return;
|
||||||
@@ -2668,23 +2869,35 @@ function runAutoCoarse() {
|
|||||||
markDirty();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAutoFine() {
|
async function runAutoFine(options = {}) {
|
||||||
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
|
if (!state.activeCase || !currentSeries() || !visibleModelRecords().length) {
|
||||||
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.autoFineRunning) return;
|
if (state.autoFineRunning && !options.lockAlreadyHeld) return;
|
||||||
const before = { ...state.pose };
|
const before = { ...state.pose };
|
||||||
const settings = readAutoSettings();
|
const settings = { ...readAutoSettings(), basePose: before };
|
||||||
const adjustable = settings.adjustable;
|
const adjustable = settings.adjustable;
|
||||||
|
const lockAlreadyHeld = Boolean(options.lockAlreadyHeld);
|
||||||
state.autoFineRunning = true;
|
state.autoFineRunning = true;
|
||||||
setAutoControlsBusy(true);
|
if (!lockAlreadyHeld) setPoseEditingLocked(true);
|
||||||
setAutoState("运行中", "warn");
|
setAutoState("运行中", "warn");
|
||||||
setAutoProgress(true, 2, "2%");
|
setAutoProgress(true, options.fromSaveIterate ? 12 : 4, "锁定位姿参数", "自动微调运行中,位姿输入、XYZ 拉伸和迭代按钮已临时锁定。", options.fromSaveIterate ? "save" : "bone");
|
||||||
if (state.windowMode !== "bone") setWindowMode("bone", true);
|
$("autoResult").textContent = "正在准备自动微调:锁定位姿控件并切换骨窗采样。";
|
||||||
$("autoResult").textContent = "已切换骨窗显示,正在按参考 STL 与 DICOM 三维盒体重叠做微调...";
|
|
||||||
await waitForUiFrame();
|
await waitForUiFrame();
|
||||||
try {
|
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 best = { pose: { ...state.pose }, score: candidateScore(state.pose, settings), mode: "初始位姿" };
|
||||||
let evaluated = 1;
|
let evaluated = 1;
|
||||||
const coarsePose = estimateCoarsePose(settings);
|
const coarsePose = estimateCoarsePose(settings);
|
||||||
@@ -2699,24 +2912,51 @@ async function runAutoFine() {
|
|||||||
});
|
});
|
||||||
for (const [index, round] of rounds.entries()) {
|
for (const [index, round] of rounds.entries()) {
|
||||||
const candidates = [];
|
const candidates = [];
|
||||||
if (adjustable.translateX) candidates.push(["translateX", round.move], ["translateX", -round.move]);
|
const enabledKeys = [];
|
||||||
if (adjustable.translateY) candidates.push(["translateY", round.move], ["translateY", -round.move]);
|
if (adjustable.translateX) enabledKeys.push("translateX");
|
||||||
if (adjustable.translateZ) candidates.push(["translateZ", round.move], ["translateZ", -round.move]);
|
if (adjustable.translateY) enabledKeys.push("translateY");
|
||||||
if (adjustable.scale) candidates.push(["scale", round.scale], ["scale", -round.scale]);
|
if (adjustable.translateZ) enabledKeys.push("translateZ");
|
||||||
for (const [key, delta] of candidates.slice(0, settings.candidates)) {
|
if (adjustable.scale) enabledKeys.push("scale");
|
||||||
const baseValue = Number(best.pose[key] ?? DEFAULT_POSE[key]);
|
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 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);
|
const score = candidateScore(pose, settings);
|
||||||
evaluated += 1;
|
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;
|
const progress = 40 + ((index + 1) / Math.max(rounds.length, 1)) * 48;
|
||||||
setAutoProgress(true, progress, `${Math.round(progress)}%`);
|
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();
|
await waitForUiFrame();
|
||||||
}
|
}
|
||||||
|
setAutoProgress(true, 94, "应用最佳位姿", "正在把本轮最高分候选写回位姿控件和融合视图。", "apply");
|
||||||
state.pose = { ...best.pose };
|
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.lastAutoPose = { before, after: { ...state.pose } };
|
||||||
state.autoDeltaBaseline = { ...before };
|
state.autoDeltaBaseline = { ...before };
|
||||||
renderPoseControls();
|
renderPoseControls();
|
||||||
@@ -2726,10 +2966,10 @@ async function runAutoFine() {
|
|||||||
setAutoState(changed ? "微调完成" : "微调无变化", changed ? "ok" : "warn");
|
setAutoState(changed ? "微调完成" : "微调无变化", changed ? "ok" : "warn");
|
||||||
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
|
const scoreText = Number.isFinite(best.score) ? best.score.toFixed(4) : "不可用";
|
||||||
$("autoResult").textContent = changed
|
$("autoResult").textContent = changed
|
||||||
? `最佳候选:${best.mode};score ${scoreText};评估 ${evaluated} 个候选;骨窗显示 + 参考 STL 盒体评分。`
|
? `最佳候选:${best.mode};score ${scoreText};评估 ${evaluated} 个候选;${settings.imageContext ? "骨窗像素采样 + STL 参考评分" : "参考 STL 盒体评分"}。`
|
||||||
: `已完成评估但最佳仍为初始位姿;score ${scoreText};评估 ${evaluated} 个候选。当前微调使用骨窗显示和参考 STL 盒体评分,若图像上仍错位,需要先粗配准或手动调整旋转后再微调。`;
|
: `已完成评估但最佳仍为初始位姿;score ${scoreText};评估 ${evaluated} 个候选。说明本轮候选没有比当前位姿更高分;若图像上仍错位,通常需要先调整大角度旋转或粗配准后再微调。`;
|
||||||
updateAutoPoseResult(before, state.pose);
|
updateAutoPoseResult(before, state.pose);
|
||||||
setAutoProgress(true, 100, "100%");
|
setAutoProgress(true, 100, changed ? "微调完成" : "未找到更优候选", changed ? `已应用 ${best.mode}。` : "候选均未超过当前位姿,因此没有强行移动模型。", "apply");
|
||||||
markDirty();
|
markDirty();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setAutoState("微调失败", "error");
|
setAutoState("微调失败", "error");
|
||||||
@@ -2737,10 +2977,27 @@ async function runAutoFine() {
|
|||||||
setAutoProgress(false);
|
setAutoProgress(false);
|
||||||
} finally {
|
} finally {
|
||||||
state.autoFineRunning = false;
|
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) {
|
async function saveRegistration(nextStatus = null) {
|
||||||
if (!state.activeCase || !currentSeries() || state.saving) return false;
|
if (!state.activeCase || !currentSeries() || state.saving) return false;
|
||||||
state.saving = true;
|
state.saving = true;
|
||||||
@@ -2813,8 +3070,12 @@ async function saveRegistration(nextStatus = null) {
|
|||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
state.saving = false;
|
state.saving = false;
|
||||||
$("saveBtn").disabled = false;
|
if (state.poseControlsLocked) {
|
||||||
$("statusBtn").disabled = false;
|
applyPoseLockState();
|
||||||
|
} else {
|
||||||
|
$("saveBtn").disabled = false;
|
||||||
|
$("statusBtn").disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2853,7 +3114,7 @@ function setWindowMode(mode, reload = true) {
|
|||||||
state.windowMode = mode || "default";
|
state.windowMode = mode || "default";
|
||||||
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item.dataset.window === state.windowMode));
|
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));
|
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) {
|
function setMappingMode(mode) {
|
||||||
@@ -2908,12 +3169,7 @@ function wireEvents() {
|
|||||||
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
|
||||||
$("autoFineBtn").addEventListener("click", runAutoFine);
|
$("autoFineBtn").addEventListener("click", runAutoFine);
|
||||||
$("savePoseBtn").addEventListener("click", () => saveRegistration());
|
$("savePoseBtn").addEventListener("click", () => saveRegistration());
|
||||||
$("saveIterateBtn").addEventListener("click", async () => {
|
$("saveIterateBtn").addEventListener("click", saveAndIteratePose);
|
||||||
const saved = await saveRegistration();
|
|
||||||
if (!saved) return;
|
|
||||||
await runAutoFine();
|
|
||||||
resetAutoDeltaBaseline();
|
|
||||||
});
|
|
||||||
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
|
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
|
||||||
const syncSampleSlices = (source) => {
|
const syncSampleSlices = (source) => {
|
||||||
const range = $("autoSampleSlices");
|
const range = $("autoSampleSlices");
|
||||||
|
|||||||
@@ -257,8 +257,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
|
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
|
||||||
<div id="autoProgress" class="auto-progress hidden">
|
<div id="autoProgress" class="auto-progress hidden">
|
||||||
<div><i id="autoProgressFill"></i></div>
|
<div class="auto-progress-head">
|
||||||
<span id="autoProgressText">0%</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
|
||||||
|
|||||||
@@ -1874,24 +1874,39 @@ input[type="range"] {
|
|||||||
|
|
||||||
.auto-progress {
|
.auto-progress {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 42px;
|
gap: 8px;
|
||||||
align-items: center;
|
min-height: 92px;
|
||||||
gap: 10px;
|
border: 1px solid rgba(59, 130, 246, 0.34);
|
||||||
min-height: 28px;
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(7, 10, 15, 0.72);
|
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;
|
height: 7px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 99px;
|
border-radius: 99px;
|
||||||
background: rgba(148, 163, 184, 0.18);
|
background: rgba(148, 163, 184, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.auto-progress i {
|
.auto-progress-track i {
|
||||||
display: block;
|
display: block;
|
||||||
width: 0%;
|
width: 0%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1900,7 +1915,7 @@ input[type="range"] {
|
|||||||
transition: width 160ms ease;
|
transition: width 160ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.auto-progress span {
|
.auto-progress-head span {
|
||||||
color: #c7d2fe;
|
color: #c7d2fe;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -1908,6 +1923,60 @@ input[type="range"] {
|
|||||||
text-align: right;
|
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 {
|
.compact-title {
|
||||||
min-height: 28px;
|
min-height: 28px;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user