diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index 3653c30..37a41fa 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -57,6 +57,7 @@ const state = { activeCase: null, series: [], selectedSeriesUid: "", + registrationSeriesUid: "", stlFiles: [], selectedStlIds: new Set(), algorithmModel: "", @@ -460,6 +461,7 @@ function clearDetail() { state.stlFiles = []; state.selectedStlIds = new Set(); state.selectedSeriesUid = ""; + state.registrationSeriesUid = ""; $("activeTitle").textContent = "未选择 CT"; $("activeMeta").textContent = "从左侧选择一个完整关联检查"; $("activeTags").innerHTML = ""; @@ -513,6 +515,7 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { const defaultSeries = pickDefaultSeries(state.series); const savedSeries = state.series.find((item) => item.series_uid === registration.series_instance_uid); const shouldUseSavedSeries = registration.registration_status === "registered" && savedSeries; + state.registrationSeriesUid = savedSeries?.series_uid || ""; state.selectedSeriesUid = shouldUseSavedSeries ? savedSeries.series_uid : defaultSeries?.series_uid || ""; const selected = currentSeries(); state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; @@ -583,14 +586,15 @@ function renderActiveHeader() { const row = state.activeCase; if (!row) return; const selected = currentSeries(); + const registration = registrationSeries(); const bodyTags = bodyPartTags(row); - const selectedLabels = selected?.annotation_labels?.length ? selected.annotation_labels.filter((label) => !String(label).includes("略过") && !String(label).includes("不采用")) : []; + const selectedLabels = registration?.annotation_labels?.length ? registration.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 || "-"} · ${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 = [ `${statusText(state.registrationStatus)}`, `${escapeHtml(state.algorithmModel || row.algorithm_model || "未指定模型")}`, - ...(selected ? [`${escapeHtml(selected.description || "当前序列")}`] : []), + ...(registration ? [`配准 ${escapeHtml(registration.description || "当前序列")}`] : []), ...selectedLabels.map((tag) => `${escapeHtml(tag)}`), ...bodyTags.map((tag) => `${escapeHtml(tag)}`), ]; @@ -603,23 +607,37 @@ function renderSeries() { $("seriesCount").textContent = state.series.length; $("seriesList").innerHTML = state.series.length ? sortedSeriesForDisplay(state.series) - .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid)) + .map((item) => seriesCardHtml(item, item.series_uid === state.selectedSeriesUid, item.series_uid === state.registrationSeriesUid)) .join("") : `
未读取到 DICOM 序列
`; - document.querySelectorAll(".series-card[data-series]").forEach((button) => { - button.addEventListener("click", () => selectSeries(button.dataset.series)); + document.querySelectorAll(".series-card[data-series]").forEach((card) => { + card.addEventListener("click", (event) => { + if (event.target.closest(".series-check")) return; + selectSeries(card.dataset.series); + }); + card.addEventListener("keydown", (event) => { + if (!["Enter", " "].includes(event.key)) return; + event.preventDefault(); + selectSeries(card.dataset.series); + }); + }); + document.querySelectorAll(".series-check[data-registration-series]").forEach((button) => { + button.addEventListener("click", async (event) => { + event.stopPropagation(); + await setRegistrationSeries(button.dataset.registrationSeries); + }); }); updateSliceControl(); renderDicomAnnotation(); } -function seriesCardHtml(item, active = false) { +function seriesCardHtml(item, active = false, registrationSelected = 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); const skipped = isSkippedSeries(item); return ` - ${escapeHtml(item.description || "未命名序列")} 拍摄 ${escapeHtml(time.length > 1 ? `${time[0]}-${time[1]}` : time[0] || "-")} ${escapeHtml(item.slice_thickness || "厚度未知")} · 序列 ${escapeHtml(item.series_number || "-")} · ${escapeHtml(item.rows || "-")}×${escapeHtml(item.columns || "-")} · ${escapeHtml(item.modality || "CT")} · ${Number(item.count || 0)} 张 @@ -627,20 +645,39 @@ function seriesCardHtml(item, active = false) { ${item.body_part_dicom ? `${escapeHtml(item.body_part_dicom)}` : ""} ${labels || `未标注`} - + `; } -async function selectSeries(uid) { +function registrationSeries() { + return state.series.find((item) => item.series_uid === state.registrationSeriesUid) || null; +} + +async function setRegistrationSeries(uid) { + if (!uid) return; + if (uid !== state.selectedSeriesUid) { + await selectSeries(uid, { markRegistration: true }); + return; + } + state.registrationSeriesUid = uid; + markDirty("series"); + renderSeries(); + renderActiveHeader(); +} + +async function selectSeries(uid, options = {}) { if (!uid || uid === state.selectedSeriesUid) return; if (!(await confirmChange())) return; state.selectedSeriesUid = uid; + if (options.markRegistration) state.registrationSeriesUid = uid; const selected = currentSeries(); state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; state.sliceRangeStart = 0; state.sliceRangeEnd = selected ? Math.max(0, Number(selected.count || 1) - 1) : 0; - setSaveState("序列未保存", "warn"); + if (options.markRegistration) markDirty("series"); + else setSaveState("正在浏览序列", ""); renderSeries(); + renderActiveHeader(); renderDicomAnnotation(); await loadFusion(); } @@ -671,7 +708,7 @@ function renderStl() { const id = Number(input.dataset.stl); if (input.checked) state.selectedStlIds.add(id); else state.selectedStlIds.delete(id); - setSaveState("模型未保存", "warn"); + markDirty("stl"); renderStl(); await loadFusion(); }); @@ -679,6 +716,27 @@ function renderStl() { renderAutoBoneOptions(); } +async function selectAllStl() { + if (!state.stlFiles.length) return; + state.selectedStlIds = new Set(state.stlFiles.map((file) => Number(file.id)).filter(Number.isFinite)); + markDirty("stl"); + renderStl(); + await loadFusion(); +} + +async function invertStlSelection() { + if (!state.stlFiles.length) return; + const next = new Set(); + state.stlFiles.forEach((file) => { + const id = Number(file.id); + if (Number.isFinite(id) && !state.selectedStlIds.has(id)) next.add(id); + }); + state.selectedStlIds = next; + markDirty("stl"); + renderStl(); + await loadFusion(); +} + function stlLikelyScore(file) { const textValue = `${file.family || ""} ${file.segment_name || ""} ${file.file_name || ""} ${file.category || ""}`.toLowerCase(); const priority = [ @@ -1014,6 +1072,11 @@ function clearGroup(group) { function clearFusion() { clearGroup(state.dicomGroup); clearGroup(state.rawModelGroup); + if (state.rawModelGroup) { + state.rawModelGroup.position.set(0, 0, 0); + state.rawModelGroup.rotation.set(0, 0, 0); + state.rawModelGroup.scale.set(1, 1, 1); + } state.volumeMeta = null; state.volumeScene = null; state.modelMeshes = []; @@ -1571,15 +1634,49 @@ async function buildDicomVolume(volume) { state.dicomSceneBox = new THREE.Box3().setFromObject(state.dicomGroup); } +function updateBaseModelScaleFromBounds() { + const size = state.modelLocalBounds?.size; + if (!size) return; + const maxModelSize = Math.max(Number(size.x) || 0, Number(size.y) || 0, Number(size.z) || 0, 1); + const volumeSize = state.volumeScene + ? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1) + : 4.8; + state.baseModelScale = (volumeSize / maxModelSize) * 0.92; +} + +function addModelBoundsFrame(size) { + if (!state.rawModelGroup || !size) return; + const frame = new THREE.LineSegments( + new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)), + new THREE.LineBasicMaterial({ + color: 0xfacc15, + transparent: true, + opacity: 0.96, + depthTest: false, + depthWrite: false, + toneMapped: false, + }), + ); + frame.name = "model-bounds"; + frame.renderOrder = 1000; + state.rawModelGroup.add(frame); + state.modelBoundsFrame = frame; +} + async function buildStlModels(volume, onProgress = null) { const signature = stlSelectionSignature(); if (signature && signature === state.stlSignature && state.rawModelGroup?.children.length) { + updateBaseModelScaleFromBounds(); applyPose(); applyModelDetail(); + if (!state.modelBoundsFrame && state.modelLocalBounds?.size) addModelBoundsFrame(state.modelLocalBounds.size); scheduleMappingDraw(); onProgress?.(100); return; } + state.rawModelGroup.position.set(0, 0, 0); + state.rawModelGroup.rotation.set(0, 0, 0); + state.rawModelGroup.scale.set(1, 1, 1); clearGroup(state.rawModelGroup); state.modelMeshes = []; state.modelBoundsFrame = null; @@ -1622,35 +1719,28 @@ async function buildStlModels(volume, onProgress = null) { state.modelMeshes.push({ mesh, file, color: cssColor(index), index }); onProgress?.(((index + 1) / Math.max(files.length, 1)) * 100); } - const bbox = new THREE.Box3().setFromObject(state.rawModelGroup); + const bbox = new THREE.Box3(); + state.modelMeshes.forEach((record) => { + record.mesh.geometry.computeBoundingBox?.(); + if (record.mesh.geometry.boundingBox) bbox.union(record.mesh.geometry.boundingBox); + }); const center = new THREE.Vector3(); const size = new THREE.Vector3(); bbox.getCenter(center); bbox.getSize(size); - state.rawModelGroup.position.set(0, 0, 0); - state.rawModelGroup.traverse((object) => { - if (!object.geometry) return; - object.geometry.translate(-center.x, -center.y, -center.z); - object.geometry.computeBoundingBox?.(); - object.geometry.computeBoundingSphere?.(); - object.geometry.computeVertexNormals?.(); + state.modelMeshes.forEach((record) => { + const geometry = record.mesh.geometry; + geometry.translate(-center.x, -center.y, -center.z); + geometry.computeBoundingBox?.(); + geometry.computeBoundingSphere?.(); + geometry.computeVertexNormals?.(); }); - const maxModelSize = Math.max(size.x, size.y, size.z, 1); - const volumeSize = state.volumeScene - ? Math.max(state.volumeScene.width, state.volumeScene.height, state.volumeScene.depth, 1) - : 4.8; - state.baseModelScale = (volumeSize / maxModelSize) * 0.92; state.modelLocalBounds = { center: { x: center.x, y: center.y, z: center.z }, size: { x: size.x, y: size.y, z: size.z }, }; - const modelBounds = new THREE.LineSegments( - new THREE.EdgesGeometry(new THREE.BoxGeometry(size.x, size.y, size.z)), - new THREE.LineBasicMaterial({ color: 0xfacc15, transparent: true, opacity: 0.78 }), - ); - modelBounds.name = "model-bounds"; - state.rawModelGroup.add(modelBounds); - state.modelBoundsFrame = modelBounds; + updateBaseModelScaleFromBounds(); + addModelBoundsFrame(size); applyPose(); scheduleMappingDraw(); onProgress?.(100); @@ -1948,11 +2038,21 @@ async function saveRegistration(nextStatus = null) { }), }); state.registrationStatus = status; - if (state.activeCase) state.activeCase.registration_status = status; + state.registrationSeriesUid = selectedSeries.series_uid; + if (state.activeCase) { + state.activeCase.registration_status = status; + state.activeCase.series_instance_uid = selectedSeries.series_uid; + state.activeCase.series_description = selectedSeries.description || ""; + } const existing = state.cases.find((row) => sameCase(row, state.activeCase)); - if (existing) existing.registration_status = status; + if (existing) { + existing.registration_status = status; + existing.series_instance_uid = selectedSeries.series_uid; + existing.series_description = selectedSeries.description || ""; + } resetDirty(); renderCases(); + renderSeries(); renderActiveHeader(); } catch (error) { setSaveState(error.message, "error"); @@ -2038,6 +2138,8 @@ function wireEvents() { $("saveBtn").addEventListener("click", () => saveRegistration()); $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); $("notes").addEventListener("input", () => markDirty("notes")); + $("selectAllStlBtn").addEventListener("click", selectAllStl); + $("invertStlBtn").addEventListener("click", invertStlSelection); $("resetRotationBtn").addEventListener("click", () => resetPose("rotation")); $("resetTransformBtn").addEventListener("click", () => resetPose("transform")); $("resetFlipBtn").addEventListener("click", () => resetPose("flip")); diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index 48c1ce6..5194f51 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -170,6 +170,10 @@
STL +
+ + +
0
diff --git a/DICOM_and_UPP配准/static/styles.css b/DICOM_and_UPP配准/static/styles.css index 768f3b3..59e1ea8 100644 --- a/DICOM_and_UPP配准/static/styles.css +++ b/DICOM_and_UPP配准/static/styles.css @@ -499,30 +499,49 @@ button { .series-card { position: relative; padding-right: 48px; + cursor: pointer; } .series-check { position: absolute; top: 13px; right: 13px; - width: 26px; - height: 26px; + width: 30px; + height: 30px; display: inline-flex !important; align-items: center; justify-content: center; margin: 0 !important; - border: 1px solid rgba(25, 214, 195, 0.62); + padding: 0; + border: 2px solid rgba(45, 212, 191, 0.9); border-radius: 999px; - background: rgba(20, 184, 166, 0.16); + background: rgba(13, 148, 136, 0.22); color: #ccfbf1 !important; font-size: 15px !important; font-weight: 950 !important; line-height: 1; + cursor: pointer; + box-shadow: 0 0 0 1px rgba(6, 182, 212, 0.18), 0 0 18px rgba(20, 184, 166, 0.18); } -.series-card:not(.active) .series-check { - border-color: rgba(148, 163, 184, 0.2); - background: rgba(148, 163, 184, 0.06); +.series-card:not(.registration-selected) .series-check { + border-color: rgba(148, 163, 184, 0.42); + background: rgba(15, 23, 42, 0.82); + color: transparent !important; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.98); +} + +.series-card:not(.registration-selected) .series-check:hover, +.series-check:focus-visible { + border-color: rgba(45, 212, 191, 0.95); + background: rgba(20, 184, 166, 0.18); + outline: none; +} + +.series-card.registration-selected .series-check { + border-color: rgba(153, 246, 228, 0.96); + background: linear-gradient(180deg, rgba(20, 184, 166, 0.96), rgba(13, 148, 136, 0.82)); + color: #ecfeff !important; } .case-card.active, @@ -532,6 +551,10 @@ button { background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96)); } +.series-card.registration-selected { + border-color: rgba(20, 184, 166, 0.82); +} + .series-card.skipped { opacity: 0.54; filter: grayscale(0.95); @@ -754,11 +777,27 @@ button { } .tool-section-title button { - border: 0; - background: transparent; - color: #c4d7ee; + border: 1px solid rgba(45, 212, 191, 0.34); + border-radius: 8px; + background: rgba(8, 17, 26, 0.62); + color: #d9fbff; font-size: 12px; font-weight: 900; + line-height: 1; + padding: 6px 9px; + cursor: pointer; +} + +.tool-section-title button:hover { + border-color: rgba(45, 212, 191, 0.76); + background: rgba(20, 184, 166, 0.16); +} + +.title-actions { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; } .tool-section-title em {