1009 lines
39 KiB
Python
1009 lines
39 KiB
Python
#!/usr/bin/env python3
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import secrets
|
|
import subprocess
|
|
import time
|
|
from collections import Counter, 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
|
|
|
|
|
|
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, 2000.0),
|
|
"soft": (40.0, 400.0),
|
|
"contrast": (80.0, 180.0),
|
|
}
|
|
DEFAULT_POSE = {
|
|
"rotateX": 0.0,
|
|
"rotateY": 0.0,
|
|
"rotateZ": 0.0,
|
|
"translateX": 0.0,
|
|
"translateY": 0.0,
|
|
"translateZ": 0.0,
|
|
"scale": 1.0,
|
|
"flipX": False,
|
|
"flipY": False,
|
|
"flipZ": False,
|
|
}
|
|
DICOM_TAGS = [
|
|
"SeriesInstanceUID",
|
|
"StudyInstanceUID",
|
|
"SeriesNumber",
|
|
"SeriesDescription",
|
|
"InstanceNumber",
|
|
"SliceLocation",
|
|
"ImagePositionPatient",
|
|
"AcquisitionTime",
|
|
"ContentTime",
|
|
"SeriesTime",
|
|
"Modality",
|
|
"BodyPartExamined",
|
|
"Rows",
|
|
"Columns",
|
|
"PixelSpacing",
|
|
"SliceThickness",
|
|
"SpacingBetweenSlices",
|
|
"WindowCenter",
|
|
"WindowWidth",
|
|
]
|
|
|
|
|
|
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 UPP Registration Workspace")
|
|
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
|
|
algorithm_model: str = "未指定模型"
|
|
registration_status: str = "unregistered"
|
|
series_instance_uid: str = ""
|
|
series_description: str = ""
|
|
selected_stl_files: list[dict[str, Any]] = []
|
|
transform: dict[str, Any] = {}
|
|
module_styles: dict[str, Any] = {}
|
|
dicom_reference: dict[str, Any] = {}
|
|
model_reference: dict[str, Any] = {}
|
|
notes: 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 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 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,
|
|
algorithm_model text NOT NULL DEFAULT '未指定模型',
|
|
registration_status text NOT NULL DEFAULT 'unregistered',
|
|
series_instance_uid text NOT NULL DEFAULT '',
|
|
series_description text NOT NULL DEFAULT '',
|
|
selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
transform jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
|
module_styles jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
|
dicom_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
|
model_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
|
notes text NOT NULL DEFAULT '',
|
|
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 algorithm_model text NOT NULL DEFAULT '未指定模型',
|
|
ADD COLUMN IF NOT EXISTS registration_status text NOT NULL DEFAULT 'unregistered',
|
|
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 selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb,
|
|
ADD COLUMN IF NOT EXISTS transform jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
|
ADD COLUMN IF NOT EXISTS module_styles 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 notes text NOT NULL DEFAULT '',
|
|
ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false,
|
|
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), ''), '未指定模型'),
|
|
registration_status = CASE
|
|
WHEN registration_status IN ('registered', 'unregistered') THEN registration_status
|
|
WHEN COALESCE(locked, false) IS TRUE THEN 'registered'
|
|
ELSE 'unregistered'
|
|
END
|
|
WHERE ct_number <> upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g'))
|
|
OR algorithm_model = ''
|
|
OR registration_status NOT IN ('registered', 'unregistered');
|
|
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 registration_status = 'registered' 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 registration_status = 'registered' 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
|
|
item record;
|
|
BEGIN
|
|
FOR item IN
|
|
SELECT conname
|
|
FROM pg_constraint
|
|
WHERE conrelid = 'public.{REGISTRATION_TABLE_SQL}'::regclass
|
|
AND contype = 'u'
|
|
AND pg_get_constraintdef(oid) NOT LIKE '%(ct_number, algorithm_model)%'
|
|
LOOP
|
|
EXECUTE format('ALTER TABLE public.{REGISTRATION_TABLE_SQL} DROP CONSTRAINT %I', item.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}_status ON public.{REGISTRATION_TABLE_SQL}(registration_status);
|
|
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_algorithm ON public.{REGISTRATION_TABLE_SQL}(algorithm_model);
|
|
""",
|
|
timeout=14,
|
|
)
|
|
|
|
|
|
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:
|
|
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()
|
|
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()
|
|
counts = {"complete_cases": 0, "registered": 0, "unregistered": 0}
|
|
if ok:
|
|
ensure_registration_table()
|
|
rows = pg_json_rows(
|
|
f"""
|
|
WITH complete AS (
|
|
SELECT DISTINCT p.ct_number, COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型') AS algorithm_model
|
|
FROM public.{PACS_TABLE_SQL} p
|
|
JOIN public.{UPP_ASSET_TABLE_SQL} u ON upper(u.ct_number) = upper(p.ct_number)
|
|
LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(p.ct_number)
|
|
WHERE COALESCE(u.stl_present, false) OR COALESCE(u.stl_file_count, 0) > 0 OR s.ct_number IS NOT NULL
|
|
)
|
|
SELECT
|
|
count(*)::int AS complete_cases,
|
|
count(*) FILTER (WHERE r.registration_status = 'registered')::int AS registered,
|
|
count(*) FILTER (WHERE COALESCE(r.registration_status, 'unregistered') <> 'registered')::int AS unregistered
|
|
FROM complete c
|
|
LEFT JOIN public.{REGISTRATION_TABLE_SQL} r
|
|
ON r.ct_number = c.ct_number AND r.algorithm_model = c.algorithm_model
|
|
""",
|
|
timeout=10,
|
|
)
|
|
if rows:
|
|
counts = rows[0]
|
|
return {
|
|
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
|
|
"counts": counts,
|
|
"links": {"pacs_viewer_url": PACS_VIEWER_URL, "relation_viewer_url": RELATION_VIEWER_URL},
|
|
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
}
|
|
|
|
|
|
def annotation_labels_sql() -> str:
|
|
return f"""
|
|
SELECT
|
|
a.ct_number,
|
|
COALESCE(jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_keys,
|
|
COALESCE(jsonb_agg(DISTINCT
|
|
CASE part.value
|
|
WHEN 'head_neck' THEN '头颈部'
|
|
WHEN 'chest' THEN '胸部'
|
|
WHEN 'upper_abdomen' THEN '上腹部'
|
|
WHEN 'lower_abdomen' THEN '下腹部'
|
|
WHEN 'pelvis' THEN '盆腔'
|
|
ELSE NULL
|
|
END
|
|
) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_labels
|
|
FROM public.{PACS_ANNOTATION_TABLE_SQL} a
|
|
LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(a.body_parts, '[]'::jsonb)) AS part(value) ON true
|
|
WHERE COALESCE(a.skipped, false) IS NOT TRUE
|
|
GROUP BY a.ct_number
|
|
"""
|
|
|
|
|
|
def relation_select(where_sql: str = "") -> str:
|
|
return f"""
|
|
WITH ann AS ({annotation_labels_sql()}),
|
|
complete AS (
|
|
SELECT
|
|
upper(p.ct_number) AS ct_key,
|
|
p.ct_number,
|
|
COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型') AS algorithm_model,
|
|
p.batch_name,
|
|
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,
|
|
u.patient_name AS upp_patient_name,
|
|
u.upp_status,
|
|
COALESCE(u.processed_stl_dir, '') AS processed_stl_dir,
|
|
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(ann.body_part_keys, ps.body_parts, '[]'::jsonb) AS body_part_keys,
|
|
COALESCE(ann.body_part_labels, '[]'::jsonb) AS body_part_labels
|
|
FROM public.{PACS_TABLE_SQL} p
|
|
JOIN public.{UPP_ASSET_TABLE_SQL} u ON upper(u.ct_number) = upper(p.ct_number)
|
|
LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(p.ct_number)
|
|
LEFT JOIN public.{PACS_SUMMARY_TABLE_SQL} ps ON ps.ct_number = p.ct_number
|
|
LEFT JOIN ann ON ann.ct_number = p.ct_number
|
|
WHERE COALESCE(u.stl_present, false) OR COALESCE(u.stl_file_count, 0) > 0 OR s.ct_number IS NOT NULL
|
|
)
|
|
SELECT
|
|
c.*,
|
|
COALESCE(r.registration_status, 'unregistered') AS registration_status,
|
|
r.series_instance_uid,
|
|
r.series_description,
|
|
r.transform,
|
|
r.selected_stl_files,
|
|
r.notes,
|
|
r.updated_at AS registration_updated_at
|
|
FROM complete c
|
|
LEFT JOIN public.{REGISTRATION_TABLE_SQL} r
|
|
ON r.ct_number = c.ct_number AND r.algorithm_model = c.algorithm_model
|
|
{where_sql}
|
|
"""
|
|
|
|
|
|
@app.get("/api/cases")
|
|
def cases(
|
|
q: str = "",
|
|
status_filter: str = Query(default="", alias="status"),
|
|
body_part: str = "",
|
|
algorithm_model: str = "",
|
|
limit: int = Query(default=120, ge=1, le=400),
|
|
_: str = Depends(require_auth),
|
|
) -> list[dict[str, Any]]:
|
|
ensure_registration_table()
|
|
clauses = []
|
|
if q.strip():
|
|
like = "%" + q.strip().replace("%", "").replace("_", "") + "%"
|
|
clauses.append(
|
|
"("
|
|
+ " OR ".join(
|
|
[
|
|
f"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"upp_patient_name ILIKE {sql_literal(like)}",
|
|
]
|
|
)
|
|
+ ")"
|
|
)
|
|
if status_filter in {"registered", "unregistered"}:
|
|
clauses.append(f"registration_status = {sql_literal(status_filter)}")
|
|
if body_part in {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}:
|
|
clauses.append(f"body_part_keys ? {sql_literal(body_part)}")
|
|
if algorithm_model.strip():
|
|
clauses.append(f"algorithm_model ILIKE {sql_literal('%' + algorithm_model.strip().replace('%', '').replace('_', '') + '%')}")
|
|
where_sql = "WHERE " + " AND ".join(clauses) if clauses else ""
|
|
return pg_json_rows(
|
|
f"""
|
|
SELECT *
|
|
FROM ({relation_select()}) relation
|
|
{where_sql}
|
|
ORDER BY
|
|
registration_status = 'registered',
|
|
COALESCE(study_date, '') DESC,
|
|
COALESCE(study_time, '') DESC,
|
|
ct_key
|
|
LIMIT {int(limit)}
|
|
""",
|
|
timeout=20,
|
|
)
|
|
|
|
|
|
@app.get("/api/cases/{ct_number}")
|
|
def case_detail(ct_number: str, algorithm_model: str = "未指定模型", _: str = Depends(require_auth)) -> dict[str, Any]:
|
|
rows = pg_json_rows(
|
|
f"""
|
|
SELECT *
|
|
FROM ({relation_select()}) relation
|
|
WHERE ct_key = {sql_literal(normalize_ct(ct_number))}
|
|
AND algorithm_model = {sql_literal(algorithm_model or '未指定模型')}
|
|
LIMIT 1
|
|
""",
|
|
timeout=16,
|
|
)
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="未找到完整关联 CT")
|
|
return rows[0]
|
|
|
|
|
|
def numeric(value: Any, fallback: float = 0.0) -> float:
|
|
try:
|
|
return float(str(value).strip().split("\\")[0])
|
|
except Exception:
|
|
return fallback
|
|
|
|
|
|
def text(value: Any) -> str:
|
|
return "" if value is None else str(value).strip()
|
|
|
|
|
|
def read_header(path: Path) -> dict[str, str]:
|
|
ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True, specific_tags=DICOM_TAGS + ["SpecificCharacterSet"])
|
|
return {tag: text(getattr(ds, tag, "")) for tag in DICOM_TAGS}
|
|
|
|
|
|
def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
|
|
path, meta = item
|
|
z = numeric(meta.get("SliceLocation"), 0.0)
|
|
position = meta.get("ImagePositionPatient", "")
|
|
if position and not meta.get("SliceLocation"):
|
|
try:
|
|
z = float(str(position).strip("[]").split(",")[-1])
|
|
except Exception:
|
|
z = 0.0
|
|
return (z, numeric(meta.get("InstanceNumber"), 0.0), str(path))
|
|
|
|
|
|
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="DICOM 检查不存在")
|
|
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():
|
|
found = next(PROCESSED_ROOT.rglob(target_folder), None)
|
|
if found:
|
|
return found
|
|
return root
|
|
|
|
|
|
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(row: dict[str, Any]) -> list[str]:
|
|
if not row:
|
|
return []
|
|
if row.get("skipped"):
|
|
return ["略过/不采用"]
|
|
labels = []
|
|
for part in parse_json_list(row.get("body_parts")):
|
|
if part == "head_neck":
|
|
labels.append("头颈部")
|
|
elif part == "chest":
|
|
labels.append("胸部")
|
|
elif part == "upper_abdomen":
|
|
phase = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(row.get("upper_abdomen_phase") or "unknown", "无法判别")
|
|
labels.append(f"上腹部-{phase}")
|
|
elif part == "lower_abdomen":
|
|
labels.append("下腹部")
|
|
elif part == "pelvis":
|
|
labels.append("盆腔")
|
|
return labels
|
|
|
|
|
|
def scan_study(ct_number: str) -> dict[str, Any]:
|
|
key = normalize_ct(ct_number)
|
|
cached = STUDY_CACHE.get(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 目录不存在:{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)
|
|
series = []
|
|
file_map = {}
|
|
for uid, items in grouped.items():
|
|
items.sort(key=sort_key)
|
|
first = items[0][1]
|
|
last = items[-1][1]
|
|
file_map[uid] = [path for path, _ in items]
|
|
annotation = annotations.get(uid) or {}
|
|
series.append(
|
|
{
|
|
"series_uid": uid,
|
|
"description": first.get("SeriesDescription") or "未命名序列",
|
|
"series_number": first.get("SeriesNumber") or "",
|
|
"count": len(items),
|
|
"modality": first.get("Modality") or "",
|
|
"rows": first.get("Rows") or "",
|
|
"columns": first.get("Columns") or "",
|
|
"body_part_dicom": first.get("BodyPartExamined") or "",
|
|
"series_time": first.get("SeriesTime") or first.get("AcquisitionTime") or first.get("ContentTime") or "",
|
|
"first_time": first.get("AcquisitionTime") or first.get("ContentTime") or "",
|
|
"last_time": last.get("AcquisitionTime") or last.get("ContentTime") or "",
|
|
"pixel_spacing": first.get("PixelSpacing") or "",
|
|
"slice_thickness": first.get("SliceThickness") or "",
|
|
"spacing_between_slices": first.get("SpacingBetweenSlices") or "",
|
|
"annotation_labels": annotation_labels(annotation),
|
|
}
|
|
)
|
|
series.sort(key=lambda item: (str(item.get("first_time") or item.get("series_time") or ""), numeric(item.get("series_number"), 999999), item.get("description") or ""))
|
|
payload = {"cached_at": time.time(), "study": study, "root": str(root), "series": series, "files": file_map}
|
|
STUDY_CACHE[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="DICOM 序列不存在")
|
|
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", 40)
|
|
width = getattr(ds, "WindowWidth", 400)
|
|
if isinstance(center, pydicom.multival.MultiValue):
|
|
center = center[0]
|
|
if isinstance(width, pydicom.multival.MultiValue):
|
|
width = width[0]
|
|
return numeric(center, 40.0), numeric(width, 400.0)
|
|
|
|
|
|
def pixel_spacing_from_ds(ds: pydicom.Dataset) -> tuple[float, float]:
|
|
spacing = getattr(ds, "PixelSpacing", None)
|
|
if spacing and len(spacing) >= 2:
|
|
return max(numeric(spacing[0], 1.0), 0.001), max(numeric(spacing[1], 1.0), 0.001)
|
|
return 1.0, 1.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 slice_spacing_from_datasets(datasets: list[pydicom.Dataset]) -> float:
|
|
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))
|
|
distances = [float(np.linalg.norm(b - a)) for a, b in zip(positions, positions[1:])]
|
|
distances = [item for item in distances if item > 0.001]
|
|
if distances:
|
|
return max(float(np.median(distances)), 0.001)
|
|
if datasets:
|
|
spacing = numeric(getattr(datasets[0], "SpacingBetweenSlices", 0), 0.0)
|
|
if spacing > 0.001:
|
|
return spacing
|
|
thickness = numeric(getattr(datasets[0], "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
|
|
unit = min(row_spacing, col_spacing)
|
|
width = max(1, int(round(pil.width * col_spacing / unit)))
|
|
height = max(1, int(round(pil.height * row_spacing / unit)))
|
|
scale = min(1.0, 1800 / max(width, height))
|
|
target = (max(1, int(round(width * scale))), max(1, int(round(height * scale))))
|
|
return pil if target == pil.size else pil.resize(target, Image.Resampling.BILINEAR)
|
|
|
|
|
|
def render_array(arr: np.ndarray, center: float, width: float, max_size: int = 900, spacing: tuple[float, float] = (1.0, 1.0), invert: bool = False) -> 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, spacing[0], 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, "datasets": datasets, "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/dicom/image")
|
|
def dicom_image(
|
|
ct_number: str,
|
|
series_uid: str,
|
|
index: int = 0,
|
|
window: str = "default",
|
|
_: str = Depends(require_auth),
|
|
) -> Response:
|
|
files = get_series_files(ct_number, series_uid)
|
|
index = min(max(0, 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, spacing=pixel_spacing_from_ds(ds), invert=getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1")
|
|
return Response(payload, media_type="image/png")
|
|
|
|
|
|
@app.get("/api/dicom/fusion-volume")
|
|
def dicom_fusion_volume(
|
|
ct_number: str,
|
|
series_uid: str,
|
|
center_index: int = 0,
|
|
window: str = "soft",
|
|
radius: int = Query(default=96, ge=4, le=512),
|
|
range_start: int | None = None,
|
|
range_end: int | None = None,
|
|
_: str = Depends(require_auth),
|
|
) -> dict[str, Any]:
|
|
stack_data = load_stack_data(ct_number, series_uid)
|
|
stack = stack_data["stack"]
|
|
datasets = stack_data["datasets"]
|
|
total = int(stack.shape[0])
|
|
center_index = min(max(0, center_index), total - 1)
|
|
if range_start is not None and range_end is not None:
|
|
start = min(max(0, int(range_start)), total - 1)
|
|
end = min(max(0, int(range_end)), total - 1)
|
|
if start > end:
|
|
start, end = end, start
|
|
else:
|
|
start = max(0, center_index - radius)
|
|
end = min(total - 1, center_index + radius)
|
|
max_frames = 72
|
|
if end - start + 1 > max_frames:
|
|
step = int(np.ceil((end - start + 1) / max_frames))
|
|
indices = list(range(start, end + 1, step))
|
|
else:
|
|
indices = list(range(start, end + 1))
|
|
sample_ds = datasets[center_index]
|
|
center, width = window_values(sample_ds, window)
|
|
frames = []
|
|
frame_width = 0
|
|
frame_height = 0
|
|
for index in indices:
|
|
png = render_array(stack[index], center, width, max_size=256, spacing=(stack_data["row_spacing"], stack_data["col_spacing"]))
|
|
frames.append("data:image/png;base64," + base64.b64encode(png).decode("ascii"))
|
|
if not frame_width:
|
|
with Image.open(io.BytesIO(png)) as pil:
|
|
frame_width, frame_height = pil.size
|
|
return {
|
|
"frames": frames,
|
|
"indices": indices,
|
|
"start": start,
|
|
"end": end,
|
|
"center": center_index,
|
|
"total": total,
|
|
"width": frame_width,
|
|
"height": frame_height,
|
|
"spacing": {"row": stack_data["row_spacing"], "column": stack_data["col_spacing"], "slice": stack_data["slice_spacing"]},
|
|
"physicalSize": {
|
|
"width": int(stack.shape[2]) * stack_data["col_spacing"],
|
|
"height": int(stack.shape[1]) * stack_data["row_spacing"],
|
|
"depth": total * stack_data["slice_spacing"],
|
|
"unit": "mm",
|
|
},
|
|
}
|
|
|
|
|
|
def stl_rows_for_ct(ct_number: str, algorithm_model: str = "") -> list[dict[str, Any]]:
|
|
rows = pg_json_rows(
|
|
f"""
|
|
SELECT
|
|
COALESCE(NULLIF(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))}
|
|
AND ({sql_literal(algorithm_model)} = '' OR COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型') = {sql_literal(algorithm_model or '未指定模型')})
|
|
ORDER BY COALESCE(NULLIF(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(str(row["processed_stl_dir"]))
|
|
if base.exists():
|
|
files = [
|
|
{
|
|
"file_name": path.name,
|
|
"segment_name": path.stem,
|
|
"family": path.stem,
|
|
"category": "未分类",
|
|
"processed_file_path": str(path),
|
|
"size_bytes": path.stat().st_size,
|
|
}
|
|
for path 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,
|
|
}
|
|
)
|
|
index += 1
|
|
return output
|
|
|
|
|
|
@app.get("/api/cases/{ct_number}/stl")
|
|
def stl_assets(ct_number: str, algorithm_model: str = "", _: str = Depends(require_auth)) -> dict[str, Any]:
|
|
files = stl_rows_for_ct(ct_number, algorithm_model)
|
|
return {
|
|
"ct_number": ct_number,
|
|
"files": files,
|
|
"families": sorted({str(item.get("family") or "") for item in files if item.get("family")}),
|
|
"algorithm_models": sorted({str(item.get("algorithm_model") or "") for item in files if item.get("algorithm_model")}),
|
|
}
|
|
|
|
|
|
@app.get("/api/stl/file")
|
|
def stl_file(ct_number: str, file_id: int, algorithm_model: str = "", _: str = Depends(require_auth)) -> FileResponse:
|
|
files = stl_rows_for_ct(ct_number, algorithm_model)
|
|
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 文件不存在")
|
|
path = Path(str(selected["path"]))
|
|
if not path.exists() or not path.is_file():
|
|
raise HTTPException(status_code=404, detail=f"STL 路径不存在:{path}")
|
|
return FileResponse(path, media_type="model/stl", filename=path.name)
|
|
|
|
|
|
@app.get("/api/registrations/{ct_number}")
|
|
def get_registration(ct_number: str, algorithm_model: str = "未指定模型", _: str = Depends(require_auth)) -> dict[str, Any]:
|
|
ensure_registration_table()
|
|
rows = pg_json_rows(
|
|
f"""
|
|
SELECT *
|
|
FROM public.{REGISTRATION_TABLE_SQL}
|
|
WHERE ct_number = {sql_literal(normalize_ct(ct_number))}
|
|
AND algorithm_model = {sql_literal(algorithm_model or '未指定模型')}
|
|
LIMIT 1
|
|
""",
|
|
timeout=12,
|
|
)
|
|
return rows[0] if rows else {
|
|
"ct_number": normalize_ct(ct_number),
|
|
"algorithm_model": algorithm_model or "未指定模型",
|
|
"registration_status": "unregistered",
|
|
"transform": DEFAULT_POSE,
|
|
"selected_stl_files": [],
|
|
"notes": "",
|
|
}
|
|
|
|
|
|
@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号不能为空")
|
|
algorithm_model = payload.algorithm_model.strip() or "未指定模型"
|
|
registration_status = payload.registration_status if payload.registration_status in {"registered", "unregistered"} else "unregistered"
|
|
transform = {**DEFAULT_POSE, **(payload.transform or {})}
|
|
pg_scalar(
|
|
f"""
|
|
INSERT INTO public.{REGISTRATION_TABLE_SQL} (
|
|
ct_number, algorithm_model, registration_status, series_instance_uid, series_description,
|
|
selected_stl_files, transform, module_styles, dicom_reference, model_reference,
|
|
notes, updated_by, updated_at
|
|
)
|
|
VALUES (
|
|
{sql_literal(ct_number)},
|
|
{sql_literal(algorithm_model)},
|
|
{sql_literal(registration_status)},
|
|
{sql_literal(payload.series_instance_uid.strip())},
|
|
{sql_literal(payload.series_description.strip())},
|
|
{json_sql(payload.selected_stl_files)},
|
|
{json_sql(transform)},
|
|
{json_sql(payload.module_styles)},
|
|
{json_sql(payload.dicom_reference)},
|
|
{json_sql(payload.model_reference)},
|
|
{sql_literal(payload.notes.strip())},
|
|
{sql_literal(user)},
|
|
now()
|
|
)
|
|
ON CONFLICT (ct_number, algorithm_model) DO UPDATE SET
|
|
registration_status = EXCLUDED.registration_status,
|
|
series_instance_uid = EXCLUDED.series_instance_uid,
|
|
series_description = EXCLUDED.series_description,
|
|
selected_stl_files = EXCLUDED.selected_stl_files,
|
|
transform = EXCLUDED.transform,
|
|
module_styles = EXCLUDED.module_styles,
|
|
dicom_reference = EXCLUDED.dicom_reference,
|
|
model_reference = EXCLUDED.model_reference,
|
|
notes = EXCLUDED.notes,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = now()
|
|
""",
|
|
timeout=14,
|
|
)
|
|
return {"ok": True, "ct_number": ct_number, "algorithm_model": algorithm_model, "registration_status": registration_status}
|