Add DICOM UPP registration workspace

This commit is contained in:
Codex
2026-05-28 11:58:59 +08:00
parent 07df6ce446
commit cb18aabb4d
19 changed files with 5644 additions and 1 deletions

View File

@@ -35,10 +35,12 @@ PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries
PACS_ANNOTATION_TABLE = os.getenv("PACS_ANNOTATION_TABLE", "pacs_dicom_series_annotations")
UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets")
UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files")
REGISTRATION_TABLE = os.getenv("REGISTRATION_TABLE", "dicom_upp_registrations")
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")
REGISTRATION_URL = os.getenv("REGISTRATION_URL", "http://127.0.0.1:8109")
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
CJK_RE = re.compile(r"[\u3400-\u9fff]")
@@ -56,6 +58,7 @@ PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE)
PACS_ANNOTATION_TABLE_SQL = ident(PACS_ANNOTATION_TABLE)
UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE)
UPP_STL_TABLE_SQL = ident(UPP_STL_TABLE)
REGISTRATION_TABLE_SQL = ident(REGISTRATION_TABLE)
USER_TABLE_SQL = ident(USER_TABLE)
app = FastAPI(title="DICOM UPP Database Visualizer")
@@ -247,6 +250,38 @@ def ensure_user_table() -> None:
)
def ensure_registration_table() -> None:
run_psql(
f"""
CREATE TABLE IF NOT EXISTS public.{REGISTRATION_TABLE_SQL} (
id bigserial PRIMARY KEY,
ct_number text NOT NULL,
series_instance_uid text NOT NULL DEFAULT '',
series_description text NOT NULL DEFAULT '',
algorithm_model text NOT NULL DEFAULT '',
stl_family text NOT NULL DEFAULT '',
view_mode text NOT NULL DEFAULT 'family',
selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb,
transform jsonb NOT NULL DEFAULT '{{}}'::jsonb,
dicom_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
model_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
segmentation jsonb NOT NULL DEFAULT '{{}}'::jsonb,
notes text NOT NULL DEFAULT '',
locked boolean NOT NULL DEFAULT false,
locked_by text,
locked_at timestamptz,
updated_by text NOT NULL DEFAULT 'admin',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (ct_number, series_instance_uid, algorithm_model, stl_family)
);
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_ct ON public.{REGISTRATION_TABLE_SQL}(ct_number);
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_locked ON public.{REGISTRATION_TABLE_SQL}(locked);
""",
timeout=10,
)
def fetch_user(username: str) -> dict[str, Any] | None:
ensure_user_table()
rows = pg_json_rows(
@@ -281,6 +316,7 @@ def admin_user(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
def startup() -> None:
try:
ensure_user_table()
ensure_registration_table()
except Exception:
pass
@@ -488,6 +524,16 @@ def relation_cte() -> str:
LEFT JOIN s_segments ON s_segments.ct_key = s_raw.ct_key
GROUP BY s_raw.ct_key
),
r AS (
SELECT
upper(ct_number) AS ct_key,
count(*)::int AS registration_count,
count(DISTINCT series_instance_uid) FILTER (WHERE series_instance_uid <> '')::int AS registered_series,
count(*) FILTER (WHERE locked)::int AS locked_count,
max(updated_at) AS registration_updated_at
FROM public.{REGISTRATION_TABLE_SQL}
GROUP BY upper(ct_number)
),
keys AS (
SELECT ct_key FROM p
UNION
@@ -573,6 +619,10 @@ def relation_cte() -> str:
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,
COALESCE(r.registration_count, 0)::int AS registration_count,
COALESCE(r.registered_series, 0)::int AS registered_series,
COALESCE(r.locked_count, 0)::int AS locked_count,
r.registration_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 ''
@@ -586,6 +636,7 @@ def relation_cte() -> str:
LEFT JOIN a_labels ON a_labels.ct_number = p.ct_number
LEFT JOIN u ON u.ct_key = k.ct_key
LEFT JOIN s ON s.ct_key = k.ct_key
LEFT JOIN r ON r.ct_key = k.ct_key
)
"""
@@ -683,6 +734,7 @@ def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
return {
"user": user,
"viewer_url": PACS_VIEWER_URL,
"registration_url": REGISTRATION_URL,
"users": rows,
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE},
"tables": {
@@ -691,6 +743,7 @@ def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
"dicom_annotations": PACS_ANNOTATION_TABLE,
"upp_assets": UPP_ASSET_TABLE,
"upp_stl": UPP_STL_TABLE,
"registration": REGISTRATION_TABLE,
},
}
@@ -750,6 +803,7 @@ def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
ok, message = db_available()
counts: dict[str, Any] = {}
if ok:
ensure_registration_table()
rows = pg_json_rows(
f"""
{relation_cte()}
@@ -764,6 +818,10 @@ def status(user: dict[str, str] = Depends(current_user)) -> 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,
count(*) FILTER (WHERE registration_count > 0)::int AS registered_ct_count,
count(*) FILTER (WHERE locked_count > 0)::int AS locked_ct_count,
COALESCE(sum(registration_count), 0)::int AS registration_count,
COALESCE(sum(locked_count), 0)::int AS locked_registration_count,
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
@@ -774,12 +832,14 @@ def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
return {
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
"viewer_url": PACS_VIEWER_URL,
"registration_url": REGISTRATION_URL,
"tables": {
"dicom": PACS_TABLE,
"dicom_summary": PACS_SUMMARY_TABLE,
"dicom_annotations": PACS_ANNOTATION_TABLE,
"upp_assets": UPP_ASSET_TABLE,
"upp_stl": UPP_STL_TABLE,
"registration": REGISTRATION_TABLE,
},
"counts": counts,
}
@@ -795,6 +855,7 @@ def relations(
offset: int = Query(default=0, ge=0),
user: dict[str, str] = Depends(current_user),
) -> list[dict[str, Any]]:
ensure_registration_table()
where = relation_where(q, status, algorithm_model, dicom_part)
rows = pg_json_rows(
f"""
@@ -810,6 +871,7 @@ def relations(
body_parts, dicom_body_parts, dicom_annotation_labels, algorithm_models,
upp_asset_count, duplicate_model_asset_count, upp_assets, stl_asset_count, stl_assets,
segment_categories, segment_families,
registration_count, registered_series, locked_count, registration_updated_at,
pacs_updated_at, upp_updated_at, stl_updated_at
FROM relation
{where}
@@ -829,6 +891,7 @@ def relations(
@app.get("/api/relations/{ct_number}")
def relation_detail(ct_number: str, user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
ensure_registration_table()
ct_key = normalize_ct(ct_number)
if not ct_key:
raise HTTPException(status_code=400, detail="invalid CT number")