Refine registration workspace controls

This commit is contained in:
Codex
2026-05-29 01:46:52 +08:00
parent f9063b674c
commit 559266d5e6
4 changed files with 576 additions and 129 deletions

View File

@@ -46,6 +46,7 @@ const state = {
links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" },
statusFilter: "",
partFilter: "",
modelFilter: "",
search: "",
caseCollapsed: false,
activeToolTab: "series",
@@ -60,11 +61,15 @@ const state = {
pose: { ...DEFAULT_POSE },
windowMode: "default",
fusionMode: "fusion",
fusionDetail: "low",
modelDetail: "standard",
mappingMode: "result",
sliceIndex: 0,
sliceRangeStart: 0,
sliceRangeEnd: 0,
dicomPreviewTimer: 0,
autoResult: null,
lastAutoPose: null,
dirty: false,
dirtyReason: "",
saving: false,
@@ -77,6 +82,8 @@ const state = {
scene: null,
controls: null,
dicomGroup: null,
dicomPlane: null,
dicomPoints: null,
modelGroup: null,
rawModelGroup: null,
modelMeshes: [],
@@ -247,6 +254,7 @@ async function loadCases(selectFirst = true) {
if (state.search) params.set("q", state.search);
if (state.statusFilter) params.set("status", state.statusFilter);
if (state.partFilter) params.set("body_part", state.partFilter);
if (state.modelFilter) params.set("algorithm_model", state.modelFilter);
const rows = await api(`/api/cases?${params.toString()}`);
state.cases = rows;
renderCases();
@@ -382,12 +390,6 @@ function clearDetail() {
$("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = "";
$("patientSummary").textContent = "未选择 CT";
$("summaryCt").textContent = "-";
$("summaryPatientId").textContent = "-";
$("summaryStudyTime").textContent = "-";
$("summaryBodyPart").textContent = "-";
$("matchedSeries").innerHTML = "";
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
$("modelRail").innerHTML = "";
@@ -407,7 +409,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
&& sameCtNumber(state.activeCase.ct_number, ctNumber)
&& (state.algorithmModel || "未指定模型") !== normalizedAlgorithm;
const previousPose = { ...state.pose };
if (!switchingModelOnly && !(await confirmChange())) return;
if (!(await confirmChange())) return;
setTopLoading(true);
setFusionLoading(true, "正在读取 CT 关联数据", 6);
try {
@@ -426,7 +428,10 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
const savedTransform = registration.transform || {};
const keepPreviousPose = switchingModelOnly;
state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform };
state.fusionDetail = registration.model_reference?.fusion_detail || state.fusionDetail || "low";
state.modelDetail = registration.model_reference?.model_detail || state.modelDetail || "standard";
state.autoResult = null;
state.lastAutoPose = null;
state.sliceIndex = 0;
$("notes").value = registration.notes || "";
@@ -447,6 +452,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
renderSeries();
renderStl();
renderPoseControls();
syncDisplayControls();
resetDirty();
await loadFusion();
} catch (error) {
@@ -503,26 +509,25 @@ function currentSeries() {
function renderActiveHeader() {
const row = state.activeCase;
if (!row) return;
const selected = currentSeries();
const bodyTags = bodyPartTags(row);
const selectedLabels = selected?.annotation_labels?.length ? selected.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : [];
$("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
$("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${row.patient_id || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${bodyTags.join("、") || row.study_description || "部位未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)}`;
const tags = [
`<span class="tag ${row.registration_status === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
...bodyPartTags(row).map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
`<span class="tag ${state.registrationStatus === "registered" ? "green" : "amber"}">${statusText(state.registrationStatus)}</span>`,
`<span class="tag">${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}</span>`,
...(selected ? [`<span class="tag">${escapeHtml(selected.description || "当前序列")}</span>`] : []),
...selectedLabels.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
...bodyTags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`),
];
$("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
$("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered");
$("patientSummary").textContent = `${row.patient_name || row.upp_patient_name || "-"} ${row.patient_sex || ""}`.trim();
$("summaryCt").textContent = row.ct_number || "-";
$("summaryPatientId").textContent = row.patient_id || "-";
$("summaryStudyTime").textContent = fmtDateTime(row.study_date, row.study_time) || "-";
$("summaryBodyPart").textContent = bodyPartTags(row).join("、") || row.study_description || "-";
}
function renderSeries() {
$("seriesCount").textContent = state.series.length;
const activeSeries = currentSeries();
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
$("seriesList").innerHTML = state.series.length
? sortedSeriesForDisplay(state.series)
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
@@ -541,6 +546,7 @@ function seriesCardHtml(item, active = false) {
const skipped = isSkippedSeries(item);
return `
<button class="series-card ${active ? "active" : ""} ${skipped ? "skipped" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
<span class="series-check" aria-hidden="true">${active ? "✓" : ""}</span>
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
<small>${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
@@ -589,6 +595,10 @@ function renderStl() {
: `<div class="empty-state">当前算法模型没有 STL 文件</div>`;
$("stlList").querySelectorAll("input[data-stl]").forEach((input) => {
input.addEventListener("change", async () => {
if (state.dirty && !(await confirmChange())) {
input.checked = !input.checked;
return;
}
const id = Number(input.dataset.stl);
if (input.checked) state.selectedStlIds.add(id);
else state.selectedStlIds.delete(id);
@@ -685,9 +695,6 @@ function updateSliceControl() {
state.sliceRangeStart = Math.max(0, Math.min(Number(state.sliceRangeStart) || 0, max));
state.sliceRangeEnd = Math.max(0, Math.min(Number(state.sliceRangeEnd) || max, max));
if (state.sliceRangeStart > state.sliceRangeEnd) [state.sliceRangeStart, state.sliceRangeEnd] = [state.sliceRangeEnd, state.sliceRangeStart];
$("sliceSlider").max = max;
$("sliceSlider").value = state.sliceIndex;
$("sliceLabel").textContent = count ? `切片 ${state.sliceIndex + 1} / ${count}` : "切片 0 / 0";
$("sliceRangeStart").max = max;
$("sliceRangeEnd").max = max;
$("sliceRangeStart").value = state.sliceRangeStart;
@@ -704,7 +711,7 @@ function renderDicomAnnotation() {
const row = state.activeCase;
const selected = currentSeries();
const count = Number(selected?.count || 0);
$("dicomPanelMeta").textContent = selected ? `${selected.description || "未命名序列"} · ${count}` : "当前序列切片";
$("mappingSummaryTitle").textContent = state.mappingMode === "image" ? "单独影像" : "可见 STL 构件";
$("dicomInfoLeft").textContent = row && selected
? `${row.patient_name || row.upp_patient_name || "-"}\n${row.patient_id || "-"}\n${fmtDateTime(row.study_date, row.study_time) || "-"}\n${selected.description || "-"}`
: "未选择序列";
@@ -712,10 +719,7 @@ function renderDicomAnnotation() {
? `${selected.modality || "CT"}\n${selected.rows || "-"}×${selected.columns || "-"}\n${selected.slice_thickness || "厚度 -"}`
: "";
$("dicomInfoBottom").textContent = count ? `Img:${state.sliceIndex + 1}/${count}` : "Img:-";
const tags = selected?.annotation_labels?.length ? selected.annotation_labels : bodyPartTags(row || {});
$("annotationTags").innerHTML = tags.length
? tags.map((tag) => `<span>${escapeHtml(tag)}</span>`).join("")
: `<span>未标注</span>`;
renderActiveHeader();
}
function updateDicomPreview() {
@@ -823,6 +827,8 @@ function clearFusion() {
state.volumeMeta = null;
state.volumeScene = null;
state.modelMeshes = [];
state.dicomPlane = null;
state.dicomPoints = null;
$("fusionMeta").textContent = "DICOM - · STL -";
$("fusionStatus").textContent = "等待选择 DICOM 与 STL";
updateSliceControl();
@@ -837,6 +843,58 @@ function applyFusionMode() {
: $("fusionMeta").textContent;
}
function fusionDetailSettings() {
return {
low: { planeOpacity: 0.24, pointOpacity: 0.18, pointSize: 0.014 },
medium: { planeOpacity: 0.38, pointOpacity: 0.3, pointSize: 0.018 },
high: { planeOpacity: 0.52, pointOpacity: 0.44, pointSize: 0.022 },
}[state.fusionDetail] || { planeOpacity: 0.24, pointOpacity: 0.18, pointSize: 0.014 };
}
function modelDetailSettings() {
return {
standard: { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 },
fine: { opacity: 0.66, wireframe: false, depthWrite: false, metalness: 0.04, roughness: 0.48 },
ultra: { opacity: 0.82, wireframe: false, depthWrite: false, metalness: 0.02, roughness: 0.42 },
solid: { opacity: 1, wireframe: false, depthWrite: true, metalness: 0.02, roughness: 0.36 },
}[state.modelDetail] || { opacity: 0.52, wireframe: false, depthWrite: false, metalness: 0.08, roughness: 0.55 };
}
function applyFusionDetail() {
const detail = fusionDetailSettings();
if (state.dicomPlane?.material) {
state.dicomPlane.material.opacity = detail.planeOpacity;
state.dicomPlane.material.needsUpdate = true;
}
if (state.dicomPoints?.material) {
state.dicomPoints.material.opacity = detail.pointOpacity;
state.dicomPoints.material.size = detail.pointSize;
state.dicomPoints.material.needsUpdate = true;
}
}
function applyModelDetail() {
const detail = modelDetailSettings();
state.modelMeshes.forEach((record) => {
const material = record.mesh.material;
if (!material) return;
material.opacity = detail.opacity;
material.transparent = detail.opacity < 1;
material.depthWrite = detail.depthWrite;
material.wireframe = detail.wireframe;
material.metalness = detail.metalness;
material.roughness = detail.roughness;
material.needsUpdate = true;
});
scheduleMappingDraw();
}
function syncDisplayControls() {
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item.dataset.fusionDetail === state.fusionDetail));
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item.dataset.modelDetail === state.modelDetail));
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
}
async function loadFusion() {
const version = ++state.fusionVersion;
const selected = currentSeries();
@@ -861,17 +919,19 @@ async function loadFusion() {
state.volumeMeta = volume;
setFusionLoading(true, "正在构建 DICOM 切片范围", 38);
await buildDicomVolume(volume);
applyFusionDetail();
if (version !== state.fusionVersion) return;
setFusionLoading(true, "正在加载 STL 模型", 62);
await buildStlModels(volume, (progress) => setFusionLoading(true, "正在加载 STL 模型", 62 + progress * 0.3));
applyModelDetail();
if (version !== state.fusionVersion) return;
if (state.sceneReady) {
applyFusionMode();
fitCamera();
}
$("fusionStatus").textContent = state.sceneReady
? `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}`
: `${selected.description || "DICOM 序列"} · 2D 映射已就绪,当前浏览器未启用 WebGL`;
? "三维融合场景已就绪"
: "2D 映射已就绪,当前浏览器未启用 WebGL";
$("fusionMeta").textContent = `DICOM ${volume.start + 1}-${volume.end + 1}/${volume.total} · STL ${state.selectedStlIds.size}`;
renderDicomAnnotation();
setFusionLoading(true, "正在渲染右侧分割映射", 96);
@@ -1094,6 +1154,11 @@ function drawMappingView() {
const image = $("dicomPreview");
const canvas = $("mappingCanvas");
const volumeScene = state.volumeScene;
canvas.style.display = state.mappingMode === "image" ? "none" : "";
if (state.mappingMode === "image") {
resetMappingCanvas("单独影像模式未叠加 STL 分割");
return;
}
if (!image?.complete || !image.naturalWidth || !volumeScene || !state.modelMeshes.length) {
resetMappingCanvas(state.modelMeshes.length ? "等待 DICOM Base Layer" : "当前没有可见 STL 构件");
return;
@@ -1178,6 +1243,8 @@ function drawMappingView() {
async function buildDicomVolume(volume) {
clearGroup(state.dicomGroup);
state.dicomPlane = null;
state.dicomPoints = null;
const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 };
const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1);
const sceneScale = 4.8 / maxPhysical;
@@ -1221,6 +1288,7 @@ async function buildDicomVolume(volume) {
state.dicomGroup.add(rangeBox);
const centerFrame = (volume.frames || [])[centerPosition];
const fusionDetail = fusionDetailSettings();
if (centerFrame) {
const texture = await loadTexture(centerFrame);
texture.colorSpace = THREE.SRGBColorSpace;
@@ -1229,13 +1297,14 @@ async function buildDicomVolume(volume) {
new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
opacity: 0.42,
opacity: fusionDetail.planeOpacity,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
plane.position.z = sliceToSceneZ(centerIndex) + 0.006;
state.dicomGroup.add(plane);
state.dicomPlane = plane;
}
const pointPositions = [];
@@ -1265,14 +1334,15 @@ async function buildDicomVolume(volume) {
const points = new THREE.Points(
geometry,
new THREE.PointsMaterial({
size: 0.018,
size: fusionDetail.pointSize,
vertexColors: true,
transparent: true,
opacity: 0.34,
opacity: fusionDetail.pointOpacity,
depthWrite: false,
}),
);
state.dicomGroup.add(points);
state.dicomPoints = points;
}
state.baseModelScale = sceneScale;
@@ -1283,6 +1353,7 @@ async function buildStlModels(volume, onProgress = null) {
const signature = stlSelectionSignature();
if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) {
applyPose();
applyModelDetail();
scheduleMappingDraw();
onProgress?.(100);
return;
@@ -1297,6 +1368,7 @@ async function buildStlModels(volume, onProgress = null) {
return;
}
const loader = new STLLoader();
const detail = modelDetailSettings();
for (const [index, file] of files.entries()) {
onProgress?.((index / Math.max(files.length, 1)) * 100);
const params = new URLSearchParams({
@@ -1310,12 +1382,13 @@ async function buildStlModels(volume, onProgress = null) {
geometry.computeBoundingBox();
const material = new THREE.MeshStandardMaterial({
color: STL_COLORS[index % STL_COLORS.length],
metalness: 0.08,
roughness: 0.5,
metalness: detail.metalness,
roughness: detail.roughness,
transparent: true,
opacity: 0.58,
opacity: detail.opacity,
side: THREE.DoubleSide,
depthWrite: false,
depthWrite: detail.depthWrite,
wireframe: detail.wireframe,
});
const mesh = new THREE.Mesh(geometry, material);
mesh.name = file.segment_name || file.file_name;
@@ -1404,10 +1477,20 @@ function stretchAxis(axis) {
markDirty("pose");
setAutoState(`${axis.toUpperCase()} 拉伸完成`, "ok");
$("autoResult").textContent = `${axis.toUpperCase()} 轴已按 DICOM 盒体粗略拉伸,系数 ${state.pose[axisKey]}`;
updateAutoPoseResult();
}
function suggestBoneSelection() {
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
const selectedBoneKeys = [...document.querySelectorAll("#autoBoneOptions input:checked")].map((input) => input.value);
const keyMap = {
vertebra: ["vertebra", "spine", "椎", "脊柱"],
rib: ["rib", "肋"],
sternum: ["sternum", "胸骨"],
pelvis: ["pelvis", "髂", "骨盆"],
};
const boneKeys = (selectedBoneKeys.length ? selectedBoneKeys : ["vertebra", "rib"])
.flatMap((key) => keyMap[key] || [key])
.concat(["bone", "骨"]);
const next = new Set();
state.stlFiles.forEach((file) => {
const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
@@ -1422,8 +1505,66 @@ function suggestBoneSelection() {
loadFusion();
}
function candidateScore(pose) {
function readAutoSettings() {
const numericValue = (id, fallback) => {
const value = Number($(id)?.value);
return Number.isFinite(value) ? value : fallback;
};
return {
adjustable: {
translateX: $("autoX")?.checked,
translateY: $("autoY")?.checked,
translateZ: $("autoZ")?.checked,
scale: $("autoScale")?.checked,
},
sampleSlices: Math.max(3, Math.min(60, Math.round(numericValue("autoSampleSlicesNumber", 9)))),
boneReward: numericValue("autoBoneReward", 1),
outsidePenalty: numericValue("autoOutsidePenalty", 0.1),
movePenalty: numericValue("autoMovePenalty", 0),
scalePenalty: numericValue("autoScalePenalty", 0),
iterations: Math.max(1, Math.min(20, Math.round(numericValue("autoIterations", 6)))),
candidates: Math.max(6, Math.min(96, Math.round(numericValue("autoCandidates", 36)))),
};
}
function poseDelta(from, to) {
return {
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)),
scale: Number(((to.scale || 1) / Math.max(0.001, from.scale || 1)).toFixed(3)),
};
}
function updateAutoPoseResult(before = state.lastAutoPose?.before || state.pose, after = state.pose) {
const delta = poseDelta(before, after);
const el = $("autoPoseResult");
if (!el) return;
el.innerHTML = [
`<span>平移 X ${delta.translateX}</span>`,
`<span>平移 Y ${delta.translateY}</span>`,
`<span>平移 Z ${delta.translateZ}</span>`,
`<span>缩放 ${delta.scale}</span>`,
].join("");
}
function restoreAutoPose() {
if (!state.lastAutoPose?.before) {
$("autoResult").textContent = "暂无可退回的自动调整位姿。";
return;
}
state.pose = { ...state.lastAutoPose.before };
renderPoseControls();
applyPose();
fitCamera();
updateAutoPoseResult(state.lastAutoPose.after, state.pose);
$("autoResult").textContent = "已退回到自动调整前的位姿。";
markDirty();
}
function candidateScore(pose, settings = null) {
if (!state.dicomSceneBox || !state.modelGroup || !state.rawModelGroup?.children.length) return -Infinity;
const config = settings || readAutoSettings();
const current = { ...state.pose };
applyPose(pose);
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
@@ -1442,8 +1583,9 @@ function candidateScore(pose) {
modelBox.getCenter(modelCenter);
state.dicomSceneBox.getCenter(dicomCenter);
const centerPenalty = modelCenter.distanceTo(dicomCenter) / Math.max(dicomSize.length(), 0.001);
const scalePenalty = Math.abs((pose.scale || 1) - 1) * 0.03;
return overlapVolume / modelVolume - centerPenalty - scalePenalty;
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;
}
function runAutoCoarse() {
@@ -1451,13 +1593,17 @@ function runAutoCoarse() {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
const before = { ...state.pose };
const settings = readAutoSettings();
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
renderPoseControls();
applyPose();
fitCamera();
state.autoResult = { score: candidateScore(state.pose), pose: { ...state.pose }, evaluated: 1 };
state.autoResult = { score: candidateScore(state.pose, settings), pose: { ...state.pose }, evaluated: 1, settings };
state.lastAutoPose = { before, after: { ...state.pose } };
setAutoState("粗配准完成", "ok");
$("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放score ${state.autoResult.score.toFixed(4)}`;
$("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放score ${state.autoResult.score.toFixed(4)};采样 ${settings.sampleSlices} 张。`;
updateAutoPoseResult(before, state.pose);
markDirty();
}
@@ -1468,40 +1614,37 @@ function runAutoFine() {
}
setAutoState("运行中", "warn");
$("autoResult").textContent = "正在进行几何重叠微调...";
const adjustable = {
translateX: $("autoX").checked,
translateY: $("autoY").checked,
translateZ: $("autoZ").checked,
scale: $("autoScale").checked,
};
let best = { pose: { ...state.pose }, score: candidateScore(state.pose), mode: "初始位姿" };
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 = [
{ move: 0.34, scale: 0.12 },
{ move: 0.18, scale: 0.06 },
{ move: 0.08, scale: 0.025 },
{ move: 0.035, scale: 0.01 },
];
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) {
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);
const score = candidateScore(pose, settings);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
}
}
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated };
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} 个候选。`;
$("autoResult").textContent = `最佳候选:${best.mode}score ${best.score.toFixed(4)};评估 ${evaluated} 个候选;采样 ${settings.sampleSlices}`;
updateAutoPoseResult(before, state.pose);
markDirty();
}
@@ -1532,7 +1675,7 @@ async function saveRegistration(nextStatus = null) {
algorithm_model: file.algorithm_model,
})),
transform: state.pose,
module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: 0.58 }])),
module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: modelDetailSettings().opacity, detail: state.modelDetail }])),
dicom_reference: {
series_uid: selectedSeries.series_uid,
series_number: selectedSeries.series_number,
@@ -1547,6 +1690,8 @@ async function saveRegistration(nextStatus = null) {
selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
fusion_mode: state.fusionMode,
fusion_detail: state.fusionDetail,
model_detail: state.modelDetail,
auto_result: state.autoResult,
},
notes: $("notes").value,
@@ -1606,6 +1751,13 @@ function setWindowMode(mode, reload = true) {
if (reload) loadFusion();
}
function setMappingMode(mode) {
state.mappingMode = mode === "image" ? "image" : "result";
document.querySelectorAll("[data-map-mode]").forEach((item) => item.classList.toggle("active", item.dataset.mapMode === state.mappingMode));
renderDicomAnnotation();
scheduleMappingDraw();
}
function setAutoModalVisible(visible) {
$("autoModal").classList.toggle("hidden", !visible);
}
@@ -1628,12 +1780,10 @@ function wireEvents() {
});
$("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("openViewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("reloadFusionBtn").addEventListener("click", () => loadFusion());
$("fitBtn").addEventListener("click", fitCamera);
$("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", markDirty);
$("notes").addEventListener("input", () => markDirty("notes"));
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
@@ -1648,6 +1798,25 @@ function wireEvents() {
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine);
$("savePoseBtn").addEventListener("click", () => saveRegistration());
$("saveIterateBtn").addEventListener("click", async () => {
await saveRegistration();
runAutoFine();
});
$("restorePoseBtn").addEventListener("click", restoreAutoPose);
const syncSampleSlices = (source) => {
const range = $("autoSampleSlices");
const number = $("autoSampleSlicesNumber");
const value = Math.max(3, Math.min(60, Number(source.value || 9)));
range.value = value;
number.value = value;
};
$("autoSampleSlices").addEventListener("input", () => syncSampleSlices($("autoSampleSlices")));
$("autoSampleSlicesNumber").addEventListener("input", () => syncSampleSlices($("autoSampleSlicesNumber")));
$("autoDefaultsBtn").addEventListener("click", () => {
$("autoSampleSlices").value = 9;
$("autoSampleSlicesNumber").value = 9;
});
$("dicomPreview").addEventListener("load", () => {
$("dicomLoading").classList.add("hidden");
scheduleMappingDraw();
@@ -1728,12 +1897,22 @@ function wireEvents() {
await loadCases(false);
});
});
document.querySelectorAll(".filter[data-model-filter]").forEach((button) => {
button.addEventListener("click", async () => {
state.modelFilter = button.dataset.modelFilter || "";
document.querySelectorAll(".filter[data-model-filter]").forEach((item) => item.classList.toggle("active", item === button));
await loadCases(false);
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.window || "default"));
});
document.querySelectorAll("[data-map-window]").forEach((button) => {
button.addEventListener("click", () => setWindowMode(button.dataset.mapWindow || "default"));
});
document.querySelectorAll("[data-map-mode]").forEach((button) => {
button.addEventListener("click", () => setMappingMode(button.dataset.mapMode || "result"));
});
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
button.addEventListener("click", () => {
state.fusionMode = button.dataset.fusionMode || "fusion";
@@ -1742,11 +1921,21 @@ function wireEvents() {
fitCamera();
});
});
$("sliceSlider").addEventListener("input", () => {
state.sliceIndex = Number($("sliceSlider").value || 0);
updateSliceControl();
renderDicomAnnotation();
updateDicomPreview();
document.querySelectorAll("[data-fusion-detail]").forEach((button) => {
button.addEventListener("click", () => {
state.fusionDetail = button.dataset.fusionDetail || "low";
document.querySelectorAll("[data-fusion-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionDetail();
markDirty("display");
});
});
document.querySelectorAll("[data-model-detail]").forEach((button) => {
button.addEventListener("click", () => {
state.modelDetail = button.dataset.modelDetail || "standard";
document.querySelectorAll("[data-model-detail]").forEach((item) => item.classList.toggle("active", item === button));
applyModelDetail();
markDirty("display");
});
});
$("mappingSliceSlider").addEventListener("input", () => {
const selected = currentSeries();