867 lines
32 KiB
Python
867 lines
32 KiB
Python
#!/usr/bin/env python3
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import hashlib
|
||
import io
|
||
import json
|
||
import os
|
||
import secrets
|
||
import subprocess
|
||
import time
|
||
import urllib.error
|
||
import urllib.request
|
||
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
|
||
|
||
|
||
APP_DIR = Path(__file__).resolve().parent
|
||
PACS_ROOT = APP_DIR.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")
|
||
PGTABLE = os.getenv("PGTABLE", "pacs_dicom_files")
|
||
WEB_USER = os.getenv("PACS_WEB_USER", "admin")
|
||
WEB_PASSWORD = os.getenv("PACS_WEB_PASSWORD", "123456")
|
||
PROCESSED_ROOT = Path(os.getenv("PACS_PROCESSED_ROOT", str(PACS_ROOT / "已处理_DICOM数据"))).resolve()
|
||
KIMI_API_KEY = os.getenv("KIMI_API_KEY", "")
|
||
KIMI_API_NAME = os.getenv("KIMI_API_NAME", "HIS_Check")
|
||
KIMI_API_URL = os.getenv("KIMI_API_URL", "https://api.moonshot.cn/v1/chat/completions")
|
||
KIMI_MODEL = os.getenv("KIMI_MODEL", "kimi-latest")
|
||
|
||
WINDOWS = {
|
||
"default": None,
|
||
"bone": (500.0, 1800.0),
|
||
"soft": (50.0, 360.0),
|
||
"contrast": (90.0, 140.0),
|
||
}
|
||
|
||
BODY_PARTS = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}
|
||
PHASES = {"arterial", "portal_venous", "unknown", ""}
|
||
ROLES = {
|
||
"管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"],
|
||
"阅片员": ["查看DICOM", "编辑标注", "AI识别"],
|
||
"访客": ["查看DICOM"],
|
||
}
|
||
|
||
app = FastAPI(title="PACS DICOM Viewer")
|
||
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, np.ndarray]] = {}
|
||
|
||
|
||
class LoginIn(BaseModel):
|
||
username: str
|
||
password: str
|
||
|
||
|
||
class AnnotationIn(BaseModel):
|
||
body_parts: list[str] = []
|
||
upper_abdomen_phase: str = ""
|
||
notes: str = ""
|
||
skipped: bool = False
|
||
|
||
|
||
class AIRequest(BaseModel):
|
||
sample_count: int = 3
|
||
|
||
|
||
class UserIn(BaseModel):
|
||
username: str
|
||
password: str
|
||
role: str = "阅片员"
|
||
status: str = "启用"
|
||
|
||
|
||
def pg_env() -> dict[str, str]:
|
||
env = os.environ.copy()
|
||
if os.getenv("PGPASSWORD"):
|
||
env["PGPASSWORD"] = os.environ["PGPASSWORD"]
|
||
return env
|
||
|
||
|
||
def sql_literal(value: Any) -> str:
|
||
if value is None:
|
||
return "NULL"
|
||
return "'" + str(value).replace("'", "''") + "'"
|
||
|
||
|
||
def run_psql(sql: str, timeout: int = 12) -> subprocess.CompletedProcess[str]:
|
||
return subprocess.run(
|
||
[
|
||
"psql",
|
||
"-h",
|
||
PGHOST,
|
||
"-p",
|
||
PGPORT,
|
||
"-U",
|
||
PGUSER,
|
||
"-d",
|
||
PGDATABASE,
|
||
"-X",
|
||
"-q",
|
||
"-t",
|
||
"-A",
|
||
"-c",
|
||
sql,
|
||
],
|
||
text=True,
|
||
capture_output=True,
|
||
timeout=timeout,
|
||
env=pg_env(),
|
||
)
|
||
|
||
|
||
def pg_scalar(sql: str, timeout: int = 12) -> 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 = 20) -> 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 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 ensure_annotation_table() -> None:
|
||
sql = """
|
||
CREATE TABLE IF NOT EXISTS public.pacs_dicom_series_annotations (
|
||
ct_number text NOT NULL,
|
||
study_instance_uid text,
|
||
series_instance_uid text NOT NULL,
|
||
series_description text,
|
||
body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||
upper_abdomen_phase text NOT NULL DEFAULT '',
|
||
skipped boolean NOT NULL DEFAULT false,
|
||
notes text NOT NULL DEFAULT '',
|
||
ai_result jsonb,
|
||
ai_model text,
|
||
updated_by text NOT NULL DEFAULT 'admin',
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||
PRIMARY KEY (ct_number, series_instance_uid)
|
||
);
|
||
ALTER TABLE public.pacs_dicom_series_annotations
|
||
ADD COLUMN IF NOT EXISTS skipped boolean NOT NULL DEFAULT false;
|
||
ALTER TABLE public.pacs_dicom_series_annotations
|
||
ADD COLUMN IF NOT EXISTS ai_result jsonb;
|
||
ALTER TABLE public.pacs_dicom_series_annotations
|
||
ADD COLUMN IF NOT EXISTS ai_model text;
|
||
"""
|
||
pg_scalar(sql)
|
||
|
||
|
||
def password_hash(password: str) -> str:
|
||
return hashlib.sha256(("pacs-dicom-web:" + password).encode("utf-8")).hexdigest()
|
||
|
||
|
||
def ensure_user_table() -> None:
|
||
sql = f"""
|
||
CREATE TABLE IF NOT EXISTS public.pacs_web_users (
|
||
username text PRIMARY KEY,
|
||
password_hash text NOT NULL,
|
||
role text NOT NULL DEFAULT '阅片员',
|
||
status text NOT NULL DEFAULT '启用',
|
||
created_at timestamptz NOT NULL DEFAULT now(),
|
||
updated_at timestamptz NOT NULL DEFAULT now()
|
||
);
|
||
INSERT INTO public.pacs_web_users (username, password_hash, role, status)
|
||
VALUES ({sql_literal(WEB_USER)}, {sql_literal(password_hash(WEB_PASSWORD))}, '管理员', '启用')
|
||
ON CONFLICT (username) DO NOTHING;
|
||
"""
|
||
pg_scalar(sql)
|
||
|
||
|
||
def web_users() -> list[dict[str, Any]]:
|
||
try:
|
||
ensure_user_table()
|
||
return pg_json_rows(
|
||
"""
|
||
SELECT username, role, status, created_at, updated_at
|
||
FROM public.pacs_web_users
|
||
ORDER BY username
|
||
"""
|
||
)
|
||
except Exception:
|
||
return [{"username": WEB_USER, "role": "管理员", "status": "启用"}]
|
||
|
||
|
||
def authenticate_web_user(username: str, password: str) -> bool:
|
||
try:
|
||
ok, _ = db_available()
|
||
if ok:
|
||
ensure_user_table()
|
||
rows = pg_json_rows(
|
||
f"""
|
||
SELECT username, password_hash, status
|
||
FROM public.pacs_web_users
|
||
WHERE username = {sql_literal(username)}
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
if rows:
|
||
row = rows[0]
|
||
return row.get("status") == "启用" and row.get("password_hash") == password_hash(password)
|
||
except Exception:
|
||
pass
|
||
return username == WEB_USER and password == WEB_PASSWORD
|
||
|
||
|
||
@app.on_event("startup")
|
||
def startup() -> None:
|
||
ok, _ = db_available()
|
||
if ok:
|
||
ensure_annotation_table()
|
||
ensure_user_table()
|
||
|
||
|
||
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.get("/")
|
||
def index() -> FileResponse:
|
||
return FileResponse(STATIC_DIR / "index.html")
|
||
|
||
|
||
@app.post("/api/auth/login")
|
||
def login(data: LoginIn) -> dict[str, str]:
|
||
if authenticate_web_user(data.username, data.password):
|
||
token = secrets.token_urlsafe(32)
|
||
TOKENS[token] = data.username
|
||
return {"token": token, "username": data.username}
|
||
raise HTTPException(status_code=401, detail="invalid credentials")
|
||
|
||
|
||
@app.get("/api/status")
|
||
def status() -> dict[str, Any]:
|
||
db_ok, db_message = db_available()
|
||
table_count = None
|
||
if db_ok:
|
||
try:
|
||
table_count = int(pg_scalar(f"SELECT count(*) FROM public.{PGTABLE}"))
|
||
except Exception:
|
||
table_count = None
|
||
return {
|
||
"database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count},
|
||
"dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()},
|
||
"ai": {"configured": bool(KIMI_API_KEY), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL},
|
||
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||
}
|
||
|
||
|
||
@app.get("/api/studies")
|
||
def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]:
|
||
where = ""
|
||
if q:
|
||
like = "%" + q.replace("%", "").replace("_", "") + "%"
|
||
where = f"WHERE ct_number ILIKE {sql_literal(like)} OR source_patient_name ILIKE {sql_literal(like)} OR patient_name_dicom ILIKE {sql_literal(like)}"
|
||
rows = pg_json_rows(
|
||
f"""
|
||
SELECT
|
||
ct_number, batch_name, target_folder_name, source_patient_name, patient_name_dicom,
|
||
patient_id, study_date, study_time, modality, dicom_file_count,
|
||
processed_path, needs_ct_number_fix, status
|
||
FROM public.{PGTABLE}
|
||
{where}
|
||
ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number
|
||
LIMIT {int(limit)}
|
||
"""
|
||
)
|
||
annotations = pg_json_rows(
|
||
"""
|
||
SELECT ct_number, count(*)::int AS annotated_series
|
||
FROM public.pacs_dicom_series_annotations
|
||
WHERE skipped IS NOT TRUE AND jsonb_array_length(body_parts) > 0
|
||
GROUP BY ct_number
|
||
""",
|
||
timeout=8,
|
||
)
|
||
annotation_map = {row["ct_number"]: row["annotated_series"] for row in annotations}
|
||
for row in rows:
|
||
row["annotated_series"] = annotation_map.get(row["ct_number"], 0)
|
||
return rows
|
||
|
||
|
||
def get_study_record(ct_number: str) -> dict[str, Any]:
|
||
rows = pg_json_rows(
|
||
f"""
|
||
SELECT * FROM public.{PGTABLE}
|
||
WHERE ct_number = {sql_literal(ct_number)}
|
||
LIMIT 1
|
||
"""
|
||
)
|
||
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:
|
||
direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}"))
|
||
if direct_matches:
|
||
return direct_matches[0]
|
||
recursive = next(PROCESSED_ROOT.rglob(target_folder), None)
|
||
if recursive:
|
||
return recursive
|
||
|
||
ct_number = str(study.get("ct_number") or "")
|
||
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",
|
||
"SeriesNumber",
|
||
"SeriesDescription",
|
||
"InstanceNumber",
|
||
"SliceLocation",
|
||
"ImagePositionPatient",
|
||
"AcquisitionTime",
|
||
"ContentTime",
|
||
"SeriesTime",
|
||
"StudyTime",
|
||
"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 sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]:
|
||
path, meta = item
|
||
instance = float(meta.get("InstanceNumber") or 0)
|
||
position = meta.get("ImagePositionPatient", "")
|
||
z = 0.0
|
||
if position:
|
||
try:
|
||
z = float(str(position).strip("[]").split(",")[-1])
|
||
except Exception:
|
||
z = 0.0
|
||
if meta.get("SliceLocation"):
|
||
try:
|
||
z = float(meta["SliceLocation"])
|
||
except Exception:
|
||
pass
|
||
return (z, instance, str(path))
|
||
|
||
|
||
def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]:
|
||
try:
|
||
rows = pg_json_rows(
|
||
f"""
|
||
SELECT series_instance_uid, body_parts, upper_abdomen_phase, skipped, notes, updated_at, ai_model
|
||
FROM public.pacs_dicom_series_annotations
|
||
WHERE ct_number = {sql_literal(ct_number)}
|
||
"""
|
||
)
|
||
except Exception:
|
||
return {}
|
||
return {row["series_instance_uid"]: row for row in rows}
|
||
|
||
|
||
def scan_study(ct_number: str) -> dict[str, Any]:
|
||
cached = STUDY_CACHE.get(ct_number)
|
||
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)
|
||
series_list = []
|
||
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, {})
|
||
series_list.append(
|
||
{
|
||
"ct_number": 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_time": first.get("StudyTime", ""),
|
||
"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", ""),
|
||
"manufacturer": first.get("Manufacturer", ""),
|
||
"rows": first.get("Rows", ""),
|
||
"columns": first.get("Columns", ""),
|
||
"pixel_spacing": first.get("PixelSpacing", ""),
|
||
"slice_thickness": first.get("SliceThickness", ""),
|
||
"spacing_between_slices": first.get("SpacingBetweenSlices", ""),
|
||
"annotation": {
|
||
"body_parts": annotation.get("body_parts", []),
|
||
"upper_abdomen_phase": annotation.get("upper_abdomen_phase", ""),
|
||
"skipped": bool(annotation.get("skipped", False)),
|
||
"notes": annotation.get("notes", ""),
|
||
"updated_at": annotation.get("updated_at", ""),
|
||
"ai_model": annotation.get("ai_model", ""),
|
||
},
|
||
}
|
||
)
|
||
|
||
def numeric(value: str) -> int:
|
||
try:
|
||
return int(float(value))
|
||
except Exception:
|
||
return 999999
|
||
|
||
series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, numeric(row["series_number"]), row["description"]))
|
||
cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map}
|
||
STUDY_CACHE[ct_number] = cached
|
||
return cached
|
||
|
||
|
||
@app.get("/api/studies/{ct_number}/series")
|
||
def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]:
|
||
data = scan_study(ct_number)
|
||
return {"study": data["study"], "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 render_array(arr: np.ndarray, center: float, width: float, invert: bool = False, rotate: int = 0, max_size: int = 900) -> 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)
|
||
if rotate:
|
||
pil = pil.rotate(-rotate, expand=True)
|
||
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(ct_number: str, series_uid: str) -> np.ndarray:
|
||
key = f"{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 = []
|
||
for path in files:
|
||
ds = pydicom.dcmread(str(path), force=True)
|
||
arrays.append(dicom_to_hu(ds))
|
||
stack = np.stack(arrays, axis=0)
|
||
STACK_CACHE[key] = (time.time(), stack)
|
||
if len(STACK_CACHE) > 2:
|
||
oldest = sorted(STACK_CACHE.items(), key=lambda item: item[1][0])[0][0]
|
||
STACK_CACHE.pop(oldest, None)
|
||
return stack
|
||
|
||
|
||
@app.get("/api/image")
|
||
def image(
|
||
ct_number: str,
|
||
series_uid: str,
|
||
index: int = 0,
|
||
plane: str = "axial",
|
||
window: str = "default",
|
||
rotate: int = 0,
|
||
_: 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", rotate)
|
||
return Response(payload, media_type="image/png")
|
||
|
||
stack = load_stack(ct_number, series_uid)
|
||
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, :]
|
||
elif plane == "sagittal":
|
||
index = min(index, stack.shape[2] - 1)
|
||
arr = stack[:, :, index]
|
||
else:
|
||
raise HTTPException(status_code=400, detail="invalid plane")
|
||
payload = render_array(np.flipud(arr), center, width, False, rotate)
|
||
return Response(payload, media_type="image/png")
|
||
|
||
|
||
@app.get("/api/dicom-info")
|
||
def dicom_info(ct_number: str, series_uid: str, index: int = 0, _: str = Depends(require_auth)) -> dict[str, Any]:
|
||
files = get_series_files(ct_number, series_uid)
|
||
index = min(max(0, index), len(files) - 1)
|
||
path = files[index]
|
||
ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True)
|
||
fields = {
|
||
"patient": {
|
||
"患者姓名": str(getattr(ds, "PatientName", "")),
|
||
"患者ID": str(getattr(ds, "PatientID", "")),
|
||
"检查号": str(getattr(ds, "AccessionNumber", "")),
|
||
"检查日期": str(getattr(ds, "StudyDate", "")),
|
||
"检查时间": str(getattr(ds, "StudyTime", "")),
|
||
"设备厂商": str(getattr(ds, "Manufacturer", "")),
|
||
},
|
||
"series": {
|
||
"序列描述": str(getattr(ds, "SeriesDescription", "")),
|
||
"序列号": str(getattr(ds, "SeriesNumber", "")),
|
||
"文件数量": len(files),
|
||
"当前文件": path.name,
|
||
"DICOM路径": str(path),
|
||
},
|
||
"image": {
|
||
"Rows": str(getattr(ds, "Rows", "")),
|
||
"Columns": str(getattr(ds, "Columns", "")),
|
||
"BitsAllocated": str(getattr(ds, "BitsAllocated", "")),
|
||
"WindowCenter": str(getattr(ds, "WindowCenter", "")),
|
||
"WindowWidth": str(getattr(ds, "WindowWidth", "")),
|
||
"Rescale": f"{getattr(ds, 'RescaleSlope', '')} / {getattr(ds, 'RescaleIntercept', '')}",
|
||
},
|
||
"spacing": {
|
||
"像素间距": str(getattr(ds, "PixelSpacing", "")),
|
||
"切片厚度": str(getattr(ds, "SliceThickness", "")),
|
||
"SpacingBetweenSlices": str(getattr(ds, "SpacingBetweenSlices", "")),
|
||
"ImagePositionPatient": str(getattr(ds, "ImagePositionPatient", "")),
|
||
},
|
||
}
|
||
return {"path": str(path), "fields": fields}
|
||
|
||
|
||
@app.put("/api/series/{ct_number}/{series_uid}/annotation")
|
||
def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: str = Depends(require_auth)) -> dict[str, Any]:
|
||
body_parts = [] if data.skipped else [part for part in data.body_parts if part in BODY_PARTS]
|
||
phase = data.upper_abdomen_phase if data.upper_abdomen_phase in PHASES else ""
|
||
if "upper_abdomen" not in body_parts:
|
||
phase = ""
|
||
study = scan_study(ct_number)
|
||
series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None)
|
||
if not series_row:
|
||
raise HTTPException(status_code=404, detail="series not found")
|
||
ensure_annotation_table()
|
||
pg_scalar(
|
||
f"""
|
||
INSERT INTO public.pacs_dicom_series_annotations (
|
||
ct_number, study_instance_uid, series_instance_uid, series_description,
|
||
body_parts, upper_abdomen_phase, skipped, notes, updated_by, updated_at
|
||
)
|
||
VALUES (
|
||
{sql_literal(ct_number)},
|
||
{sql_literal(series_row.get('study_uid', ''))},
|
||
{sql_literal(series_uid)},
|
||
{sql_literal(series_row.get('description', ''))},
|
||
{sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb,
|
||
{sql_literal(phase)},
|
||
{'true' if data.skipped else 'false'},
|
||
{sql_literal(data.notes)},
|
||
{sql_literal(user)},
|
||
now()
|
||
)
|
||
ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET
|
||
study_instance_uid = EXCLUDED.study_instance_uid,
|
||
series_description = EXCLUDED.series_description,
|
||
body_parts = EXCLUDED.body_parts,
|
||
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
|
||
skipped = EXCLUDED.skipped,
|
||
notes = EXCLUDED.notes,
|
||
updated_by = EXCLUDED.updated_by,
|
||
updated_at = now()
|
||
"""
|
||
)
|
||
STUDY_CACHE.pop(ct_number, None)
|
||
return {"ok": True, "body_parts": body_parts, "upper_abdomen_phase": phase, "skipped": data.skipped}
|
||
|
||
|
||
def representative_images(ct_number: str, series_uid: str) -> list[tuple[str, bytes]]:
|
||
files = get_series_files(ct_number, series_uid)
|
||
axial_index = max(0, min(len(files) - 1, len(files) // 2))
|
||
ds = pydicom.dcmread(str(files[axial_index]), force=True)
|
||
center, width = window_values(ds, "soft")
|
||
images = [("横断面", render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", max_size=720))]
|
||
|
||
if len(files) >= 3:
|
||
try:
|
||
stack = load_stack(ct_number, series_uid)
|
||
sample_ds = pydicom.dcmread(str(files[axial_index]), stop_before_pixels=True, force=True)
|
||
center, width = window_values(sample_ds, "soft")
|
||
coronal = np.flipud(stack[:, stack.shape[1] // 2, :])
|
||
sagittal = np.flipud(stack[:, :, stack.shape[2] // 2])
|
||
images.append(("矢状面", render_array(sagittal, center, width, False, max_size=720)))
|
||
images.append(("冠状面", render_array(coronal, center, width, False, max_size=720)))
|
||
except Exception:
|
||
pass
|
||
return images
|
||
|
||
|
||
def parse_ai_json(content: str) -> dict[str, Any]:
|
||
text = content.strip()
|
||
if text.startswith("```"):
|
||
text = text.strip("`")
|
||
if text.startswith("json"):
|
||
text = text[4:]
|
||
start = text.find("{")
|
||
end = text.rfind("}")
|
||
if start >= 0 and end > start:
|
||
text = text[start : end + 1]
|
||
try:
|
||
return json.loads(text)
|
||
except Exception:
|
||
return {"body_parts": [], "upper_abdomen_phase": "", "skipped": False, "notes": content[:800]}
|
||
|
||
|
||
@app.post("/api/series/{ct_number}/{series_uid}/ai")
|
||
def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depends(require_auth)) -> dict[str, Any]:
|
||
if not KIMI_API_KEY:
|
||
raise HTTPException(status_code=400, detail="KIMI_API_KEY 未配置")
|
||
study = scan_study(ct_number)
|
||
series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None)
|
||
if not series_row:
|
||
raise HTTPException(status_code=404, detail="series not found")
|
||
|
||
content: list[dict[str, Any]] = [
|
||
{
|
||
"type": "text",
|
||
"text": (
|
||
"请根据这组CT序列的代表图像判断该序列所属部位。"
|
||
"可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), "
|
||
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。"
|
||
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。"
|
||
"如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。"
|
||
"只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"skipped\":false,\"notes\":\"\"}。"
|
||
f"PACS张数: {series_row.get('count', 0)}。"
|
||
f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。"
|
||
),
|
||
}
|
||
]
|
||
for label, png in representative_images(ct_number, series_uid):
|
||
content.append({"type": "text", "text": label})
|
||
content.append({"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode("ascii")}})
|
||
|
||
payload = {
|
||
"model": KIMI_MODEL,
|
||
"messages": [
|
||
{"role": "system", "content": "你是医学影像序列分拣助手,只做部位和期相粗分类,不输出诊断。"},
|
||
{"role": "user", "content": content},
|
||
],
|
||
"temperature": 0.1,
|
||
}
|
||
request = urllib.request.Request(
|
||
KIMI_API_URL,
|
||
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
||
headers={"Authorization": f"Bearer {KIMI_API_KEY}", "Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=60) as response:
|
||
raw = response.read().decode("utf-8")
|
||
except urllib.error.HTTPError as exc:
|
||
detail = exc.read().decode("utf-8", errors="replace")[:800]
|
||
raise HTTPException(status_code=502, detail=f"Kimi API 错误: {detail}") from exc
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail=f"Kimi API 调用失败: {exc}") from exc
|
||
|
||
data = json.loads(raw)
|
||
message = data.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||
suggestion = parse_ai_json(message)
|
||
body_parts = [part for part in suggestion.get("body_parts", []) if part in BODY_PARTS]
|
||
skipped = bool(suggestion.get("skipped", False))
|
||
phase = suggestion.get("upper_abdomen_phase", "")
|
||
if phase not in PHASES or "upper_abdomen" not in body_parts:
|
||
phase = ""
|
||
ensure_annotation_table()
|
||
pg_scalar(
|
||
f"""
|
||
INSERT INTO public.pacs_dicom_series_annotations (
|
||
ct_number, study_instance_uid, series_instance_uid, series_description,
|
||
body_parts, upper_abdomen_phase, skipped, notes, ai_result, ai_model, updated_by, updated_at
|
||
)
|
||
VALUES (
|
||
{sql_literal(ct_number)},
|
||
{sql_literal(series_row.get('study_uid', ''))},
|
||
{sql_literal(series_uid)},
|
||
{sql_literal(series_row.get('description', ''))},
|
||
'[]'::jsonb,
|
||
'',
|
||
false,
|
||
'',
|
||
{sql_literal(json.dumps(suggestion, ensure_ascii=False))}::jsonb,
|
||
{sql_literal(KIMI_MODEL)},
|
||
{sql_literal(user)},
|
||
now()
|
||
)
|
||
ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET
|
||
ai_result = EXCLUDED.ai_result,
|
||
ai_model = EXCLUDED.ai_model,
|
||
updated_by = EXCLUDED.updated_by,
|
||
updated_at = now()
|
||
"""
|
||
)
|
||
STUDY_CACHE.pop(ct_number, None)
|
||
return {
|
||
"ok": True,
|
||
"provider": "Kimi",
|
||
"name": KIMI_API_NAME,
|
||
"model": KIMI_MODEL,
|
||
"body_parts": [] if skipped else body_parts,
|
||
"upper_abdomen_phase": "" if skipped else phase,
|
||
"skipped": skipped,
|
||
"notes": str(suggestion.get("notes", "")),
|
||
"raw": suggestion,
|
||
}
|
||
|
||
|
||
@app.get("/api/settings")
|
||
def settings(_: str = Depends(require_auth)) -> dict[str, Any]:
|
||
return {
|
||
"users": web_users(),
|
||
"roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()],
|
||
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE},
|
||
"ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "url": KIMI_API_URL},
|
||
"dicom": {"processed_root": str(PROCESSED_ROOT)},
|
||
}
|
||
|
||
|
||
@app.post("/api/settings/users")
|
||
def create_user(data: UserIn, user: str = Depends(require_auth)) -> dict[str, Any]:
|
||
if user != WEB_USER:
|
||
raise HTTPException(status_code=403, detail="仅管理员可创建用户")
|
||
username = data.username.strip()
|
||
if not username or len(username) > 64:
|
||
raise HTTPException(status_code=400, detail="账号格式不正确")
|
||
if len(data.password) < 6:
|
||
raise HTTPException(status_code=400, detail="密码至少 6 位")
|
||
role = data.role if data.role in ROLES else "阅片员"
|
||
status = data.status if data.status in {"启用", "停用"} else "启用"
|
||
ensure_user_table()
|
||
exists = int(pg_scalar(f"SELECT count(*) FROM public.pacs_web_users WHERE username = {sql_literal(username)}") or "0")
|
||
if exists:
|
||
raise HTTPException(status_code=409, detail="账号已存在")
|
||
pg_scalar(
|
||
f"""
|
||
INSERT INTO public.pacs_web_users (username, password_hash, role, status, updated_at)
|
||
VALUES ({sql_literal(username)}, {sql_literal(password_hash(data.password))}, {sql_literal(role)}, {sql_literal(status)}, now())
|
||
"""
|
||
)
|
||
return {"ok": True, "username": username, "role": role, "status": status}
|
||
|
||
|
||
@app.get("/health")
|
||
def health() -> Response:
|
||
return Response("ok", media_type="text/plain")
|