Files
PACS/DICOM_and_UPP配准/app.py
2026-05-28 18:41:49 +08:00

1149 lines
45 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import io
import json
import os
import re
import secrets
import subprocess
import tempfile
import time
import zipfile
from collections import defaultdict
from pathlib import Path
from typing import Any
import numpy as np
import pydicom
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Response
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from PIL import Image
from starlette.background import BackgroundTask
APP_DIR = Path(__file__).resolve().parent
STATIC_DIR = APP_DIR / "static"
def load_env_file() -> None:
env_file = APP_DIR / ".env"
if not env_file.exists():
return
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
load_env_file()
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")
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")
WEB_USER = os.getenv("REGISTRATION_WEB_USER", "admin")
WEB_PASSWORD = os.getenv("REGISTRATION_WEB_PASSWORD", "123456")
PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107")
RELATION_VIEWER_URL = os.getenv("RELATION_VIEWER_URL", "http://127.0.0.1:8108")
PROCESSED_ROOT = Path(os.getenv("PACS_PROCESSED_ROOT", "/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据"))
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
WINDOWS = {
"default": None,
"bone": (500.0, 1800.0),
"soft": (50.0, 360.0),
"contrast": (90.0, 140.0),
}
DEFAULT_POSE = {
"translateX": 0.0,
"translateY": 0.0,
"translateZ": 0.0,
"rotateX": 0.0,
"rotateY": 0.0,
"rotateZ": 0.0,
"scale": 1.0,
"flipX": False,
"flipY": False,
"flipZ": False,
}
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)
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)
app = FastAPI(title="DICOM and UPP Registration")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
TOKENS: dict[str, str] = {}
STUDY_CACHE: dict[str, dict[str, Any]] = {}
STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
class LoginPayload(BaseModel):
username: str
password: str
class RegistrationPayload(BaseModel):
ct_number: str
series_instance_uid: str = ""
series_description: str = ""
algorithm_model: str = ""
stl_family: str = ""
view_mode: str = "family"
selected_stl_files: list[dict[str, Any]] = []
transform: dict[str, Any] = {}
dicom_reference: dict[str, Any] = {}
model_reference: dict[str, Any] = {}
segmentation: dict[str, Any] = {}
notes: str = ""
locked: bool = False
force_unlock: bool = False
class ExportPayload(BaseModel):
ct_number: str
mode: str = "family"
file_ids: list[int] = []
families: list[str] = []
include_pose: bool = True
transform: dict[str, Any] = {}
label: str = ""
def pg_env() -> dict[str, str]:
env = os.environ.copy()
env.update({"PGHOST": PGHOST, "PGPORT": PGPORT, "PGUSER": PGUSER, "PGDATABASE": PGDATABASE})
if os.getenv("PGPASSWORD"):
env["PGPASSWORD"] = os.environ["PGPASSWORD"]
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:
if value is None:
return "NULL"
return "'" + str(value).replace("'", "''") + "'"
def json_sql(value: Any) -> str:
return f"{sql_literal(json.dumps(value, ensure_ascii=False, separators=(',', ':')))}::jsonb"
def normalize_ct(value: Any) -> str:
return re.sub(r"\s+", "", str(value or "")).upper()
def ensure_registration_table() -> None:
pg_scalar(
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, algorithm_model)
);
ALTER TABLE public.{REGISTRATION_TABLE_SQL}
ADD COLUMN IF NOT EXISTS series_instance_uid text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS series_description text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS algorithm_model text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS stl_family text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS view_mode text NOT NULL DEFAULT 'family',
ADD COLUMN IF NOT EXISTS selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb,
ADD COLUMN IF NOT EXISTS transform jsonb NOT NULL DEFAULT '{{}}'::jsonb,
ADD COLUMN IF NOT EXISTS dicom_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
ADD COLUMN IF NOT EXISTS model_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
ADD COLUMN IF NOT EXISTS segmentation jsonb NOT NULL DEFAULT '{{}}'::jsonb,
ADD COLUMN IF NOT EXISTS notes text NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false,
ADD COLUMN IF NOT EXISTS locked_by text,
ADD COLUMN IF NOT EXISTS locked_at timestamptz,
ADD COLUMN IF NOT EXISTS updated_by text NOT NULL DEFAULT 'admin',
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
UPDATE public.{REGISTRATION_TABLE_SQL}
SET
ct_number = upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g')),
algorithm_model = COALESCE(NULLIF(btrim(algorithm_model), ''), '未指定模型'),
stl_family = COALESCE(NULLIF(btrim(stl_family), ''), '全部STL')
WHERE ct_number <> upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g'))
OR algorithm_model = ''
OR stl_family = '';
CREATE TABLE IF NOT EXISTS public.{REGISTRATION_TABLE_SQL}_duplicate_archive (
archived_at timestamptz NOT NULL DEFAULT now(),
reason text NOT NULL,
row_data jsonb NOT NULL
);
WITH ranked AS (
SELECT
id,
row_number() OVER (
PARTITION BY ct_number, algorithm_model
ORDER BY locked DESC, updated_at DESC NULLS LAST, id DESC
) AS rn
FROM public.{REGISTRATION_TABLE_SQL}
),
duplicates AS (
SELECT t.*
FROM public.{REGISTRATION_TABLE_SQL} t
JOIN ranked r ON r.id = t.id
WHERE r.rn > 1
)
INSERT INTO public.{REGISTRATION_TABLE_SQL}_duplicate_archive(reason, row_data)
SELECT 'ct_algorithm_unique_migration', row_to_json(duplicates)::jsonb
FROM duplicates;
WITH ranked AS (
SELECT
id,
row_number() OVER (
PARTITION BY ct_number, algorithm_model
ORDER BY locked DESC, updated_at DESC NULLS LAST, id DESC
) AS rn
FROM public.{REGISTRATION_TABLE_SQL}
)
DELETE FROM public.{REGISTRATION_TABLE_SQL} t
USING ranked r
WHERE t.id = r.id AND r.rn > 1;
DO $$
DECLARE
old_constraint record;
BEGIN
FOR old_constraint IN
SELECT conname
FROM pg_constraint
WHERE conrelid = 'public.{REGISTRATION_TABLE_SQL}'::regclass
AND contype = 'u'
AND pg_get_constraintdef(oid) LIKE '%(ct_number, series_instance_uid, algorithm_model, stl_family)%'
LOOP
EXECUTE format('ALTER TABLE public.{REGISTRATION_TABLE_SQL} DROP CONSTRAINT %I', old_constraint.conname);
END LOOP;
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conrelid = 'public.{REGISTRATION_TABLE_SQL}'::regclass
AND contype = 'u'
AND conname = '{REGISTRATION_TABLE_SQL}_ct_algorithm_key'
) THEN
ALTER TABLE public.{REGISTRATION_TABLE_SQL}
ADD CONSTRAINT {REGISTRATION_TABLE_SQL}_ct_algorithm_key UNIQUE (ct_number, algorithm_model);
END IF;
END $$;
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}_algorithm ON public.{REGISTRATION_TABLE_SQL}(algorithm_model);
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_locked ON public.{REGISTRATION_TABLE_SQL}(locked);
""",
timeout=12,
)
def db_available() -> tuple[bool, str]:
if not os.getenv("PGPASSWORD"):
return False, "PGPASSWORD 未设置"
try:
pg_scalar("SELECT 1", timeout=4)
return True, "connected"
except Exception as exc: # noqa: BLE001
return False, str(exc)
def require_auth(authorization: str | None = Header(default=None), access_token: str = Query(default="")) -> str:
token = access_token.strip()
if not token and authorization and authorization.startswith("Bearer "):
token = authorization.removeprefix("Bearer ").strip()
if not token:
raise HTTPException(status_code=401, detail="unauthorized")
user = TOKENS.get(token)
if not user:
raise HTTPException(status_code=401, detail="unauthorized")
return user
@app.on_event("startup")
def startup() -> None:
try:
ensure_registration_table()
except Exception:
pass
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/health")
def health() -> str:
return "ok"
@app.post("/api/auth/login")
def login(payload: LoginPayload) -> dict[str, str]:
username = payload.username.strip()
if username != WEB_USER or payload.password != WEB_PASSWORD:
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = secrets.token_urlsafe(32)
TOKENS[token] = username
return {"token": token, "username": username, "role": "管理员"}
@app.get("/api/auth/me")
def me(user: str = Depends(require_auth)) -> dict[str, str]:
return {"username": user, "role": "管理员"}
@app.get("/api/status")
def status(_: str = Depends(require_auth)) -> dict[str, Any]:
ok, message = db_available()
registration_count = None
locked_count = None
if ok:
try:
ensure_registration_table()
rows = pg_json_rows(
f"""
SELECT count(*)::int AS registration_count,
count(*) FILTER (WHERE locked)::int AS locked_count
FROM public.{REGISTRATION_TABLE_SQL}
""",
timeout=8,
)
if rows:
registration_count = rows[0].get("registration_count")
locked_count = rows[0].get("locked_count")
except Exception:
pass
return {
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
"registration": {"table": REGISTRATION_TABLE, "count": registration_count, "locked": locked_count},
"links": {"pacs_viewer_url": PACS_VIEWER_URL, "relation_viewer_url": RELATION_VIEWER_URL},
"dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()},
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
}
def relation_base_cte() -> str:
return f"""
WITH
p AS (
SELECT p.*, upper(p.ct_number) AS ct_key
FROM public.{PACS_TABLE_SQL} p
),
ps AS (
SELECT *
FROM public.{PACS_SUMMARY_TABLE_SQL}
),
a AS (
SELECT
ct_number,
count(DISTINCT series_instance_uid)::int AS annotated_series,
count(DISTINCT series_instance_uid) FILTER (
WHERE skipped IS NOT TRUE
AND (
(body_parts ? 'upper_abdomen' AND COALESCE(NULLIF(upper_abdomen_phase, ''), 'unknown') = 'unknown')
OR
(body_parts ? 'chest' AND COALESCE(NULLIF(chest_window, ''), 'unknown') = 'unknown')
)
)::int AS undetermined_series,
COALESCE(jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_parts
FROM public.{PACS_ANNOTATION_TABLE_SQL}
LEFT JOIN LATERAL jsonb_array_elements_text(body_parts) AS part(value) ON true
GROUP BY ct_number
),
u AS (
SELECT
upper(ct_number) AS ct_key,
(array_agg(ct_number ORDER BY updated_at DESC NULLS LAST))[1] AS ct_number,
bool_or(COALESCE(stl_present, false)) AS stl_present,
(array_agg(patient_name ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_name, '') IS NOT NULL))[1] AS patient_name,
array_to_string(array_remove(array_agg(DISTINCT NULLIF(algorithm_model, '')), NULL), '') AS algorithm_model,
COALESCE(sum(COALESCE(stl_file_count, 0)), 0)::int AS stl_file_count,
COALESCE(sum(COALESCE(stl_total_bytes, 0)), 0)::bigint AS stl_total_bytes,
max(updated_at) AS updated_at
FROM public.{UPP_ASSET_TABLE_SQL}
GROUP BY upper(ct_number)
),
s AS (
SELECT
upper(ct_number) AS ct_key,
(array_agg(ct_number ORDER BY updated_at DESC NULLS LAST))[1] AS ct_number,
COALESCE(sum(COALESCE(file_count, 0)), 0)::int AS file_count,
COALESCE(sum(COALESCE(total_bytes, 0)), 0)::bigint AS total_bytes,
COALESCE(jsonb_agg(DISTINCT family.value) FILTER (WHERE family.value IS NOT NULL), '[]'::jsonb) AS segment_families,
COALESCE(jsonb_agg(DISTINCT category.value) FILTER (WHERE category.value IS NOT NULL), '[]'::jsonb) AS segment_categories,
max(updated_at) AS updated_at
FROM public.{UPP_STL_TABLE_SQL}
LEFT JOIN LATERAL jsonb_array_elements_text(segment_families) AS family(value) ON true
LEFT JOIN LATERAL jsonb_array_elements_text(segment_categories) AS category(value) ON true
GROUP BY upper(ct_number)
),
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)
),
relation AS (
SELECT
p.ct_key,
p.ct_number AS pacs_ct_number,
COALESCE(s.ct_number, u.ct_number) AS stl_ct_number,
p.source_patient_name,
p.patient_name_dicom,
COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) AS patient_name,
p.patient_id,
p.study_date,
p.study_time,
p.study_description,
p.series_count,
p.dicom_file_count,
p.processed_path,
p.updated_at AS pacs_updated_at,
COALESCE(ps.completed, false) AS completed,
COALESCE(ps.annotated_series, a.annotated_series, 0)::int AS annotated_series,
COALESCE(ps.undetermined_series, a.undetermined_series, 0)::int AS undetermined_series,
COALESCE(ps.body_parts, a.body_parts, '[]'::jsonb) AS body_parts,
u.patient_name AS upp_patient_name,
u.algorithm_model,
COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present,
COALESCE(u.stl_file_count, s.file_count, 0)::int AS stl_file_count,
COALESCE(u.stl_total_bytes, s.total_bytes, 0)::bigint AS stl_total_bytes,
COALESCE(s.segment_families, '[]'::jsonb) AS segment_families,
COALESCE(s.segment_categories, '[]'::jsonb) AS segment_categories,
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
FROM p
LEFT JOIN ps ON ps.ct_number = p.ct_number
LEFT JOIN a ON a.ct_number = p.ct_number
LEFT JOIN u ON u.ct_key = p.ct_key
LEFT JOIN s ON s.ct_key = p.ct_key
LEFT JOIN r ON r.ct_key = p.ct_key
)
"""
@app.get("/api/cases")
def cases(q: str = "", limit: int = Query(default=80, ge=1, le=300), _: str = Depends(require_auth)) -> list[dict[str, Any]]:
ensure_registration_table()
clauses = ["stl_present IS TRUE"]
if q.strip():
like = "%" + q.strip().replace("%", "").replace("_", "") + "%"
clauses.append(
"("
+ " OR ".join(
[
f"ct_key ILIKE {sql_literal(like)}",
f"pacs_ct_number ILIKE {sql_literal(like)}",
f"patient_name ILIKE {sql_literal(like)}",
f"patient_id ILIKE {sql_literal(like)}",
f"algorithm_model ILIKE {sql_literal(like)}",
f"study_description ILIKE {sql_literal(like)}",
]
)
+ ")"
)
where = "WHERE " + " AND ".join(clauses)
return pg_json_rows(
f"""
{relation_base_cte()}
SELECT *
FROM relation
{where}
ORDER BY
locked_count DESC,
registration_count DESC,
COALESCE(study_date, '') DESC,
COALESCE(study_time, '') DESC,
ct_key
LIMIT {int(limit)}
""",
timeout=24,
)
@app.get("/api/cases/{ct_number}")
def case_detail(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
ensure_registration_table()
ct_key = normalize_ct(ct_number)
rows = pg_json_rows(
f"""
{relation_base_cte()}
SELECT *
FROM relation
WHERE ct_key = {sql_literal(ct_key)}
LIMIT 1
""",
timeout=24,
)
if not rows:
raise HTTPException(status_code=404, detail="CT not found")
return rows[0]
def get_study_record(ct_number: str) -> dict[str, Any]:
rows = pg_json_rows(
f"""
SELECT *
FROM public.{PACS_TABLE_SQL}
WHERE upper(ct_number) = {sql_literal(normalize_ct(ct_number))}
LIMIT 1
""",
timeout=12,
)
if not rows:
raise HTTPException(status_code=404, detail="study not found")
return rows[0]
def resolve_study_root(study: dict[str, Any]) -> Path:
root = Path(study.get("processed_path") or "")
if root.exists():
return root
target_folder = str(study.get("target_folder_name") or "")
if target_folder and PROCESSED_ROOT.exists():
direct = list(PROCESSED_ROOT.glob(f"*/{target_folder}"))
if direct:
return direct[0]
recursive = next(PROCESSED_ROOT.rglob(target_folder), None)
if recursive:
return recursive
ct_number = str(study.get("ct_number") or "")
if ct_number and PROCESSED_ROOT.exists():
recursive = next(PROCESSED_ROOT.rglob(f"{ct_number}-*"), None)
if recursive:
return recursive
return root
def read_header(path: Path) -> dict[str, str]:
tags = [
"SeriesInstanceUID",
"StudyInstanceUID",
"AccessionNumber",
"PatientName",
"PatientID",
"PatientBirthDate",
"PatientSex",
"InstitutionName",
"StudyDate",
"StudyTime",
"SeriesNumber",
"SeriesDescription",
"InstanceNumber",
"SliceLocation",
"ImagePositionPatient",
"AcquisitionTime",
"ContentTime",
"SeriesTime",
"Modality",
"BodyPartExamined",
"Manufacturer",
"Rows",
"Columns",
"PixelSpacing",
"SliceThickness",
"SpacingBetweenSlices",
"WindowCenter",
"WindowWidth",
]
ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True, specific_tags=tags + ["SpecificCharacterSet"])
return {tag: str(getattr(ds, tag, "")).strip() for tag in tags}
def numeric(value: Any, fallback: float = 0.0) -> float:
try:
return float(str(value).strip())
except Exception:
return fallback
def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
path, meta = item
instance = numeric(meta.get("InstanceNumber"), 0.0)
z = 0.0
position = meta.get("ImagePositionPatient", "")
if position:
try:
z = float(str(position).strip("[]").split(",")[-1])
except Exception:
z = 0.0
if meta.get("SliceLocation"):
z = numeric(meta.get("SliceLocation"), z)
return (z, instance, str(path))
def parse_json_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, str) and value:
try:
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else []
except Exception:
return []
return []
def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]:
try:
rows = pg_json_rows(
f"""
SELECT *
FROM public.{PACS_ANNOTATION_TABLE_SQL}
WHERE upper(ct_number) = {sql_literal(normalize_ct(ct_number))}
""",
timeout=12,
)
except Exception:
return {}
return {str(row.get("series_instance_uid") or ""): row for row in rows}
def annotation_labels(annotation: dict[str, Any]) -> list[str]:
if not annotation:
return []
if annotation.get("skipped"):
return ["略过/不采用"]
labels: list[str] = []
parts = parse_json_list(annotation.get("body_parts"))
for part in parts:
if part == "head_neck":
labels.append("头颈部")
elif part == "chest":
window = annotation.get("chest_window") or "unknown"
labels.append("胸部" if not window or window == "unknown" else f"胸部-{'肺窗' if window == 'lung' else '纵隔窗'}")
elif part == "upper_abdomen":
phase = annotation.get("upper_abdomen_phase") or "unknown"
phase_label = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(phase, phase)
labels.append(f"上腹部-{phase_label}")
elif part == "lower_abdomen":
labels.append("下腹部")
elif part == "pelvis":
labels.append("盆腔")
return labels
def registration_map(ct_number: str) -> dict[str, dict[str, Any]]:
ensure_registration_table()
rows = pg_json_rows(
f"""
SELECT *
FROM public.{REGISTRATION_TABLE_SQL}
WHERE upper(ct_number) = {sql_literal(normalize_ct(ct_number))}
ORDER BY updated_at DESC
""",
timeout=12,
)
mapped: dict[str, dict[str, Any]] = {}
for row in rows:
uid = str(row.get("series_instance_uid") or "")
existing = mapped.get(uid)
if not existing:
mapped[uid] = {
"registered": True,
"locked": bool(row.get("locked")),
"count": 1,
"latest_updated_at": row.get("updated_at", ""),
"labels": [row.get("stl_family") or row.get("algorithm_model") or "配准"],
}
else:
existing["count"] += 1
existing["locked"] = existing["locked"] or bool(row.get("locked"))
label = row.get("stl_family") or row.get("algorithm_model") or "配准"
if label not in existing["labels"]:
existing["labels"].append(label)
return mapped
def scan_study(ct_number: str) -> dict[str, Any]:
ct_key = normalize_ct(ct_number)
cached = STUDY_CACHE.get(ct_key)
if cached and time.time() - cached["cached_at"] < 600:
return cached
study = get_study_record(ct_number)
root = resolve_study_root(study)
if not root.exists():
raise HTTPException(status_code=404, detail=f"DICOM path not found: {root}")
grouped: dict[str, list[tuple[Path, dict[str, str]]]] = defaultdict(list)
for path in root.rglob("*.dcm"):
try:
meta = read_header(path)
except Exception:
continue
uid = meta.get("SeriesInstanceUID") or path.parent.name
grouped[uid].append((path, meta))
annotations = get_annotations(ct_number)
reg_map = registration_map(ct_number)
series_list = []
file_map = {}
for uid, items in grouped.items():
items.sort(key=sort_key)
first = items[0][1]
last = items[-1][1]
annotation = annotations.get(uid) or {}
file_map[uid] = [path for path, _ in items]
series_list.append(
{
"ct_number": study.get("ct_number") or ct_number,
"series_uid": uid,
"study_uid": first.get("StudyInstanceUID", ""),
"series_number": first.get("SeriesNumber", ""),
"description": first.get("SeriesDescription", "") or "未命名序列",
"count": len(items),
"modality": first.get("Modality", ""),
"body_part_dicom": first.get("BodyPartExamined", ""),
"study_date": first.get("StudyDate", "") or study.get("study_date", ""),
"study_time": first.get("StudyTime", "") or study.get("study_time", ""),
"series_time": first.get("SeriesTime", "") or first.get("AcquisitionTime", "") or first.get("ContentTime", ""),
"first_time": first.get("AcquisitionTime", "") or first.get("ContentTime", ""),
"last_time": last.get("AcquisitionTime", "") or last.get("ContentTime", ""),
"rows": first.get("Rows", ""),
"columns": first.get("Columns", ""),
"pixel_spacing": first.get("PixelSpacing", ""),
"slice_thickness": first.get("SliceThickness", ""),
"spacing_between_slices": first.get("SpacingBetweenSlices", ""),
"window_center": first.get("WindowCenter", ""),
"window_width": first.get("WindowWidth", ""),
"annotation": {
"labels": annotation_labels(annotation),
"raw": annotation,
},
"registration": reg_map.get(uid, {"registered": False, "locked": False, "count": 0, "labels": []}),
}
)
series_list.sort(key=lambda row: (str(row.get("first_time") or row.get("series_time") or ""), numeric(row.get("series_number"), 999999), row.get("description", "")))
payload = {"cached_at": time.time(), "study": study, "root": str(root), "series": series_list, "files": file_map}
STUDY_CACHE[ct_key] = payload
return payload
@app.get("/api/cases/{ct_number}/series")
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
data = scan_study(ct_number)
return {"study": data["study"], "root": data["root"], "series": data["series"]}
def get_series_files(ct_number: str, series_uid: str) -> list[Path]:
data = scan_study(ct_number)
files = data["files"].get(series_uid)
if not files:
raise HTTPException(status_code=404, detail="series not found")
return files
def window_values(ds: pydicom.Dataset, preset: str) -> tuple[float, float]:
if preset in WINDOWS and WINDOWS[preset]:
return WINDOWS[preset] # type: ignore[return-value]
center = getattr(ds, "WindowCenter", 50)
width = getattr(ds, "WindowWidth", 360)
if isinstance(center, pydicom.multival.MultiValue):
center = center[0]
if isinstance(width, pydicom.multival.MultiValue):
width = width[0]
try:
return float(center), float(width)
except Exception:
return 50.0, 360.0
def dicom_to_hu(ds: pydicom.Dataset) -> np.ndarray:
arr = ds.pixel_array.astype(np.float32)
slope = float(getattr(ds, "RescaleSlope", 1) or 1)
intercept = float(getattr(ds, "RescaleIntercept", 0) or 0)
return arr * slope + intercept
def pixel_spacing_from_ds(ds: pydicom.Dataset) -> tuple[float, float]:
spacing = getattr(ds, "PixelSpacing", None)
if spacing and len(spacing) >= 2:
row_spacing = max(numeric(spacing[0], 1.0), 0.001)
col_spacing = max(numeric(spacing[1], row_spacing), 0.001)
return row_spacing, col_spacing
return 1.0, 1.0
def slice_spacing_from_datasets(datasets: list[pydicom.Dataset]) -> float:
distances = []
positions = []
for ds in datasets:
position = getattr(ds, "ImagePositionPatient", None)
if position and len(position) >= 3:
positions.append(np.array([numeric(position[0]), numeric(position[1]), numeric(position[2])], dtype=np.float32))
for first, second in zip(positions, positions[1:]):
distance = float(np.linalg.norm(second - first))
if distance > 0.001:
distances.append(distance)
if distances:
return max(float(np.median(distances)), 0.001)
sample = datasets[0] if datasets else None
if sample is not None:
spacing = numeric(getattr(sample, "SpacingBetweenSlices", 0), 0.0)
if spacing > 0.001:
return spacing
thickness = numeric(getattr(sample, "SliceThickness", 0), 0.0)
if thickness > 0.001:
return thickness
return 1.0
def resize_for_spacing(pil: Image.Image, row_spacing: float, col_spacing: float) -> Image.Image:
if abs(row_spacing - col_spacing) < 0.01:
return pil
base = min(row_spacing, col_spacing)
target_w = max(1, int(round(pil.width * col_spacing / base)))
target_h = max(1, int(round(pil.height * row_spacing / base)))
scale = min(1.0, 2200 / max(target_w, target_h))
target_size = (max(1, int(round(target_w * scale))), max(1, int(round(target_h * scale))))
return pil if target_size == pil.size else pil.resize(target_size, Image.Resampling.BILINEAR)
def render_array(arr: np.ndarray, center: float, width: float, invert: bool = False, max_size: int = 1100, pixel_spacing: tuple[float, float] = (1.0, 1.0)) -> bytes:
low = center - width / 2.0
high = center + width / 2.0
img = ((np.clip(arr, low, high) - low) / max(high - low, 1.0) * 255.0).astype(np.uint8)
if invert:
img = 255 - img
pil = Image.fromarray(img)
pil = resize_for_spacing(pil, pixel_spacing[0], pixel_spacing[1])
if max(pil.size) > max_size:
pil.thumbnail((max_size, max_size), Image.Resampling.BILINEAR)
output = io.BytesIO()
pil.save(output, format="PNG", optimize=True)
return output.getvalue()
def load_stack_data(ct_number: str, series_uid: str) -> dict[str, Any]:
key = f"{normalize_ct(ct_number)}|{series_uid}"
cached = STACK_CACHE.get(key)
if cached:
STACK_CACHE[key] = (time.time(), cached[1])
return cached[1]
files = get_series_files(ct_number, series_uid)
arrays = []
datasets = []
for path in files:
ds = pydicom.dcmread(str(path), force=True)
datasets.append(ds)
arrays.append(dicom_to_hu(ds))
stack = np.stack(arrays, axis=0)
row_spacing, col_spacing = pixel_spacing_from_ds(datasets[min(len(datasets) - 1, len(datasets) // 2)])
payload = {"stack": stack, "row_spacing": row_spacing, "col_spacing": col_spacing, "slice_spacing": slice_spacing_from_datasets(datasets)}
STACK_CACHE[key] = (time.time(), payload)
if len(STACK_CACHE) > 2:
oldest = sorted(STACK_CACHE.items(), key=lambda item: item[1][0])[0][0]
STACK_CACHE.pop(oldest, None)
return payload
@app.get("/api/image")
def image(
ct_number: str,
series_uid: str,
index: int = 0,
plane: str = "axial",
window: str = "default",
_: str = Depends(require_auth),
) -> Response:
files = get_series_files(ct_number, series_uid)
index = max(0, index)
if plane == "axial" or len(files) < 2:
index = min(index, len(files) - 1)
ds = pydicom.dcmread(str(files[index]), force=True)
center, width = window_values(ds, window)
payload = render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", pixel_spacing=pixel_spacing_from_ds(ds))
return Response(payload, media_type="image/png")
stack_data = load_stack_data(ct_number, series_uid)
stack = stack_data["stack"]
sample_ds = pydicom.dcmread(str(files[min(len(files) - 1, len(files) // 2)]), stop_before_pixels=True, force=True)
center, width = window_values(sample_ds, window)
if plane == "coronal":
index = min(index, stack.shape[1] - 1)
arr = stack[:, index, :]
spacing = (stack_data["slice_spacing"], stack_data["col_spacing"])
elif plane == "sagittal":
index = min(index, stack.shape[2] - 1)
arr = stack[:, :, index]
spacing = (stack_data["slice_spacing"], stack_data["row_spacing"])
else:
raise HTTPException(status_code=400, detail="invalid plane")
payload = render_array(np.flipud(arr), center, width, False, pixel_spacing=spacing)
return Response(payload, media_type="image/png")
def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]:
rows = pg_json_rows(
f"""
SELECT
COALESCE(u.algorithm_model, '未指定模型') AS algorithm_model,
u.processed_stl_dir,
CASE
WHEN jsonb_typeof(u.stl_files) = 'array' AND jsonb_array_length(u.stl_files) > 0 THEN u.stl_files
ELSE COALESCE(s.files, '[]'::jsonb)
END AS files
FROM public.{UPP_ASSET_TABLE_SQL} u
LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(u.ct_number)
WHERE upper(u.ct_number) = {sql_literal(normalize_ct(ct_number))}
ORDER BY COALESCE(u.algorithm_model, '未指定模型'), u.updated_at DESC NULLS LAST
""",
timeout=14,
)
output: list[dict[str, Any]] = []
index = 0
for asset_index, row in enumerate(rows):
files = parse_json_list(row.get("files"))
if not files and row.get("processed_stl_dir"):
base = Path(row["processed_stl_dir"])
if base.exists():
files = [
{
"file_name": item.name,
"segment_name": item.stem,
"family": item.stem,
"category": "未分类",
"processed_file_path": str(item),
"size_bytes": item.stat().st_size,
}
for item in sorted(base.glob("*.stl"))
]
for file_info in files:
path = file_info.get("processed_file_path") or file_info.get("source_file_path")
if not path:
continue
output.append(
{
"id": index,
"asset_id": asset_index,
"algorithm_model": row.get("algorithm_model") or "未指定模型",
"file_name": file_info.get("file_name") or Path(path).name,
"segment_name": file_info.get("segment_name") or Path(path).stem,
"family": file_info.get("family") or file_info.get("segment_name") or Path(path).stem,
"category": file_info.get("category") or "未分类",
"size_bytes": int(file_info.get("size_bytes") or 0),
"path": path,
"visible": True,
}
)
index += 1
return output
@app.get("/api/cases/{ct_number}/stl")
def stl_assets(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
files = stl_rows_for_ct(ct_number)
families = sorted({str(item.get("family") or "") for item in files if item.get("family")})
categories = sorted({str(item.get("category") or "") for item in files if item.get("category")})
models = sorted({str(item.get("algorithm_model") or "") for item in files if item.get("algorithm_model")})
return {"ct_number": ct_number, "files": files, "families": families, "categories": categories, "algorithm_models": models}
@app.get("/api/stl")
def stl_file(ct_number: str, file_id: int, _: str = Depends(require_auth)) -> FileResponse:
files = stl_rows_for_ct(ct_number)
selected = next((item for item in files if int(item["id"]) == int(file_id)), None)
if not selected:
raise HTTPException(status_code=404, detail="STL file not found")
path = Path(str(selected["path"]))
if not path.exists() or not path.is_file():
raise HTTPException(status_code=404, detail=f"STL path not found: {path}")
return FileResponse(path, media_type="model/stl", filename=path.name)
@app.get("/api/registrations/{ct_number}")
def registrations(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
ensure_registration_table()
rows = pg_json_rows(
f"""
SELECT *
FROM public.{REGISTRATION_TABLE_SQL}
WHERE upper(ct_number) = {sql_literal(normalize_ct(ct_number))}
ORDER BY updated_at DESC
""",
timeout=12,
)
return {"ct_number": ct_number, "registrations": rows}
@app.post("/api/registrations")
def save_registration(payload: RegistrationPayload, user: str = Depends(require_auth)) -> dict[str, Any]:
ensure_registration_table()
ct_number = normalize_ct(payload.ct_number)
if not ct_number:
raise HTTPException(status_code=400, detail="CT号不能为空")
series_uid = payload.series_instance_uid.strip()
algorithm_model = payload.algorithm_model.strip() or "未指定模型"
stl_family = payload.stl_family.strip() or "全部STL"
existing = pg_json_rows(
f"""
SELECT locked
FROM public.{REGISTRATION_TABLE_SQL}
WHERE ct_number = {sql_literal(ct_number)}
AND algorithm_model = {sql_literal(algorithm_model)}
LIMIT 1
""",
timeout=8,
)
was_locked = bool(existing and existing[0].get("locked"))
if was_locked and not payload.force_unlock and not payload.locked:
raise HTTPException(status_code=423, detail="该配准结果已锁定,请先解锁")
locked_by_sql = sql_literal(user) if payload.locked else "NULL"
locked_at_sql = "now()" if payload.locked else "NULL"
transform = {**DEFAULT_POSE, **(payload.transform or {})}
pg_scalar(
f"""
INSERT INTO public.{REGISTRATION_TABLE_SQL} (
ct_number, series_instance_uid, series_description, algorithm_model, stl_family,
view_mode, selected_stl_files, transform, dicom_reference, model_reference,
segmentation, notes, locked, locked_by, locked_at, updated_by, updated_at
)
VALUES (
{sql_literal(ct_number)},
{sql_literal(series_uid)},
{sql_literal(payload.series_description.strip())},
{sql_literal(algorithm_model)},
{sql_literal(stl_family)},
{sql_literal(payload.view_mode.strip() or 'family')},
{json_sql(payload.selected_stl_files)},
{json_sql(transform)},
{json_sql(payload.dicom_reference)},
{json_sql(payload.model_reference)},
{json_sql(payload.segmentation)},
{sql_literal(payload.notes.strip())},
{'true' if payload.locked else 'false'},
{locked_by_sql},
{locked_at_sql},
{sql_literal(user)},
now()
)
ON CONFLICT (ct_number, algorithm_model) DO UPDATE SET
series_instance_uid = EXCLUDED.series_instance_uid,
series_description = EXCLUDED.series_description,
stl_family = EXCLUDED.stl_family,
view_mode = EXCLUDED.view_mode,
selected_stl_files = EXCLUDED.selected_stl_files,
transform = EXCLUDED.transform,
dicom_reference = EXCLUDED.dicom_reference,
model_reference = EXCLUDED.model_reference,
segmentation = EXCLUDED.segmentation,
notes = EXCLUDED.notes,
locked = EXCLUDED.locked,
locked_by = EXCLUDED.locked_by,
locked_at = EXCLUDED.locked_at,
updated_by = EXCLUDED.updated_by,
updated_at = now()
""",
timeout=12,
)
STUDY_CACHE.pop(ct_number, None)
return {"ok": True, "ct_number": ct_number, "locked": payload.locked, "updated_by": user}
def remove_file(path: str) -> None:
try:
Path(path).unlink(missing_ok=True)
except Exception:
pass
def safe_archive_name(value: Any, fallback: str = "file") -> str:
text = str(value or "").strip()
cleaned = "".join("_" if char in '<>:"/\\|?*\0' else char for char in text).strip().strip(".")
return cleaned[:160] or fallback
@app.post("/api/export/stl")
def export_stl(payload: ExportPayload, _: str = Depends(require_auth)) -> FileResponse:
ct_number = normalize_ct(payload.ct_number)
files = stl_rows_for_ct(ct_number)
selected = []
wanted_ids = {int(item) for item in payload.file_ids}
wanted_families = {str(item) for item in payload.families if str(item)}
for row in files:
if wanted_ids and int(row["id"]) in wanted_ids:
selected.append(row)
elif wanted_families and row.get("family") in wanted_families:
selected.append(row)
if not wanted_ids and not wanted_families:
selected = files
if not selected:
raise HTTPException(status_code=400, detail="没有可导出的STL")
temp = tempfile.NamedTemporaryFile(prefix="dicom_upp_registration_stl_", suffix=".zip", delete=False)
temp_path = temp.name
temp.close()
with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_STORED, allowZip64=True) as zip_file:
manifest = {"ct_number": ct_number, "mode": payload.mode, "label": payload.label, "transform": payload.transform, "files": []}
for row in selected:
path = Path(str(row["path"]))
if not path.exists():
continue
family = safe_archive_name(row.get("family"), "family")
name = safe_archive_name(row.get("file_name"), path.name)
archive_name = Path(family) / name if payload.mode == "family" else Path(name)
zip_file.write(path, str(archive_name))
manifest["files"].append({key: row.get(key) for key in ["file_name", "segment_name", "family", "category", "algorithm_model"]})
if payload.include_pose:
zip_file.writestr("registration_pose.json", json.dumps(manifest, ensure_ascii=False, indent=2))
filename = f"DICOM_UPP_STL_{safe_archive_name(ct_number)}_{time.strftime('%Y%m%d_%H%M%S')}.zip"
return FileResponse(temp_path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, temp_path))