未选择 CT
+从左侧选择一个 CT 号查看两个系统之间的关系
+diff --git a/数据库Web可视化/.env.example b/数据库Web可视化/.env.example new file mode 100644 index 0000000..3bbf821 --- /dev/null +++ b/数据库Web可视化/.env.example @@ -0,0 +1,9 @@ +PGHOST=192.168.3.3 +PGPORT=5432 +PGUSER=his_user +PGPASSWORD=change_me +PGDATABASE=pacs_db +PACS_TABLE=pacs_dicom_files +PACS_SUMMARY_TABLE=pacs_dicom_study_summaries +UPP_ASSET_TABLE=upp_exam_assets +UPP_STL_TABLE=upp_stl_files diff --git a/数据库Web可视化/Dockerfile b/数据库Web可视化/Dockerfile new file mode 100644 index 0000000..d008520 --- /dev/null +++ b/数据库Web可视化/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py ./app.py +COPY static ./static + +EXPOSE 8108 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8108"] diff --git a/数据库Web可视化/README.md b/数据库Web可视化/README.md new file mode 100644 index 0000000..f28fa1e --- /dev/null +++ b/数据库Web可视化/README.md @@ -0,0 +1,23 @@ +# PACS / UPP 数据库关联可视化 + +本网页端以 CT 号为索引,把 PostgreSQL 中的 PACS DICOM 检查、UPP 列表资产和 UPP STL 文件聚合表放在同一视图中查看。 + +## 启动 + +```bash +cd /home/wkmgc/Desktop/PACS数据处理/数据库Web可视化 +cp .env.example .env +# 编辑 .env,填写本机 PostgreSQL 密码 +docker compose up -d --build +``` + +浏览器打开 `http://127.0.0.1:8108`。 + +## 数据来源 + +- `pacs_dicom_files`:PACS DICOM 检查索引。 +- `pacs_dicom_study_summaries`:PACS 检查标注/完成状态汇总。 +- `upp_exam_assets`:UPP 列表与 STL 资产主索引。 +- `upp_stl_files`:UPP STL 文件、分割名称和分类聚合。 + +CT 号关联时会优先展示原始 CT 号,同时使用去掉开头 `D` 的规范 CT 号进行跨系统匹配。 diff --git a/数据库Web可视化/app.py b/数据库Web可视化/app.py new file mode 100644 index 0000000..77042c3 --- /dev/null +++ b/数据库Web可视化/app.py @@ -0,0 +1,341 @@ +from __future__ import annotations + +import json +import os +import re +import subprocess +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + + +BASE_DIR = Path(__file__).resolve().parent +STATIC_DIR = BASE_DIR / "static" + +PGHOST = os.getenv("PGHOST", "192.168.3.3") +PGPORT = os.getenv("PGPORT", "5432") +PGUSER = os.getenv("PGUSER", "his_user") +PGDATABASE = os.getenv("PGDATABASE", "pacs_db") +PACS_TABLE = os.getenv("PACS_TABLE", "pacs_dicom_files") +PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries") +UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets") +UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files") + +IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +def ident(name: str) -> str: + if not IDENT_RE.fullmatch(name): + raise RuntimeError(f"invalid SQL identifier: {name}") + return name + + +PACS_TABLE_SQL = ident(PACS_TABLE) +PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE) +UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE) +UPP_STL_TABLE_SQL = ident(UPP_STL_TABLE) + +app = FastAPI(title="PACS UPP Database Visualizer") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + +def pg_env() -> dict[str, str]: + env = os.environ.copy() + env.update({"PGHOST": PGHOST, "PGPORT": PGPORT, "PGUSER": PGUSER, "PGDATABASE": PGDATABASE}) + return env + + +def run_psql(sql: str, timeout: int = 20) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["psql", "-X", "-q", "-t", "-A", "-v", "ON_ERROR_STOP=1", "-c", sql], + text=True, + capture_output=True, + timeout=timeout, + env=pg_env(), + ) + + +def pg_scalar(sql: str, timeout: int = 20) -> str: + result = run_psql(sql, timeout=timeout) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or result.stdout.strip()) + return result.stdout.strip() + + +def pg_json_rows(select_sql: str, timeout: int = 24) -> list[dict[str, Any]]: + payload = pg_scalar(f"SELECT COALESCE(json_agg(row_to_json(q)), '[]'::json)::text FROM ({select_sql}) q", timeout=timeout) + return json.loads(payload or "[]") + + +def sql_literal(value: Any) -> str: + return "'" + str(value).replace("'", "''") + "'" + + +def normalize_ct(value: str) -> str: + return re.sub(r"^D", "", re.sub(r"\s+", "", str(value or "").upper())) + + +def db_available() -> tuple[bool, str]: + try: + pg_scalar("SELECT 1", timeout=4) + return True, "connected" + except Exception as exc: # noqa: BLE001 + return False, str(exc) + + +def relation_cte() -> str: + return f""" + WITH + p AS ( + SELECT + p.*, + regexp_replace(upper(p.ct_number), '^D', '') AS norm_ct + FROM public.{PACS_TABLE_SQL} p + ), + ps AS ( + SELECT * + FROM public.{PACS_SUMMARY_TABLE_SQL} + ), + u_raw AS ( + SELECT + u.*, + regexp_replace(upper(u.ct_number), '^D', '') AS norm_ct + FROM public.{UPP_ASSET_TABLE_SQL} u + ), + u AS ( + SELECT DISTINCT ON (norm_ct) * + FROM u_raw + ORDER BY norm_ct, 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 + FROM public.{UPP_STL_TABLE_SQL} s + ), + s AS ( + SELECT DISTINCT ON (norm_ct) * + FROM s_raw + ORDER BY norm_ct, file_count DESC, updated_at DESC NULLS LAST + ), + keys AS ( + SELECT norm_ct FROM p + UNION + SELECT norm_ct FROM u + ), + relation AS ( + SELECT + k.norm_ct, + p.ct_number AS pacs_ct_number, + u.ct_number AS upp_ct_number, + s.ct_number AS stl_ct_number, + p.ct_number IS NOT NULL AS pacs_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 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' + 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, + p.source_patient_name, + p.patient_name_dicom, + COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) AS pacs_patient_name, + p.patient_id, + p.patient_sex AS pacs_patient_sex, + p.study_date, + p.study_time, + p.study_description, + p.modality, + p.series_count AS pacs_series_count, + p.dicom_file_count, + p.processed_path, + p.updated_at AS pacs_updated_at, + ps.annotated_series, + ps.undetermined_series, + ps.completed, + ps.body_parts, + u.patient_name AS upp_patient_name, + u.patient_sex AS upp_patient_sex, + u.patient_age, + u.patient_id_masked, + u.exam_date, + u.task_created_at, + u.exam_description, + u.exam_device, + u.algorithm_model, + u.upp_status, + u.list_present, + u.list_record_count, + u.processed_stl_dir, + u.stl_file_count, + u.stl_total_bytes, + u.updated_at AS upp_updated_at, + u.selected_list_record, + u.list_records, + u.stl_candidates, + 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.updated_at AS stl_updated_at, + CASE + WHEN COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) IS NULL + OR NULLIF(u.patient_name, '') IS NULL THEN '' + WHEN regexp_replace(COALESCE(NULLIF(p.source_patient_name, ''), p.patient_name_dicom), '\\s+', '', 'g') + = regexp_replace(u.patient_name, '\\s+', '', 'g') THEN 'same' + ELSE 'different' + END AS patient_name_match + FROM keys k + LEFT JOIN p ON p.norm_ct = k.norm_ct + 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 + ) + """ + + +def relation_where(q: str, status: str) -> str: + clauses: list[str] = [] + query = q.strip() + if query: + like = "%" + query.replace("%", "").replace("_", "") + "%" + literal = sql_literal(like) + clauses.append( + " OR ".join( + [ + f"norm_ct ILIKE {literal}", + f"pacs_ct_number ILIKE {literal}", + f"upp_ct_number ILIKE {literal}", + f"pacs_patient_name ILIKE {literal}", + f"upp_patient_name ILIKE {literal}", + f"patient_id ILIKE {literal}", + f"exam_description ILIKE {literal}", + f"study_description ILIKE {literal}", + ] + ).join(("(", ")")) + ) + if status == "complete": + clauses.append("relation_status = 'complete'") + elif status == "no_stl": + 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 == "undetermined": + clauses.append("COALESCE(undetermined_series, 0) > 0") + elif status != "all": + raise HTTPException(status_code=400, detail="invalid status filter") + return "WHERE " + " AND ".join(clauses) if clauses else "" + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/health") +def health() -> str: + return "ok" + + +@app.get("/api/status") +def status() -> dict[str, Any]: + ok, message = db_available() + counts: dict[str, Any] = {} + if ok: + rows = pg_json_rows( + f""" + {relation_cte()} + 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_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, + COALESCE(sum(COALESCE(undetermined_series, 0)), 0)::int AS undetermined_series + FROM relation + """, + timeout=24, + ) + counts = rows[0] if rows else {} + return { + "database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE}, + "tables": { + "pacs": PACS_TABLE, + "pacs_summary": PACS_SUMMARY_TABLE, + "upp_assets": UPP_ASSET_TABLE, + "upp_stl": UPP_STL_TABLE, + }, + "counts": counts, + } + + +@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) + 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, + 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, + pacs_updated_at, upp_updated_at, stl_updated_at + FROM relation + {where} + ORDER BY + pacs_present DESC, + relation_status, + COALESCE(study_date, '') DESC, + COALESCE(study_time, '') DESC, + norm_ct + LIMIT {int(limit)} + """, + timeout=24, + ) + + +@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: + 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)} + LIMIT 1 + """, + timeout=24, + ) + if not rows: + raise HTTPException(status_code=404, detail="CT number not found") + return rows[0] diff --git a/数据库Web可视化/docker-compose.yml b/数据库Web可视化/docker-compose.yml new file mode 100644 index 0000000..36c8da6 --- /dev/null +++ b/数据库Web可视化/docker-compose.yml @@ -0,0 +1,12 @@ +name: pacs-database-visualizer + +services: + pacs-database-visualizer: + build: + context: . + container_name: pacs-database-visualizer + restart: unless-stopped + env_file: + - .env + ports: + - "8108:8108" diff --git a/数据库Web可视化/requirements.txt b/数据库Web可视化/requirements.txt new file mode 100644 index 0000000..babedd0 --- /dev/null +++ b/数据库Web可视化/requirements.txt @@ -0,0 +1,2 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 diff --git a/数据库Web可视化/static/app.js b/数据库Web可视化/static/app.js new file mode 100644 index 0000000..10415cb --- /dev/null +++ b/数据库Web可视化/static/app.js @@ -0,0 +1,309 @@ +const app = { + rows: [], + active: null, + filter: "all", + search: "", + searchTimer: null, +}; + +const $ = (id) => document.getElementById(id); + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function json(path) { + const res = await fetch(path); + if (!res.ok) { + let detail = res.statusText; + try { + const data = await res.json(); + detail = data.detail || detail; + } catch (_) { + detail = await res.text(); + } + throw new Error(detail); + } + return res.json(); +} + +function fmtDate(value) { + const text = String(value || ""); + if (!text) return ""; + if (/^\d{8}$/.test(text)) return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; + return text.replace("T", " ").slice(0, 19); +} + +function fmtTime(value) { + const digits = String(value || "").replace(/\D/g, "").slice(0, 6); + if (digits.length < 6) return value || ""; + return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`; +} + +function fmtBytes(value) { + const size = Number(value || 0); + if (!size) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))); + return `${(size / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`; +} + +function asList(value) { + return Array.isArray(value) ? value : []; +} + +function uniq(list) { + return Array.from(new Set(list.filter(Boolean))); +} + +function statusLabel(value) { + return { + complete: "PACS + UPP + STL", + no_stl: "PACS + UPP", + pacs_only: "仅 PACS", + upp_only: "仅 UPP", + unknown: "未知", + }[value] || value || "未知"; +} + +function matchLabel(value) { + return { exact: "CT 号精确一致", normalized: "去 D 前缀匹配" }[value] || "未匹配"; +} + +function partLabel(value) { + return { + head_neck: "头颈部", + chest: "胸部", + upper_abdomen: "上腹部", + lower_abdomen: "下腹部", + pelvis: "盆腔", + }[value] || value; +} + +function setDbStatus(data) { + const pill = $("dbStatus"); + if (data.database?.ok) { + pill.textContent = `${data.database.database} 已连接`; + pill.className = "status-pill online"; + } else { + pill.textContent = "数据库异常"; + pill.className = "status-pill offline"; + } +} + +function renderMetrics(counts = {}) { + const items = [ + ["CT 索引", counts.total_ct], + ["PACS", counts.pacs_count], + ["UPP", counts.upp_count], + ["STL", counts.stl_count], + ["完整关联", counts.complete_count], + ["待判别序列", counts.undetermined_series], + ]; + $("metrics").innerHTML = items + .map(([label, value]) => `
没有匹配记录
`; + return; + } + list.innerHTML = ""; + 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.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); + card.innerHTML = ` +没有 STL 分割文件
`; + return; + } + $("segmentGroups").innerHTML = Array.from(groups.entries()) + .map( + ([group, names]) => ` +以 CT 号关联 PACS DICOM、UPP 列表与 STL 重建资产
+从左侧选择一个 CT 号查看两个系统之间的关系
+