#!/usr/bin/env python3 from __future__ import annotations import base64 import hashlib import io import json import os import secrets import subprocess import tempfile import threading import time import urllib.error import urllib.request import zipfile from collections import defaultdict from datetime import datetime, timedelta 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 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_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"] BODY_PARTS = set(BODY_PART_ORDER) PHASES = {"plain", "arterial", "portal_venous", "delayed", "unknown", ""} CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} ROLES = { "管理员": ["查看DICOM", "编辑标注", "AI识别", "影像导出", "批量导出", "用户创建", "权限控制", "系统设置"], "阅片员": ["查看DICOM", "编辑标注", "AI识别"], "访客": ["查看DICOM"], } app = FastAPI(title="DICOM 阅片分类系统") 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]]] = {} SUMMARY_REFRESH_LOCK = threading.Lock() SUMMARY_REFRESH_STATE: dict[str, Any] = {"running": False, "started_at": "", "finished_at": "", "message": "未运行"} SUMMARY_SCHEDULER_STARTED = False 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 = "" chest_window: str = "" manual_chest_window: str = "" ai_chest_window: 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 AISettingsIn(BaseModel): enabled: bool = True class StudyCompletionIn(BaseModel): completed: bool = False class ExportStudiesIn(BaseModel): ct_numbers: list[str] = [] 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 '', chest_window text NOT NULL DEFAULT '', manual_chest_window text NOT NULL DEFAULT '', ai_chest_window 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 chest_window text NOT NULL DEFAULT ''; ALTER TABLE public.pacs_dicom_series_annotations ADD COLUMN IF NOT EXISTS manual_chest_window text NOT NULL DEFAULT ''; ALTER TABLE public.pacs_dicom_series_annotations ADD COLUMN IF NOT EXISTS ai_chest_window 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 ensure_settings_table() -> None: sql = """ CREATE TABLE IF NOT EXISTS public.pacs_web_settings ( key text PRIMARY KEY, value text NOT NULL, updated_at timestamptz NOT NULL DEFAULT now() ); INSERT INTO public.pacs_web_settings (key, value) VALUES ('ai_enabled', 'true') ON CONFLICT (key) DO NOTHING; """ pg_scalar(sql) def ensure_study_summary_table() -> None: sql = """ CREATE TABLE IF NOT EXISTS public.pacs_dicom_study_summaries ( ct_number text PRIMARY KEY, series_count integer NOT NULL DEFAULT 0, annotated_series integer NOT NULL DEFAULT 0, unannotated_series integer NOT NULL DEFAULT 0, body_parts jsonb NOT NULL DEFAULT '[]'::jsonb, completed boolean NOT NULL DEFAULT false, completed_by text, completed_at timestamptz, refreshed_at timestamptz NOT NULL DEFAULT now() ); ALTER TABLE public.pacs_dicom_study_summaries ADD COLUMN IF NOT EXISTS body_parts jsonb NOT NULL DEFAULT '[]'::jsonb; ALTER TABLE public.pacs_dicom_study_summaries ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false; ALTER TABLE public.pacs_dicom_study_summaries ADD COLUMN IF NOT EXISTS completed_by text; ALTER TABLE public.pacs_dicom_study_summaries ADD COLUMN IF NOT EXISTS completed_at timestamptz; """ pg_scalar(sql) def setting_value(key: str, default: str = "") -> str: try: ensure_settings_table() rows = pg_json_rows( f""" SELECT value FROM public.pacs_web_settings WHERE key = {sql_literal(key)} LIMIT 1 """ ) return str(rows[0]["value"]) if rows else default except Exception: return default def set_setting_value(key: str, value: str) -> None: ensure_settings_table() pg_scalar( f""" INSERT INTO public.pacs_web_settings (key, value, updated_at) VALUES ({sql_literal(key)}, {sql_literal(value)}, now()) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now() """ ) def ai_enabled() -> bool: return setting_value("ai_enabled", "true").lower() in {"true", "1", "yes", "on"} 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 web_user_record(username: str) -> dict[str, Any]: try: ensure_user_table() rows = pg_json_rows( f""" SELECT username, role, status FROM public.pacs_web_users WHERE username = {sql_literal(username)} LIMIT 1 """ ) if rows: return rows[0] except Exception: pass if username == WEB_USER: return {"username": WEB_USER, "role": "管理员", "status": "启用"} return {"username": username, "role": "访客", "status": "启用"} def user_role(username: str) -> str: return str(web_user_record(username).get("role") or "访客") 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: global SUMMARY_SCHEDULER_STARTED ok, _ = db_available() if ok: ensure_annotation_table() ensure_user_table() ensure_settings_table() ensure_study_summary_table() if not SUMMARY_SCHEDULER_STARTED: SUMMARY_SCHEDULER_STARTED = True threading.Thread(target=summary_scheduler_loop, daemon=True).start() 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 def require_admin(user: str = Depends(require_auth)) -> str: if user_role(user) != "管理员": raise HTTPException(status_code=403, detail="仅管理员可访问设置") return user @app.get("/") def index() -> FileResponse: return FileResponse(STATIC_DIR / "index.html") @app.get("/settings") def settings_page() -> 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, "role": user_role(data.username)} raise HTTPException(status_code=401, detail="invalid credentials") @app.get("/api/auth/me") def me(user: str = Depends(require_auth)) -> dict[str, str]: return {"username": user, "role": user_role(user)} @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), "enabled": ai_enabled(), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL}, "server_time": time.strftime("%Y-%m-%d %H:%M:%S"), } def annotation_overview_by_study() -> dict[str, dict[str, Any]]: try: annotations = pg_json_rows( """ SELECT a.ct_number, count(DISTINCT a.series_instance_uid) FILTER ( WHERE a.skipped IS TRUE OR jsonb_array_length(a.body_parts) > 0 OR NULLIF(a.notes, '') IS NOT NULL OR a.ai_result IS NOT NULL )::int AS annotated_series, COALESCE( jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb ) AS body_parts FROM public.pacs_dicom_series_annotations a LEFT JOIN LATERAL jsonb_array_elements_text(a.body_parts) AS part(value) ON true GROUP BY a.ct_number """, timeout=8, ) except Exception: return {} return {row["ct_number"]: row for row in annotations} def summary_rows_by_study() -> dict[str, dict[str, Any]]: try: ensure_study_summary_table() rows = pg_json_rows( """ SELECT ct_number, series_count, annotated_series, unannotated_series, body_parts, completed, completed_by, completed_at, refreshed_at FROM public.pacs_dicom_study_summaries """, timeout=8, ) except Exception: return {} return {row["ct_number"]: row for row in rows} def study_completion_fields(ct_number: str) -> dict[str, Any]: try: ensure_study_summary_table() rows = pg_json_rows( f""" SELECT completed, completed_by, completed_at FROM public.pacs_dicom_study_summaries WHERE ct_number = {sql_literal(ct_number)} LIMIT 1 """, timeout=8, ) except Exception: rows = [] row = rows[0] if rows else {} return { "completed": bool(row.get("completed", False)), "completed_by": row.get("completed_by", ""), "completed_at": row.get("completed_at", ""), } @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, series_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)} """ ) annotation_map = annotation_overview_by_study() summary_map = summary_rows_by_study() for row in rows: summary = summary_map.get(row["ct_number"]) annotation_summary = annotation_map.get(row["ct_number"], {}) total_series = int((summary or {}).get("series_count") or row.get("series_count") or 0) row["series_count"] = total_series row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_summary.get("annotated_series", 0))) row["unannotated_series"] = max(0, total_series - int(row["annotated_series"])) row["body_parts"] = valid_parts((summary or {}).get("body_parts") or annotation_summary.get("body_parts") or []) row["completed"] = bool((summary or {}).get("completed", False)) row["completed_by"] = (summary or {}).get("completed_by", "") row["completed_at"] = (summary or {}).get("completed_at", "") row["summary_updated_at"] = (summary or {}).get("refreshed_at", "") 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 safe_archive_name(value: Any, fallback: str = "study") -> str: text = str(value or "").strip() cleaned = "".join("_" if char in '<>:"/\\|?*\0' else char for char in text) cleaned = cleaned.strip().strip(".") return cleaned[:160] or fallback def study_export_folder_name(study: dict[str, Any]) -> str: ct_number = safe_archive_name(study.get("ct_number"), "study") name = safe_archive_name(study.get("source_patient_name") or study.get("patient_name_dicom") or "", "") return f"{ct_number}_{name}" if name else ct_number def add_directory_to_zip(zip_file: zipfile.ZipFile, root: Path, prefix: str) -> int: root = root.resolve() count = 0 for path in root.rglob("*"): if not path.is_file(): continue try: relative = path.resolve().relative_to(root) except ValueError: continue zip_file.write(path, Path(prefix) / relative) count += 1 return count def remove_file(path: str) -> None: try: Path(path).unlink(missing_ok=True) except Exception: pass def build_study_export_zip(ct_numbers: list[str]) -> tuple[str, str]: unique_numbers = [] for ct_number in ct_numbers: value = str(ct_number or "").strip() if value and value not in unique_numbers: unique_numbers.append(value) if not unique_numbers: raise HTTPException(status_code=400, detail="请选择需要导出的检查") if len(unique_numbers) > 200: raise HTTPException(status_code=400, detail="单次最多导出 200 个检查") temp = tempfile.NamedTemporaryFile(prefix="pacs_dicom_export_", suffix=".zip", delete=False) temp_path = temp.name temp.close() total_files = 0 notes = [] try: with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_STORED, allowZip64=True) as zip_file: for ct_number in unique_numbers: study = get_study_record(ct_number) root = resolve_study_root(study) if not root.exists(): notes.append(f"{ct_number}: DICOM 路径不存在 - {root}") continue folder = study_export_folder_name(study) file_count = add_directory_to_zip(zip_file, root, folder) total_files += file_count if file_count == 0: notes.append(f"{ct_number}: 未找到可导出的文件 - {root}") if notes: zip_file.writestr("导出说明.txt", "\n".join(notes)) if total_files == 0: remove_file(temp_path) raise HTTPException(status_code=404, detail="没有找到可导出的 DICOM 文件") except HTTPException: raise except Exception as exc: remove_file(temp_path) raise HTTPException(status_code=500, detail=f"导出失败:{exc}") from exc filename = f"PACS_DICOM_{safe_archive_name(unique_numbers[0])}.zip" if len(unique_numbers) == 1 else f"PACS_DICOM_batch_{time.strftime('%Y%m%d_%H%M%S')}.zip" return temp_path, filename 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 valid_chest_window(value: Any) -> str: return value if value in CHEST_WINDOWS 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]: 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 "upper_abdomen" not in body_parts: return "" return valid_phase(manual_phase) or valid_phase(ai_phase) def effective_chest_window(manual_window: str, ai_window: str, body_parts: list[str], skipped: bool) -> str: if "chest" not in body_parts: return "" return valid_chest_window(manual_window) or valid_chest_window(ai_window) or "unknown" def effective_plain_ct(manual_plain_ct: bool | None, ai_plain_ct: bool | None, fallback: bool, skipped: bool) -> bool: 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 "") manual_chest_window = valid_chest_window(row.get("manual_chest_window") or "") ai_chest_window = valid_chest_window(row.get("ai_chest_window") or "") if not manual_chest_window and not ai_chest_window: manual_chest_window = valid_chest_window(row.get("chest_window") 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": manual_parts, "ai_body_parts": ai_parts, "upper_abdomen_phase": effective_phase(manual_phase, ai_phase, body_parts, skipped), "manual_upper_abdomen_phase": manual_phase, "ai_upper_abdomen_phase": ai_phase, "chest_window": effective_chest_window(manual_chest_window, ai_chest_window, body_parts, skipped), "manual_chest_window": manual_chest_window, "ai_chest_window": ai_chest_window, "plain_ct": plain_ct, "manual_plain_ct": bool_or_none(row.get("manual_plain_ct")), "ai_plain_ct": 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", ""), "updated_by": row.get("updated_by", ""), } def dicom_auto_annotation(meta: dict[str, str]) -> tuple[list[str], str, str]: marker = f"{meta.get('BodyPartExamined', '')} {meta.get('SeriesDescription', '')}".upper() parts: list[str] = [] phase = "" chest_window = "" if "CHEST" in marker and "chest" not in parts: parts.append("chest") chest_window = "unknown" if "ABDOMEN" in marker and "upper_abdomen" not in parts: parts.append("upper_abdomen") phase = "unknown" return parts, phase, chest_window 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, chest_window, manual_chest_window, ai_chest_window, plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, updated_at, ai_model, updated_by 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 = [] auto_annotation_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) auto_parts, auto_phase, auto_chest_window = dicom_auto_annotation(first) can_apply_auto = bool(auto_parts) and not annotation.get("body_parts") if can_apply_auto: annotation["manual_body_parts"] = auto_parts annotation["body_parts"] = auto_parts if auto_phase: annotation["manual_upper_abdomen_phase"] = auto_phase annotation["upper_abdomen_phase"] = auto_phase if auto_chest_window: annotation["manual_chest_window"] = auto_chest_window annotation["chest_window"] = auto_chest_window auto_annotation_rows.append((uid, first, auto_parts, auto_phase, auto_chest_window)) default_skipped = len(items) < 80 and (not raw_annotation or annotation.get("updated_by") == "system") if default_skipped: annotation["skipped"] = True 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, auto_parts, auto_phase, auto_chest_window in auto_annotation_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, upper_abdomen_phase, manual_upper_abdomen_phase, chest_window, manual_chest_window, updated_by, updated_at ) VALUES ( {sql_literal(ct_number)}, {sql_literal(first.get('StudyInstanceUID', ''))}, {sql_literal(uid)}, {sql_literal(first.get('SeriesDescription', '') or '未命名序列')}, {sql_literal(json.dumps(auto_parts, ensure_ascii=False))}::jsonb, {sql_literal(json.dumps(auto_parts, ensure_ascii=False))}::jsonb, {sql_literal(auto_phase)}, {sql_literal(auto_phase)}, {sql_literal(auto_chest_window)}, {sql_literal(auto_chest_window)}, 'system', now() ) ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET body_parts = EXCLUDED.body_parts, manual_body_parts = EXCLUDED.manual_body_parts, upper_abdomen_phase = EXCLUDED.upper_abdomen_phase, manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase, chest_window = EXCLUDED.chest_window, manual_chest_window = EXCLUDED.manual_chest_window, updated_by = 'system', updated_at = now() WHERE pacs_dicom_series_annotations.updated_by = 'system' AND jsonb_array_length(pacs_dicom_series_annotations.body_parts) = 0 """, timeout=8, ) except Exception: pass 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, 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 pacs_dicom_series_annotations.updated_by = 'system' """, 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 save_study_summary(study_summary_payload(cached)) return cached def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]: series_list = data.get("series", []) total = len(series_list) body_parts = [ part for part in BODY_PART_ORDER if any(part in valid_parts(row.get("annotation", {}).get("body_parts") or []) for row in series_list) ] annotated = sum( 1 for row in series_list if row.get("annotation", {}).get("skipped") or row.get("annotation", {}).get("body_parts") or row.get("annotation", {}).get("notes") or row.get("annotation", {}).get("ai_model") ) return { "ct_number": data.get("study", {}).get("ct_number", ""), "series_count": total, "annotated_series": annotated, "unannotated_series": max(0, total - annotated), "body_parts": body_parts, "summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), } def save_study_summary(summary: dict[str, Any]) -> None: try: ensure_study_summary_table() pg_scalar( f""" INSERT INTO public.pacs_dicom_study_summaries ( ct_number, series_count, annotated_series, unannotated_series, body_parts, refreshed_at ) VALUES ( {sql_literal(summary.get('ct_number', ''))}, {int(summary.get('series_count') or 0)}, {int(summary.get('annotated_series') or 0)}, {int(summary.get('unannotated_series') or 0)}, {sql_literal(json.dumps(valid_parts(summary.get('body_parts') or []), ensure_ascii=False))}::jsonb, now() ) ON CONFLICT (ct_number) DO UPDATE SET series_count = EXCLUDED.series_count, annotated_series = EXCLUDED.annotated_series, unannotated_series = EXCLUDED.unannotated_series, body_parts = EXCLUDED.body_parts, refreshed_at = now() """, timeout=8, ) except Exception: pass def all_ct_numbers() -> list[str]: rows = pg_json_rows( f""" SELECT ct_number FROM public.{PGTABLE} ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number """, timeout=20, ) return [str(row["ct_number"]) for row in rows if row.get("ct_number")] def refresh_all_study_summaries(reason: str = "manual") -> None: if not SUMMARY_REFRESH_LOCK.acquire(blocking=False): return SUMMARY_REFRESH_STATE.update({"running": True, "started_at": time.strftime("%Y-%m-%d %H:%M:%S"), "finished_at": "", "message": f"{reason} 刷新中"}) try: numbers = all_ct_numbers() for index, ct_number in enumerate(numbers, start=1): STUDY_CACHE.pop(ct_number, None) try: data = scan_study(ct_number) save_study_summary(study_summary_payload(data)) SUMMARY_REFRESH_STATE["message"] = f"{reason} 刷新中:{index}/{len(numbers)}" except Exception as exc: # noqa: BLE001 SUMMARY_REFRESH_STATE["message"] = f"{ct_number} 刷新失败:{exc}" SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新完成:{len(numbers)} 个检查"}) except Exception as exc: # noqa: BLE001 SUMMARY_REFRESH_STATE.update({"finished_at": time.strftime("%Y-%m-%d %H:%M:%S"), "message": f"{reason} 刷新失败:{exc}"}) finally: SUMMARY_REFRESH_STATE["running"] = False SUMMARY_REFRESH_LOCK.release() def next_four_am() -> datetime: now = datetime.now() target = now.replace(hour=4, minute=0, second=0, microsecond=0) if target <= now: target += timedelta(days=1) return target def summary_scheduler_loop() -> None: while True: sleep_seconds = max(60.0, (next_four_am() - datetime.now()).total_seconds()) time.sleep(sleep_seconds) refresh_all_study_summaries("凌晨4点定时") @app.get("/api/studies/{ct_number}/series") def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: data = scan_study(ct_number) study = {**data["study"], **study_completion_fields(ct_number)} return {"study": study, "series": data["series"]} @app.get("/api/studies/{ct_number}/summary") def study_summary(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: return {**study_summary_payload(scan_study(ct_number)), **study_completion_fields(ct_number)} @app.put("/api/studies/{ct_number}/completion") def update_study_completion(ct_number: str, data: StudyCompletionIn, user: str = Depends(require_auth)) -> dict[str, Any]: get_study_record(ct_number) ensure_study_summary_table() pg_scalar( f""" INSERT INTO public.pacs_dicom_study_summaries ( ct_number, completed, completed_by, completed_at, refreshed_at ) VALUES ( {sql_literal(ct_number)}, {'true' if data.completed else 'false'}, {sql_literal(user)}, {'now()' if data.completed else 'NULL'}, now() ) ON CONFLICT (ct_number) DO UPDATE SET completed = EXCLUDED.completed, completed_by = EXCLUDED.completed_by, completed_at = EXCLUDED.completed_at """ ) return {"ok": True, "ct_number": ct_number, **study_completion_fields(ct_number)} @app.get("/api/studies/export") def export_studies_get(ct_numbers: list[str] = Query(default=[]), _: str = Depends(require_admin)) -> FileResponse: path, filename = build_study_export_zip(ct_numbers) return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path)) @app.get("/api/studies/{ct_number}/export") def export_study(ct_number: str, _: str = Depends(require_admin)) -> FileResponse: path, filename = build_study_export_zip([ct_number]) return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path)) @app.post("/api/studies/export") def export_studies(data: ExportStudiesIn, _: str = Depends(require_admin)) -> FileResponse: path, filename = build_study_export_zip(data.ct_numbers) return FileResponse(path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, path)) 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_chest_window: str, ai_chest_window: 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) manual_chest_window = valid_chest_window(manual_chest_window) ai_chest_window = valid_chest_window(ai_chest_window) if "chest" not in body_parts: manual_chest_window = "" ai_chest_window = "" chest_window = effective_chest_window(manual_chest_window, ai_chest_window, 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, chest_window, manual_chest_window, ai_chest_window, 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(manual_parts, ensure_ascii=False))}::jsonb, {sql_literal(json.dumps(ai_parts, ensure_ascii=False))}::jsonb, {sql_literal(phase)}, {sql_literal(manual_phase)}, {sql_literal(ai_phase)}, {sql_literal(chest_window)}, {sql_literal(manual_chest_window)}, {sql_literal(ai_chest_window)}, {'true' if plain_ct else 'false'}, {sql_bool_or_null(manual_plain_ct)}, {sql_bool_or_null(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, chest_window = EXCLUDED.chest_window, manual_chest_window = EXCLUDED.manual_chest_window, ai_chest_window = EXCLUDED.ai_chest_window, 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": manual_parts, "ai_body_parts": ai_parts, "upper_abdomen_phase": phase, "manual_upper_abdomen_phase": manual_phase, "ai_upper_abdomen_phase": ai_phase, "chest_window": chest_window, "manual_chest_window": manual_chest_window, "ai_chest_window": ai_chest_window, "plain_ct": plain_ct, "manual_plain_ct": manual_plain_ct, "ai_plain_ct": 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_chest_window=data.manual_chest_window or data.chest_window, ai_chest_window=data.ai_chest_window, 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": "", "chest_window": "", "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 or not ai_enabled(): raise HTTPException(status_code=400, detail="AI 功能未启用或 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(平扫)、arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。" "如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、unknown(无法判别)。" "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"chest_window\":\"\",\"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 = "" chest_window = valid_chest_window(suggestion.get("chest_window", "")) if "chest" not in body_parts: chest_window = "" elif not chest_window: chest_window = "unknown" 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_chest_window=current.get("manual_chest_window", ""), ai_chest_window="" if skipped else chest_window, manual_plain_ct=None, ai_plain_ct=None, 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_admin)) -> 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), "enabled": ai_enabled(), "url": KIMI_API_URL}, "dicom": {"processed_root": str(PROCESSED_ROOT)}, "summary_refresh": SUMMARY_REFRESH_STATE, } @app.post("/api/settings/ai") def update_ai_settings(data: AISettingsIn, _: str = Depends(require_admin)) -> dict[str, Any]: set_setting_value("ai_enabled", "true" if data.enabled else "false") return {"ok": True, "enabled": data.enabled} @app.post("/api/settings/refresh-summaries") def refresh_summaries_now(_: str = Depends(require_admin)) -> dict[str, Any]: if SUMMARY_REFRESH_STATE.get("running"): return {"ok": True, **SUMMARY_REFRESH_STATE} threading.Thread(target=refresh_all_study_summaries, args=("手动",), daemon=True).start() return {"ok": True, "running": True, "message": "已开始后台刷新"} @app.post("/api/settings/users") def create_user(data: UserIn, user: str = Depends(require_admin)) -> dict[str, Any]: 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")