Refine DICOM UPP registration workstation layout

This commit is contained in:
Codex
2026-05-28 22:38:27 +08:00
parent 4ffdd28fbe
commit 7a38272e8b
3 changed files with 777 additions and 58 deletions

View File

@@ -44,6 +44,8 @@ const state = {
statusFilter: "",
partFilter: "",
search: "",
caseCollapsed: false,
activeToolTab: "series",
cases: [],
activeCase: null,
series: [],
@@ -54,7 +56,10 @@ const state = {
registrationStatus: "unregistered",
pose: { ...DEFAULT_POSE },
windowMode: "default",
fusionMode: "fusion",
sliceIndex: 0,
dicomPreviewTimer: 0,
autoResult: null,
dirty: false,
saving: false,
fusionVersion: 0,
@@ -68,7 +73,9 @@ const state = {
modelGroup: null,
rawModelGroup: null,
baseModelScale: 1,
dicomSceneBox: null,
resizeObserver: null,
resizeScene: null,
};
function escapeHtml(value) {
@@ -98,6 +105,13 @@ function setSaveState(text, tone = "") {
el.className = tone;
}
function setAutoState(text, tone = "") {
const el = $("autoState");
if (!el) return;
el.textContent = text;
el.className = tone;
}
function markDirty() {
state.dirty = true;
setSaveState("未保存", "warn");
@@ -298,9 +312,16 @@ 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 = "";
renderDicomAnnotation();
clearFusion();
}
@@ -327,6 +348,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
state.stlFiles = stlPayload.files || [];
state.registrationStatus = registration.registration_status || "unregistered";
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
state.autoResult = null;
state.sliceIndex = 0;
$("notes").value = registration.notes || "";
@@ -389,34 +411,43 @@ function renderActiveHeader() {
$("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
? state.series
.map((item) => {
const active = item.series_uid === state.selectedSeriesUid;
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
return `
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
<strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span>
<small>序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张</small>
<div class="mini-tags">
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
${labels || `<i>未标注</i>`}
</div>
</button>
`;
})
.map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
.join("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
$("seriesList").querySelectorAll(".series-card").forEach((button) => {
document.querySelectorAll(".series-card[data-series]").forEach((button) => {
button.addEventListener("click", () => selectSeries(button.dataset.series));
});
updateSliceControl();
renderDicomAnnotation();
}
function seriesCardHtml(item, active = false) {
const labels = (item.annotation_labels || []).map((label) => `<i>${escapeHtml(label)}</i>`).join("");
const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
return `
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
<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>
<div class="mini-tags">
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
${labels || `<i>未标注</i>`}
</div>
</button>
`;
}
async function selectSeries(uid) {
@@ -427,6 +458,7 @@ async function selectSeries(uid) {
state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
markDirty();
renderSeries();
renderDicomAnnotation();
await loadFusion();
}
@@ -479,8 +511,11 @@ function buildPoseControls() {
(item) => `
<div class="pose-control" data-pose="${item.key}">
<label>${item.label}</label>
<button type="button" data-nudge="${item.key}" data-delta="${-item.step}">-</button>
<input type="range" min="${item.min}" max="${item.max}" step="${item.step}" />
<button type="button" data-nudge="${item.key}" data-delta="${item.step}">+</button>
<input type="number" min="${item.min}" max="${item.max}" step="${item.step}" />
${item.key.startsWith("rotate") ? `<div class="quick-row"><button type="button" data-nudge="${item.key}" data-delta="-90">-90°</button><button type="button" data-nudge="${item.key}" data-delta="90">+90°</button></div>` : ""}
</div>
`,
).join("");
@@ -499,6 +534,9 @@ function buildPoseControls() {
range.addEventListener("input", () => sync(range.value, range));
number.addEventListener("input", () => sync(number.value, number));
});
document.querySelectorAll("[data-nudge]").forEach((button) => {
button.addEventListener("click", () => nudgePose(button.dataset.nudge, Number(button.dataset.delta || 0)));
});
document.querySelectorAll("[data-flip]").forEach((button) => {
button.addEventListener("click", () => {
const key = button.dataset.flip;
@@ -510,6 +548,17 @@ function buildPoseControls() {
});
}
function nudgePose(key, delta) {
if (!key || !Number.isFinite(delta)) return;
const option = POSE_CONTROLS.find((item) => item.key === key);
const current = Number(state.pose[key] ?? DEFAULT_POSE[key]);
const next = Math.max(option?.min ?? -Infinity, Math.min(option?.max ?? Infinity, current + delta));
state.pose[key] = Number(next.toFixed(3));
renderPoseControls();
applyPose();
markDirty();
}
function renderPoseControls() {
POSE_CONTROLS.forEach((item) => {
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
@@ -532,6 +581,46 @@ function updateSliceControl() {
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0";
}
function renderDicomAnnotation() {
const row = state.activeCase;
const selected = currentSeries();
const count = Number(selected?.count || 0);
$("dicomPanelMeta").textContent = selected ? `${selected.description || "未命名序列"} · ${count}` : "当前序列切片";
$("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 || "-"}`
: "未选择序列";
$("dicomInfoRight").textContent = selected
? `${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>`;
}
function updateDicomPreview() {
const selected = currentSeries();
if (!state.activeCase || !selected) {
$("dicomPreview").removeAttribute("src");
renderDicomAnnotation();
return;
}
window.clearTimeout(state.dicomPreviewTimer);
state.dicomPreviewTimer = window.setTimeout(() => {
const params = new URLSearchParams({
ct_number: state.activeCase.ct_number,
series_uid: selected.series_uid,
index: String(state.sliceIndex),
window: state.windowMode,
access_token: state.token,
});
$("dicomLoading").classList.remove("hidden");
$("dicomPreview").src = `/api/dicom/image?${params.toString()}`;
renderDicomAnnotation();
}, 80);
}
function initScene() {
if (state.sceneReady) return;
const viewport = $("fusionViewport");
@@ -570,6 +659,7 @@ function initScene() {
camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix();
};
state.resizeScene = resize;
state.resizeObserver = new ResizeObserver(resize);
state.resizeObserver.observe(viewport);
resize();
@@ -606,6 +696,14 @@ function clearFusion() {
updateSliceControl();
}
function applyFusionMode() {
if (state.dicomGroup) state.dicomGroup.visible = state.fusionMode !== "model";
if (state.modelGroup) state.modelGroup.visible = true;
$("fusionMeta").textContent = state.fusionMode === "model"
? `单独模型 · STL ${state.selectedStlIds.size}`
: $("fusionMeta").textContent;
}
async function loadFusion() {
const version = ++state.fusionVersion;
const selected = currentSeries();
@@ -634,9 +732,12 @@ async function loadFusion() {
if (version !== state.fusionVersion) return;
await buildStlModels(volume);
if (version !== state.fusionVersion) return;
applyFusionMode();
fitCamera();
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}`;
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size}`;
renderDicomAnnotation();
updateDicomPreview();
} catch (error) {
clearFusion();
$("fusionStatus").textContent = error.message;
@@ -682,6 +783,7 @@ async function buildDicomVolume(volume) {
const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
state.dicomGroup.add(lines);
state.baseModelScale = sceneScale;
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
}
async function buildStlModels(volume) {
@@ -723,9 +825,9 @@ async function buildStlModels(volume) {
applyPose();
}
function applyPose() {
function applyPose(poseInput = state.pose) {
if (!state.modelGroup) return;
const pose = { ...DEFAULT_POSE, ...state.pose };
const pose = { ...DEFAULT_POSE, ...poseInput };
state.modelGroup.rotation.set(
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
@@ -751,6 +853,120 @@ function fitCamera() {
state.controls.update();
}
function resetPose(kind) {
if (kind === "rotation") {
state.pose = { ...state.pose, rotateX: 0, rotateY: 0, rotateZ: 0 };
} else if (kind === "transform") {
state.pose = { ...state.pose, translateX: 0, translateY: 0, translateZ: 0, scale: 1 };
} else if (kind === "flip") {
state.pose = { ...state.pose, flipX: false, flipY: false, flipZ: false };
} else {
state.pose = { ...DEFAULT_POSE };
}
renderPoseControls();
applyPose();
markDirty();
}
function suggestBoneSelection() {
const boneKeys = ["rib", "vertebra", "sternum", "bone", "spine", "肋", "椎", "骨", "胸骨"];
const next = new Set();
state.stlFiles.forEach((file) => {
const haystack = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase();
if (boneKeys.some((key) => haystack.includes(key))) next.add(Number(file.id));
});
if (!next.size) {
state.stlFiles.slice(0, Math.min(6, state.stlFiles.length)).forEach((file) => next.add(Number(file.id)));
}
state.selectedStlIds = next;
renderStl();
markDirty();
loadFusion();
}
function candidateScore(pose) {
if (!state.dicomSceneBox || !state.modelGroup || !state.rawModelGroup?.children.length) return -Infinity;
const current = { ...state.pose };
applyPose(pose);
const modelBox = new THREE.Box3().setFromObject(state.modelGroup);
applyPose(current);
const overlap = modelBox.clone().intersect(state.dicomSceneBox);
const overlapSize = new THREE.Vector3();
overlap.getSize(overlapSize);
const modelSize = new THREE.Vector3();
const dicomSize = new THREE.Vector3();
modelBox.getSize(modelSize);
state.dicomSceneBox.getSize(dicomSize);
const overlapVolume = Math.max(0, overlapSize.x) * Math.max(0, overlapSize.y) * Math.max(0, overlapSize.z);
const modelVolume = Math.max(modelSize.x * modelSize.y * modelSize.z, 0.001);
const modelCenter = new THREE.Vector3();
const dicomCenter = new THREE.Vector3();
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;
}
function runAutoCoarse() {
if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
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 };
setAutoState("粗配准完成", "ok");
$("autoResult").textContent = `已按 DICOM 物理尺寸复位平移/缩放score ${state.autoResult.score.toFixed(4)}`;
markDirty();
}
function runAutoFine() {
if (!state.activeCase || !currentSeries() || !state.rawModelGroup?.children.length) {
$("autoResult").textContent = "请先选择 DICOM 序列和至少一个 STL。";
return;
}
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: "初始位姿" };
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 },
];
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) {
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);
evaluated += 1;
if (score > best.score) best = { pose, score, mode: `${key} ${delta > 0 ? "+" : "-"}` };
}
}
state.pose = { ...best.pose };
state.autoResult = { ...best, evaluated };
renderPoseControls();
applyPose();
fitCamera();
setAutoState("微调完成", "ok");
$("autoResult").textContent = `最佳候选:${best.mode}score ${best.score.toFixed(4)};评估 ${evaluated} 个候选。`;
markDirty();
}
async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return;
state.saving = true;
@@ -792,6 +1008,8 @@ async function saveRegistration(nextStatus = null) {
algorithm_model: state.algorithmModel,
selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
fusion_mode: state.fusionMode,
auto_result: state.autoResult,
},
notes: $("notes").value,
}),
@@ -846,16 +1064,42 @@ function openLinkedApp(kind) {
function wireEvents() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout());
$("caseToggleBtn").addEventListener("click", () => {
state.caseCollapsed = !state.caseCollapsed;
document.querySelector(".workspace").classList.toggle("case-collapsed", state.caseCollapsed);
$("caseToggleBtn").classList.toggle("active", state.caseCollapsed);
window.setTimeout(() => {
state.resizeScene?.();
fitCamera();
}, 230);
});
$("refreshBtn").addEventListener("click", async () => {
await loadStatus();
await loadCases(false);
});
$("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);
$("resetRotationBtn").addEventListener("click", () => resetPose("rotation"));
$("resetTransformBtn").addEventListener("click", () => resetPose("transform"));
$("resetFlipBtn").addEventListener("click", () => resetPose("flip"));
$("suggestBoneBtn").addEventListener("click", suggestBoneSelection);
$("autoCoarseBtn").addEventListener("click", runAutoCoarse);
$("autoFineBtn").addEventListener("click", runAutoFine);
$("dicomPreview").addEventListener("load", () => $("dicomLoading").classList.add("hidden"));
$("dicomPreview").addEventListener("error", () => $("dicomLoading").classList.add("hidden"));
document.querySelectorAll("[data-tool-tab]").forEach((button) => {
button.addEventListener("click", () => {
state.activeToolTab = button.dataset.toolTab || "series";
document.querySelectorAll("[data-tool-tab]").forEach((item) => item.classList.toggle("active", item === button));
document.querySelectorAll("[data-tool-pane]").forEach((pane) => pane.classList.toggle("active", pane.dataset.toolPane === state.activeToolTab));
});
});
$("caseSearch").addEventListener("input", () => {
state.search = $("caseSearch").value.trim();
window.clearTimeout(state.searchTimer);
@@ -875,16 +1119,26 @@ function wireEvents() {
await loadCases(false);
});
});
document.querySelectorAll(".segmented button").forEach((button) => {
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", async () => {
state.windowMode = button.dataset.window || "default";
document.querySelectorAll(".segmented button").forEach((item) => item.classList.toggle("active", item === button));
document.querySelectorAll("[data-window]").forEach((item) => item.classList.toggle("active", item === button));
await loadFusion();
});
});
document.querySelectorAll("[data-fusion-mode]").forEach((button) => {
button.addEventListener("click", () => {
state.fusionMode = button.dataset.fusionMode || "fusion";
document.querySelectorAll("[data-fusion-mode]").forEach((item) => item.classList.toggle("active", item === button));
applyFusionMode();
fitCamera();
});
});
$("sliceSlider").addEventListener("input", () => {
state.sliceIndex = Number($("sliceSlider").value || 0);
updateSliceControl();
renderDicomAnnotation();
updateDicomPreview();
});
$("sliceSlider").addEventListener("change", () => loadFusion());
}