From c4f0fd54d6e5c2d423019eee5a6dc1c1388de835 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 23:43:49 +0800 Subject: [PATCH] Migrate registration workspace fusion mapping views --- DICOM_and_UPP配准/static/app.js | 580 +++++++++++++++++++++++++--- DICOM_and_UPP配准/static/index.html | 50 ++- DICOM_and_UPP配准/static/styles.css | 237 +++++++++++- 3 files changed, 802 insertions(+), 65 deletions(-) diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index c7a832b..c3927fc 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -20,6 +20,9 @@ const DEFAULT_POSE = { translateY: 0, translateZ: 0, scale: 1, + scaleX: 1, + scaleY: 1, + scaleZ: 1, flipX: false, flipY: false, flipZ: false, @@ -61,9 +64,11 @@ const state = { dicomPreviewTimer: 0, autoResult: null, dirty: false, + dirtyReason: "", saving: false, fusionVersion: 0, volumeMeta: null, + volumeScene: null, sceneReady: false, renderer: null, camera: null, @@ -72,8 +77,12 @@ const state = { dicomGroup: null, modelGroup: null, rawModelGroup: null, + modelMeshes: [], baseModelScale: 1, dicomSceneBox: null, + mappingViewport: { scale: 1, offsetX: 0, offsetY: 0 }, + mappingDrag: null, + mappingDrawFrame: 0, resizeObserver: null, resizeScene: null, }; @@ -112,13 +121,15 @@ function setAutoState(text, tone = "") { el.className = tone; } -function markDirty() { +function markDirty(reason = "pose") { state.dirty = true; + state.dirtyReason = reason; setSaveState("未保存", "warn"); } function resetDirty() { state.dirty = false; + state.dirtyReason = ""; setSaveState("已保存", "ok"); } @@ -185,6 +196,7 @@ async function bootstrap() { try { initScene(); } catch (error) { + initGeometryFallback(); $("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`; console.warn(error); } @@ -242,6 +254,10 @@ function sameCase(a, b) { return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型"); } +function sameCtNumber(a, b) { + return normalize(a) === normalize(b); +} + function normalize(value) { return String(value || "").trim().toUpperCase(); } @@ -263,6 +279,26 @@ function statusText(value) { return value === "registered" ? "已配准" : "未配准"; } +function seriesLabels(item) { + return Array.isArray(item?.annotation_labels) ? item.annotation_labels.map((label) => String(label || "")) : []; +} + +function isSkippedSeries(item) { + return seriesLabels(item).some((label) => label.includes("略过") || label.includes("不采用")); +} + +function hasLabel(item, matcher) { + return seriesLabels(item).some((label) => matcher(String(label))); +} + +function isUpperAbdomenSeries(item) { + return hasLabel(item, (label) => label.includes("上腹部")); +} + +function isPortalPhaseSeries(item) { + return hasLabel(item, (label) => label.includes("上腹部") && (label.includes("门脉期") || label.includes("门静脉期"))); +} + function bodyPartTags(row) { const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : []; const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : []; @@ -331,11 +367,17 @@ async function confirmChange() { } async function selectCase(ctNumber, algorithmModel = "未指定模型") { - if (!ctNumber || !(await confirmChange())) return; + if (!ctNumber) return; + const normalizedAlgorithm = algorithmModel || "未指定模型"; + const switchingModelOnly = state.activeCase + && sameCtNumber(state.activeCase.ct_number, ctNumber) + && (state.algorithmModel || "未指定模型") !== normalizedAlgorithm; + const previousPose = { ...state.pose }; + if (!switchingModelOnly && !(await confirmChange())) return; setTopLoading(true); setFusionLoading(true, "正在读取 CT 关联数据"); try { - const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" }); + const params = new URLSearchParams({ algorithm_model: normalizedAlgorithm }); const [detail, seriesPayload, stlPayload, registration] = await Promise.all([ api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`), api(`/api/cases/${encodeURIComponent(ctNumber)}/series`), @@ -347,7 +389,9 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { state.series = seriesPayload.series || []; state.stlFiles = stlPayload.files || []; state.registrationStatus = registration.registration_status || "unregistered"; - state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) }; + const savedTransform = registration.transform || {}; + const keepPreviousPose = switchingModelOnly; + state.pose = keepPreviousPose ? { ...DEFAULT_POSE, ...previousPose } : { ...DEFAULT_POSE, ...savedTransform }; state.autoResult = null; state.sliceIndex = 0; $("notes").value = registration.notes || ""; @@ -363,7 +407,8 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { renderSeries(); renderStl(); renderPoseControls(); - resetDirty(); + if (keepPreviousPose) markDirty("model"); + else resetDirty(); await loadFusion(); } catch (error) { $("fusionStatus").textContent = error.message; @@ -375,9 +420,27 @@ async function selectCase(ctNumber, algorithmModel = "未指定模型") { } function pickDefaultSeries(series) { - return [...series] - .filter((item) => Number(item.count || 0) > 1) - .sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[0] || series[0] || null; + const candidates = [...series].filter((item) => Number(item.count || 0) > 1); + const usable = candidates.filter((item) => !isSkippedSeries(item)); + const pool = usable.length ? usable : candidates; + const algorithm = String(state.algorithmModel || "").toLowerCase(); + const rank = (item) => { + let score = Number(item.count || 0) / 10000; + if (algorithm.includes("肝胆") || algorithm.includes("liver") || algorithm.includes("bile")) { + if (isPortalPhaseSeries(item)) score += 900; + else if (isUpperAbdomenSeries(item)) score += 720; + } else if (isUpperAbdomenSeries(item)) { + score += 120; + } + if (isSkippedSeries(item)) score -= 1000; + return score; + }; + return [...pool] + .sort((a, b) => { + const diff = rank(b) - rank(a); + if (Math.abs(diff) > 1e-6) return diff; + return Number(a.series_number || 999999) - Number(b.series_number || 999999); + })[0] || series[0] || null; } function pickDefaultStlIds(files) { @@ -437,8 +500,9 @@ function renderSeries() { 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); + const skipped = isSkippedSeries(item); return ` - 数据库 @@ -52,9 +49,14 @@