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: "", statusFilter: "",
partFilter: "", partFilter: "",
search: "", search: "",
caseCollapsed: false,
activeToolTab: "series",
cases: [], cases: [],
activeCase: null, activeCase: null,
series: [], series: [],
@@ -54,7 +56,10 @@ const state = {
registrationStatus: "unregistered", registrationStatus: "unregistered",
pose: { ...DEFAULT_POSE }, pose: { ...DEFAULT_POSE },
windowMode: "default", windowMode: "default",
fusionMode: "fusion",
sliceIndex: 0, sliceIndex: 0,
dicomPreviewTimer: 0,
autoResult: null,
dirty: false, dirty: false,
saving: false, saving: false,
fusionVersion: 0, fusionVersion: 0,
@@ -68,7 +73,9 @@ const state = {
modelGroup: null, modelGroup: null,
rawModelGroup: null, rawModelGroup: null,
baseModelScale: 1, baseModelScale: 1,
dicomSceneBox: null,
resizeObserver: null, resizeObserver: null,
resizeScene: null,
}; };
function escapeHtml(value) { function escapeHtml(value) {
@@ -98,6 +105,13 @@ function setSaveState(text, tone = "") {
el.className = tone; el.className = tone;
} }
function setAutoState(text, tone = "") {
const el = $("autoState");
if (!el) return;
el.textContent = text;
el.className = tone;
}
function markDirty() { function markDirty() {
state.dirty = true; state.dirty = true;
setSaveState("未保存", "warn"); setSaveState("未保存", "warn");
@@ -298,9 +312,16 @@ function clearDetail() {
$("activeTitle").textContent = "未选择 CT"; $("activeTitle").textContent = "未选择 CT";
$("activeMeta").textContent = "从左侧选择一个完整关联检查"; $("activeMeta").textContent = "从左侧选择一个完整关联检查";
$("activeTags").innerHTML = ""; $("activeTags").innerHTML = "";
$("patientSummary").textContent = "未选择 CT";
$("summaryCt").textContent = "-";
$("summaryPatientId").textContent = "-";
$("summaryStudyTime").textContent = "-";
$("summaryBodyPart").textContent = "-";
$("matchedSeries").innerHTML = "";
$("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`; $("seriesList").innerHTML = `<div class="empty-state">未选择 CT</div>`;
$("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`; $("stlList").innerHTML = `<div class="empty-state">未选择 STL</div>`;
$("modelRail").innerHTML = ""; $("modelRail").innerHTML = "";
renderDicomAnnotation();
clearFusion(); clearFusion();
} }
@@ -327,6 +348,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") {
state.stlFiles = stlPayload.files || []; state.stlFiles = stlPayload.files || [];
state.registrationStatus = registration.registration_status || "unregistered"; state.registrationStatus = registration.registration_status || "unregistered";
state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) }; state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) };
state.autoResult = null;
state.sliceIndex = 0; state.sliceIndex = 0;
$("notes").value = registration.notes || ""; $("notes").value = registration.notes || "";
@@ -389,34 +411,43 @@ function renderActiveHeader() {
$("activeTags").innerHTML = tags.join(""); $("activeTags").innerHTML = tags.join("");
$("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准"; $("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准";
$("statusBtn").classList.toggle("unregistered", 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() { function renderSeries() {
$("seriesCount").textContent = state.series.length; $("seriesCount").textContent = state.series.length;
const activeSeries = currentSeries();
$("matchedSeries").innerHTML = activeSeries ? seriesCardHtml(activeSeries, true) : `<div class="empty-state">尚未选择配准序列</div>`;
$("seriesList").innerHTML = state.series.length $("seriesList").innerHTML = state.series.length
? state.series ? state.series
.map((item) => { .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid))
const active = item.series_uid === state.selectedSeriesUid; .join("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
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 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); const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean);
return ` return `
<button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}"> <button class="series-card ${active ? "active" : ""}" data-series="${escapeHtml(item.series_uid)}" title="${escapeHtml(item.description)}">
<strong>${escapeHtml(item.description || "未命名序列")}</strong> <strong>${escapeHtml(item.description || "未命名序列")}</strong>
<span>拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")}</span> <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> <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"> <div class="mini-tags">
${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""} ${item.body_part_dicom ? `<i>${escapeHtml(item.body_part_dicom)}</i>` : ""}
${labels || `<i>未标注</i>`} ${labels || `<i>未标注</i>`}
</div> </div>
</button> </button>
`; `;
})
.join("")
: `<div class="empty-state">未读取到 DICOM 序列</div>`;
$("seriesList").querySelectorAll(".series-card").forEach((button) => {
button.addEventListener("click", () => selectSeries(button.dataset.series));
});
updateSliceControl();
} }
async function selectSeries(uid) { 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; state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0;
markDirty(); markDirty();
renderSeries(); renderSeries();
renderDicomAnnotation();
await loadFusion(); await loadFusion();
} }
@@ -479,8 +511,11 @@ function buildPoseControls() {
(item) => ` (item) => `
<div class="pose-control" data-pose="${item.key}"> <div class="pose-control" data-pose="${item.key}">
<label>${item.label}</label> <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}" /> <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}" /> <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> </div>
`, `,
).join(""); ).join("");
@@ -499,6 +534,9 @@ function buildPoseControls() {
range.addEventListener("input", () => sync(range.value, range)); range.addEventListener("input", () => sync(range.value, range));
number.addEventListener("input", () => sync(number.value, number)); 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) => { document.querySelectorAll("[data-flip]").forEach((button) => {
button.addEventListener("click", () => { button.addEventListener("click", () => {
const key = button.dataset.flip; 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() { function renderPoseControls() {
POSE_CONTROLS.forEach((item) => { POSE_CONTROLS.forEach((item) => {
const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`); const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`);
@@ -532,6 +581,46 @@ function updateSliceControl() {
$("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0"; $("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() { function initScene() {
if (state.sceneReady) return; if (state.sceneReady) return;
const viewport = $("fusionViewport"); const viewport = $("fusionViewport");
@@ -570,6 +659,7 @@ function initScene() {
camera.aspect = rect.width / rect.height; camera.aspect = rect.width / rect.height;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
}; };
state.resizeScene = resize;
state.resizeObserver = new ResizeObserver(resize); state.resizeObserver = new ResizeObserver(resize);
state.resizeObserver.observe(viewport); state.resizeObserver.observe(viewport);
resize(); resize();
@@ -606,6 +696,14 @@ function clearFusion() {
updateSliceControl(); 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() { async function loadFusion() {
const version = ++state.fusionVersion; const version = ++state.fusionVersion;
const selected = currentSeries(); const selected = currentSeries();
@@ -634,9 +732,12 @@ async function loadFusion() {
if (version !== state.fusionVersion) return; if (version !== state.fusionVersion) return;
await buildStlModels(volume); await buildStlModels(volume);
if (version !== state.fusionVersion) return; if (version !== state.fusionVersion) return;
applyFusionMode();
fitCamera(); fitCamera();
$("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}`; $("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)}`;
$("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size}`; $("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size}`;
renderDicomAnnotation();
updateDicomPreview();
} catch (error) { } catch (error) {
clearFusion(); clearFusion();
$("fusionStatus").textContent = error.message; $("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 })); const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 }));
state.dicomGroup.add(lines); state.dicomGroup.add(lines);
state.baseModelScale = sceneScale; state.baseModelScale = sceneScale;
state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup);
} }
async function buildStlModels(volume) { async function buildStlModels(volume) {
@@ -723,9 +825,9 @@ async function buildStlModels(volume) {
applyPose(); applyPose();
} }
function applyPose() { function applyPose(poseInput = state.pose) {
if (!state.modelGroup) return; if (!state.modelGroup) return;
const pose = { ...DEFAULT_POSE, ...state.pose }; const pose = { ...DEFAULT_POSE, ...poseInput };
state.modelGroup.rotation.set( state.modelGroup.rotation.set(
THREE.MathUtils.degToRad(Number(pose.rotateX) || 0), THREE.MathUtils.degToRad(Number(pose.rotateX) || 0),
THREE.MathUtils.degToRad(Number(pose.rotateY) || 0), THREE.MathUtils.degToRad(Number(pose.rotateY) || 0),
@@ -751,6 +853,120 @@ function fitCamera() {
state.controls.update(); 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) { async function saveRegistration(nextStatus = null) {
if (!state.activeCase || !currentSeries() || state.saving) return; if (!state.activeCase || !currentSeries() || state.saving) return;
state.saving = true; state.saving = true;
@@ -792,6 +1008,8 @@ async function saveRegistration(nextStatus = null) {
algorithm_model: state.algorithmModel, algorithm_model: state.algorithmModel,
selected_count: selectedFiles.length, selected_count: selectedFiles.length,
families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))], families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))],
fusion_mode: state.fusionMode,
auto_result: state.autoResult,
}, },
notes: $("notes").value, notes: $("notes").value,
}), }),
@@ -846,16 +1064,42 @@ function openLinkedApp(kind) {
function wireEvents() { function wireEvents() {
$("loginForm").addEventListener("submit", login); $("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", () => logout()); $("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 () => { $("refreshBtn").addEventListener("click", async () => {
await loadStatus(); await loadStatus();
await loadCases(false); await loadCases(false);
}); });
$("relationBtn").addEventListener("click", () => openLinkedApp("relation")); $("relationBtn").addEventListener("click", () => openLinkedApp("relation"));
$("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("openViewerBtn").addEventListener("click", () => openLinkedApp("viewer"));
$("reloadFusionBtn").addEventListener("click", () => loadFusion());
$("fitBtn").addEventListener("click", fitCamera); $("fitBtn").addEventListener("click", fitCamera);
$("saveBtn").addEventListener("click", () => saveRegistration()); $("saveBtn").addEventListener("click", () => saveRegistration());
$("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered"));
$("notes").addEventListener("input", markDirty); $("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", () => { $("caseSearch").addEventListener("input", () => {
state.search = $("caseSearch").value.trim(); state.search = $("caseSearch").value.trim();
window.clearTimeout(state.searchTimer); window.clearTimeout(state.searchTimer);
@@ -875,16 +1119,26 @@ function wireEvents() {
await loadCases(false); await loadCases(false);
}); });
}); });
document.querySelectorAll(".segmented button").forEach((button) => { document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", async () => { button.addEventListener("click", async () => {
state.windowMode = button.dataset.window || "default"; 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(); 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", () => { $("sliceSlider").addEventListener("input", () => {
state.sliceIndex = Number($("sliceSlider").value || 0); state.sliceIndex = Number($("sliceSlider").value || 0);
updateSliceControl(); updateSliceControl();
renderDicomAnnotation();
updateDicomPreview();
}); });
$("sliceSlider").addEventListener("change", () => loadFusion()); $("sliceSlider").addEventListener("change", () => loadFusion());
} }

View File

@@ -36,6 +36,9 @@
</div> </div>
</div> </div>
<div class="top-actions"> <div class="top-actions">
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
<span></span><span></span><span></span>
</button>
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button> <button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button> <button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
<span id="dbStatus" class="status-pill">数据库</span> <span id="dbStatus" class="status-pill">数据库</span>
@@ -81,17 +84,90 @@
<div id="activeTags" class="tag-line"></div> <div id="activeTags" class="tag-line"></div>
</section> </section>
<section class="work-grid"> <section class="registration-board">
<aside class="series-panel panel"> <aside class="tool-dock panel">
<div class="panel-head compact"> <div class="tool-tabs">
<h2>DICOM 序列</h2> <button class="active" data-tool-tab="series">序列</button>
<span id="seriesCount">0</span> <button data-tool-tab="models">组织分割</button>
<button data-tool-tab="pose">标记</button>
</div> </div>
<div id="seriesList" class="series-list"></div>
<section class="tool-pane active" data-tool-pane="series">
<div class="patient-card">
<strong id="patientSummary">未选择 CT</strong>
<dl>
<div><dt>检查号</dt><dd id="summaryCt">-</dd></div>
<div><dt>患者号</dt><dd id="summaryPatientId">-</dd></div>
<div><dt>检查日期</dt><dd id="summaryStudyTime">-</dd></div>
<div><dt>检查部位</dt><dd id="summaryBodyPart">-</dd></div>
</dl>
</div>
<div class="tool-section-title">
<span>3D 重建所需序列</span>
<button id="reloadFusionBtn" type="button">重选序列</button>
</div>
<div id="matchedSeries" class="matched-series"></div>
<div class="tool-section-title">
<span>全部检查和序列</span>
<em id="seriesCount">0</em>
</div>
<div id="seriesList" class="series-list compact-list"></div>
</section>
<section class="tool-pane" data-tool-pane="models">
<div class="tool-section-title">
<span>STL 模型</span>
<em id="stlCount">0</em>
</div>
<div id="modelRail" class="chip-rail"></div>
<div id="stlList" class="stl-list compact-list"></div>
</section>
<section class="tool-pane" data-tool-pane="pose">
<div class="tool-section-title">
<span>位姿手动调整</span>
<em id="saveState">未保存</em>
</div>
<div class="pose-actions">
<button id="resetRotationBtn" type="button">重置旋转</button>
<button id="resetTransformBtn" type="button">重置平移缩放</button>
<button id="resetFlipBtn" type="button">重置镜像</button>
</div>
<div class="flip-row">
<button data-flip="flipX">镜像 X</button>
<button data-flip="flipY">镜像 Y</button>
<button data-flip="flipZ">镜像 Z</button>
</div>
<div id="poseGrid" class="pose-grid"></div>
<div class="tool-section-title">
<span>位姿自动调整</span>
<em id="autoState">未运行</em>
</div>
<div class="auto-box">
<div class="auto-options">
<label><input id="autoX" type="checkbox" checked /> X</label>
<label><input id="autoY" type="checkbox" checked /> Y</label>
<label><input id="autoZ" type="checkbox" checked /> Z</label>
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
</div>
<div class="pose-actions">
<button id="suggestBoneBtn" type="button">建议骨骼模型</button>
<button id="autoCoarseBtn" type="button">自动粗配准</button>
<button id="autoFineBtn" type="button">自动微调</button>
</div>
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
</div>
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
</section>
</aside> </aside>
<section class="viewer-panel panel"> <section class="viewer-panel fusion-panel panel">
<div class="viewer-head"> <div class="viewer-head">
<div class="segmented">
<button data-fusion-mode="fusion" class="active">融合结果</button>
<button data-fusion-mode="model">单独模型</button>
</div>
<div class="segmented"> <div class="segmented">
<button data-window="default" class="active">默认</button> <button data-window="default" class="active">默认</button>
<button data-window="bone">骨窗</button> <button data-window="bone">骨窗</button>
@@ -119,30 +195,28 @@
</div> </div>
</section> </section>
<aside class="right-panel"> <section class="viewer-panel dicom-panel panel">
<section class="panel stl-panel"> <div class="viewer-head">
<div class="panel-head compact"> <div>
<h2>STL 模型</h2> <h2>DICOM 标注</h2>
<span id="stlCount">0</span> <p id="dicomPanelMeta">当前序列切片</p>
</div> </div>
<div id="modelRail" class="chip-rail"></div> <button id="openViewerBtn" class="ghost-btn" type="button">阅片系统</button>
<div id="stlList" class="stl-list"></div> </div>
<div class="dicom-stage">
<img id="dicomPreview" alt="DICOM 切片" />
<div class="crosshair horizontal"></div>
<div class="crosshair vertical"></div>
<div class="dicom-overlay top-left" id="dicomInfoLeft">未选择序列</div>
<div class="dicom-overlay top-right" id="dicomInfoRight"></div>
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
<div id="dicomLoading" class="fusion-loading hidden">
<span>正在加载 DICOM 标注视图</span>
<div><i></i></div>
</div>
</div>
<div id="annotationTags" class="annotation-tags"></div>
</section> </section>
<section class="panel pose-panel">
<div class="panel-head compact">
<h2>位姿参数</h2>
<span id="saveState">未保存</span>
</div>
<div id="poseGrid" class="pose-grid"></div>
<div class="flip-row">
<button data-flip="flipX">镜像 X</button>
<button data-flip="flipY">镜像 Y</button>
<button data-flip="flipZ">镜像 Z</button>
</div>
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
</section>
</aside>
</section> </section>
</section> </section>
</main> </main>

View File

@@ -111,6 +111,50 @@ button {
grid-template-columns: 338px minmax(0, 1fr); grid-template-columns: 338px minmax(0, 1fr);
gap: 12px; gap: 12px;
padding: 12px; padding: 12px;
transition: grid-template-columns 0.22s ease;
}
.workspace.case-collapsed {
grid-template-columns: 0 minmax(0, 1fr);
}
.workspace.case-collapsed .case-panel {
opacity: 0;
pointer-events: none;
transform: translateX(-18px);
}
.icon-btn {
width: 38px;
height: 34px;
display: grid;
grid-template-columns: 8px 1fr;
grid-template-rows: repeat(2, 1fr);
gap: 4px;
flex: 0 0 auto;
border: 1px solid rgba(148, 163, 184, 0.38);
border-radius: 10px;
background: linear-gradient(180deg, rgba(25, 38, 56, 0.95), rgba(9, 15, 23, 0.95));
padding: 7px;
}
.icon-btn span {
border: 2px solid #afbfd2;
border-radius: 3px;
}
.icon-btn span:first-child {
grid-row: 1 / 3;
}
.icon-btn span:nth-child(2),
.icon-btn span:nth-child(3) {
grid-column: 2;
}
.icon-btn.active {
border-color: rgba(25, 214, 195, 0.66);
background: rgba(20, 184, 166, 0.18);
} }
.panel { .panel {
@@ -153,6 +197,8 @@ button {
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden;
transition: opacity 0.2s ease, transform 0.2s ease;
} }
.search-input, .search-input,
@@ -179,6 +225,11 @@ button {
padding: 10px; padding: 10px;
} }
.tool-pane .notes {
width: 100%;
margin: 10px 0 0;
}
.filter-row, .filter-row,
.part-filter, .part-filter,
.chip-rail, .chip-rail,
@@ -189,6 +240,10 @@ button {
overflow-x: auto; overflow-x: auto;
} }
.tool-pane .chip-rail {
padding: 0 0 10px;
}
.filter, .filter,
.chip, .chip,
.tag, .tag,
@@ -376,6 +431,148 @@ button {
gap: 12px; gap: 12px;
} }
.registration-board {
min-height: 0;
display: grid;
grid-template-columns: 360px minmax(520px, 1.08fr) minmax(420px, 0.92fr);
gap: 12px;
}
.tool-dock {
min-width: 0;
min-height: 0;
display: grid;
grid-template-rows: 44px minmax(0, 1fr);
overflow: hidden;
}
.tool-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-bottom: 1px solid var(--line);
background: rgba(6, 10, 15, 0.55);
}
.tool-tabs button {
border: 0;
border-right: 1px solid rgba(148, 163, 184, 0.2);
background: rgba(18, 24, 36, 0.72);
color: #c5d3e5;
font-weight: 900;
}
.tool-tabs button.active {
background: linear-gradient(180deg, rgba(75, 86, 111, 0.88), rgba(30, 39, 58, 0.9));
color: #ffffff;
box-shadow: inset 0 -2px 0 rgba(25, 214, 195, 0.75);
}
.tool-pane {
min-height: 0;
display: none;
overflow-y: auto;
padding: 10px;
}
.tool-pane.active {
display: flex;
flex-direction: column;
}
.patient-card {
flex: 0 0 auto;
margin-bottom: 10px;
border: 1px solid rgba(148, 163, 184, 0.16);
border-radius: 10px;
background: linear-gradient(180deg, rgba(28, 35, 49, 0.92), rgba(15, 21, 31, 0.92));
padding: 12px;
}
.patient-card strong {
display: block;
overflow: hidden;
color: #fff;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.patient-card dl {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 9px 14px;
margin: 12px 0 0;
}
.patient-card div {
min-width: 0;
}
.patient-card dt {
color: var(--muted);
font-size: 11px;
}
.patient-card dd {
margin: 3px 0 0;
overflow: hidden;
color: #edf5ff;
font-size: 12px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.tool-section-title {
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex: 0 0 auto;
margin: 8px -10px 8px;
border-top: 1px solid rgba(148, 163, 184, 0.15);
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
background: rgba(24, 31, 45, 0.9);
color: #f1f7ff;
font-size: 13px;
font-weight: 900;
padding: 0 10px;
}
.tool-section-title button {
border: 0;
background: transparent;
color: #c4d7ee;
font-size: 12px;
font-weight: 900;
}
.tool-section-title em {
color: var(--muted);
font-size: 12px;
font-style: normal;
}
.matched-series {
flex: 0 0 auto;
}
.matched-series .series-card {
border-color: rgba(25, 214, 195, 0.75);
background: rgba(20, 184, 166, 0.12);
}
.compact-list {
min-height: 0;
padding: 0;
}
.tool-pane .compact-list {
flex: 1;
}
.series-panel, .series-panel,
.right-panel { .right-panel {
min-height: 0; min-height: 0;
@@ -402,6 +599,10 @@ button {
overflow: hidden; overflow: hidden;
} }
.dicom-panel {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.viewer-head { .viewer-head {
min-height: 58px; min-height: 58px;
display: flex; display: flex;
@@ -410,7 +611,23 @@ button {
gap: 12px; gap: 12px;
padding: 10px 12px; padding: 10px 12px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
overflow-x: auto; overflow: hidden;
}
.fusion-panel .viewer-head {
align-content: center;
flex-wrap: wrap;
}
.viewer-head h2 {
margin: 0;
font-size: 16px;
}
.viewer-head p {
margin: 3px 0 0;
color: var(--muted);
font-size: 11px;
} }
.segmented { .segmented {
@@ -506,15 +723,18 @@ button {
white-space: nowrap; white-space: nowrap;
} }
#saveState.ok { #saveState.ok,
#autoState.ok {
color: #b8f7da; color: #b8f7da;
} }
#saveState.warn { #saveState.warn,
#autoState.warn {
color: #ffe1a6; color: #ffe1a6;
} }
#saveState.error { #saveState.error,
#autoState.error {
color: #fecdd3; color: #fecdd3;
} }
@@ -628,6 +848,94 @@ button {
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} }
.dicom-stage {
position: relative;
min-height: 0;
overflow: hidden;
background: #000;
}
.dicom-stage img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: contain;
image-rendering: auto;
}
.crosshair {
position: absolute;
z-index: 3;
pointer-events: none;
opacity: 0.82;
}
.crosshair.horizontal {
top: 50%;
left: 0;
right: 0;
height: 1px;
background: rgba(52, 211, 153, 0.72);
}
.crosshair.vertical {
top: 0;
bottom: 0;
left: 50%;
width: 1px;
background: rgba(248, 113, 113, 0.72);
}
.dicom-overlay {
position: absolute;
z-index: 4;
max-width: 48%;
color: rgba(255, 255, 255, 0.86);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.35;
text-shadow: 0 1px 2px #000;
white-space: pre-line;
}
.dicom-overlay.top-left {
top: 12px;
left: 12px;
}
.dicom-overlay.top-right {
top: 12px;
right: 12px;
text-align: right;
}
.dicom-overlay.bottom-left {
bottom: 12px;
left: 12px;
}
.annotation-tags {
min-height: 48px;
display: flex;
gap: 8px;
align-items: center;
overflow-x: auto;
border-top: 1px solid var(--line);
padding: 8px 12px;
}
.annotation-tags span {
flex: 0 0 auto;
border: 1px solid rgba(25, 214, 195, 0.55);
border-radius: 999px;
background: rgba(20, 184, 166, 0.12);
color: #ccfbf1;
font-size: 12px;
font-weight: 900;
padding: 5px 10px;
}
.slice-row span { .slice-row span {
color: var(--muted); color: var(--muted);
font-size: 12px; font-size: 12px;
@@ -663,13 +971,13 @@ input[type="range"] {
.pose-grid { .pose-grid {
display: grid; display: grid;
gap: 10px; gap: 10px;
padding: 0 12px 12px; padding: 0 0 12px;
} }
.pose-control { .pose-control {
display: grid; display: grid;
grid-template-columns: 72px minmax(0, 1fr) 72px; grid-template-columns: 56px 26px minmax(0, 1fr) 26px 64px;
gap: 8px; gap: 6px;
align-items: center; align-items: center;
} }
@@ -690,11 +998,44 @@ input[type="range"] {
padding: 0 7px; padding: 0 7px;
} }
.pose-control button,
.pose-actions button,
.auto-box button {
min-height: 28px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(8, 13, 20, 0.82);
color: #c6d4e5;
font-size: 11px;
font-weight: 900;
}
.pose-control button:hover,
.pose-actions button:hover,
.auto-box button:hover {
border-color: rgba(25, 214, 195, 0.65);
color: #ccfbf1;
}
.quick-row {
grid-column: 2 / span 3;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.pose-actions {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 10px;
}
.flip-row { .flip-row {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
gap: 8px; gap: 8px;
padding: 0 12px 12px; padding: 0 0 12px;
} }
.flip-row button { .flip-row button {
@@ -712,6 +1053,45 @@ input[type="range"] {
background: rgba(20, 184, 166, 0.16); background: rgba(20, 184, 166, 0.16);
} }
.auto-box {
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 10px;
background: rgba(8, 13, 20, 0.62);
padding: 10px;
}
.auto-options {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 10px;
}
.auto-options label {
min-height: 28px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
background: rgba(15, 22, 32, 0.82);
color: #c7d6e8;
font-size: 11px;
font-weight: 900;
}
.auto-result {
min-height: 36px;
margin-top: 8px;
border-radius: 8px;
background: rgba(7, 10, 15, 0.78);
color: var(--muted);
font-size: 11px;
line-height: 1.5;
padding: 8px;
}
.empty-state { .empty-state {
padding: 22px; padding: 22px;
color: var(--muted); color: var(--muted);
@@ -776,4 +1156,15 @@ input[type="range"] {
.work-grid { .work-grid {
grid-template-columns: 270px minmax(360px, 1fr) 300px; grid-template-columns: 270px minmax(360px, 1fr) 300px;
} }
.registration-board {
grid-template-columns: 320px minmax(460px, 1fr) minmax(360px, 0.8fr);
}
}
@media (max-width: 1250px) {
.registration-board {
overflow-x: auto;
grid-template-columns: 320px 560px 420px;
}
} }