1149 lines
45 KiB
Python
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))
|