From b6149a31eb3f40bf57efe609bcb55aa6d4a0e5f9 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 08:17:54 +0800 Subject: [PATCH] Use exact CT links for PACS STL view --- 数据库Web可视化/README.md | 8 +-- 数据库Web可视化/app.py | 96 +++++++++++++++++++------------ 数据库Web可视化/static/app.js | 75 ++++++++++++++---------- 数据库Web可视化/static/index.html | 42 ++++++++------ 数据库Web可视化/static/styles.css | 38 +++++++++--- 5 files changed, 160 insertions(+), 99 deletions(-) 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 = `
- ${escapeHtml(row.norm_ct)} + ${escapeHtml(row.ct_key)} ${escapeHtml(statusLabel(row.relation_status))}
${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")} ${escapeHtml(date || "无时间")} · PACS ${Number(row.dicom_file_count || 0)} 张 · STL ${stlCount} 个 + 重建 ${escapeHtml(row.algorithm_model || "无")} · DICOM ${escapeHtml(dicomModels)} ${escapeHtml(matchLabel(row.match_type))}${Number(row.undetermined_series || 0) ? ` · ${Number(row.undetermined_series)} 待判别` : ""} `; - card.onclick = () => selectRelation(row.norm_ct); + card.onclick = () => selectRelation(row.ct_key); list.appendChild(card); } } @@ -203,16 +210,15 @@ function renderSegments(row) { function renderDetail(row) { app.active = row; - $("activeCt").textContent = row.norm_ct || "未选择 CT"; - $("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"} ↔ ${row.upp_ct_number || "无 UPP"} · ${matchLabel(row.match_type)}`; + $("activeCt").textContent = row.ct_key || "未选择 CT"; + $("activeSubtitle").textContent = `${row.pacs_ct_number || "无 PACS"} ↔ ${row.stl_ct_number || "无 STL"} · ${matchLabel(row.match_type)}`; const badge = $("relationBadge"); - badge.className = `relation-badge ${row.relation_status || "idle"} ${row.match_type === "normalized" ? "normalized" : ""}`; + badge.className = `relation-badge ${row.relation_status || "idle"}`; badge.textContent = statusLabel(row.relation_status); - $("nodeCtValue").textContent = row.norm_ct || "-"; + $("nodeCtValue").textContent = row.ct_key || "-"; markNode("nodePacs", row.pacs_present ? "ok" : "missing", row.pacs_ct_number || "缺失", `${Number(row.dicom_file_count || 0)} 张 · ${Number(row.pacs_series_count || 0)} 序列`); - markNode("nodeUpp", row.upp_present ? "ok" : "missing", row.upp_ct_number || "缺失", row.upp_status || row.algorithm_model || "无任务状态"); - markNode("nodeStl", row.stl_present ? "ok" : "warn", row.stl_present ? "已生成" : "未生成", `${Number(row.stl_file_count || row.stl_file_count_agg || 0)} STL · ${fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)}`); + markNode("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`); renderDl("pacsDetails", [ ["CT号", row.pacs_ct_number], @@ -224,22 +230,19 @@ function renderDetail(row) { ["标注", `${Number(row.annotated_series || 0)} 已标注 · ${Number(row.undetermined_series || 0)} 待判别`], ["完成", row.completed ? "已完成" : "待处理"], ["部位", asList(row.body_parts).map(partLabel).join("、")], + ["DICOM模型", asList(row.dicom_models).join("、")], ]); - renderDl("uppDetails", [ - ["CT号", row.upp_ct_number], + renderDl("stlDetails", [ + ["CT号", row.stl_ct_number], + ["STL状态", row.stl_present ? "存在" : "缺失"], + ["重建模型", row.algorithm_model], + ["UPP状态", row.upp_status], ["患者", row.upp_patient_name], ["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")], ["检查时间", fmtDate(row.exam_date)], ["任务时间", fmtDate(row.task_created_at)], - ["算法模型", row.algorithm_model], - ["状态", row.upp_status], ["列表记录", row.list_present ? `${Number(row.list_record_count || 0)} 条` : "无"], - ["患者名", row.patient_name_match === "same" ? "两侧一致" : row.patient_name_match === "different" ? "两侧不同" : "无法判断"], - ]); - - renderDl("stlDetails", [ - ["STL状态", row.stl_present ? "存在" : "缺失"], ["文件数", Number(row.stl_file_count || row.stl_file_count_agg || 0)], ["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)], ["目录", row.processed_stl_dir], @@ -252,8 +255,8 @@ function renderDetail(row) { renderList(); } -async function selectRelation(normCt) { - const row = await json(`/api/relations/${encodeURIComponent(normCt)}`); +async function selectRelation(ctKey) { + const row = await json(`/api/relations/${encodeURIComponent(ctKey)}`); renderDetail(row); } @@ -268,12 +271,14 @@ async function loadRelations() { params.set("status", app.filter); params.set("limit", "500"); if (app.search) params.set("q", app.search); + if (app.algorithmModel) params.set("algorithm_model", app.algorithmModel); + if (app.dicomModel) params.set("dicom_model", app.dicomModel); app.rows = await json(`/api/relations?${params.toString()}`); - if (app.rows.length && !app.rows.some((row) => row.norm_ct === app.active?.norm_ct)) { - await selectRelation(app.rows[0].norm_ct); + if (app.rows.length && !app.rows.some((row) => row.ct_key === app.active?.ct_key)) { + await selectRelation(app.rows[0].ct_key); } else { renderList(); - if (!app.active && app.rows.length) await selectRelation(app.rows[0].norm_ct); + if (!app.active && app.rows.length) await selectRelation(app.rows[0].ct_key); } } @@ -296,6 +301,14 @@ function wire() { loadRelations(); }, 250); }); + $("algorithmFilter").addEventListener("change", () => { + app.algorithmModel = $("algorithmFilter").value; + loadRelations(); + }); + $("dicomModelFilter").addEventListener("change", () => { + app.dicomModel = $("dicomModelFilter").value; + loadRelations(); + }); document.querySelectorAll("[data-filter]").forEach((button) => { button.addEventListener("click", () => { app.filter = button.dataset.filter; diff --git a/数据库Web可视化/static/index.html b/数据库Web可视化/static/index.html index af27f46..b35908b 100644 --- a/数据库Web可视化/static/index.html +++ b/数据库Web可视化/static/index.html @@ -3,7 +3,7 @@ - PACS / UPP 数据库关联可视化 + PACS / STL 数据库关联可视化 @@ -11,8 +11,8 @@
-

PACS / UPP 数据库关联可视化

-

以 CT 号关联 PACS DICOM、UPP 列表与 STL 重建资产

+

PACS / STL 数据库关联可视化

+

以 CT 号精确关联 PACS DICOM 与 UPP STL 重建资产

@@ -28,14 +28,28 @@

CT 索引

0
- + +
+ + +
- - + +
@@ -52,7 +66,7 @@

未选择 CT

-

从左侧选择一个 CT 号查看两个系统之间的关系

+

从左侧选择一个 CT 号查看 PACS 与 STL 之间的关系

等待选择
@@ -70,16 +84,10 @@ 规范索引 - - @@ -89,10 +97,6 @@

PACS DICOM

-
-

UPP 列表

-
-

UPP STL

diff --git a/数据库Web可视化/static/styles.css b/数据库Web可视化/static/styles.css index 9c0b08b..8947ff1 100644 --- a/数据库Web可视化/static/styles.css +++ b/数据库Web可视化/static/styles.css @@ -112,14 +112,14 @@ button { .status-pill.offline, .relation-badge.pacs_only, -.relation-badge.upp_only { +.relation-badge.stl_only, +.relation-badge.list_only { border-color: rgba(251, 113, 133, 0.46); color: #fecdd3; background: rgba(251, 113, 133, 0.08); } -.relation-badge.no_stl, -.relation-badge.normalized { +.relation-badge.no_stl { border-color: rgba(240, 181, 78, 0.5); color: #ffe0a3; background: rgba(240, 181, 78, 0.08); @@ -218,6 +218,28 @@ button { border-color: rgba(52, 118, 246, 0.76); } +.model-filters { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-top: 10px; +} + +.model-filters select { + min-width: 0; + height: 34px; + padding: 0 10px; + border: 1px solid var(--line); + border-radius: 8px; + outline: 0; + background: #080d15; + color: var(--text); +} + +.model-filters select:focus { + border-color: rgba(52, 118, 246, 0.76); +} + .filter-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -303,14 +325,14 @@ button { color: #a5f5d4; } -.mini-badge.no_stl, -.mini-badge.normalized { +.mini-badge.no_stl { border-color: rgba(240, 181, 78, 0.5); color: #ffe0a3; } .mini-badge.pacs_only, -.mini-badge.upp_only { +.mini-badge.stl_only, +.mini-badge.list_only { border-color: rgba(251, 113, 133, 0.48); color: #fecdd3; } @@ -342,7 +364,7 @@ button { .link-map { display: grid; - grid-template-columns: minmax(150px, 1fr) 48px minmax(150px, 0.8fr) 48px minmax(150px, 1fr) 48px minmax(150px, 1fr); + grid-template-columns: minmax(180px, 1fr) 56px minmax(160px, 0.72fr) 56px minmax(180px, 1fr); align-items: center; gap: 8px; margin-top: 16px; @@ -401,7 +423,7 @@ button { .detail-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }