Aggregate multiple STL assets per CT

This commit is contained in:
Codex
2026-05-28 10:03:15 +08:00
parent 026f6ce83c
commit 2c6274ac0d
2 changed files with 184 additions and 42 deletions

View File

@@ -170,40 +170,53 @@ def near_pinyin_match(left: str, candidates: set[str]) -> bool:
return any(len(candidate) >= 6 and edit_distance_with_cap(left, candidate, cap=1) <= 1 for candidate in candidates)
def match_patient_names(pacs_name: Any, upp_name: Any) -> tuple[str, str]:
pacs_text = str(pacs_name or "")
upp_text = str(upp_name or "")
if not pacs_text or not upp_text:
return "", ""
pacs_latin = normalize_latin_name(pacs_text)
upp_latin = normalize_latin_name(upp_text)
pacs_cjk = normalize_cjk_name(pacs_text)
upp_cjk = normalize_cjk_name(upp_text)
if pacs_cjk and upp_cjk and pacs_cjk == upp_cjk:
return "same", "中文姓名一致"
if pacs_latin and upp_latin and pacs_latin == upp_latin:
return "same", "拼音/拉丁姓名一致"
if upp_latin and upp_latin in pinyin_name_candidates(pacs_text):
return "same", "DICOM中文姓名与UPP拼音匹配"
if upp_latin and near_pinyin_match(upp_latin, pinyin_name_candidates(pacs_text)):
return "same", "DICOM中文姓名与UPP拼音近似匹配"
if pacs_latin and pacs_latin in pinyin_name_candidates(upp_text):
return "same", "UPP中文姓名与DICOM拼音匹配"
if pacs_latin and near_pinyin_match(pacs_latin, pinyin_name_candidates(upp_text)):
return "same", "UPP中文姓名与DICOM拼音近似匹配"
return "different", f"DICOM患者{pacs_text}UPP患者{upp_text}"
def enrich_patient_match(row: dict[str, Any]) -> dict[str, Any]:
pacs_name = row.get("pacs_patient_name") or ""
upp_name = row.get("upp_patient_name") or ""
if not pacs_name or not upp_name:
upp_names = [row.get("upp_patient_name"), *(row.get("upp_patient_names") or [])]
upp_names = list(dict.fromkeys([str(name) for name in upp_names if name]))
if not pacs_name or not upp_names:
row["patient_name_match"] = ""
row["patient_name_match_note"] = ""
return row
pacs_latin = normalize_latin_name(pacs_name)
upp_latin = normalize_latin_name(upp_name)
pacs_cjk = normalize_cjk_name(pacs_name)
upp_cjk = normalize_cjk_name(upp_name)
if pacs_cjk and upp_cjk and pacs_cjk == upp_cjk:
row["patient_name_match"] = "same"
row["patient_name_match_note"] = "中文姓名一致"
elif pacs_latin and upp_latin and pacs_latin == upp_latin:
row["patient_name_match"] = "same"
row["patient_name_match_note"] = "拼音/拉丁姓名一致"
elif upp_latin and upp_latin in pinyin_name_candidates(pacs_name):
row["patient_name_match"] = "same"
row["patient_name_match_note"] = "DICOM中文姓名与UPP拼音匹配"
elif upp_latin and near_pinyin_match(upp_latin, pinyin_name_candidates(pacs_name)):
row["patient_name_match"] = "same"
row["patient_name_match_note"] = "DICOM中文姓名与UPP拼音近似匹配"
elif pacs_latin and pacs_latin in pinyin_name_candidates(upp_name):
row["patient_name_match"] = "same"
row["patient_name_match_note"] = "UPP中文姓名与DICOM拼音匹配"
elif pacs_latin and near_pinyin_match(pacs_latin, pinyin_name_candidates(upp_name)):
row["patient_name_match"] = "same"
row["patient_name_match_note"] = "UPP中文姓名与DICOM拼音近似匹配"
else:
row["patient_name_match"] = "different"
row["patient_name_match_note"] = f"DICOM患者{pacs_name}UPP患者{upp_name}"
mismatch_notes = []
for upp_name in upp_names:
status, note = match_patient_names(pacs_name, upp_name)
if status == "same":
row["patient_name_match"] = "same"
row["patient_name_match_note"] = note
row["upp_patient_name"] = row.get("upp_patient_name") or upp_name
return row
if note:
mismatch_notes.append(note)
row["patient_name_match"] = "different"
row["patient_name_match_note"] = "".join(mismatch_notes[:3])
return row
@@ -338,9 +351,46 @@ def relation_cte() -> str:
FROM public.{UPP_ASSET_TABLE_SQL} u
),
u AS (
SELECT DISTINCT ON (ct_key) *
SELECT
ct_key,
(array_agg(ct_number ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE ct_number IS NOT NULL))[1] AS ct_number,
bool_or(COALESCE(list_present, false)) AS list_present,
bool_or(COALESCE(stl_present, false)) AS stl_present,
(array_agg(patient_name ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_name, '') IS NOT NULL))[1] AS patient_name,
to_jsonb(array_remove(array_agg(DISTINCT NULLIF(patient_name, '')), NULL)) AS patient_names,
(array_agg(patient_sex ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_sex, '') IS NOT NULL))[1] AS patient_sex,
(array_agg(patient_age ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_age, '') IS NOT NULL))[1] AS patient_age,
(array_agg(patient_id_masked ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_id_masked, '') IS NOT NULL))[1] AS patient_id_masked,
max(exam_date) AS exam_date,
max(task_created_at) AS task_created_at,
(array_agg(exam_description ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(exam_description, '') IS NOT NULL))[1] AS exam_description,
(array_agg(exam_device ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(exam_device, '') IS NOT NULL))[1] AS exam_device,
array_to_string(array_remove(array_agg(DISTINCT NULLIF(algorithm_model, '')), NULL), '') AS algorithm_model,
to_jsonb(array_remove(array_agg(DISTINCT NULLIF(algorithm_model, '')), NULL)) AS algorithm_models,
array_to_string(array_remove(array_agg(DISTINCT NULLIF(upp_status, '')), NULL), '') AS upp_status,
COALESCE(sum(COALESCE(list_record_count, 0)), 0)::int AS list_record_count,
(array_agg(processed_stl_dir ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(processed_stl_dir, '') IS NOT NULL))[1] AS processed_stl_dir,
COALESCE(sum(COALESCE(stl_file_count, 0)), 0)::int AS stl_file_count,
COALESCE(sum(COALESCE(stl_total_bytes, 0)), 0)::bigint AS stl_total_bytes,
max(updated_at) AS updated_at,
(array_agg(selected_list_record ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE selected_list_record IS NOT NULL))[1] AS selected_list_record,
jsonb_agg(list_records ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE list_records IS NOT NULL) AS list_records,
jsonb_agg(stl_candidates ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE stl_candidates IS NOT NULL) AS stl_candidates,
count(*)::int AS upp_asset_count,
jsonb_agg(
jsonb_build_object(
'ct_number', ct_number,
'algorithm_model', algorithm_model,
'upp_status', upp_status,
'file_count', stl_file_count,
'total_bytes', stl_total_bytes,
'processed_stl_dir', processed_stl_dir,
'updated_at', updated_at
)
ORDER BY updated_at DESC NULLS LAST
) AS upp_assets
FROM u_raw
ORDER BY ct_key, stl_present DESC, list_present DESC, updated_at DESC NULLS LAST
GROUP BY ct_key
),
s_raw AS (
SELECT
@@ -348,25 +398,85 @@ def relation_cte() -> str:
upper(s.ct_number) AS ct_key
FROM public.{UPP_STL_TABLE_SQL} s
),
s AS (
SELECT DISTINCT ON (ct_key) *
s_segment_names AS (
SELECT
ct_key,
COALESCE(jsonb_agg(DISTINCT name.value) FILTER (WHERE name.value IS NOT NULL), '[]'::jsonb) AS segment_names
FROM s_raw
ORDER BY ct_key, file_count DESC, updated_at DESC NULLS LAST
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_names, '[]'::jsonb)) AS name(value)
GROUP BY ct_key
),
s_segment_families AS (
SELECT
ct_key,
COALESCE(jsonb_agg(DISTINCT family.value) FILTER (WHERE family.value IS NOT NULL), '[]'::jsonb) AS segment_families
FROM s_raw
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_families, '[]'::jsonb)) AS family(value)
GROUP BY ct_key
),
s_segment_categories AS (
SELECT
ct_key,
COALESCE(jsonb_agg(DISTINCT category.value) FILTER (WHERE category.value IS NOT NULL), '[]'::jsonb) AS segment_categories
FROM s_raw
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_categories, '[]'::jsonb)) AS category(value)
GROUP BY ct_key
),
s_segments AS (
SELECT
s_raw.ct_key,
COALESCE(s_segment_names.segment_names, '[]'::jsonb) AS segment_names,
COALESCE(s_segment_families.segment_families, '[]'::jsonb) AS segment_families,
COALESCE(s_segment_categories.segment_categories, '[]'::jsonb) AS segment_categories
FROM (SELECT DISTINCT ct_key FROM s_raw) s_raw
LEFT JOIN s_segment_names ON s_segment_names.ct_key = s_raw.ct_key
LEFT JOIN s_segment_families ON s_segment_families.ct_key = s_raw.ct_key
LEFT JOIN s_segment_categories ON s_segment_categories.ct_key = s_raw.ct_key
),
s AS (
SELECT
s_raw.ct_key,
(array_agg(s_raw.ct_number ORDER BY s_raw.updated_at DESC NULLS LAST) FILTER (WHERE s_raw.ct_number IS NOT NULL))[1] AS ct_number,
COALESCE(sum(COALESCE(s_raw.file_count, 0)), 0)::int AS file_count,
COALESCE(sum(COALESCE(s_raw.total_bytes, 0)), 0)::bigint AS total_bytes,
COALESCE((array_agg(s_segments.segment_names) FILTER (WHERE s_segments.segment_names IS NOT NULL))[1], '[]'::jsonb) AS segment_names,
COALESCE((array_agg(s_segments.segment_families) FILTER (WHERE s_segments.segment_families IS NOT NULL))[1], '[]'::jsonb) AS segment_families,
COALESCE((array_agg(s_segments.segment_categories) FILTER (WHERE s_segments.segment_categories IS NOT NULL))[1], '[]'::jsonb) AS segment_categories,
jsonb_agg(s_raw.file_names ORDER BY s_raw.updated_at DESC NULLS LAST) FILTER (WHERE s_raw.file_names IS NOT NULL) AS file_names,
jsonb_agg(s_raw.files ORDER BY s_raw.updated_at DESC NULLS LAST) FILTER (WHERE s_raw.files IS NOT NULL) AS stl_files,
max(s_raw.updated_at) AS updated_at,
count(*)::int AS stl_asset_count,
jsonb_agg(
jsonb_build_object(
'ct_number', s_raw.ct_number,
'file_count', s_raw.file_count,
'total_bytes', s_raw.total_bytes,
'updated_at', s_raw.updated_at,
'segment_categories', s_raw.segment_categories,
'segment_families', s_raw.segment_families
)
ORDER BY s_raw.updated_at DESC NULLS LAST
) AS stl_assets
FROM s_raw
LEFT JOIN s_segments ON s_segments.ct_key = s_raw.ct_key
GROUP BY s_raw.ct_key
),
keys AS (
SELECT ct_key FROM p
UNION
SELECT ct_key FROM u
UNION
SELECT ct_key FROM s
),
relation AS (
SELECT
k.ct_key,
p.ct_number AS pacs_ct_number,
u.ct_number AS stl_ct_number,
COALESCE(s.ct_number, u.ct_number) AS stl_ct_number,
u.ct_number AS upp_ct_number,
s.ct_number AS stl_file_ct_number,
p.ct_number IS NOT NULL AS pacs_present,
u.ct_number IS NOT NULL AS stl_asset_present,
u.ct_number IS NOT NULL OR s.ct_number IS NOT NULL AS stl_asset_present,
u.ct_number IS NOT NULL AS upp_present,
COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present,
CASE
@@ -408,6 +518,7 @@ def relation_cte() -> str:
CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END
], NULL)) AS dicom_body_parts,
u.patient_name AS upp_patient_name,
COALESCE(u.patient_names, '[]'::jsonb) AS upp_patient_names,
u.patient_sex AS upp_patient_sex,
u.patient_age,
u.patient_id_masked,
@@ -416,6 +527,7 @@ def relation_cte() -> str:
u.exam_description,
u.exam_device,
u.algorithm_model,
COALESCE(u.algorithm_models, '[]'::jsonb) AS algorithm_models,
u.upp_status,
u.list_present,
u.list_record_count,
@@ -426,14 +538,18 @@ def relation_cte() -> str:
u.selected_list_record,
u.list_records,
u.stl_candidates,
COALESCE(u.upp_asset_count, 0) AS upp_asset_count,
COALESCE(u.upp_assets, '[]'::jsonb) AS upp_assets,
s.file_count AS stl_file_count_agg,
s.total_bytes AS stl_total_bytes_agg,
s.segment_names,
s.segment_families,
s.segment_categories,
s.file_names,
s.files AS stl_files,
s.stl_files,
s.updated_at AS stl_updated_at,
COALESCE(s.stl_asset_count, 0) AS stl_asset_count,
COALESCE(s.stl_assets, '[]'::jsonb) AS stl_assets,
CASE
WHEN COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) IS NULL
OR NULLIF(u.patient_name, '') IS NULL THEN ''
@@ -663,12 +779,14 @@ def relations(
SELECT
ct_key, pacs_ct_number, stl_ct_number,
pacs_present, stl_asset_present, list_present, stl_present, relation_status, match_type,
pacs_patient_name, upp_patient_name, patient_name_match, patient_id, patient_id_masked,
pacs_patient_name, upp_patient_name, upp_patient_names, patient_name_match, patient_id, patient_id_masked,
study_date, study_time, exam_date, task_created_at,
study_description, exam_description, algorithm_model, upp_status,
pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed,
list_present, list_record_count, stl_file_count, stl_file_count_agg,
body_parts, dicom_body_parts, dicom_annotation_labels, segment_categories, segment_families,
body_parts, dicom_body_parts, dicom_annotation_labels, algorithm_models,
upp_asset_count, upp_assets, stl_asset_count, stl_assets,
segment_categories, segment_families,
pacs_updated_at, upp_updated_at, stl_updated_at
FROM relation
{where}

View File

@@ -149,6 +149,26 @@ function patientNameMismatch(row) {
return row.pacs_present && row.stl_asset_present && row.patient_name_match === "different";
}
function stlFileCount(row) {
return Number(row.stl_file_count_agg || row.stl_file_count || 0);
}
function stlAssetCount(row) {
return Number(row.stl_asset_count || row.upp_asset_count || 0);
}
function assetSummary(row) {
const assets = asList(row.upp_assets).length ? asList(row.upp_assets) : asList(row.stl_assets);
return assets
.slice(0, 6)
.map((asset) => {
const model = asset.algorithm_model || "STL";
const count = Number(asset.file_count || 0);
return `${model}${count ? ` ${count}` : ""}`;
})
.join("");
}
function setDbStatus(data) {
app.viewerUrl = data.viewer_url || app.viewerUrl;
const pill = $("dbStatus");
@@ -205,14 +225,15 @@ function renderList() {
card.className = "relation-card";
card.classList.toggle("active", app.active?.ct_key === row.ct_key);
card.title = cardTitle(row);
const stlCount = Number(row.stl_file_count || row.stl_file_count_agg || 0);
const stlCount = stlFileCount(row);
const assetCount = stlAssetCount(row);
const labels = dicomParts(row);
card.innerHTML = `
<div class="card-topline">
<div class="card-main">
<strong>${escapeHtml(row.ct_key)}</strong>
<span>${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}</span>
<small>DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount}` : ""}</small>
<small>DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount}` : ""}${assetCount > 1 ? ` · ${assetCount}` : ""}</small>
</div>
<div class="card-status">
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
@@ -310,7 +331,7 @@ function renderDetail(row) {
"nodeStl",
row.stl_asset_present ? (row.stl_present ? "ok" : "warn") : "missing",
row.stl_ct_number || "缺失",
`${row.algorithm_model || "无重建模型"} · ${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL`,
`${row.algorithm_model || "无重建模型"} · ${stlFileCount(row)} STL${stlAssetCount(row) > 1 ? ` · ${stlAssetCount(row)} 组资产` : ""}`,
);
renderDl("pacsDetails", [
@@ -330,12 +351,15 @@ function renderDetail(row) {
["患者", row.upp_patient_name],
["STL状态", row.stl_present ? "存在" : "缺失"],
["重建模型", row.algorithm_model],
["STL资产", `${stlAssetCount(row)}`],
["资产明细", assetSummary(row)],
["UPP记录", row.upp_asset_count ? `${Number(row.upp_asset_count)}` : ""],
["UPP状态", row.upp_status],
["姓名核对", row.patient_name_match === "different" ? row.patient_name_match_note : row.patient_name_match === "same" ? row.patient_name_match_note || "匹配" : ""],
["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")],
["检查时间", fmtDate(row.exam_date)],
["任务时间", fmtDate(row.task_created_at)],
["文件数", Number(row.stl_file_count || row.stl_file_count_agg || 0)],
["文件数", stlFileCount(row)],
["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)],
["目录", row.processed_stl_dir],
["分割分类", uniq(asList(row.segment_categories)).join("、")],