Align DICOM UPP registration uniqueness

This commit is contained in:
Codex
2026-05-28 18:41:49 +08:00
parent cb18aabb4d
commit 1679ddb03a
4 changed files with 166 additions and 19 deletions

View File

@@ -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/`

View File

@@ -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,

View File

@@ -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) => `<button class="chip ${model === currentAlgorithmModel() ? "active" : ""}" data-model="${escapeHtml(model)}" type="button">${escapeHtml(model)}</button>`)
.join("");
const visibleFiles = stlFilesForSelectedModel();
const families = Array.from(new Set(visibleFiles.map((item) => item.family).filter(Boolean)));
$("familyRail").innerHTML = families
.map((family) => `<button class="chip ${state.selectedFamilies.has(family) ? "active" : ""}" data-family="${escapeHtml(family)}" type="button">${escapeHtml(family)}</button>`)
.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,

View File

@@ -61,6 +61,7 @@
<button id="modeFile" class="active" type="button">单独</button>
<button id="modeFamily" type="button">Family</button>
</div>
<div id="modelRail" class="chip-rail"></div>
<div id="familyRail" class="chip-rail"></div>
<div id="stlList" class="stl-list"></div>
</section>