Aggregate multiple STL assets per CT
This commit is contained in:
194
数据库Web可视化/app.py
194
数据库Web可视化/app.py
@@ -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}
|
||||
|
||||
@@ -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("、")],
|
||||
|
||||
Reference in New Issue
Block a user