diff --git a/数据库Web可视化/README.md b/数据库Web可视化/README.md index f28fa1e..a7962c8 100644 --- a/数据库Web可视化/README.md +++ b/数据库Web可视化/README.md @@ -1,6 +1,6 @@ -# PACS / UPP 数据库关联可视化 +# PACS / STL 数据库关联可视化 -本网页端以 CT 号为索引,把 PostgreSQL 中的 PACS DICOM 检查、UPP 列表资产和 UPP STL 文件聚合表放在同一视图中查看。 +本网页端以 CT 号为索引,把 PostgreSQL 中的 PACS DICOM 检查与 UPP STL 重建资产放在同一视图中查看。UPP 列表字段作为 STL 资产的补充信息展示,不作为独立系统节点。 ## 启动 @@ -17,7 +17,7 @@ docker compose up -d --build - `pacs_dicom_files`:PACS DICOM 检查索引。 - `pacs_dicom_study_summaries`:PACS 检查标注/完成状态汇总。 -- `upp_exam_assets`:UPP 列表与 STL 资产主索引。 +- `upp_exam_assets`:UPP 列表与 STL 资产主索引,为 STL 资产提供患者、任务、算法模型、状态等补充信息。 - `upp_stl_files`:UPP STL 文件、分割名称和分类聚合。 -CT 号关联时会优先展示原始 CT 号,同时使用去掉开头 `D` 的规范 CT 号进行跨系统匹配。 +CT 号关联使用严格一致匹配,`DCT...` 与 `CT...` 会被视为两个不同 CT 号。 diff --git a/数据库Web可视化/app.py b/数据库Web可视化/app.py index 77042c3..1c743fb 100644 --- a/数据库Web可视化/app.py +++ b/数据库Web可视化/app.py @@ -75,7 +75,7 @@ def sql_literal(value: Any) -> str: def normalize_ct(value: str) -> str: - return re.sub(r"^D", "", re.sub(r"\s+", "", str(value or "").upper())) + return re.sub(r"\s+", "", str(value or "").upper()) def db_available() -> tuple[bool, str]: @@ -92,7 +92,7 @@ def relation_cte() -> str: p AS ( SELECT p.*, - regexp_replace(upper(p.ct_number), '^D', '') AS norm_ct + upper(p.ct_number) AS ct_key FROM public.{PACS_TABLE_SQL} p ), ps AS ( @@ -102,49 +102,51 @@ def relation_cte() -> str: u_raw AS ( SELECT u.*, - regexp_replace(upper(u.ct_number), '^D', '') AS norm_ct + upper(u.ct_number) AS ct_key FROM public.{UPP_ASSET_TABLE_SQL} u ), u AS ( - SELECT DISTINCT ON (norm_ct) * + SELECT DISTINCT ON (ct_key) * FROM u_raw - ORDER BY norm_ct, stl_present DESC, list_present DESC, updated_at DESC NULLS LAST + ORDER BY ct_key, stl_present DESC, list_present DESC, updated_at DESC NULLS LAST ), s_raw AS ( SELECT s.*, - regexp_replace(upper(s.ct_number), '^D', '') AS norm_ct + upper(s.ct_number) AS ct_key FROM public.{UPP_STL_TABLE_SQL} s ), s AS ( - SELECT DISTINCT ON (norm_ct) * + SELECT DISTINCT ON (ct_key) * FROM s_raw - ORDER BY norm_ct, file_count DESC, updated_at DESC NULLS LAST + ORDER BY ct_key, file_count DESC, updated_at DESC NULLS LAST ), keys AS ( - SELECT norm_ct FROM p + SELECT ct_key FROM p UNION - SELECT norm_ct FROM u + SELECT ct_key FROM u ), relation AS ( SELECT - k.norm_ct, + k.ct_key, p.ct_number AS pacs_ct_number, + u.ct_number AS stl_ct_number, u.ct_number AS upp_ct_number, - s.ct_number AS stl_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 AS upp_present, COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present, CASE - WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL AND (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'complete' + WHEN p.ct_number IS NOT NULL AND (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'complete' WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL THEN 'no_stl' WHEN p.ct_number IS NOT NULL THEN 'pacs_only' - WHEN u.ct_number IS NOT NULL THEN 'upp_only' + WHEN (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'stl_only' + WHEN u.ct_number IS NOT NULL THEN 'list_only' ELSE 'unknown' END AS relation_status, CASE WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL AND p.ct_number = u.ct_number THEN 'exact' - WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL THEN 'normalized' ELSE '' END AS match_type, p.batch_name, @@ -165,6 +167,13 @@ def relation_cte() -> str: ps.undetermined_series, ps.completed, ps.body_parts, + to_jsonb(array_remove(ARRAY[ + CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '肝胆模型' END, + CASE WHEN ps.body_parts ? 'chest' THEN '胸外模型' END, + CASE WHEN ps.body_parts ? 'upper_abdomen' + OR ps.body_parts ? 'lower_abdomen' + OR ps.body_parts ? 'pelvis' THEN '泌尿模型' END + ], NULL)) AS dicom_models, u.patient_name AS upp_patient_name, u.patient_sex AS upp_patient_sex, u.patient_age, @@ -200,15 +209,15 @@ def relation_cte() -> str: ELSE 'different' END AS patient_name_match FROM keys k - LEFT JOIN p ON p.norm_ct = k.norm_ct + LEFT JOIN p ON p.ct_key = k.ct_key LEFT JOIN ps ON ps.ct_number = p.ct_number - LEFT JOIN u ON u.norm_ct = k.norm_ct - LEFT JOIN s ON s.norm_ct = k.norm_ct + LEFT JOIN u ON u.ct_key = k.ct_key + LEFT JOIN s ON s.ct_key = k.ct_key ) """ -def relation_where(q: str, status: str) -> str: +def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str) -> str: clauses: list[str] = [] query = q.strip() if query: @@ -217,12 +226,14 @@ def relation_where(q: str, status: str) -> str: clauses.append( " OR ".join( [ - f"norm_ct ILIKE {literal}", + f"ct_key ILIKE {literal}", f"pacs_ct_number ILIKE {literal}", - f"upp_ct_number ILIKE {literal}", + f"stl_ct_number ILIKE {literal}", f"pacs_patient_name ILIKE {literal}", f"upp_patient_name ILIKE {literal}", f"patient_id ILIKE {literal}", + f"algorithm_model ILIKE {literal}", + f"upp_status ILIKE {literal}", f"exam_description ILIKE {literal}", f"study_description ILIKE {literal}", ] @@ -234,14 +245,18 @@ def relation_where(q: str, status: str) -> str: clauses.append("relation_status = 'no_stl'") elif status == "pacs_only": clauses.append("relation_status = 'pacs_only'") - elif status == "upp_only": - clauses.append("relation_status = 'upp_only'") - elif status == "normalized": - clauses.append("match_type = 'normalized'") + elif status == "stl_only": + clauses.append("relation_status = 'stl_only'") + elif status == "list_only": + clauses.append("relation_status = 'list_only'") elif status == "undetermined": clauses.append("COALESCE(undetermined_series, 0) > 0") elif status != "all": raise HTTPException(status_code=400, detail="invalid status filter") + if algorithm_model: + clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}") + if dicom_model: + clauses.append(f"dicom_models ? {sql_literal(dicom_model)}") return "WHERE " + " AND ".join(clauses) if clauses else "" @@ -266,13 +281,14 @@ def status() -> dict[str, Any]: SELECT count(*)::int AS total_ct, count(*) FILTER (WHERE pacs_present)::int AS pacs_count, - count(*) FILTER (WHERE upp_present)::int AS upp_count, + count(*) FILTER (WHERE stl_asset_present)::int AS stl_asset_count, + count(*) FILTER (WHERE list_present)::int AS list_count, count(*) FILTER (WHERE stl_present)::int AS stl_count, count(*) FILTER (WHERE relation_status = 'complete')::int AS complete_count, count(*) FILTER (WHERE relation_status = 'no_stl')::int AS no_stl_count, count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_count, - count(*) FILTER (WHERE relation_status = 'upp_only')::int AS upp_only_count, - count(*) FILTER (WHERE match_type = 'normalized')::int AS normalized_match_count, + count(*) FILTER (WHERE relation_status = 'stl_only')::int AS stl_only_count, + count(*) FILTER (WHERE relation_status = 'list_only')::int AS list_only_count, COALESCE(sum(COALESCE(undetermined_series, 0)), 0)::int AS undetermined_series FROM relation """, @@ -292,20 +308,26 @@ def status() -> dict[str, Any]: @app.get("/api/relations") -def relations(q: str = "", status: str = "all", limit: int = Query(default=300, ge=1, le=1000)) -> list[dict[str, Any]]: - where = relation_where(q, status) +def relations( + q: str = "", + status: str = "all", + algorithm_model: str = "", + dicom_model: str = "", + limit: int = Query(default=300, ge=1, le=1000), +) -> list[dict[str, Any]]: + where = relation_where(q, status, algorithm_model, dicom_model) return pg_json_rows( f""" {relation_cte()} SELECT - norm_ct, pacs_ct_number, upp_ct_number, stl_ct_number, - pacs_present, upp_present, stl_present, relation_status, match_type, + 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, 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, segment_categories, segment_families, + body_parts, dicom_models, segment_categories, segment_families, pacs_updated_at, upp_updated_at, stl_updated_at FROM relation {where} @@ -314,7 +336,7 @@ def relations(q: str = "", status: str = "all", limit: int = Query(default=300, relation_status, COALESCE(study_date, '') DESC, COALESCE(study_time, '') DESC, - norm_ct + ct_key LIMIT {int(limit)} """, timeout=24, @@ -323,15 +345,15 @@ def relations(q: str = "", status: str = "all", limit: int = Query(default=300, @app.get("/api/relations/{ct_number}") def relation_detail(ct_number: str) -> dict[str, Any]: - norm_ct = normalize_ct(ct_number) - if not norm_ct: + ct_key = normalize_ct(ct_number) + if not ct_key: raise HTTPException(status_code=400, detail="invalid CT number") rows = pg_json_rows( f""" {relation_cte()} SELECT * FROM relation - WHERE norm_ct = {sql_literal(norm_ct)} + WHERE ct_key = {sql_literal(ct_key)} LIMIT 1 """, timeout=24, diff --git a/数据库Web可视化/static/app.js b/数据库Web可视化/static/app.js index 10415cb..54e96e9 100644 --- a/数据库Web可视化/static/app.js +++ b/数据库Web可视化/static/app.js @@ -3,6 +3,8 @@ const app = { active: null, filter: "all", search: "", + algorithmModel: "", + dicomModel: "", searchTimer: null, }; @@ -63,16 +65,17 @@ function uniq(list) { function statusLabel(value) { return { - complete: "PACS + UPP + STL", - no_stl: "PACS + UPP", + complete: "PACS + STL", + no_stl: "PACS + 列表", pacs_only: "仅 PACS", - upp_only: "仅 UPP", + stl_only: "仅 STL", + list_only: "列表无STL", unknown: "未知", }[value] || value || "未知"; } function matchLabel(value) { - return { exact: "CT 号精确一致", normalized: "去 D 前缀匹配" }[value] || "未匹配"; + return { exact: "CT 号一致" }[value] || "未匹配"; } function partLabel(value) { @@ -100,7 +103,7 @@ function renderMetrics(counts = {}) { const items = [ ["CT 索引", counts.total_ct], ["PACS", counts.pacs_count], - ["UPP", counts.upp_count], + ["STL资产", counts.stl_asset_count], ["STL", counts.stl_count], ["完整关联", counts.complete_count], ["待判别序列", counts.undetermined_series], @@ -112,12 +115,14 @@ function renderMetrics(counts = {}) { function cardTitle(row) { return [ - `规范CT号:${row.norm_ct}`, + `CT号:${row.ct_key}`, `PACS:${row.pacs_ct_number || "无"}`, - `UPP:${row.upp_ct_number || "无"}`, + `STL:${row.stl_ct_number || "无"}`, `状态:${statusLabel(row.relation_status)}`, `PACS患者:${row.pacs_patient_name || "无"}`, - `UPP患者:${row.upp_patient_name || "无"}`, + `STL列表患者:${row.upp_patient_name || "无"}`, + `重建模型:${row.algorithm_model || "无"}`, + `DICOM模型:${asList(row.dicom_models).join("、") || "无"}`, ].join("\n"); } @@ -132,20 +137,22 @@ function renderList() { for (const row of app.rows) { const card = document.createElement("button"); card.className = "relation-card"; - card.classList.toggle("active", app.active?.norm_ct === row.norm_ct); + card.classList.toggle("active", app.active?.ct_key === row.ct_key); card.title = cardTitle(row); const date = row.study_date ? `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}` : fmtDate(row.exam_date || row.task_created_at); const stlCount = Number(row.stl_file_count || row.stl_file_count_agg || 0); + const dicomModels = asList(row.dicom_models).join("、") || "无"; card.innerHTML = `
以 CT 号关联 PACS DICOM、UPP 列表与 STL 重建资产
+以 CT 号精确关联 PACS DICOM 与 UPP STL 重建资产
从左侧选择一个 CT 号查看两个系统之间的关系
+从左侧选择一个 CT 号查看 PACS 与 STL 之间的关系