diff --git a/DICOM_and_UPP配准/README.md b/DICOM_and_UPP配准/README.md index 3c6152c..2266578 100644 --- a/DICOM_and_UPP配准/README.md +++ b/DICOM_and_UPP配准/README.md @@ -2,6 +2,8 @@ 用于在浏览器中人工核对 DICOM 序列与 UPP STL 模型的配准关系。页面会读取 PACS DICOM、UPP STL 资产和 DICOM 阅片分类结果,配准位姿单独写入 `dicom_upp_registrations` 表。 +配准维护表按 `ct_number + algorithm_model` 建唯一约束:同一个 CT、同一个算法模型只保留一条 STL 配准记录;再次保存会更新原记录。 + 默认端口: - 本机:`http://127.0.0.1:8109/` diff --git a/DICOM_and_UPP配准/app.py b/DICOM_and_UPP配准/app.py index 2a1d3c6..74a5458 100644 --- a/DICOM_and_UPP配准/app.py +++ b/DICOM_and_UPP配准/app.py @@ -198,9 +198,95 @@ def ensure_registration_table() -> None: updated_by text NOT NULL DEFAULT 'admin', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), - UNIQUE (ct_number, series_instance_uid, algorithm_model, stl_family) + UNIQUE (ct_number, algorithm_model) ); + ALTER TABLE public.{REGISTRATION_TABLE_SQL} + ADD COLUMN IF NOT EXISTS series_instance_uid text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS series_description text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS algorithm_model text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS stl_family text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS view_mode text NOT NULL DEFAULT 'family', + ADD COLUMN IF NOT EXISTS selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb, + ADD COLUMN IF NOT EXISTS transform jsonb NOT NULL DEFAULT '{{}}'::jsonb, + ADD COLUMN IF NOT EXISTS dicom_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb, + ADD COLUMN IF NOT EXISTS model_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb, + ADD COLUMN IF NOT EXISTS segmentation jsonb NOT NULL DEFAULT '{{}}'::jsonb, + ADD COLUMN IF NOT EXISTS notes text NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS locked_by text, + ADD COLUMN IF NOT EXISTS locked_at timestamptz, + ADD COLUMN IF NOT EXISTS updated_by text NOT NULL DEFAULT 'admin', + ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + UPDATE public.{REGISTRATION_TABLE_SQL} + SET + ct_number = upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g')), + algorithm_model = COALESCE(NULLIF(btrim(algorithm_model), ''), '未指定模型'), + stl_family = COALESCE(NULLIF(btrim(stl_family), ''), '全部STL') + WHERE ct_number <> upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g')) + OR algorithm_model = '' + OR stl_family = ''; + CREATE TABLE IF NOT EXISTS public.{REGISTRATION_TABLE_SQL}_duplicate_archive ( + archived_at timestamptz NOT NULL DEFAULT now(), + reason text NOT NULL, + row_data jsonb NOT NULL + ); + WITH ranked AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY ct_number, algorithm_model + ORDER BY locked DESC, updated_at DESC NULLS LAST, id DESC + ) AS rn + FROM public.{REGISTRATION_TABLE_SQL} + ), + duplicates AS ( + SELECT t.* + FROM public.{REGISTRATION_TABLE_SQL} t + JOIN ranked r ON r.id = t.id + WHERE r.rn > 1 + ) + INSERT INTO public.{REGISTRATION_TABLE_SQL}_duplicate_archive(reason, row_data) + SELECT 'ct_algorithm_unique_migration', row_to_json(duplicates)::jsonb + FROM duplicates; + WITH ranked AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY ct_number, algorithm_model + ORDER BY locked DESC, updated_at DESC NULLS LAST, id DESC + ) AS rn + FROM public.{REGISTRATION_TABLE_SQL} + ) + DELETE FROM public.{REGISTRATION_TABLE_SQL} t + USING ranked r + WHERE t.id = r.id AND r.rn > 1; + DO $$ + DECLARE + old_constraint record; + BEGIN + FOR old_constraint IN + SELECT conname + FROM pg_constraint + WHERE conrelid = 'public.{REGISTRATION_TABLE_SQL}'::regclass + AND contype = 'u' + AND pg_get_constraintdef(oid) LIKE '%(ct_number, series_instance_uid, algorithm_model, stl_family)%' + LOOP + EXECUTE format('ALTER TABLE public.{REGISTRATION_TABLE_SQL} DROP CONSTRAINT %I', old_constraint.conname); + END LOOP; + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conrelid = 'public.{REGISTRATION_TABLE_SQL}'::regclass + AND contype = 'u' + AND conname = '{REGISTRATION_TABLE_SQL}_ct_algorithm_key' + ) THEN + ALTER TABLE public.{REGISTRATION_TABLE_SQL} + ADD CONSTRAINT {REGISTRATION_TABLE_SQL}_ct_algorithm_key UNIQUE (ct_number, algorithm_model); + END IF; + END $$; CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_ct ON public.{REGISTRATION_TABLE_SQL}(ct_number); + CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_algorithm ON public.{REGISTRATION_TABLE_SQL}(algorithm_model); CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_locked ON public.{REGISTRATION_TABLE_SQL}(locked); """, timeout=12, @@ -852,11 +938,14 @@ def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]: SELECT COALESCE(u.algorithm_model, '未指定模型') AS algorithm_model, u.processed_stl_dir, - COALESCE(s.files, u.stl_files, '[]'::jsonb) AS files + CASE + WHEN jsonb_typeof(u.stl_files) = 'array' AND jsonb_array_length(u.stl_files) > 0 THEN u.stl_files + ELSE COALESCE(s.files, '[]'::jsonb) + END AS files FROM public.{UPP_ASSET_TABLE_SQL} u LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(u.ct_number) WHERE upper(u.ct_number) = {sql_literal(normalize_ct(ct_number))} - ORDER BY u.updated_at DESC NULLS LAST + ORDER BY COALESCE(u.algorithm_model, '未指定模型'), u.updated_at DESC NULLS LAST """, timeout=14, ) @@ -950,9 +1039,7 @@ def save_registration(payload: RegistrationPayload, user: str = Depends(require_ SELECT locked FROM public.{REGISTRATION_TABLE_SQL} WHERE ct_number = {sql_literal(ct_number)} - AND series_instance_uid = {sql_literal(series_uid)} AND algorithm_model = {sql_literal(algorithm_model)} - AND stl_family = {sql_literal(stl_family)} LIMIT 1 """, timeout=8, @@ -989,8 +1076,10 @@ def save_registration(payload: RegistrationPayload, user: str = Depends(require_ {sql_literal(user)}, now() ) - ON CONFLICT (ct_number, series_instance_uid, algorithm_model, stl_family) DO UPDATE SET + ON CONFLICT (ct_number, algorithm_model) DO UPDATE SET + series_instance_uid = EXCLUDED.series_instance_uid, series_description = EXCLUDED.series_description, + stl_family = EXCLUDED.stl_family, view_mode = EXCLUDED.view_mode, selected_stl_files = EXCLUDED.selected_stl_files, transform = EXCLUDED.transform, diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index 8637405..1fa7971 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -12,6 +12,7 @@ const state = { stlFiles: [], selectedStlIds: new Set(), selectedFamilies: new Set(), + selectedAlgorithmModel: "", mode: "file", registrations: [], pose: defaultPose(), @@ -335,15 +336,46 @@ async function selectCase(ctKey) { function chooseDefaultStl() { state.selectedStlIds.clear(); state.selectedFamilies.clear(); - if (!state.stlFiles.length) return; - const families = Array.from(new Set(state.stlFiles.map((item) => item.family).filter(Boolean))); + if (!state.stlFiles.length) { + state.selectedAlgorithmModel = ""; + return; + } + const models = algorithmModels(); + const nextModel = models.includes(state.selectedAlgorithmModel) ? state.selectedAlgorithmModel : models[0] || ""; + selectDefaultStlForModel(nextModel); +} + +function algorithmModels() { + return Array.from(new Set(state.stlFiles.map((item) => item.algorithm_model || "未指定模型").filter(Boolean))).sort(); +} + +function currentAlgorithmModel() { + const selected = selectedStlFiles(); + const selectedModel = selected.find((item) => item.algorithm_model)?.algorithm_model; + if (selectedModel) return selectedModel; + if (state.selectedAlgorithmModel) return state.selectedAlgorithmModel; + return algorithmModels()[0] || state.activeCase?.algorithm_model || "未指定模型"; +} + +function stlFilesForSelectedModel() { + const model = currentAlgorithmModel(); + return model ? state.stlFiles.filter((item) => (item.algorithm_model || "未指定模型") === model) : state.stlFiles; +} + +function selectDefaultStlForModel(model) { + state.selectedStlIds.clear(); + state.selectedFamilies.clear(); + state.selectedAlgorithmModel = model || algorithmModels()[0] || ""; + const candidates = stlFilesForSelectedModel(); + if (!candidates.length) return; + const families = Array.from(new Set(candidates.map((item) => item.family).filter(Boolean))); const preferred = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen"]; const selectedFamily = preferred.find((family) => families.includes(family)) || families[0]; if (selectedFamily) { state.selectedFamilies.add(selectedFamily); - state.stlFiles.filter((item) => item.family === selectedFamily).forEach((item) => state.selectedStlIds.add(Number(item.id))); + candidates.filter((item) => item.family === selectedFamily).forEach((item) => state.selectedStlIds.add(Number(item.id))); } else { - state.selectedStlIds.add(Number(state.stlFiles[0].id)); + state.selectedStlIds.add(Number(candidates[0].id)); } } @@ -423,24 +455,40 @@ function renderStl() { $("stlCount").textContent = String(state.stlFiles.length); $("modeFile").classList.toggle("active", state.mode === "file"); $("modeFamily").classList.toggle("active", state.mode === "family"); - const families = Array.from(new Set(state.stlFiles.map((item) => item.family).filter(Boolean))); + const models = algorithmModels(); + if (models.length && !models.includes(state.selectedAlgorithmModel)) { + state.selectedAlgorithmModel = models[0]; + } + $("modelRail").innerHTML = models + .map((model) => ``) + .join(""); + const visibleFiles = stlFilesForSelectedModel(); + const families = Array.from(new Set(visibleFiles.map((item) => item.family).filter(Boolean))); $("familyRail").innerHTML = families .map((family) => ``) .join(""); + document.querySelectorAll("[data-model]").forEach((button) => { + button.addEventListener("click", async () => { + selectDefaultStlForModel(button.dataset.model); + markUnsaved(); + renderStl(); + await reloadStlVisuals(); + }); + }); document.querySelectorAll("[data-family]").forEach((button) => { button.addEventListener("click", async () => { const family = button.dataset.family; if (state.selectedFamilies.has(family)) state.selectedFamilies.delete(family); else state.selectedFamilies.add(family); state.selectedStlIds.clear(); - state.stlFiles.filter((item) => state.selectedFamilies.has(item.family)).forEach((item) => state.selectedStlIds.add(Number(item.id))); + visibleFiles.filter((item) => state.selectedFamilies.has(item.family)).forEach((item) => state.selectedStlIds.add(Number(item.id))); markUnsaved(); renderStl(); await reloadStlVisuals(); }); }); - $("stlList").innerHTML = state.stlFiles.length - ? state.stlFiles + $("stlList").innerHTML = visibleFiles.length + ? visibleFiles .map((row) => { const checked = state.selectedStlIds.has(Number(row.id)); return ` @@ -533,9 +581,11 @@ async function reloadStlVisuals() { function applyLatestRegistration() { const seriesUid = state.selectedSeries?.series_uid || ""; - const selectedLabel = currentStlLabel(); - const exact = state.registrations.find((item) => item.series_instance_uid === seriesUid && item.stl_family === selectedLabel); - const latest = exact || state.registrations.find((item) => item.series_instance_uid === seriesUid) || null; + const model = currentAlgorithmModel(); + const latest = + state.registrations.find((item) => item.algorithm_model === model) || + state.registrations.find((item) => item.series_instance_uid === seriesUid) || + null; if (latest?.transform) { state.pose = { ...defaultPose(), ...latest.transform }; $("registrationNotes").value = latest.notes || ""; @@ -557,11 +607,16 @@ async function saveRegistration(locked = false, forceUnlock = false) { alert("请至少选择一个 STL 构件"); return; } + const selectedModels = Array.from(new Set(selected.map((item) => item.algorithm_model || "未指定模型"))); + if (selectedModels.length > 1) { + alert("同一次配准只能选择同一个算法模型的 STL"); + return; + } const payload = { ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key, series_instance_uid: state.selectedSeries.series_uid, series_description: state.selectedSeries.description || "", - algorithm_model: state.activeCase.algorithm_model || selected[0]?.algorithm_model || "未指定模型", + algorithm_model: selectedModels[0] || currentAlgorithmModel(), stl_family: currentStlLabel(), view_mode: state.mode, selected_stl_files: selected.map((item) => ({ @@ -584,7 +639,7 @@ async function saveRegistration(locked = false, forceUnlock = false) { slice_thickness: state.selectedSeries.slice_thickness, spacing_between_slices: state.selectedSeries.spacing_between_slices, }, - model_reference: { selected_count: selected.length, families: Array.from(state.selectedFamilies) }, + model_reference: { algorithm_model: selectedModels[0] || currentAlgorithmModel(), selected_count: selected.length, families: Array.from(state.selectedFamilies) }, segmentation: { preview: state.maskVisible, source: "visible_stl_preview" }, notes: $("registrationNotes").value.trim(), locked, diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index 3ffdc70..a8703f5 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -61,6 +61,7 @@ +