Improve DICOM UPP relation visualizer

This commit is contained in:
Codex
2026-05-28 08:49:16 +08:00
parent b6149a31eb
commit 4fe6466e20
7 changed files with 831 additions and 119 deletions

View File

@@ -1,13 +1,16 @@
from __future__ import annotations
import json
import hashlib
import os
import re
import secrets
import subprocess
from pathlib import Path
from typing import Any
from fastapi import FastAPI, HTTPException, Query
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from pydantic import BaseModel
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
@@ -23,6 +26,10 @@ 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")
USER_TABLE = os.getenv("VISUALIZER_USER_TABLE", "db_visualizer_users")
WEB_ADMIN_USER = os.getenv("VISUALIZER_ADMIN_USER", "admin")
WEB_ADMIN_PASSWORD = os.getenv("VISUALIZER_ADMIN_PASSWORD", "123456")
PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107")
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
@@ -37,9 +44,27 @@ 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)
USER_TABLE_SQL = ident(USER_TABLE)
app = FastAPI(title="PACS UPP Database Visualizer")
app = FastAPI(title="DICOM UPP Database Visualizer")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
TOKENS: dict[str, dict[str, str]] = {}
class LoginPayload(BaseModel):
username: str
password: str
class UserPayload(BaseModel):
username: str
password: str = ""
role: str = "阅片员"
status: str = "启用"
class PasswordPayload(BaseModel):
password: str
def pg_env() -> dict[str, str]:
@@ -74,6 +99,10 @@ def sql_literal(value: Any) -> str:
return "'" + str(value).replace("'", "''") + "'"
def password_hash(password: str) -> str:
return hashlib.sha256(password.encode("utf-8")).hexdigest()
def normalize_ct(value: str) -> str:
return re.sub(r"\s+", "", str(value or "").upper())
@@ -86,6 +115,63 @@ def db_available() -> tuple[bool, str]:
return False, str(exc)
def ensure_user_table() -> None:
run_psql(
f"""
CREATE TABLE IF NOT EXISTS public.{USER_TABLE_SQL} (
username text PRIMARY KEY,
password_hash text NOT NULL,
role text NOT NULL DEFAULT '阅片员',
status text NOT NULL DEFAULT '启用',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status)
VALUES ({sql_literal(WEB_ADMIN_USER)}, {sql_literal(password_hash(WEB_ADMIN_PASSWORD))}, '管理员', '启用')
ON CONFLICT (username) DO NOTHING;
""",
timeout=10,
)
def fetch_user(username: str) -> dict[str, Any] | None:
ensure_user_table()
rows = pg_json_rows(
f"""
SELECT username, password_hash, role, status, created_at, updated_at
FROM public.{USER_TABLE_SQL}
WHERE username = {sql_literal(username)}
LIMIT 1
""",
timeout=10,
)
return rows[0] if rows else None
def current_user(authorization: str | None = Header(default=None)) -> dict[str, str]:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="unauthorized")
token = authorization.removeprefix("Bearer ").strip()
user = TOKENS.get(token)
if not user:
raise HTTPException(status_code=401, detail="unauthorized")
return user
def admin_user(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
if user.get("role") != "管理员":
raise HTTPException(status_code=403, detail="forbidden")
return user
@app.on_event("startup")
def startup() -> None:
try:
ensure_user_table()
except Exception:
pass
def relation_cte() -> str:
return f"""
WITH
@@ -168,12 +254,12 @@ def relation_cte() -> str:
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,
CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END,
CASE WHEN ps.body_parts ? 'chest' THEN '' END,
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END,
CASE WHEN ps.body_parts ? 'lower_abdomen' THEN '下腹部' END,
CASE WHEN ps.body_parts ? 'pelvis' THEN '盆腔' END
], NULL)) AS dicom_body_parts,
u.patient_name AS upp_patient_name,
u.patient_sex AS upp_patient_sex,
u.patient_age,
@@ -217,7 +303,7 @@ def relation_cte() -> str:
"""
def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str) -> str:
def relation_where(q: str, status: str, algorithm_model: str, dicom_part: str) -> str:
clauses: list[str] = []
query = q.strip()
if query:
@@ -236,6 +322,7 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str)
f"upp_status ILIKE {literal}",
f"exam_description ILIKE {literal}",
f"study_description ILIKE {literal}",
f"COALESCE(dicom_body_parts::text, '') ILIKE {literal}",
]
).join(("(", ")"))
)
@@ -249,14 +336,19 @@ def relation_where(q: str, status: str, algorithm_model: str, dicom_model: str)
clauses.append("relation_status = 'stl_only'")
elif status == "list_only":
clauses.append("relation_status = 'list_only'")
elif status == "pending_dicom":
clauses.append("pacs_present AND COALESCE(completed, false) IS NOT TRUE")
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)}")
if dicom_part:
valid_parts = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}
if dicom_part not in valid_parts:
raise HTTPException(status_code=400, detail="invalid DICOM part filter")
clauses.append(f"body_parts ? {sql_literal(dicom_part)}")
return "WHERE " + " AND ".join(clauses) if clauses else ""
@@ -270,8 +362,103 @@ def health() -> str:
return "ok"
@app.post("/api/auth/login")
def login(payload: LoginPayload) -> dict[str, str]:
username = payload.username.strip()
try:
user = fetch_user(username)
except Exception:
user = None
if not user and username == WEB_ADMIN_USER:
user = {"username": WEB_ADMIN_USER, "password_hash": password_hash(WEB_ADMIN_PASSWORD), "role": "管理员", "status": "启用"}
if not user or user.get("status") != "启用" or user.get("password_hash") != password_hash(payload.password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = secrets.token_urlsafe(32)
TOKENS[token] = {"username": user["username"], "role": user.get("role", "阅片员")}
return {"token": token, "username": user["username"], "role": user.get("role", "阅片员")}
@app.get("/api/auth/me")
def me(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
return user
@app.get("/api/settings")
def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
rows = pg_json_rows(
f"""
SELECT username, role, status, created_at, updated_at
FROM public.{USER_TABLE_SQL}
ORDER BY username
""",
timeout=10,
)
return {
"user": user,
"viewer_url": PACS_VIEWER_URL,
"users": rows,
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE},
"tables": {
"dicom": PACS_TABLE,
"dicom_summary": PACS_SUMMARY_TABLE,
"upp_assets": UPP_ASSET_TABLE,
"upp_stl": UPP_STL_TABLE,
},
}
@app.post("/api/settings/users")
def save_user(payload: UserPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]:
username = payload.username.strip()
if not username:
raise HTTPException(status_code=400, detail="账号不能为空")
role = payload.role if payload.role in {"管理员", "阅片员", "访客"} else "阅片员"
status = payload.status if payload.status in {"启用", "停用"} else "启用"
existing = fetch_user(username)
if existing and not payload.password:
run_psql(
f"""
UPDATE public.{USER_TABLE_SQL}
SET role = {sql_literal(role)}, status = {sql_literal(status)}, updated_at = now()
WHERE username = {sql_literal(username)}
""",
timeout=10,
)
else:
if not payload.password:
raise HTTPException(status_code=400, detail="新账号需要初始密码")
run_psql(
f"""
INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status, updated_at)
VALUES ({sql_literal(username)}, {sql_literal(password_hash(payload.password))}, {sql_literal(role)}, {sql_literal(status)}, now())
ON CONFLICT (username) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
status = EXCLUDED.status,
updated_at = now()
""",
timeout=10,
)
return {"status": "ok", "updated_by": user["username"]}
@app.put("/api/settings/users/{username}/password")
def change_password(username: str, payload: PasswordPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]:
if not payload.password:
raise HTTPException(status_code=400, detail="密码不能为空")
run_psql(
f"""
UPDATE public.{USER_TABLE_SQL}
SET password_hash = {sql_literal(password_hash(payload.password))}, updated_at = now()
WHERE username = {sql_literal(username)}
""",
timeout=10,
)
return {"status": "ok", "updated_by": user["username"]}
@app.get("/api/status")
def status() -> dict[str, Any]:
def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
ok, message = db_available()
counts: dict[str, Any] = {}
if ok:
@@ -289,7 +476,8 @@ def status() -> dict[str, Any]:
count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_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
count(*) FILTER (WHERE pacs_present AND COALESCE(completed, false) IS NOT TRUE)::int AS pending_dicom_count,
COALESCE(sum(CASE WHEN pacs_present THEN COALESCE(undetermined_series, 0) ELSE 0 END), 0)::int AS pending_dicom_series
FROM relation
""",
timeout=24,
@@ -297,9 +485,10 @@ def status() -> dict[str, Any]:
counts = rows[0] if rows else {}
return {
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
"viewer_url": PACS_VIEWER_URL,
"tables": {
"pacs": PACS_TABLE,
"pacs_summary": PACS_SUMMARY_TABLE,
"dicom": PACS_TABLE,
"dicom_summary": PACS_SUMMARY_TABLE,
"upp_assets": UPP_ASSET_TABLE,
"upp_stl": UPP_STL_TABLE,
},
@@ -312,10 +501,11 @@ def relations(
q: str = "",
status: str = "all",
algorithm_model: str = "",
dicom_model: str = "",
dicom_part: str = "",
limit: int = Query(default=300, ge=1, le=1000),
user: dict[str, str] = Depends(current_user),
) -> list[dict[str, Any]]:
where = relation_where(q, status, algorithm_model, dicom_model)
where = relation_where(q, status, algorithm_model, dicom_part)
return pg_json_rows(
f"""
{relation_cte()}
@@ -327,7 +517,7 @@ def relations(
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_models, segment_categories, segment_families,
body_parts, dicom_body_parts, segment_categories, segment_families,
pacs_updated_at, upp_updated_at, stl_updated_at
FROM relation
{where}
@@ -344,7 +534,7 @@ def relations(
@app.get("/api/relations/{ct_number}")
def relation_detail(ct_number: str) -> dict[str, Any]:
def relation_detail(ct_number: str, user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
ct_key = normalize_ct(ct_number)
if not ct_key:
raise HTTPException(status_code=400, detail="invalid CT number")