Files
PACS/PACS_DICOM处理/数据处理网页端/app.py
2026-05-27 10:25:33 +08:00

1253 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, dict[str, Any]]] = {}
class LoginIn(BaseModel):
username: str
password: str
class AnnotationIn(BaseModel):
body_parts: list[str] = []
manual_body_parts: list[str] = []
ai_body_parts: list[str] = []
upper_abdomen_phase: str = ""
manual_upper_abdomen_phase: str = ""
ai_upper_abdomen_phase: str = ""
plain_ct: bool = False
manual_plain_ct: bool | None = None
ai_plain_ct: bool | None = None
notes: str = ""
skipped: bool = False
ai_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,
manual_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
ai_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
upper_abdomen_phase text NOT NULL DEFAULT '',
manual_upper_abdomen_phase text NOT NULL DEFAULT '',
ai_upper_abdomen_phase text NOT NULL DEFAULT '',
plain_ct boolean NOT NULL DEFAULT false,
manual_plain_ct boolean,
ai_plain_ct boolean,
skipped boolean NOT NULL DEFAULT false,
ai_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 manual_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE public.pacs_dicom_series_annotations
ADD COLUMN IF NOT EXISTS ai_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE public.pacs_dicom_series_annotations
ADD COLUMN IF NOT EXISTS manual_upper_abdomen_phase text NOT NULL DEFAULT '';
ALTER TABLE public.pacs_dicom_series_annotations
ADD COLUMN IF NOT EXISTS ai_upper_abdomen_phase text NOT NULL DEFAULT '';
ALTER TABLE public.pacs_dicom_series_annotations
ADD COLUMN IF NOT EXISTS plain_ct boolean NOT NULL DEFAULT false;
ALTER TABLE public.pacs_dicom_series_annotations
ADD COLUMN IF NOT EXISTS manual_plain_ct boolean;
ALTER TABLE public.pacs_dicom_series_annotations
ADD COLUMN IF NOT EXISTS ai_plain_ct boolean;
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_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;
UPDATE public.pacs_dicom_series_annotations
SET manual_body_parts = body_parts,
manual_upper_abdomen_phase = upper_abdomen_phase
WHERE jsonb_array_length(body_parts) > 0
AND jsonb_array_length(manual_body_parts) = 0
AND jsonb_array_length(ai_body_parts) = 0;
"""
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",
"AccessionNumber",
"PatientName",
"PatientID",
"PatientBirthDate",
"PatientSex",
"InstitutionName",
"StudyDate",
"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 valid_parts(parts: Any) -> list[str]:
if not isinstance(parts, list):
return []
return [part for part in parts if part in BODY_PARTS]
def valid_phase(value: Any) -> str:
return value if value in PHASES else ""
def bool_or_none(value: Any) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
if value.lower() in {"true", "t", "1", "yes", "y"}:
return True
if value.lower() in {"false", "f", "0", "no", "n"}:
return False
return bool(value)
def merge_parts(manual_parts: list[str], ai_parts: list[str], skipped: bool) -> list[str]:
if skipped:
return []
merged: list[str] = []
for part in manual_parts + ai_parts:
if part in BODY_PARTS and part not in merged:
merged.append(part)
return merged
def effective_phase(manual_phase: str, ai_phase: str, body_parts: list[str], skipped: bool) -> str:
if skipped or "upper_abdomen" not in body_parts:
return ""
return valid_phase(manual_phase) or valid_phase(ai_phase)
def effective_plain_ct(manual_plain_ct: bool | None, ai_plain_ct: bool | None, fallback: bool, skipped: bool) -> bool:
if skipped:
return False
if manual_plain_ct is not None:
return manual_plain_ct
if ai_plain_ct is not None:
return ai_plain_ct
return bool(fallback)
def normalize_annotation(row: dict[str, Any] | None, default_skipped: bool = False) -> dict[str, Any]:
row = row or {}
manual_parts = valid_parts(row.get("manual_body_parts") or [])
ai_parts = valid_parts(row.get("ai_body_parts") or [])
if not manual_parts and not ai_parts:
manual_parts = valid_parts(row.get("body_parts") or [])
skipped = bool(row.get("skipped", default_skipped))
ai_skipped = bool(row.get("ai_skipped", False))
manual_phase = valid_phase(row.get("manual_upper_abdomen_phase") or "")
ai_phase = valid_phase(row.get("ai_upper_abdomen_phase") or "")
if not manual_phase and not ai_phase:
manual_phase = valid_phase(row.get("upper_abdomen_phase") or "")
body_parts = merge_parts(manual_parts, ai_parts, skipped)
plain_ct = effective_plain_ct(
bool_or_none(row.get("manual_plain_ct")),
bool_or_none(row.get("ai_plain_ct")),
bool(row.get("plain_ct", False)),
skipped,
)
return {
"body_parts": body_parts,
"manual_body_parts": [] if skipped else manual_parts,
"ai_body_parts": [] if skipped else ai_parts,
"upper_abdomen_phase": effective_phase(manual_phase, ai_phase, body_parts, skipped),
"manual_upper_abdomen_phase": "" if skipped else manual_phase,
"ai_upper_abdomen_phase": "" if skipped else ai_phase,
"plain_ct": plain_ct,
"manual_plain_ct": None if skipped else bool_or_none(row.get("manual_plain_ct")),
"ai_plain_ct": None if skipped else bool_or_none(row.get("ai_plain_ct")),
"skipped": skipped,
"ai_skipped": ai_skipped,
"notes": row.get("notes", ""),
"updated_at": row.get("updated_at", ""),
"ai_model": row.get("ai_model", ""),
}
def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]:
try:
ensure_annotation_table()
rows = pg_json_rows(
f"""
SELECT
series_instance_uid, body_parts, manual_body_parts, ai_body_parts,
upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase,
plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_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 = {}
default_skip_rows = []
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]
raw_annotation = annotations.get(uid)
annotation = normalize_annotation(raw_annotation)
default_skipped = len(items) < 80 and not annotation.get("body_parts") and not annotation.get("plain_ct")
if default_skipped:
annotation["skipped"] = True
annotation["manual_body_parts"] = []
annotation["ai_body_parts"] = []
annotation["body_parts"] = []
annotation["plain_ct"] = False
if default_skipped:
default_skip_rows.append((uid, first, len(items)))
series_list.append(
{
"ct_number": ct_number,
"series_uid": uid,
"study_uid": first.get("StudyInstanceUID", ""),
"accession_number": first.get("AccessionNumber", ""),
"patient_name": first.get("PatientName", ""),
"patient_id": first.get("PatientID", ""),
"patient_birth_date": first.get("PatientBirthDate", ""),
"patient_sex": first.get("PatientSex", ""),
"institution_name": first.get("InstitutionName", ""),
"study_date": first.get("StudyDate", "") or study.get("study_date", ""),
"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", ""),
"window_center": first.get("WindowCenter", ""),
"window_width": first.get("WindowWidth", ""),
"default_skipped": default_skipped,
"annotation": annotation,
}
)
for uid, first, count in default_skip_rows:
try:
pg_scalar(
f"""
INSERT INTO public.pacs_dicom_series_annotations (
ct_number, study_instance_uid, series_instance_uid, series_description,
body_parts, manual_body_parts, ai_body_parts, upper_abdomen_phase,
plain_ct, skipped, notes, updated_by, updated_at
)
VALUES (
{sql_literal(ct_number)},
{sql_literal(first.get('StudyInstanceUID', ''))},
{sql_literal(uid)},
{sql_literal(first.get('SeriesDescription', '') or '未命名序列')},
'[]'::jsonb,
'[]'::jsonb,
'[]'::jsonb,
'',
false,
true,
{sql_literal(f'少于80张默认略过{count}张)')},
'system',
now()
)
ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET
skipped = true,
body_parts = '[]'::jsonb,
manual_body_parts = '[]'::jsonb,
ai_body_parts = '[]'::jsonb,
upper_abdomen_phase = '',
manual_upper_abdomen_phase = '',
ai_upper_abdomen_phase = '',
plain_ct = false,
notes = CASE
WHEN pacs_dicom_series_annotations.notes = ''
THEN EXCLUDED.notes
ELSE pacs_dicom_series_annotations.notes
END,
updated_by = 'system',
updated_at = now()
WHERE jsonb_array_length(pacs_dicom_series_annotations.body_parts) = 0
AND pacs_dicom_series_annotations.plain_ct IS NOT TRUE
""",
timeout=8,
)
except Exception:
pass
def series_time_key(row: dict[str, Any]) -> tuple[str, int, str]:
return (
row.get("series_time") or row.get("first_time") or row.get("study_time") or "",
numeric(row.get("series_number", "")),
row.get("description", ""),
)
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, *series_time_key(row)))
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 as_float(value: Any, default: float = 1.0) -> float:
try:
return float(value)
except Exception:
return default
def pixel_spacing_from_ds(ds: pydicom.Dataset) -> tuple[float, float]:
spacing = getattr(ds, "PixelSpacing", None)
if spacing and len(spacing) >= 2:
row_spacing = as_float(spacing[0], 1.0)
col_spacing = as_float(spacing[1], row_spacing)
return max(row_spacing, 0.001), max(col_spacing, 0.001)
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([as_float(position[0]), as_float(position[1]), as_float(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)
locations = []
for ds in datasets:
value = getattr(ds, "SliceLocation", None)
if value is not None:
locations.append(as_float(value, 0.0))
for first, second in zip(locations, locations[1:]):
distance = abs(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 = as_float(getattr(sample, "SpacingBetweenSlices", 0), 0.0)
if spacing > 0.001:
return spacing
thickness = as_float(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:
row_spacing = max(row_spacing, 0.001)
col_spacing = max(col_spacing, 0.001)
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)))
max_edge = 2200
scale = min(1.0, max_edge / max(target_w, target_h))
target_size = (max(1, int(round(target_w * scale))), max(1, int(round(target_h * scale))))
if target_size == pil.size:
return pil
return pil.resize(target_size, Image.Resampling.BILINEAR)
def render_array(
arr: np.ndarray,
center: float,
width: float,
invert: bool = False,
rotate: int = 0,
max_size: int = 900,
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 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_data(ct_number: str, series_uid: str) -> dict[str, Any]:
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 = []
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",
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,
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, rotate, pixel_spacing=spacing)
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}
def sql_bool_or_null(value: bool | None) -> str:
if value is None:
return "NULL"
return "true" if value else "false"
def save_annotation_payload(
ct_number: str,
series_uid: str,
series_row: dict[str, Any],
manual_parts: list[str],
ai_parts: list[str],
manual_phase: str,
ai_phase: str,
manual_plain_ct: bool | None,
ai_plain_ct: bool | None,
skipped: bool,
ai_skipped: bool,
notes: str,
user: str,
ai_result: dict[str, Any] | None = None,
ai_model: str | None = None,
) -> dict[str, Any]:
manual_parts = valid_parts(manual_parts)
ai_parts = valid_parts(ai_parts)
body_parts = merge_parts(manual_parts, ai_parts, skipped)
manual_phase = valid_phase(manual_phase)
ai_phase = valid_phase(ai_phase)
phase = effective_phase(manual_phase, ai_phase, body_parts, skipped)
plain_ct = effective_plain_ct(manual_plain_ct, ai_plain_ct, False, skipped)
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, manual_body_parts, ai_body_parts,
upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase,
plain_ct, manual_plain_ct, ai_plain_ct,
skipped, ai_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', ''))},
{sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb,
{sql_literal(json.dumps([] if skipped else manual_parts, ensure_ascii=False))}::jsonb,
{sql_literal(json.dumps([] if skipped else ai_parts, ensure_ascii=False))}::jsonb,
{sql_literal(phase)},
{sql_literal('' if skipped else manual_phase)},
{sql_literal('' if skipped else ai_phase)},
{'true' if plain_ct else 'false'},
{sql_bool_or_null(None if skipped else manual_plain_ct)},
{sql_bool_or_null(None if skipped else ai_plain_ct)},
{'true' if skipped else 'false'},
{'true' if ai_skipped else 'false'},
{sql_literal(notes)},
{sql_literal(json.dumps(ai_result, ensure_ascii=False)) + '::jsonb' if ai_result is not None else 'NULL'},
{sql_literal(ai_model) if ai_model is not None else 'NULL'},
{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,
manual_body_parts = EXCLUDED.manual_body_parts,
ai_body_parts = EXCLUDED.ai_body_parts,
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase,
ai_upper_abdomen_phase = EXCLUDED.ai_upper_abdomen_phase,
plain_ct = EXCLUDED.plain_ct,
manual_plain_ct = EXCLUDED.manual_plain_ct,
ai_plain_ct = EXCLUDED.ai_plain_ct,
skipped = EXCLUDED.skipped,
ai_skipped = EXCLUDED.ai_skipped,
notes = EXCLUDED.notes,
ai_result = COALESCE(EXCLUDED.ai_result, pacs_dicom_series_annotations.ai_result),
ai_model = COALESCE(EXCLUDED.ai_model, pacs_dicom_series_annotations.ai_model),
updated_by = EXCLUDED.updated_by,
updated_at = now()
"""
)
return normalize_annotation(
{
"body_parts": body_parts,
"manual_body_parts": [] if skipped else manual_parts,
"ai_body_parts": [] if skipped else ai_parts,
"upper_abdomen_phase": phase,
"manual_upper_abdomen_phase": "" if skipped else manual_phase,
"ai_upper_abdomen_phase": "" if skipped else ai_phase,
"plain_ct": plain_ct,
"manual_plain_ct": None if skipped else manual_plain_ct,
"ai_plain_ct": None if skipped else ai_plain_ct,
"skipped": skipped,
"ai_skipped": ai_skipped,
"notes": notes,
"ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""),
}
)
@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]:
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")
annotation = save_annotation_payload(
ct_number=ct_number,
series_uid=series_uid,
series_row=series_row,
manual_parts=data.manual_body_parts or data.body_parts,
ai_parts=data.ai_body_parts,
manual_phase=data.manual_upper_abdomen_phase or data.upper_abdomen_phase,
ai_phase=data.ai_upper_abdomen_phase,
manual_plain_ct=data.manual_plain_ct if data.manual_plain_ct is not None else (data.plain_ct if data.plain_ct and data.ai_plain_ct is None else None),
ai_plain_ct=data.ai_plain_ct,
skipped=data.skipped,
ai_skipped=data.ai_skipped,
notes=data.notes,
user=user,
)
STUDY_CACHE.pop(ct_number, None)
return {"ok": True, **annotation}
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,
pixel_spacing=pixel_spacing_from_ds(ds),
),
)
]
if len(files) >= 3:
try:
stack_data = load_stack_data(ct_number, series_uid)
stack = stack_data["stack"]
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, pixel_spacing=(stack_data["slice_spacing"], stack_data["row_spacing"]))))
images.append(("冠状位重建", render_array(coronal, center, width, False, max_size=720, pixel_spacing=(stack_data["slice_spacing"], stack_data["col_spacing"]))))
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。"
"请同时判断 plain_ct 是否为平扫CT。"
"如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。"
"只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"plain_ct\":false,\"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))
plain_ct = bool(suggestion.get("plain_ct", False))
phase = suggestion.get("upper_abdomen_phase", "")
if phase not in PHASES or "upper_abdomen" not in body_parts:
phase = ""
current = normalize_annotation(series_row.get("annotation", {}))
annotation = save_annotation_payload(
ct_number=ct_number,
series_uid=series_uid,
series_row=series_row,
manual_parts=current.get("manual_body_parts", []),
ai_parts=[] if skipped else body_parts,
manual_phase=current.get("manual_upper_abdomen_phase", ""),
ai_phase="" if skipped else phase,
manual_plain_ct=current.get("manual_plain_ct"),
ai_plain_ct=None if skipped else plain_ct,
skipped=skipped,
ai_skipped=skipped,
notes=str(suggestion.get("notes", "")) or current.get("notes", ""),
user=user,
ai_result=suggestion,
ai_model=KIMI_MODEL,
)
STUDY_CACHE.pop(ct_number, None)
return {
"ok": True,
"provider": "Kimi",
"name": KIMI_API_NAME,
"model": KIMI_MODEL,
**annotation,
"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")