From 7a38272e8bff5c9a4fb2b7ead5363c3e509ce0d0 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 22:38:27 +0800 Subject: [PATCH] Refine DICOM UPP registration workstation layout --- DICOM_and_UPP配准/static/app.js | 296 ++++++++++++++++++-- DICOM_and_UPP配准/static/index.html | 132 +++++++-- DICOM_and_UPP配准/static/styles.css | 407 +++++++++++++++++++++++++++- 3 files changed, 777 insertions(+), 58 deletions(-) diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index e7e5d7f..c7a832b 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -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 = `
未选择 CT
`; $("stlList").innerHTML = `
未选择 STL
`; $("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) : `
尚未选择配准序列
`; $("seriesList").innerHTML = state.series.length ? state.series - .map((item) => { - const active = item.series_uid === state.selectedSeriesUid; - const labels = (item.annotation_labels || []).map((label) => `${escapeHtml(label)}`).join(""); - const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean); - return ` - - `; - }) + .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid)) .join("") : `
未读取到 DICOM 序列
`; - $("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) => `${escapeHtml(label)}`).join(""); + const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean); + return ` + + `; } 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) => `
+ + + ${item.key.startsWith("rotate") ? `
` : ""}
`, ).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) => `${escapeHtml(tag)}`).join("") + : `未标注`; +} + +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()); } diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index fcc2259..c404c1f 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -36,6 +36,9 @@
+ 数据库 @@ -81,17 +84,90 @@
-
-
diff --git a/DICOM_and_UPP配准/static/styles.css b/DICOM_and_UPP配准/static/styles.css index ab54144..0c88d96 100644 --- a/DICOM_and_UPP配准/static/styles.css +++ b/DICOM_and_UPP配准/static/styles.css @@ -111,6 +111,50 @@ button { grid-template-columns: 338px minmax(0, 1fr); gap: 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 { @@ -153,6 +197,8 @@ button { min-height: 0; display: flex; flex-direction: column; + overflow: hidden; + transition: opacity 0.2s ease, transform 0.2s ease; } .search-input, @@ -179,6 +225,11 @@ button { padding: 10px; } +.tool-pane .notes { + width: 100%; + margin: 10px 0 0; +} + .filter-row, .part-filter, .chip-rail, @@ -189,6 +240,10 @@ button { overflow-x: auto; } +.tool-pane .chip-rail { + padding: 0 0 10px; +} + .filter, .chip, .tag, @@ -376,6 +431,148 @@ button { 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, .right-panel { min-height: 0; @@ -402,6 +599,10 @@ button { overflow: hidden; } +.dicom-panel { + grid-template-rows: auto minmax(0, 1fr) auto; +} + .viewer-head { min-height: 58px; display: flex; @@ -410,7 +611,23 @@ button { gap: 12px; padding: 10px 12px; 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 { @@ -506,15 +723,18 @@ button { white-space: nowrap; } -#saveState.ok { +#saveState.ok, +#autoState.ok { color: #b8f7da; } -#saveState.warn { +#saveState.warn, +#autoState.warn { color: #ffe1a6; } -#saveState.error { +#saveState.error, +#autoState.error { color: #fecdd3; } @@ -628,6 +848,94 @@ button { 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 { color: var(--muted); font-size: 12px; @@ -663,13 +971,13 @@ input[type="range"] { .pose-grid { display: grid; gap: 10px; - padding: 0 12px 12px; + padding: 0 0 12px; } .pose-control { display: grid; - grid-template-columns: 72px minmax(0, 1fr) 72px; - gap: 8px; + grid-template-columns: 56px 26px minmax(0, 1fr) 26px 64px; + gap: 6px; align-items: center; } @@ -690,11 +998,44 @@ input[type="range"] { 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 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; - padding: 0 12px 12px; + padding: 0 0 12px; } .flip-row button { @@ -712,6 +1053,45 @@ input[type="range"] { 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 { padding: 22px; color: var(--muted); @@ -776,4 +1156,15 @@ input[type="range"] { .work-grid { 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; + } }