diff --git a/PACS_DICOM处理/数据处理网页端/.dockerignore b/PACS_DICOM处理/数据处理网页端/.dockerignore new file mode 100644 index 0000000..23f84d2 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/.dockerignore @@ -0,0 +1,7 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ +data/ +logs/ +*.log diff --git a/PACS_DICOM处理/数据处理网页端/.env.example b/PACS_DICOM处理/数据处理网页端/.env.example new file mode 100644 index 0000000..a26ac0f --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/.env.example @@ -0,0 +1,15 @@ +PGHOST=192.168.3.3 +PGPORT=5432 +PGUSER=his_user +PGPASSWORD=change_me +PGDATABASE=pacs_db +PGTABLE=pacs_dicom_files +PG_ANNOTATION_TABLE=pacs_dicom_series_annotations +PACS_PROCESSED_ROOT=/dicom/已处理_DICOM数据 +PACS_BATCH_NAME=2026_5_27_PACS下载数据 +PACS_WEB_USER=admin +PACS_WEB_PASSWORD=123456 +KIMI_API_NAME=HIS_Check +KIMI_API_KEY=change_me +KIMI_API_URL=https://api.moonshot.cn/v1/chat/completions +KIMI_MODEL=kimi-latest diff --git a/PACS_DICOM处理/数据处理网页端/Dockerfile b/PACS_DICOM处理/数据处理网页端/Dockerfile new file mode 100644 index 0000000..8a43d5b --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/Dockerfile @@ -0,0 +1,20 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app.py ./app.py +COPY static ./static + +EXPOSE 8107 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8107"] diff --git a/PACS_DICOM处理/数据处理网页端/README.md b/PACS_DICOM处理/数据处理网页端/README.md new file mode 100644 index 0000000..996a5ff --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/README.md @@ -0,0 +1,51 @@ +# PACS DICOM 网页端 + +本目录提供本地 DICOM 检查浏览、序列查看和序列部位标注界面。 + +## Docker 启动 + +本网页端推荐用 Docker Compose 启动。DICOM 数据通过只读 volume 挂载到容器,不会打包进镜像。 + +```bash +cd /home/wkmgc/Desktop/PACS数据处理/PACS_DICOM处理/数据处理网页端 +cp .env.example .env +# 编辑 .env,填写本机 PostgreSQL 密码和可选 Kimi API Key +docker compose up -d --build +``` + +浏览器打开 `http://127.0.0.1:8107`。 + +默认登录账号为 `admin`,密码为 `123456`。可以通过 `.env` 或 `docker-compose.yml` 中的 `PACS_WEB_USER`、`PACS_WEB_PASSWORD` 覆盖。 + +## 本机直接启动 + +先在当前 shell 设置 PostgreSQL 连接信息,再启动 FastAPI: + +```bash +export PGHOST=192.168.3.3 +export PGPORT=5432 +export PGUSER=his_user +export PGPASSWORD='请在本机填写' +export PGDATABASE=pacs_db +export PGTABLE=pacs_dicom_files +export PACS_PROCESSED_ROOT=/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据 + +cd /home/wkmgc/Desktop/PACS数据处理/PACS_DICOM处理/数据处理网页端 +uvicorn app:app --host 127.0.0.1 --port 8107 +``` + +## 功能 + +- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`。 +- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。 +- 右侧查看器:支持横断面、矢状面、冠状面,窗宽窗位、旋转、切片进度条。 +- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。 +- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 +- 序列标注:选择略过、头颈部、胸部、上腹部、下腹部、盆腔;上腹部需继续选择动脉期、门静脉期或无法判别。 +- AI 识别:配置 Kimi 后,可把序列张数及横断面、矢状面、冠状面代表图像传入 AI,自动给出部位、期相和备注建议。 +- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。 +- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`。 + +## 数据安全 + +网页端代码不提交 DICOM 原始数据、已处理数据、处理结果和 `.env`。数据库密码请只放在本机环境变量或本机 `.env` 中。 diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py new file mode 100644 index 0000000..71711f1 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -0,0 +1,866 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import base64 +import hashlib +import io +import json +import os +import secrets +import subprocess +import time +import urllib.error +import urllib.request +from collections import defaultdict +from pathlib import Path +from typing import Any + +import numpy as np +import pydicom +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Response +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel +from PIL import Image + + +APP_DIR = Path(__file__).resolve().parent +PACS_ROOT = APP_DIR.parent +STATIC_DIR = APP_DIR / "static" + + +def load_env_file() -> None: + env_file = APP_DIR / ".env" + if not env_file.exists(): + return + for line in env_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) + + +load_env_file() + +PGHOST = os.getenv("PGHOST", "192.168.3.3") +PGPORT = os.getenv("PGPORT", "5432") +PGUSER = os.getenv("PGUSER", "his_user") +PGDATABASE = os.getenv("PGDATABASE", "pacs_db") +PGTABLE = os.getenv("PGTABLE", "pacs_dicom_files") +WEB_USER = os.getenv("PACS_WEB_USER", "admin") +WEB_PASSWORD = os.getenv("PACS_WEB_PASSWORD", "123456") +PROCESSED_ROOT = Path(os.getenv("PACS_PROCESSED_ROOT", str(PACS_ROOT / "已处理_DICOM数据"))).resolve() +KIMI_API_KEY = os.getenv("KIMI_API_KEY", "") +KIMI_API_NAME = os.getenv("KIMI_API_NAME", "HIS_Check") +KIMI_API_URL = os.getenv("KIMI_API_URL", "https://api.moonshot.cn/v1/chat/completions") +KIMI_MODEL = os.getenv("KIMI_MODEL", "kimi-latest") + +WINDOWS = { + "default": None, + "bone": (500.0, 1800.0), + "soft": (50.0, 360.0), + "contrast": (90.0, 140.0), +} + +BODY_PARTS = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"} +PHASES = {"arterial", "portal_venous", "unknown", ""} +ROLES = { + "管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"], + "阅片员": ["查看DICOM", "编辑标注", "AI识别"], + "访客": ["查看DICOM"], +} + +app = FastAPI(title="PACS DICOM Viewer") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + +TOKENS: dict[str, str] = {} +STUDY_CACHE: dict[str, dict[str, Any]] = {} +STACK_CACHE: dict[str, tuple[float, np.ndarray]] = {} + + +class LoginIn(BaseModel): + username: str + password: str + + +class AnnotationIn(BaseModel): + body_parts: list[str] = [] + upper_abdomen_phase: str = "" + notes: str = "" + skipped: bool = False + + +class AIRequest(BaseModel): + sample_count: int = 3 + + +class UserIn(BaseModel): + username: str + password: str + role: str = "阅片员" + status: str = "启用" + + +def pg_env() -> dict[str, str]: + env = os.environ.copy() + if os.getenv("PGPASSWORD"): + env["PGPASSWORD"] = os.environ["PGPASSWORD"] + return env + + +def sql_literal(value: Any) -> str: + if value is None: + return "NULL" + return "'" + str(value).replace("'", "''") + "'" + + +def run_psql(sql: str, timeout: int = 12) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [ + "psql", + "-h", + PGHOST, + "-p", + PGPORT, + "-U", + PGUSER, + "-d", + PGDATABASE, + "-X", + "-q", + "-t", + "-A", + "-c", + sql, + ], + text=True, + capture_output=True, + timeout=timeout, + env=pg_env(), + ) + + +def pg_scalar(sql: str, timeout: int = 12) -> str: + result = run_psql(sql, timeout=timeout) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or result.stdout.strip()) + return result.stdout.strip() + + +def pg_json_rows(select_sql: str, timeout: int = 20) -> list[dict[str, Any]]: + payload = pg_scalar( + f"SELECT COALESCE(json_agg(row_to_json(q)), '[]'::json)::text FROM ({select_sql}) q", + timeout=timeout, + ) + return json.loads(payload or "[]") + + +def db_available() -> tuple[bool, str]: + if not os.getenv("PGPASSWORD"): + return False, "PGPASSWORD 未设置" + try: + pg_scalar("SELECT 1", timeout=4) + return True, "connected" + except Exception as exc: # noqa: BLE001 + return False, str(exc) + + +def ensure_annotation_table() -> None: + sql = """ + CREATE TABLE IF NOT EXISTS public.pacs_dicom_series_annotations ( + ct_number text NOT NULL, + study_instance_uid text, + series_instance_uid text NOT NULL, + series_description text, + body_parts jsonb NOT NULL DEFAULT '[]'::jsonb, + upper_abdomen_phase text NOT NULL DEFAULT '', + skipped boolean NOT NULL DEFAULT false, + notes text NOT NULL DEFAULT '', + ai_result jsonb, + ai_model text, + updated_by text NOT NULL DEFAULT 'admin', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + PRIMARY KEY (ct_number, series_instance_uid) + ); + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS skipped boolean NOT NULL DEFAULT false; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_result jsonb; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_model text; + """ + pg_scalar(sql) + + +def password_hash(password: str) -> str: + return hashlib.sha256(("pacs-dicom-web:" + password).encode("utf-8")).hexdigest() + + +def ensure_user_table() -> None: + sql = f""" + CREATE TABLE IF NOT EXISTS public.pacs_web_users ( + username text PRIMARY KEY, + password_hash text NOT NULL, + role text NOT NULL DEFAULT '阅片员', + status text NOT NULL DEFAULT '启用', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() + ); + INSERT INTO public.pacs_web_users (username, password_hash, role, status) + VALUES ({sql_literal(WEB_USER)}, {sql_literal(password_hash(WEB_PASSWORD))}, '管理员', '启用') + ON CONFLICT (username) DO NOTHING; + """ + pg_scalar(sql) + + +def web_users() -> list[dict[str, Any]]: + try: + ensure_user_table() + return pg_json_rows( + """ + SELECT username, role, status, created_at, updated_at + FROM public.pacs_web_users + ORDER BY username + """ + ) + except Exception: + return [{"username": WEB_USER, "role": "管理员", "status": "启用"}] + + +def authenticate_web_user(username: str, password: str) -> bool: + try: + ok, _ = db_available() + if ok: + ensure_user_table() + rows = pg_json_rows( + f""" + SELECT username, password_hash, status + FROM public.pacs_web_users + WHERE username = {sql_literal(username)} + LIMIT 1 + """ + ) + if rows: + row = rows[0] + return row.get("status") == "启用" and row.get("password_hash") == password_hash(password) + except Exception: + pass + return username == WEB_USER and password == WEB_PASSWORD + + +@app.on_event("startup") +def startup() -> None: + ok, _ = db_available() + if ok: + ensure_annotation_table() + ensure_user_table() + + +def require_auth(authorization: str | None = Header(default=None), access_token: str = Query(default="")) -> str: + token = access_token.strip() + if not token and authorization and authorization.startswith("Bearer "): + token = authorization.removeprefix("Bearer ").strip() + if not token: + raise HTTPException(status_code=401, detail="unauthorized") + user = TOKENS.get(token) + if not user: + raise HTTPException(status_code=401, detail="unauthorized") + return user + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.post("/api/auth/login") +def login(data: LoginIn) -> dict[str, str]: + if authenticate_web_user(data.username, data.password): + token = secrets.token_urlsafe(32) + TOKENS[token] = data.username + return {"token": token, "username": data.username} + raise HTTPException(status_code=401, detail="invalid credentials") + + +@app.get("/api/status") +def status() -> dict[str, Any]: + db_ok, db_message = db_available() + table_count = None + if db_ok: + try: + table_count = int(pg_scalar(f"SELECT count(*) FROM public.{PGTABLE}")) + except Exception: + table_count = None + return { + "database": {"ok": db_ok, "message": db_message, "host": PGHOST, "database": PGDATABASE, "table": PGTABLE, "rows": table_count}, + "dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()}, + "ai": {"configured": bool(KIMI_API_KEY), "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL}, + "server_time": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + +@app.get("/api/studies") +def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> list[dict[str, Any]]: + where = "" + if q: + like = "%" + q.replace("%", "").replace("_", "") + "%" + where = f"WHERE ct_number ILIKE {sql_literal(like)} OR source_patient_name ILIKE {sql_literal(like)} OR patient_name_dicom ILIKE {sql_literal(like)}" + rows = pg_json_rows( + f""" + SELECT + ct_number, batch_name, target_folder_name, source_patient_name, patient_name_dicom, + patient_id, study_date, study_time, modality, dicom_file_count, + processed_path, needs_ct_number_fix, status + FROM public.{PGTABLE} + {where} + ORDER BY study_date DESC NULLS LAST, study_time DESC NULLS LAST, ct_number + LIMIT {int(limit)} + """ + ) + annotations = pg_json_rows( + """ + SELECT ct_number, count(*)::int AS annotated_series + FROM public.pacs_dicom_series_annotations + WHERE skipped IS NOT TRUE AND jsonb_array_length(body_parts) > 0 + GROUP BY ct_number + """, + timeout=8, + ) + annotation_map = {row["ct_number"]: row["annotated_series"] for row in annotations} + for row in rows: + row["annotated_series"] = annotation_map.get(row["ct_number"], 0) + return rows + + +def get_study_record(ct_number: str) -> dict[str, Any]: + rows = pg_json_rows( + f""" + SELECT * FROM public.{PGTABLE} + WHERE ct_number = {sql_literal(ct_number)} + LIMIT 1 + """ + ) + if not rows: + raise HTTPException(status_code=404, detail="study not found") + return rows[0] + + +def resolve_study_root(study: dict[str, Any]) -> Path: + root = Path(study.get("processed_path") or "") + if root.exists(): + return root + + target_folder = str(study.get("target_folder_name") or "") + if target_folder: + direct_matches = list(PROCESSED_ROOT.glob(f"*/{target_folder}")) + if direct_matches: + return direct_matches[0] + recursive = next(PROCESSED_ROOT.rglob(target_folder), None) + if recursive: + return recursive + + ct_number = str(study.get("ct_number") or "") + recursive = next(PROCESSED_ROOT.rglob(f"{ct_number}-*"), None) + if recursive: + return recursive + return root + + +def read_header(path: Path) -> dict[str, str]: + tags = [ + "SeriesInstanceUID", + "StudyInstanceUID", + "SeriesNumber", + "SeriesDescription", + "InstanceNumber", + "SliceLocation", + "ImagePositionPatient", + "AcquisitionTime", + "ContentTime", + "SeriesTime", + "StudyTime", + "Modality", + "BodyPartExamined", + "Manufacturer", + "Rows", + "Columns", + "PixelSpacing", + "SliceThickness", + "SpacingBetweenSlices", + "WindowCenter", + "WindowWidth", + ] + ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True, specific_tags=tags + ["SpecificCharacterSet"]) + return {tag: str(getattr(ds, tag, "")).strip() for tag in tags} + + +def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]: + path, meta = item + instance = float(meta.get("InstanceNumber") or 0) + position = meta.get("ImagePositionPatient", "") + z = 0.0 + if position: + try: + z = float(str(position).strip("[]").split(",")[-1]) + except Exception: + z = 0.0 + if meta.get("SliceLocation"): + try: + z = float(meta["SliceLocation"]) + except Exception: + pass + return (z, instance, str(path)) + + +def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: + try: + rows = pg_json_rows( + f""" + SELECT series_instance_uid, body_parts, upper_abdomen_phase, skipped, notes, updated_at, ai_model + FROM public.pacs_dicom_series_annotations + WHERE ct_number = {sql_literal(ct_number)} + """ + ) + except Exception: + return {} + return {row["series_instance_uid"]: row for row in rows} + + +def scan_study(ct_number: str) -> dict[str, Any]: + cached = STUDY_CACHE.get(ct_number) + if cached and time.time() - cached["cached_at"] < 600: + return cached + + study = get_study_record(ct_number) + root = resolve_study_root(study) + if not root.exists(): + raise HTTPException(status_code=404, detail=f"DICOM path not found: {root}") + + grouped: dict[str, list[tuple[Path, dict[str, str]]]] = defaultdict(list) + for path in root.rglob("*.dcm"): + try: + meta = read_header(path) + except Exception: + continue + uid = meta.get("SeriesInstanceUID") or path.parent.name + grouped[uid].append((path, meta)) + + annotations = get_annotations(ct_number) + series_list = [] + file_map = {} + for uid, items in grouped.items(): + items.sort(key=sort_key) + first = items[0][1] + last = items[-1][1] + file_map[uid] = [path for path, _ in items] + annotation = annotations.get(uid, {}) + series_list.append( + { + "ct_number": ct_number, + "series_uid": uid, + "study_uid": first.get("StudyInstanceUID", ""), + "series_number": first.get("SeriesNumber", ""), + "description": first.get("SeriesDescription", "") or "未命名序列", + "count": len(items), + "modality": first.get("Modality", ""), + "body_part_dicom": first.get("BodyPartExamined", ""), + "study_time": first.get("StudyTime", ""), + "series_time": first.get("SeriesTime", "") or first.get("AcquisitionTime", "") or first.get("ContentTime", ""), + "first_time": first.get("AcquisitionTime", "") or first.get("ContentTime", ""), + "last_time": last.get("AcquisitionTime", "") or last.get("ContentTime", ""), + "manufacturer": first.get("Manufacturer", ""), + "rows": first.get("Rows", ""), + "columns": first.get("Columns", ""), + "pixel_spacing": first.get("PixelSpacing", ""), + "slice_thickness": first.get("SliceThickness", ""), + "spacing_between_slices": first.get("SpacingBetweenSlices", ""), + "annotation": { + "body_parts": annotation.get("body_parts", []), + "upper_abdomen_phase": annotation.get("upper_abdomen_phase", ""), + "skipped": bool(annotation.get("skipped", False)), + "notes": annotation.get("notes", ""), + "updated_at": annotation.get("updated_at", ""), + "ai_model": annotation.get("ai_model", ""), + }, + } + ) + + def numeric(value: str) -> int: + try: + return int(float(value)) + except Exception: + return 999999 + + series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, numeric(row["series_number"]), row["description"])) + cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map} + STUDY_CACHE[ct_number] = cached + return cached + + +@app.get("/api/studies/{ct_number}/series") +def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: + data = scan_study(ct_number) + return {"study": data["study"], "series": data["series"]} + + +def get_series_files(ct_number: str, series_uid: str) -> list[Path]: + data = scan_study(ct_number) + files = data["files"].get(series_uid) + if not files: + raise HTTPException(status_code=404, detail="series not found") + return files + + +def window_values(ds: pydicom.Dataset, preset: str) -> tuple[float, float]: + if preset in WINDOWS and WINDOWS[preset]: + return WINDOWS[preset] # type: ignore[return-value] + center = getattr(ds, "WindowCenter", 50) + width = getattr(ds, "WindowWidth", 360) + if isinstance(center, pydicom.multival.MultiValue): + center = center[0] + if isinstance(width, pydicom.multival.MultiValue): + width = width[0] + try: + return float(center), float(width) + except Exception: + return 50.0, 360.0 + + +def dicom_to_hu(ds: pydicom.Dataset) -> np.ndarray: + arr = ds.pixel_array.astype(np.float32) + slope = float(getattr(ds, "RescaleSlope", 1) or 1) + intercept = float(getattr(ds, "RescaleIntercept", 0) or 0) + return arr * slope + intercept + + +def render_array(arr: np.ndarray, center: float, width: float, invert: bool = False, rotate: int = 0, max_size: int = 900) -> bytes: + low = center - width / 2.0 + high = center + width / 2.0 + img = ((np.clip(arr, low, high) - low) / max(high - low, 1.0) * 255.0).astype(np.uint8) + if invert: + img = 255 - img + pil = Image.fromarray(img) + if rotate: + pil = pil.rotate(-rotate, expand=True) + if max(pil.size) > max_size: + pil.thumbnail((max_size, max_size), Image.Resampling.BILINEAR) + output = io.BytesIO() + pil.save(output, format="PNG", optimize=True) + return output.getvalue() + + +def load_stack(ct_number: str, series_uid: str) -> np.ndarray: + key = f"{ct_number}|{series_uid}" + cached = STACK_CACHE.get(key) + if cached: + STACK_CACHE[key] = (time.time(), cached[1]) + return cached[1] + files = get_series_files(ct_number, series_uid) + arrays = [] + for path in files: + ds = pydicom.dcmread(str(path), force=True) + arrays.append(dicom_to_hu(ds)) + stack = np.stack(arrays, axis=0) + STACK_CACHE[key] = (time.time(), stack) + if len(STACK_CACHE) > 2: + oldest = sorted(STACK_CACHE.items(), key=lambda item: item[1][0])[0][0] + STACK_CACHE.pop(oldest, None) + return stack + + +@app.get("/api/image") +def image( + ct_number: str, + series_uid: str, + index: int = 0, + plane: str = "axial", + window: str = "default", + rotate: int = 0, + _: str = Depends(require_auth), +) -> Response: + files = get_series_files(ct_number, series_uid) + index = max(0, index) + if plane == "axial" or len(files) < 2: + index = min(index, len(files) - 1) + ds = pydicom.dcmread(str(files[index]), force=True) + center, width = window_values(ds, window) + payload = render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", rotate) + return Response(payload, media_type="image/png") + + stack = load_stack(ct_number, series_uid) + sample_ds = pydicom.dcmread(str(files[min(len(files) - 1, len(files) // 2)]), stop_before_pixels=True, force=True) + center, width = window_values(sample_ds, window) + if plane == "coronal": + index = min(index, stack.shape[1] - 1) + arr = stack[:, index, :] + elif plane == "sagittal": + index = min(index, stack.shape[2] - 1) + arr = stack[:, :, index] + else: + raise HTTPException(status_code=400, detail="invalid plane") + payload = render_array(np.flipud(arr), center, width, False, rotate) + return Response(payload, media_type="image/png") + + +@app.get("/api/dicom-info") +def dicom_info(ct_number: str, series_uid: str, index: int = 0, _: str = Depends(require_auth)) -> dict[str, Any]: + files = get_series_files(ct_number, series_uid) + index = min(max(0, index), len(files) - 1) + path = files[index] + ds = pydicom.dcmread(str(path), stop_before_pixels=True, force=True) + fields = { + "patient": { + "患者姓名": str(getattr(ds, "PatientName", "")), + "患者ID": str(getattr(ds, "PatientID", "")), + "检查号": str(getattr(ds, "AccessionNumber", "")), + "检查日期": str(getattr(ds, "StudyDate", "")), + "检查时间": str(getattr(ds, "StudyTime", "")), + "设备厂商": str(getattr(ds, "Manufacturer", "")), + }, + "series": { + "序列描述": str(getattr(ds, "SeriesDescription", "")), + "序列号": str(getattr(ds, "SeriesNumber", "")), + "文件数量": len(files), + "当前文件": path.name, + "DICOM路径": str(path), + }, + "image": { + "Rows": str(getattr(ds, "Rows", "")), + "Columns": str(getattr(ds, "Columns", "")), + "BitsAllocated": str(getattr(ds, "BitsAllocated", "")), + "WindowCenter": str(getattr(ds, "WindowCenter", "")), + "WindowWidth": str(getattr(ds, "WindowWidth", "")), + "Rescale": f"{getattr(ds, 'RescaleSlope', '')} / {getattr(ds, 'RescaleIntercept', '')}", + }, + "spacing": { + "像素间距": str(getattr(ds, "PixelSpacing", "")), + "切片厚度": str(getattr(ds, "SliceThickness", "")), + "SpacingBetweenSlices": str(getattr(ds, "SpacingBetweenSlices", "")), + "ImagePositionPatient": str(getattr(ds, "ImagePositionPatient", "")), + }, + } + return {"path": str(path), "fields": fields} + + +@app.put("/api/series/{ct_number}/{series_uid}/annotation") +def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: str = Depends(require_auth)) -> dict[str, Any]: + body_parts = [] if data.skipped else [part for part in data.body_parts if part in BODY_PARTS] + phase = data.upper_abdomen_phase if data.upper_abdomen_phase in PHASES else "" + if "upper_abdomen" not in body_parts: + phase = "" + study = scan_study(ct_number) + series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None) + if not series_row: + raise HTTPException(status_code=404, detail="series not found") + ensure_annotation_table() + pg_scalar( + f""" + INSERT INTO public.pacs_dicom_series_annotations ( + ct_number, study_instance_uid, series_instance_uid, series_description, + body_parts, upper_abdomen_phase, skipped, notes, updated_by, updated_at + ) + VALUES ( + {sql_literal(ct_number)}, + {sql_literal(series_row.get('study_uid', ''))}, + {sql_literal(series_uid)}, + {sql_literal(series_row.get('description', ''))}, + {sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb, + {sql_literal(phase)}, + {'true' if data.skipped else 'false'}, + {sql_literal(data.notes)}, + {sql_literal(user)}, + now() + ) + ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET + study_instance_uid = EXCLUDED.study_instance_uid, + series_description = EXCLUDED.series_description, + body_parts = EXCLUDED.body_parts, + upper_abdomen_phase = EXCLUDED.upper_abdomen_phase, + skipped = EXCLUDED.skipped, + notes = EXCLUDED.notes, + updated_by = EXCLUDED.updated_by, + updated_at = now() + """ + ) + STUDY_CACHE.pop(ct_number, None) + return {"ok": True, "body_parts": body_parts, "upper_abdomen_phase": phase, "skipped": data.skipped} + + +def representative_images(ct_number: str, series_uid: str) -> list[tuple[str, bytes]]: + files = get_series_files(ct_number, series_uid) + axial_index = max(0, min(len(files) - 1, len(files) // 2)) + ds = pydicom.dcmread(str(files[axial_index]), force=True) + center, width = window_values(ds, "soft") + images = [("横断面", render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", max_size=720))] + + if len(files) >= 3: + try: + stack = load_stack(ct_number, series_uid) + sample_ds = pydicom.dcmread(str(files[axial_index]), stop_before_pixels=True, force=True) + center, width = window_values(sample_ds, "soft") + coronal = np.flipud(stack[:, stack.shape[1] // 2, :]) + sagittal = np.flipud(stack[:, :, stack.shape[2] // 2]) + images.append(("矢状面", render_array(sagittal, center, width, False, max_size=720))) + images.append(("冠状面", render_array(coronal, center, width, False, max_size=720))) + except Exception: + pass + return images + + +def parse_ai_json(content: str) -> dict[str, Any]: + text = content.strip() + if text.startswith("```"): + text = text.strip("`") + if text.startswith("json"): + text = text[4:] + start = text.find("{") + end = text.rfind("}") + if start >= 0 and end > start: + text = text[start : end + 1] + try: + return json.loads(text) + except Exception: + return {"body_parts": [], "upper_abdomen_phase": "", "skipped": False, "notes": content[:800]} + + +@app.post("/api/series/{ct_number}/{series_uid}/ai") +def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depends(require_auth)) -> dict[str, Any]: + if not KIMI_API_KEY: + raise HTTPException(status_code=400, detail="KIMI_API_KEY 未配置") + study = scan_study(ct_number) + series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None) + if not series_row: + raise HTTPException(status_code=404, detail="series not found") + + content: list[dict[str, Any]] = [ + { + "type": "text", + "text": ( + "请根据这组CT序列的代表图像判断该序列所属部位。" + "可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), " + "lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。" + "如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。" + "如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。" + "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"skipped\":false,\"notes\":\"\"}。" + f"PACS张数: {series_row.get('count', 0)}。" + f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。" + ), + } + ] + for label, png in representative_images(ct_number, series_uid): + content.append({"type": "text", "text": label}) + content.append({"type": "image_url", "image_url": {"url": "data:image/png;base64," + base64.b64encode(png).decode("ascii")}}) + + payload = { + "model": KIMI_MODEL, + "messages": [ + {"role": "system", "content": "你是医学影像序列分拣助手,只做部位和期相粗分类,不输出诊断。"}, + {"role": "user", "content": content}, + ], + "temperature": 0.1, + } + request = urllib.request.Request( + KIMI_API_URL, + data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), + headers={"Authorization": f"Bearer {KIMI_API_KEY}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=60) as response: + raw = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace")[:800] + raise HTTPException(status_code=502, detail=f"Kimi API 错误: {detail}") from exc + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Kimi API 调用失败: {exc}") from exc + + data = json.loads(raw) + message = data.get("choices", [{}])[0].get("message", {}).get("content", "") + suggestion = parse_ai_json(message) + body_parts = [part for part in suggestion.get("body_parts", []) if part in BODY_PARTS] + skipped = bool(suggestion.get("skipped", False)) + phase = suggestion.get("upper_abdomen_phase", "") + if phase not in PHASES or "upper_abdomen" not in body_parts: + phase = "" + ensure_annotation_table() + pg_scalar( + f""" + INSERT INTO public.pacs_dicom_series_annotations ( + ct_number, study_instance_uid, series_instance_uid, series_description, + body_parts, upper_abdomen_phase, skipped, notes, ai_result, ai_model, updated_by, updated_at + ) + VALUES ( + {sql_literal(ct_number)}, + {sql_literal(series_row.get('study_uid', ''))}, + {sql_literal(series_uid)}, + {sql_literal(series_row.get('description', ''))}, + '[]'::jsonb, + '', + false, + '', + {sql_literal(json.dumps(suggestion, ensure_ascii=False))}::jsonb, + {sql_literal(KIMI_MODEL)}, + {sql_literal(user)}, + now() + ) + ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET + ai_result = EXCLUDED.ai_result, + ai_model = EXCLUDED.ai_model, + updated_by = EXCLUDED.updated_by, + updated_at = now() + """ + ) + STUDY_CACHE.pop(ct_number, None) + return { + "ok": True, + "provider": "Kimi", + "name": KIMI_API_NAME, + "model": KIMI_MODEL, + "body_parts": [] if skipped else body_parts, + "upper_abdomen_phase": "" if skipped else phase, + "skipped": skipped, + "notes": str(suggestion.get("notes", "")), + "raw": suggestion, + } + + +@app.get("/api/settings") +def settings(_: str = Depends(require_auth)) -> dict[str, Any]: + return { + "users": web_users(), + "roles": [{"name": name, "permissions": permissions} for name, permissions in ROLES.items()], + "database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE, "table": PGTABLE}, + "ai": {"provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, "configured": bool(KIMI_API_KEY), "url": KIMI_API_URL}, + "dicom": {"processed_root": str(PROCESSED_ROOT)}, + } + + +@app.post("/api/settings/users") +def create_user(data: UserIn, user: str = Depends(require_auth)) -> dict[str, Any]: + if user != WEB_USER: + raise HTTPException(status_code=403, detail="仅管理员可创建用户") + username = data.username.strip() + if not username or len(username) > 64: + raise HTTPException(status_code=400, detail="账号格式不正确") + if len(data.password) < 6: + raise HTTPException(status_code=400, detail="密码至少 6 位") + role = data.role if data.role in ROLES else "阅片员" + status = data.status if data.status in {"启用", "停用"} else "启用" + ensure_user_table() + exists = int(pg_scalar(f"SELECT count(*) FROM public.pacs_web_users WHERE username = {sql_literal(username)}") or "0") + if exists: + raise HTTPException(status_code=409, detail="账号已存在") + pg_scalar( + f""" + INSERT INTO public.pacs_web_users (username, password_hash, role, status, updated_at) + VALUES ({sql_literal(username)}, {sql_literal(password_hash(data.password))}, {sql_literal(role)}, {sql_literal(status)}, now()) + """ + ) + return {"ok": True, "username": username, "role": role, "status": status} + + +@app.get("/health") +def health() -> Response: + return Response("ok", media_type="text/plain") diff --git a/PACS_DICOM处理/数据处理网页端/docker-compose.yml b/PACS_DICOM处理/数据处理网页端/docker-compose.yml new file mode 100644 index 0000000..e4a84be --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/docker-compose.yml @@ -0,0 +1,20 @@ +name: pacs-dicom-web + +services: + pacs-dicom-web: + build: + context: . + container_name: pacs-dicom-web + restart: unless-stopped + env_file: + - .env + environment: + PACS_PROCESSED_ROOT: /dicom/已处理_DICOM数据 + PACS_BATCH_NAME: 2026_5_27_PACS下载数据 + PACS_WEB_USER: admin + PACS_WEB_PASSWORD: "123456" + PG_ANNOTATION_TABLE: pacs_dicom_series_annotations + ports: + - "8107:8107" + volumes: + - /home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据:/dicom/已处理_DICOM数据:ro diff --git a/PACS_DICOM处理/数据处理网页端/requirements.txt b/PACS_DICOM处理/数据处理网页端/requirements.txt new file mode 100644 index 0000000..5c2bdb8 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +pydicom==2.4.5 +numpy>=2.0 +pillow>=11.0 diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js new file mode 100644 index 0000000..18648b5 --- /dev/null +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -0,0 +1,613 @@ +const app = { + token: localStorage.getItem("pacs_web_token") || "", + studies: [], + study: null, + series: [], + activeSeries: null, + slice: 0, + plane: "axial", + window: "default", + rotate: 0, + zoom: 1, + panX: 0, + panY: 0, + imageUrl: "", + searchTimer: null, + statusTimer: null, + pendingImage: null, + drag: null, +}; + +const $ = (id) => document.getElementById(id); +const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +async function request(path, options = {}) { + const headers = { ...(options.headers || {}) }; + if (app.token) headers.Authorization = `Bearer ${app.token}`; + if (options.body) headers["Content-Type"] = "application/json"; + const res = await fetch(path, { ...options, headers }); + if (res.status === 401) { + showLogin(true); + throw new Error("unauthorized"); + } + if (!res.ok) { + let detail = res.statusText; + try { + const data = await res.json(); + detail = data.detail || detail; + } catch (_) { + detail = await res.text(); + } + throw new Error(detail); + } + return res; +} + +async function json(path, options = {}) { + const res = await request(path, options); + return res.json(); +} + +function showLogin(show) { + $("loginOverlay").classList.toggle("hidden", !show); +} + +function fmtDate(value) { + const text = String(value || ""); + if (text.length !== 8) return text; + return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; +} + +function fmtTime(value) { + const digits = String(value || "").replace(/\D/g, "").slice(0, 6); + if (digits.length < 6) return value || ""; + return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`; +} + +function timeRange(series) { + const first = fmtTime(series.first_time || series.series_time || series.study_time); + const last = fmtTime(series.last_time); + if (first && last && first !== last) return `${first}-${last}`; + return first || last || "未记录"; +} + +function phaseLabel(value) { + return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || ""; +} + +function partLabel(value) { + return { + head_neck: "头颈部", + chest: "胸部", + upper_abdomen: "上腹部", + lower_abdomen: "下腹部", + pelvis: "盆腔", + }[value] || value; +} + +async function login(event) { + event.preventDefault(); + $("loginError").textContent = ""; + try { + const data = await json("/api/auth/login", { + method: "POST", + body: JSON.stringify({ username: $("username").value, password: $("password").value }), + }); + app.token = data.token; + localStorage.setItem("pacs_web_token", app.token); + showLogin(false); + await boot(); + } catch (_) { + $("loginError").textContent = "登录失败"; + } +} + +function logout() { + app.token = ""; + localStorage.removeItem("pacs_web_token"); + showLogin(true); +} + +async function refreshStatus() { + try { + const data = await fetch("/api/status").then((res) => res.json()); + const pill = $("dbStatus"); + pill.textContent = data.database?.ok ? `数据库 ${data.database.rows ?? ""}` : "数据库异常"; + pill.title = data.database?.message || ""; + pill.classList.toggle("offline", !data.database?.ok); + pill.classList.toggle("online", Boolean(data.database?.ok)); + } catch (_) { + $("dbStatus").textContent = "数据库异常"; + $("dbStatus").classList.add("offline"); + $("dbStatus").classList.remove("online"); + } +} + +async function loadStudies() { + const q = encodeURIComponent($("studySearch").value.trim()); + app.studies = await json(`/api/studies?q=${q}&limit=500`); + $("studyCount").textContent = String(app.studies.length); + renderStudies(); + if (!app.study && app.studies.length) selectStudy(app.studies[0].ct_number); +} + +function renderStudies() { + const list = $("studyList"); + list.innerHTML = ""; + for (const study of app.studies) { + const button = document.createElement("button"); + button.className = "study-card"; + button.classList.toggle("active", app.study?.ct_number === study.ct_number); + const name = study.source_patient_name || study.patient_name_dicom || "无姓名"; + button.innerHTML = ` + ${escapeHtml(study.ct_number)} + ${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")} + ${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张 + ${Number(study.annotated_series || 0)} 个序列已标注 + `; + button.onclick = () => selectStudy(study.ct_number); + list.appendChild(button); + } +} + +async function selectStudy(ctNumber) { + app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber }; + app.series = []; + app.activeSeries = null; + $("activeStudyLabel").textContent = ctNumber; + $("seriesCount").textContent = "读取中"; + $("seriesGrid").innerHTML = ""; + resetViewer(); + renderStudies(); + try { + const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`); + app.study = data.study; + app.series = data.series || []; + $("seriesCount").textContent = String(app.series.length); + renderSeries(); + if (app.series.length) selectSeries(app.series[0].series_uid); + } catch (err) { + $("seriesGrid").innerHTML = `
${escapeHtml(err.message)}
`; + } +} + +function renderSeries() { + const grid = $("seriesGrid"); + grid.innerHTML = ""; + for (const series of app.series) { + const skipped = Boolean(series.annotation?.skipped); + const parts = series.annotation?.body_parts || []; + const tags = skipped + ? ["略过"] + : [series.body_part_dicom, ...parts.map(partLabel), phaseLabel(series.annotation?.upper_abdomen_phase)].filter(Boolean); + const card = document.createElement("button"); + card.className = "series-card"; + card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid); + card.classList.toggle("skipped", skipped); + card.innerHTML = ` +${escapeHtml(key)}${escapeHtml(value || "-")}
`) + .join("")}`; + content.appendChild(section); + } + $("infoModal").classList.remove("hidden"); +} + +function settingsTable(users) { + return ` +| 账号 | 角色 | 状态 |
|---|---|---|
| ${escapeHtml(user.username)} | ${escapeHtml(user.role)} | ${escapeHtml(user.status)} |