Compare commits

...

2 Commits

Author SHA1 Message Date
Codex
9c478ed392 Add Docker PACS DICOM web viewer 2026-05-27 08:43:33 +08:00
Codex
f3e3cfff1d Add PACS DICOM preprocessing workflow 2026-05-27 00:54:14 +08:00
13 changed files with 3006 additions and 0 deletions

7
.gitignore vendored
View File

@@ -21,3 +21,10 @@ UPP列表处理/数据处理工作区/06_PostgreSQL建表结构.sql
UPP_STL处理/
UPP_数据库构建/UPP_STL资产同步报告.json
UPP_数据库构建/UPP_STL文件family顺序明细.csv
# PACS DICOM 实数据、软链接数据目录和批次处理结果不提交
PACS_DICOM处理/待处理_DICOM数据
PACS_DICOM处理/待处理_DICOM数据/
PACS_DICOM处理/已处理_DICOM数据
PACS_DICOM处理/已处理_DICOM数据/
PACS_DICOM处理/数据处理结果区/

View File

@@ -0,0 +1,19 @@
# PACS DICOM 数据处理
本目录用于 PACS DICOM 批次数据的本地预处理和归档脚本管理。
## 目录约定
- `待处理_DICOM数据/`:本地待处理 DICOM 数据目录,通常为数据盘软链接,不提交到 Git。
- `已处理_DICOM数据/`:本地已处理 DICOM 数据目录,通常为数据盘软链接,不提交到 Git。
- `数据处理工作区/`:可提交的处理脚本、模板和说明。
- `数据处理结果区/`批次处理产生的清单、报告、SQL 输出等结果数据,不提交到 Git。
- `数据处理网页端/`:预留给后续网页端工具。
## 当前预处理逻辑
`数据处理工作区/preprocess_pacs_dicom_batch.py` 会读取 DICOM 元数据中的 `AccessionNumber` 作为真实 `ct_number`,用于修正 PACS 导出顶层目录名中的检查号。
脚本会生成检查级清单、文件级清单和改名计划。实际落地已处理数据时,优先使用硬链接,避免在同一数据盘上重复占用整份 DICOM 空间。
数据目录和处理结果包含 DICOM 或患者相关信息,已在 `.gitignore` 中排除。

View File

@@ -0,0 +1,403 @@
#!/usr/bin/env python3
"""Preprocess a PACS DICOM batch by normalizing study folders.
The top-level PACS export folder is assumed to contain one folder per study.
The exported folder name may contain the wrong CT number, so the canonical
ct_number is read from DICOM AccessionNumber (0008,0050).
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import re
import shutil
import sys
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
import pydicom
DICOM_TAGS = [
"AccessionNumber",
"PatientName",
"PatientID",
"PatientBirthDate",
"PatientSex",
"StudyInstanceUID",
"StudyDate",
"StudyTime",
"StudyID",
"Modality",
"BodyPartExamined",
"ProtocolName",
"StudyDescription",
"SeriesInstanceUID",
"SOPInstanceUID",
]
@dataclass
class StudyRow:
batch_name: str
source_folder_name: str
source_ct_number: str
source_patient_name: str
ct_number: str
target_folder_name: str
needs_ct_number_fix: bool
patient_name_dicom: str
patient_id: str
patient_birth_date: str
patient_sex: str
study_date: str
study_time: str
study_id: str
modality: str
body_part_examined: str
protocol_name: str
study_description: str
accession_numbers: str
raw_accession_numbers: str
study_instance_uids: str
series_count: int
dicom_file_count: int
total_file_count: int
total_bytes: int
source_path: str
processed_path: str
status: str
notes: str
def text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def most_common(counter: Counter[str]) -> str:
if not counter:
return ""
return counter.most_common(1)[0][0]
def sanitize_component(name: str) -> str:
cleaned = name.replace("/", "_").replace("\x00", "_").strip()
return cleaned or "UNKNOWN"
def parse_export_folder_name(name: str) -> tuple[str, str]:
if "-" not in name:
return name, ""
ct_number, patient_name = name.split("-", 1)
return ct_number.strip(), patient_name.strip()
def canonical_ct_number(accession_number: str) -> str:
accession_number = accession_number.strip()
match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number)
if match:
return match.group(1)
return accession_number
def iter_files(folder: Path) -> Iterable[Path]:
for path in folder.rglob("*"):
if path.is_file():
yield path
def read_meta(path: Path) -> dict[str, str]:
ds = pydicom.dcmread(
str(path),
stop_before_pixels=True,
force=True,
specific_tags=DICOM_TAGS + ["SpecificCharacterSet"],
)
return {tag: text(getattr(ds, tag, "")) for tag in DICOM_TAGS}
def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path) -> tuple[StudyRow, list[dict[str, str]]]:
source_ct_number, source_patient_name = parse_export_folder_name(folder.name)
files = sorted(iter_files(folder))
counters: dict[str, Counter[str]] = defaultdict(Counter)
file_rows: list[dict[str, str]] = []
dicom_file_count = 0
total_bytes = 0
errors = []
for path in files:
try:
size = path.stat().st_size
total_bytes += size
meta = read_meta(path)
dicom_file_count += 1
except Exception as exc: # noqa: BLE001 - keep scanning and report the file.
errors.append(f"{path}: {exc}")
continue
for key in DICOM_TAGS:
value = meta.get(key, "")
if value:
counters[key][value] += 1
file_rows.append(
{
"ct_number": "",
"source_folder_name": folder.name,
"source_relative_path": str(path.relative_to(folder)),
"processed_relative_path": "",
"sop_instance_uid": meta.get("SOPInstanceUID", ""),
"series_instance_uid": meta.get("SeriesInstanceUID", ""),
"study_instance_uid": meta.get("StudyInstanceUID", ""),
"bytes": str(size),
}
)
raw_accession_numbers = sorted(counters["AccessionNumber"])
accession_numbers = sorted({canonical_ct_number(value) for value in raw_accession_numbers if value})
study_instance_uids = sorted(counters["StudyInstanceUID"])
status = "ok"
notes: list[str] = []
if errors:
status = "error"
notes.append(f"metadata_read_errors={len(errors)}")
if raw_accession_numbers and raw_accession_numbers != accession_numbers:
notes.append("normalized_accession_suffixes")
if not accession_numbers:
status = "error"
notes.append("missing_accession_number")
ct_number = source_ct_number
elif len(accession_numbers) > 1:
status = "error"
notes.append("multiple_accession_numbers")
ct_number = most_common(counters["AccessionNumber"])
else:
ct_number = accession_numbers[0]
target_folder_name = sanitize_component(ct_number)
if source_patient_name:
target_folder_name = f"{target_folder_name}-{sanitize_component(source_patient_name)}"
processed_path = processed_batch_root / target_folder_name
needs_ct_number_fix = source_ct_number != ct_number
for row in file_rows:
row["ct_number"] = ct_number
row["processed_relative_path"] = str(Path(target_folder_name) / row["source_relative_path"])
row = StudyRow(
batch_name=batch_name,
source_folder_name=folder.name,
source_ct_number=source_ct_number,
source_patient_name=source_patient_name,
ct_number=ct_number,
target_folder_name=target_folder_name,
needs_ct_number_fix=needs_ct_number_fix,
patient_name_dicom=most_common(counters["PatientName"]),
patient_id=most_common(counters["PatientID"]),
patient_birth_date=most_common(counters["PatientBirthDate"]),
patient_sex=most_common(counters["PatientSex"]),
study_date=most_common(counters["StudyDate"]),
study_time=most_common(counters["StudyTime"]),
study_id=most_common(counters["StudyID"]),
modality=most_common(counters["Modality"]),
body_part_examined=most_common(counters["BodyPartExamined"]),
protocol_name=most_common(counters["ProtocolName"]),
study_description=most_common(counters["StudyDescription"]),
accession_numbers="|".join(accession_numbers),
raw_accession_numbers="|".join(raw_accession_numbers),
study_instance_uids="|".join(study_instance_uids),
series_count=len(counters["SeriesInstanceUID"]),
dicom_file_count=dicom_file_count,
total_file_count=len(files),
total_bytes=total_bytes,
source_path=str(folder),
processed_path=str(processed_path),
status=status,
notes=";".join(notes),
)
return row, file_rows
def write_csv(path: Path, rows: list[dict[str, object]], fieldnames: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def hardlink_tree(source_folder: Path, target_folder: Path) -> tuple[int, int]:
linked = 0
already_ok = 0
for source in iter_files(source_folder):
rel = source.relative_to(source_folder)
target = target_folder / rel
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
try:
if os.path.samefile(source, target):
already_ok += 1
continue
except OSError:
pass
if target.stat().st_size != source.stat().st_size:
raise RuntimeError(f"target exists with different size: {target}")
target.unlink()
try:
os.link(source, target)
except OSError as exc:
if exc.errno != 18: # EXDEV
raise
shutil.copy2(source, target)
linked += 1
return linked, already_ok
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--processed-root", type=Path, required=True)
parser.add_argument("--results-root", type=Path, required=True)
parser.add_argument("--batch-name", default="")
parser.add_argument("--apply", action="store_true", help="create processed hardlink tree")
parser.add_argument("--write-file-manifest", action="store_true")
args = parser.parse_args()
source_root = args.source.resolve()
batch_name = args.batch_name or source_root.name
processed_batch_root = (args.processed_root / batch_name).resolve()
result_dir = (args.results_root / batch_name).resolve()
result_dir.mkdir(parents=True, exist_ok=True)
started_at = datetime.now().isoformat(timespec="seconds")
study_folders = sorted([p for p in source_root.iterdir() if p.is_dir()])
study_rows: list[StudyRow] = []
file_rows_all: list[dict[str, str]] = []
for index, folder in enumerate(study_folders, start=1):
print(f"[{index}/{len(study_folders)}] scan {folder.name}", flush=True)
study_row, file_rows = scan_study_folder(batch_name, folder, processed_batch_root)
study_rows.append(study_row)
if args.write_file_manifest:
file_rows_all.extend(file_rows)
ct_counts = Counter(row.ct_number for row in study_rows if row.ct_number)
target_counts = Counter(row.target_folder_name for row in study_rows if row.target_folder_name)
duplicate_ct_numbers = sorted([ct for ct, count in ct_counts.items() if count > 1])
duplicate_target_folders = sorted([name for name, count in target_counts.items() if count > 1])
for row in study_rows:
notes = [row.notes] if row.notes else []
if row.ct_number in duplicate_ct_numbers:
row.status = "error"
notes.append("duplicate_ct_number")
if row.target_folder_name in duplicate_target_folders:
row.status = "error"
notes.append("duplicate_target_folder")
row.notes = ";".join([note for note in notes if note])
study_dicts = [asdict(row) for row in study_rows]
study_fieldnames = list(asdict(study_rows[0]).keys()) if study_rows else list(StudyRow.__dataclass_fields__)
write_csv(result_dir / "study_manifest.csv", study_dicts, study_fieldnames)
write_csv(
result_dir / "rename_plan.csv",
[
{
"source_folder_name": row.source_folder_name,
"source_ct_number": row.source_ct_number,
"ct_number": row.ct_number,
"target_folder_name": row.target_folder_name,
"needs_ct_number_fix": row.needs_ct_number_fix,
"status": row.status,
"notes": row.notes,
}
for row in study_rows
],
[
"source_folder_name",
"source_ct_number",
"ct_number",
"target_folder_name",
"needs_ct_number_fix",
"status",
"notes",
],
)
if args.write_file_manifest:
write_csv(
result_dir / "file_manifest.csv",
file_rows_all,
[
"ct_number",
"source_folder_name",
"source_relative_path",
"processed_relative_path",
"sop_instance_uid",
"series_instance_uid",
"study_instance_uid",
"bytes",
],
)
ok_rows = [row for row in study_rows if row.status == "ok"]
error_rows = [row for row in study_rows if row.status != "ok"]
summary = {
"batch_name": batch_name,
"source_root": str(source_root),
"processed_batch_root": str(processed_batch_root),
"result_dir": str(result_dir),
"started_at": started_at,
"finished_scan_at": datetime.now().isoformat(timespec="seconds"),
"apply": args.apply,
"source_study_folder_count": len(study_folders),
"ok_study_count": len(ok_rows),
"error_study_count": len(error_rows),
"needs_ct_number_fix_count": sum(1 for row in study_rows if row.needs_ct_number_fix),
"duplicate_ct_numbers": duplicate_ct_numbers,
"duplicate_target_folders": duplicate_target_folders,
"dicom_file_count": sum(row.dicom_file_count for row in study_rows),
"total_file_count": sum(row.total_file_count for row in study_rows),
"total_bytes": sum(row.total_bytes for row in study_rows),
"linked_file_count": 0,
"already_linked_file_count": 0,
}
if error_rows:
summary_path = result_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"ERROR: found {len(error_rows)} invalid study rows; see {summary_path}", file=sys.stderr)
return 2
if args.apply:
processed_batch_root.mkdir(parents=True, exist_ok=True)
linked_total = 0
already_total = 0
for index, row in enumerate(ok_rows, start=1):
print(f"[{index}/{len(ok_rows)}] link {row.source_folder_name} -> {row.target_folder_name}", flush=True)
linked, already_ok = hardlink_tree(Path(row.source_path), Path(row.processed_path))
linked_total += linked
already_total += already_ok
summary["linked_file_count"] = linked_total
summary["already_linked_file_count"] = already_total
summary["finished_apply_at"] = datetime.now().isoformat(timespec="seconds")
summary_path = result_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
.pytest_cache/
data/
logs/
*.log

View File

@@ -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

View File

@@ -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"]

View File

@@ -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` 中。

View File

@@ -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")

View File

@@ -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

View File

@@ -0,0 +1,5 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pydicom==2.4.5
numpy>=2.0
pillow>=11.0

View File

@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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 = `
<strong>${escapeHtml(study.ct_number)}</strong>
<span>${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")}</span>
<span>${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张</span>
<span>${Number(study.annotated_series || 0)} 个序列已标注</span>
`;
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 = `<p class="error-line">${escapeHtml(err.message)}</p>`;
}
}
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 = `
<div class="thumb">
<img alt="" />
<b>${Number(series.count || 0)} 张</b>
${skipped ? "<i>略过</i>" : ""}
</div>
<div class="series-copy">
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
<div class="tag-line">${tags.length ? tags.map((tag) => `<em>${escapeHtml(tag)}</em>`).join("") : "<em>未标注</em>"}</div>
</div>
`;
card.onclick = () => selectSeries(series.series_uid);
grid.appendChild(card);
loadThumb(series, card.querySelector("img"));
}
}
function imageUrlFor(series = app.activeSeries, index = app.slice, windowName = app.window, plane = app.plane) {
return `/api/image?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(series.series_uid)}&index=${index}&plane=${plane}&window=${windowName}&rotate=0`;
}
async function loadThumb(series, img) {
try {
const index = Math.floor((Number(series.count) || 1) / 2);
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
img.src = URL.createObjectURL(blob);
} catch (_) {
img.removeAttribute("src");
}
}
function maxSlice() {
if (!app.activeSeries) return 0;
if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1);
if (app.plane === "coronal") return Math.max(0, Number(app.activeSeries.rows || 1) - 1);
return Math.max(0, Number(app.activeSeries.count || 1) - 1);
}
function resetViewState() {
app.rotate = 0;
app.zoom = 1;
app.panX = 0;
app.panY = 0;
applyTransform();
}
function selectSeries(seriesUid) {
const series = app.series.find((item) => item.series_uid === seriesUid);
if (!series) return;
app.activeSeries = series;
app.slice = Math.floor(maxSlice() / 2);
resetViewState();
$("sliceSlider").max = String(maxSlice());
$("sliceSlider").value = String(app.slice);
hydrateAnnotation();
renderSeries();
updateImage();
}
function resetViewer() {
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
app.imageUrl = "";
app.pendingImage = null;
app.activeSeries = null;
$("dicomImage").removeAttribute("src");
$("imageEmpty").classList.remove("hidden");
$("sliceText").textContent = "0 / 0";
$("saveState").textContent = "未保存";
resetViewState();
}
function applyTransform() {
$("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`;
}
async function updateImage() {
if (!app.study || !app.activeSeries) return;
const max = maxSlice();
app.slice = clamp(app.slice, 0, max);
$("sliceSlider").max = String(max);
$("sliceSlider").value = String(app.slice);
$("sliceText").textContent = `${app.slice + 1} / ${max + 1}`;
$("imageEmpty").classList.add("hidden");
const ticket = Symbol("image");
app.pendingImage = ticket;
try {
const blob = await request(imageUrlFor()).then((res) => res.blob());
if (app.pendingImage !== ticket) return;
if (app.imageUrl) URL.revokeObjectURL(app.imageUrl);
app.imageUrl = URL.createObjectURL(blob);
$("dicomImage").src = app.imageUrl;
applyTransform();
} catch (err) {
$("saveState").textContent = `图像读取失败:${err.message}`;
}
}
function isSkippedChecked() {
return Boolean(document.querySelector('.part-grid input[value="skip"]')?.checked);
}
function selectedParts() {
return Array.from(document.querySelectorAll('.part-grid input:checked:not([value="skip"])')).map((input) => input.value);
}
function syncPartState() {
const skipped = isSkippedChecked();
document.querySelectorAll('.part-grid input:not([value="skip"])').forEach((input) => {
input.disabled = skipped;
if (skipped) input.checked = false;
});
if (skipped) {
document.querySelectorAll("input[name=phase]").forEach((input) => {
input.checked = false;
});
}
const show = !skipped && selectedParts().includes("upper_abdomen");
$("phaseBox").classList.toggle("visible", show);
}
function hydrateAnnotation() {
const annotation = app.activeSeries?.annotation || {};
const parts = new Set(annotation.body_parts || []);
document.querySelectorAll(".part-grid input").forEach((input) => {
if (input.value === "skip") {
input.checked = Boolean(annotation.skipped);
return;
}
input.checked = !annotation.skipped && parts.has(input.value);
input.disabled = Boolean(annotation.skipped);
});
document.querySelectorAll("input[name=phase]").forEach((input) => {
input.checked = !annotation.skipped && input.value === (annotation.upper_abdomen_phase || "");
});
$("annotationNotes").value = annotation.notes || "";
syncPartState();
}
async function reloadCurrentStudySeries(keepUid) {
const data = await json(`/api/studies/${encodeURIComponent(app.study.ct_number)}/series`);
app.study = data.study;
app.series = data.series || [];
app.activeSeries = app.series.find((item) => item.series_uid === keepUid) || app.series[0] || null;
$("seriesCount").textContent = String(app.series.length);
renderSeries();
hydrateAnnotation();
}
async function saveAnnotation() {
if (!app.study || !app.activeSeries) return;
const skipped = isSkippedChecked();
const parts = skipped ? [] : selectedParts();
let phase = skipped ? "" : document.querySelector("input[name=phase]:checked")?.value || "";
if (parts.includes("upper_abdomen") && !phase) {
$("saveState").textContent = "请选择上腹部期相";
return;
}
if (!parts.includes("upper_abdomen")) phase = "";
$("saveState").textContent = "保存中";
const uid = app.activeSeries.series_uid;
try {
await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, {
method: "PUT",
body: JSON.stringify({ body_parts: parts, upper_abdomen_phase: phase, skipped, notes: $("annotationNotes").value }),
});
$("saveState").textContent = "已保存";
await reloadCurrentStudySeries(uid);
} catch (err) {
$("saveState").textContent = err.message;
}
}
function applyAiSuggestion(data) {
const parts = new Set(data.body_parts || []);
document.querySelectorAll(".part-grid input").forEach((input) => {
if (input.value === "skip") {
input.checked = Boolean(data.skipped);
return;
}
input.checked = !data.skipped && parts.has(input.value);
});
document.querySelectorAll("input[name=phase]").forEach((input) => {
input.checked = !data.skipped && input.value === (data.upper_abdomen_phase || "");
});
if (data.notes) $("annotationNotes").value = data.notes;
syncPartState();
}
async function runAI() {
if (!app.study || !app.activeSeries) return;
const button = $("aiClassify");
button.disabled = true;
$("saveState").textContent = "AI 识别中";
try {
const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(app.activeSeries.series_uid)}/ai`, {
method: "POST",
body: JSON.stringify({ sample_count: 3 }),
});
applyAiSuggestion(data);
$("saveState").textContent = "AI 建议已填入,确认后保存";
} catch (err) {
$("saveState").textContent = err.message;
} finally {
button.disabled = false;
}
}
async function openInfo() {
if (!app.study || !app.activeSeries) return;
const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`);
const content = $("infoContent");
content.innerHTML = "";
for (const [title, fields] of Object.entries(data.fields || {})) {
const section = document.createElement("section");
section.className = "info-card";
section.innerHTML = `<h3>${escapeHtml(title)}</h3>${Object.entries(fields)
.map(([key, value]) => `<p class="info-row"><span>${escapeHtml(key)}</span><b>${escapeHtml(value || "-")}</b></p>`)
.join("")}`;
content.appendChild(section);
}
$("infoModal").classList.remove("hidden");
}
function settingsTable(users) {
return `
<table class="settings-table">
<thead><tr><th>账号</th><th>角色</th><th>状态</th></tr></thead>
<tbody>
${(users || [])
.map((user) => `<tr><td>${escapeHtml(user.username)}</td><td>${escapeHtml(user.role)}</td><td>${escapeHtml(user.status)}</td></tr>`)
.join("")}
</tbody>
</table>
`;
}
function roleCards(roles) {
return (roles || [])
.map(
(role) => `
<div class="role-card">
<strong>${escapeHtml(role.name)}</strong>
<span>${(role.permissions || []).map(escapeHtml).join(" / ")}</span>
</div>
`,
)
.join("");
}
async function openSettings() {
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title"><h3>用户创建</h3><span>当前登录admin</span></div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" autocomplete="off" />
<input name="password" type="password" placeholder="初始密码" autocomplete="new-password" />
<select name="role">
<option value="阅片员">阅片员</option>
<option value="管理员">管理员</option>
<option value="访客">访客</option>
</select>
<button type="submit">创建</button>
</form>
${settingsTable(settings.users)}
</section>
<section class="settings-section">
<div class="settings-title"><h3>权限控制</h3><span>按角色控制查看、标注、AI 与系统设置</span></div>
<div class="role-grid">${roleCards(settings.roles)}</div>
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>AI 设置</h3><span>${settings.ai?.configured ? "已配置" : "未配置"}</span></div>
<dl>
<dt>供应商</dt><dd>${escapeHtml(settings.ai?.provider || "-")}</dd>
<dt>名称</dt><dd>${escapeHtml(settings.ai?.name || "-")}</dd>
<dt>模型</dt><dd>${escapeHtml(settings.ai?.model || "-")}</dd>
</dl>
</div>
<div>
<div class="settings-title"><h3>数据库</h3><span>${status.database?.ok ? "已连接" : "异常"}</span></div>
<dl>
<dt>主机</dt><dd>${escapeHtml(settings.database?.host || "-")}:${escapeHtml(settings.database?.port || "-")}</dd>
<dt>库表</dt><dd>${escapeHtml(settings.database?.database || "-")}.${escapeHtml(settings.database?.table || "-")}</dd>
<dt>DICOM</dt><dd>${escapeHtml(settings.dicom?.processed_root || "-")}</dd>
</dl>
</div>
</section>
`;
$("createUserForm").addEventListener("submit", createUser);
$("settingsModal").classList.remove("hidden");
}
async function createUser(event) {
event.preventDefault();
const button = event.currentTarget.querySelector("button");
const form = new FormData(event.currentTarget);
const payload = {
username: String(form.get("username") || "").trim(),
password: String(form.get("password") || ""),
role: String(form.get("role") || "阅片员"),
};
if (!payload.username || !payload.password) return;
button.disabled = true;
button.textContent = "创建中";
try {
await json("/api/settings/users", { method: "POST", body: JSON.stringify(payload) });
await openSettings();
} catch (err) {
button.textContent = err.message;
setTimeout(() => {
button.disabled = false;
button.textContent = "创建";
}, 1800);
}
}
function changeZoom(multiplier) {
app.zoom = clamp(app.zoom * multiplier, 0.25, 6);
applyTransform();
}
function wireImageGestures() {
const wrap = document.querySelector(".image-wrap");
document.querySelector(".slice-rail").addEventListener("pointerdown", (event) => event.stopPropagation());
wrap.addEventListener(
"wheel",
(event) => {
if (!app.activeSeries) return;
event.preventDefault();
changeZoom(event.deltaY < 0 ? 1.12 : 1 / 1.12);
},
{ passive: false },
);
wrap.addEventListener("pointerdown", (event) => {
if (!app.activeSeries) return;
app.drag = { x: event.clientX, y: event.clientY, panX: app.panX, panY: app.panY };
wrap.classList.add("dragging");
wrap.setPointerCapture(event.pointerId);
});
wrap.addEventListener("pointermove", (event) => {
if (!app.drag) return;
app.panX = app.drag.panX + event.clientX - app.drag.x;
app.panY = app.drag.panY + event.clientY - app.drag.y;
applyTransform();
});
["pointerup", "pointercancel", "pointerleave"].forEach((name) => {
wrap.addEventListener(name, () => {
app.drag = null;
wrap.classList.remove("dragging");
});
});
}
function wire() {
$("loginForm").addEventListener("submit", login);
$("logoutBtn").addEventListener("click", logout);
$("settingsBtn").addEventListener("click", openSettings);
$("infoBtn").addEventListener("click", openInfo);
$("aiClassify").addEventListener("click", runAI);
$("zoomIn").addEventListener("click", () => changeZoom(1.18));
$("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18));
$("studySearch").addEventListener("input", () => {
clearTimeout(app.searchTimer);
app.searchTimer = setTimeout(loadStudies, 250);
});
$("sliceSlider").addEventListener("input", (event) => {
app.slice = Number(event.target.value);
updateImage();
});
document.querySelectorAll("[data-plane]").forEach((button) => {
button.addEventListener("click", () => {
app.plane = button.dataset.plane;
app.slice = Math.floor(maxSlice() / 2);
document.querySelectorAll("[data-plane]").forEach((b) => b.classList.toggle("active", b === button));
updateImage();
});
});
document.querySelectorAll("[data-window]").forEach((button) => {
button.addEventListener("click", () => {
app.window = button.dataset.window;
document.querySelectorAll("[data-window]").forEach((b) => b.classList.toggle("active", b === button));
updateImage();
});
});
$("rotateLeft").addEventListener("click", () => {
app.rotate = (app.rotate - 90 + 360) % 360;
applyTransform();
});
$("rotateRight").addEventListener("click", () => {
app.rotate = (app.rotate + 90) % 360;
applyTransform();
});
$("resetView").addEventListener("click", resetViewState);
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", syncPartState));
$("saveAnnotation").addEventListener("click", saveAnnotation);
document.querySelectorAll("[data-close]").forEach((button) => {
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
});
wireImageGestures();
}
async function boot() {
await refreshStatus();
await loadStudies();
if (!app.statusTimer) app.statusTimer = setInterval(refreshStatus, 15000);
}
wire();
refreshStatus();
if (app.token) {
showLogin(false);
boot().catch(() => showLogin(true));
} else {
showLogin(true);
}

View File

@@ -0,0 +1,156 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PACS DICOM Viewer</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div id="loginOverlay" class="login-overlay">
<form id="loginForm" class="login-panel">
<div class="brand-mark">PACS</div>
<h1>DICOM 阅片标注</h1>
<label>
<span>账号</span>
<input id="username" autocomplete="username" value="admin" />
</label>
<label>
<span>密码</span>
<input id="password" type="password" autocomplete="current-password" value="123456" />
</label>
<button type="submit">登录</button>
<p id="loginError"></p>
</form>
</div>
<div class="app-shell">
<header class="topbar">
<div class="product">
<div class="logo-dot"></div>
<div>
<strong>PACS DICOM Viewer</strong>
<span id="activeStudyLabel">未选择检查</span>
</div>
</div>
<div class="top-actions">
<div id="dbStatus" class="status-pill offline">数据库</div>
<button id="settingsBtn" class="ghost-btn">设置</button>
<button id="logoutBtn" class="dark-btn">退出</button>
</div>
</header>
<main class="workspace">
<aside class="study-pane">
<div class="pane-head">
<h2>检查列表</h2>
<span id="studyCount">0</span>
</div>
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
<div id="studyList" class="study-list"></div>
</aside>
<section class="series-pane">
<div class="pane-head">
<h2>序列</h2>
<span id="seriesCount">0</span>
</div>
<div id="seriesGrid" class="series-grid"></div>
</section>
<section class="viewer-pane">
<div class="viewer-toolbar">
<div class="segmented" data-group="plane">
<button data-plane="axial" class="active">横断面</button>
<button data-plane="sagittal">矢状面</button>
<button data-plane="coronal">冠状面</button>
</div>
<div class="segmented" data-group="window">
<button data-window="default" class="active">默认</button>
<button data-window="bone">骨窗</button>
<button data-window="soft">软组织</button>
<button data-window="contrast">高对比</button>
</div>
<div class="tool-row">
<button id="rotateLeft">↶ 左转</button>
<button id="rotateRight">↷ 右转</button>
<button id="zoomOut"> 缩小</button>
<button id="zoomIn">+ 放大</button>
<button id="resetView">↺ 复位</button>
<button id="infoBtn">DICOM 信息</button>
</div>
</div>
<div class="viewer-stage">
<div class="image-wrap">
<img id="dicomImage" alt="" />
<div id="imageEmpty" class="image-empty"></div>
<div class="slice-rail">
<input id="sliceSlider" type="range" min="0" max="0" value="0" orient="vertical" />
<span id="sliceText">0 / 0</span>
</div>
</div>
<aside class="annotation-panel">
<div class="annotation-head">
<div>
<h2>部位标注</h2>
<span id="saveState">未保存</span>
</div>
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
</div>
<div class="part-grid">
<label class="skip-option"><input type="checkbox" value="skip" />略过</label>
<label><input type="checkbox" value="head_neck" />头颈部</label>
<label><input type="checkbox" value="chest" />胸部</label>
<label><input type="checkbox" value="upper_abdomen" />上腹部</label>
<label><input type="checkbox" value="lower_abdomen" />下腹部</label>
<label><input type="checkbox" value="pelvis" />盆腔</label>
</div>
<div id="phaseBox" class="phase-box">
<span>上腹部期相</span>
<div class="phase-options">
<label><input name="phase" type="radio" value="arterial" />动脉期</label>
<label><input name="phase" type="radio" value="portal_venous" />门静脉期</label>
<label><input name="phase" type="radio" value="unknown" />无法判别</label>
</div>
</div>
<div class="annotation-actions">
<input id="annotationNotes" class="note-input" placeholder="备注" />
<button id="saveAnnotation" class="primary-btn">保存标注</button>
</div>
</aside>
</div>
</section>
</main>
</div>
<div id="infoModal" class="modal hidden">
<div class="modal-card">
<div class="modal-head">
<div>
<h2>DICOM 详细信息</h2>
<span>基础元数据、图像矩阵、空间距离</span>
</div>
<button class="icon-btn" data-close="infoModal">×</button>
</div>
<div id="infoContent" class="info-grid"></div>
</div>
</div>
<div id="settingsModal" class="modal hidden">
<div class="modal-card settings-card">
<div class="modal-head">
<div>
<h2>设置</h2>
<span>用户、权限、AI 与数据源</span>
</div>
<button class="icon-btn" data-close="settingsModal">×</button>
</div>
<div id="settingsContent" class="settings-content"></div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,824 @@
:root {
color-scheme: dark;
--bg: #06080c;
--panel: #10151d;
--panel-2: #151c27;
--panel-3: #202b3a;
--stroke: #2a3444;
--stroke-strong: #3a475c;
--muted: #92a3ba;
--text: #eff5ff;
--blue: #3474f6;
--cyan: #19d4c2;
--green: #12b981;
--amber: #f0b54e;
--red: #fb7185;
--shadow: 0 18px 58px rgba(0, 0, 0, 0.38);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 1280px;
min-height: 100vh;
background:
linear-gradient(90deg, rgba(25, 212, 194, 0.05) 0 1px, transparent 1px 100%),
linear-gradient(180deg, rgba(52, 116, 246, 0.04) 0 1px, transparent 1px 100%),
#06080c;
background-size: 42px 42px;
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
}
button,
input,
select {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
button:disabled {
cursor: wait;
opacity: 0.66;
}
.login-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background: rgba(6, 8, 12, 0.86);
backdrop-filter: blur(14px);
}
.login-overlay.hidden {
display: none;
}
.login-panel {
width: 380px;
padding: 28px;
border: 1px solid var(--stroke);
border-radius: 10px;
background: linear-gradient(180deg, rgba(21, 28, 39, 0.98), rgba(8, 11, 16, 0.98));
box-shadow: var(--shadow);
}
.brand-mark {
width: 54px;
height: 54px;
display: grid;
place-items: center;
border-radius: 8px;
background: var(--blue);
font-weight: 800;
}
.login-panel h1 {
margin: 18px 0 24px;
font-size: 24px;
}
.login-panel label {
display: block;
margin-bottom: 14px;
color: var(--muted);
}
.login-panel span {
display: block;
margin-bottom: 7px;
font-size: 13px;
}
.login-panel input,
.search,
.note-input,
.settings-form input,
.settings-form select {
width: 100%;
border: 1px solid var(--stroke);
border-radius: 8px;
outline: none;
background: #080c12;
color: var(--text);
}
.login-panel input,
.search,
.note-input,
.settings-form input,
.settings-form select {
height: 38px;
padding: 0 12px;
}
.login-panel button,
.primary-btn {
height: 40px;
border-radius: 8px;
background: var(--blue);
color: white;
font-weight: 700;
}
.login-panel button,
.primary-btn {
width: 100%;
}
#loginError {
min-height: 18px;
color: var(--red);
font-size: 13px;
}
.app-shell {
min-height: 100vh;
display: grid;
grid-template-rows: 66px 1fr;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 22px;
border-bottom: 1px solid var(--stroke);
background: rgba(8, 11, 17, 0.95);
backdrop-filter: blur(12px);
}
.product {
display: flex;
align-items: center;
gap: 14px;
}
.logo-dot {
width: 34px;
height: 34px;
border-radius: 8px;
background: linear-gradient(135deg, var(--cyan), var(--blue));
}
.product strong {
display: block;
font-size: 17px;
}
.product span {
display: block;
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.status-pill {
min-width: 118px;
height: 32px;
display: grid;
place-items: center;
border: 1px solid var(--stroke);
border-radius: 999px;
color: var(--muted);
font-size: 12px;
}
.status-pill.online {
border-color: rgba(18, 185, 129, 0.35);
color: #9af4cf;
background: rgba(18, 185, 129, 0.08);
}
.status-pill.offline {
border-color: rgba(251, 113, 133, 0.35);
color: #fecdd3;
background: rgba(251, 113, 133, 0.08);
}
.ghost-btn,
.dark-btn,
.tool-row button,
.segmented button,
.icon-btn {
height: 34px;
padding: 0 13px;
border: 1px solid var(--stroke);
border-radius: 8px;
background: var(--panel-2);
color: var(--text);
}
.ghost-btn:hover,
.dark-btn:hover,
.tool-row button:hover,
.segmented button:hover {
border-color: var(--stroke-strong);
background: #1b2532;
}
.dark-btn {
background: #070a10;
}
.workspace {
height: calc(100vh - 66px);
display: grid;
grid-template-columns: 300px 450px minmax(650px, 1fr);
gap: 14px;
padding: 14px;
}
.study-pane,
.series-pane,
.viewer-pane {
min-height: 0;
border: 1px solid var(--stroke);
border-radius: 8px;
background: rgba(16, 21, 29, 0.9);
box-shadow: var(--shadow);
}
.study-pane,
.series-pane {
padding: 14px;
display: grid;
grid-template-rows: auto auto 1fr;
gap: 12px;
}
.pane-head,
.annotation-head,
.settings-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.pane-head h2,
.annotation-head h2 {
margin: 0;
font-size: 15px;
}
.pane-head span,
.annotation-head span,
.settings-title span {
color: var(--muted);
font-size: 12px;
}
.study-list,
.series-grid {
min-height: 0;
overflow: auto;
padding-right: 4px;
}
.study-card {
width: 100%;
padding: 12px;
margin-bottom: 10px;
border: 1px solid transparent;
border-radius: 8px;
background: #0b0f16;
color: var(--text);
text-align: left;
}
.study-card.active {
border-color: rgba(52, 116, 246, 0.82);
background: linear-gradient(180deg, rgba(52, 116, 246, 0.22), #0b0f16);
}
.study-card strong,
.series-card strong {
display: block;
font-size: 14px;
}
.study-card span {
display: block;
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.35;
}
.series-grid {
display: grid;
grid-template-columns: 1fr;
align-content: start;
gap: 12px;
}
.series-card {
width: 100%;
min-height: 156px;
display: grid;
grid-template-columns: 142px minmax(0, 1fr);
gap: 12px;
padding: 10px;
border: 1px solid transparent;
border-radius: 8px;
background: #0b0f16;
color: var(--text);
text-align: left;
}
.series-card.active {
border-color: rgba(25, 212, 194, 0.72);
background: #0d141c;
}
.series-card.skipped {
border-color: rgba(240, 181, 78, 0.36);
background: linear-gradient(180deg, rgba(240, 181, 78, 0.1), #0b0f16 42%);
}
.thumb {
position: relative;
width: 132px;
height: 132px;
align-self: start;
overflow: hidden;
border: 1px solid #1d2734;
border-radius: 6px;
background:
linear-gradient(45deg, rgba(255, 255, 255, 0.025) 25%, transparent 25% 75%, rgba(255, 255, 255, 0.025) 75%),
#020306;
}
.thumb img {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
.thumb b,
.thumb i {
position: absolute;
right: 8px;
padding: 3px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.74);
color: #f8fafc;
font-size: 11px;
font-style: normal;
}
.thumb b {
bottom: 8px;
}
.thumb i {
top: 8px;
background: rgba(240, 181, 78, 0.86);
color: #1b1305;
font-weight: 700;
}
.series-copy {
min-width: 0;
display: grid;
align-content: start;
gap: 7px;
}
.series-copy strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shot-time,
.series-copy small {
color: var(--muted);
font-size: 12px;
}
.tag-line {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-line em {
max-width: 100%;
padding: 3px 7px;
overflow: hidden;
border: 1px solid rgba(146, 163, 186, 0.18);
border-radius: 999px;
color: #c8d6ea;
font-size: 11px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.viewer-pane {
min-width: 0;
display: grid;
grid-template-rows: auto 1fr;
overflow: hidden;
}
.viewer-toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border-bottom: 1px solid var(--stroke);
background: rgba(8, 11, 17, 0.86);
}
.segmented {
display: flex;
gap: 5px;
padding: 5px;
border: 1px solid var(--stroke);
border-radius: 9px;
background: var(--panel);
}
.segmented button.active {
border-color: var(--blue);
background: var(--blue);
}
.tool-row {
display: flex;
gap: 6px;
margin-left: auto;
}
.viewer-stage {
min-height: 0;
display: grid;
grid-template-rows: minmax(360px, 1fr) auto;
}
.image-wrap {
position: relative;
min-height: 0;
display: grid;
place-items: center;
overflow: hidden;
background: #000;
cursor: grab;
}
.image-wrap.dragging {
cursor: grabbing;
}
#dicomImage {
max-width: calc(100% - 64px);
max-height: 100%;
object-fit: contain;
transform-origin: center center;
will-change: transform;
user-select: none;
}
.image-empty {
display: none;
}
.slice-rail {
position: absolute;
top: 16px;
right: 12px;
bottom: 16px;
width: 38px;
display: grid;
grid-template-rows: 1fr auto;
justify-items: center;
align-items: center;
padding: 10px 0;
border: 1px solid rgba(146, 163, 186, 0.24);
border-radius: 8px;
background: rgba(8, 12, 18, 0.86);
backdrop-filter: blur(6px);
}
#sliceSlider {
width: 28px;
height: 100%;
writing-mode: vertical-lr;
direction: rtl;
accent-color: var(--cyan);
}
#sliceText {
margin-top: 8px;
color: var(--muted);
font-size: 11px;
writing-mode: vertical-rl;
}
.annotation-panel {
min-width: 0;
padding: 12px;
border-top: 1px solid var(--stroke);
background: var(--panel);
}
.annotation-head {
margin-bottom: 10px;
}
.ai-btn {
min-width: 86px;
color: #baf8ee;
}
.part-grid {
display: grid;
grid-template-columns: repeat(6, minmax(82px, 1fr));
gap: 8px;
}
.part-grid label,
.phase-options label {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 34px;
padding: 0 10px;
border: 1px solid var(--stroke);
border-radius: 8px;
color: var(--text);
background: #0b0f16;
white-space: nowrap;
}
.part-grid label:has(input:checked),
.phase-options label:has(input:checked) {
border-color: rgba(25, 212, 194, 0.58);
background: rgba(25, 212, 194, 0.1);
}
.part-grid .skip-option:has(input:checked) {
border-color: rgba(240, 181, 78, 0.72);
background: rgba(240, 181, 78, 0.12);
}
.part-grid input:disabled + * {
color: var(--muted);
}
.phase-box {
display: none;
margin-top: 10px;
}
.phase-box.visible {
display: grid;
grid-template-columns: 96px 1fr;
align-items: center;
gap: 10px;
}
.phase-box > span {
color: var(--muted);
font-size: 12px;
}
.phase-options {
display: grid;
grid-template-columns: repeat(3, minmax(110px, 1fr));
gap: 8px;
}
.annotation-actions {
display: grid;
grid-template-columns: minmax(220px, 1fr) 160px;
gap: 10px;
margin-top: 10px;
}
.note-input {
height: 38px;
}
.modal {
position: fixed;
inset: 0;
z-index: 12;
display: grid;
place-items: center;
background: rgba(6, 8, 12, 0.68);
backdrop-filter: blur(12px);
}
.modal.hidden {
display: none;
}
.modal-card {
width: min(900px, 86vw);
max-height: 82vh;
overflow: auto;
border: 1px solid #d7e1ef;
border-radius: 10px;
background: #f8fafc;
color: #132033;
box-shadow: var(--shadow);
}
.settings-card {
width: min(980px, 90vw);
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 24px;
border-bottom: 1px solid #e2e8f0;
}
.modal-head h2 {
margin: 0 0 4px;
font-size: 18px;
}
.modal-head span {
color: #7890ad;
font-size: 13px;
}
.icon-btn {
width: 36px;
padding: 0;
background: #eef2f7;
color: #18304f;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
padding: 24px;
}
.info-card,
.settings-section {
padding: 16px;
border-radius: 8px;
background: #f1f5f9;
}
.info-card h3,
.settings-title h3 {
margin: 0 0 12px;
font-size: 14px;
}
.info-row {
display: flex;
justify-content: space-between;
gap: 14px;
padding: 5px 0;
color: #7b8da7;
font-size: 13px;
}
.info-row b {
color: #14233a;
text-align: right;
overflow-wrap: anywhere;
}
.settings-content {
display: grid;
gap: 14px;
padding: 24px;
}
.settings-section.split {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.settings-form {
display: grid;
grid-template-columns: 1fr 1fr 140px 88px;
gap: 8px;
margin: 10px 0 14px;
}
.settings-form input,
.settings-form select {
border-color: #cdd8e7;
background: white;
color: #132033;
}
.settings-form button {
border-radius: 8px;
background: #1d5ff0;
color: white;
font-weight: 700;
}
.settings-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 8px;
background: white;
font-size: 13px;
}
.settings-table th,
.settings-table td {
padding: 9px 10px;
border-bottom: 1px solid #e5edf7;
text-align: left;
}
.settings-table th {
color: #6f819a;
font-weight: 700;
}
.role-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.role-card {
min-height: 78px;
padding: 12px;
border: 1px solid #dce5f1;
border-radius: 8px;
background: white;
}
.role-card strong {
display: block;
margin-bottom: 8px;
}
.role-card span,
.settings-section dd,
.settings-section dt {
color: #6f819a;
font-size: 13px;
}
.settings-section dl {
display: grid;
grid-template-columns: 70px minmax(0, 1fr);
gap: 8px 12px;
margin: 12px 0 0;
}
.settings-section dt,
.settings-section dd {
margin: 0;
}
.settings-section dd {
color: #17263d;
overflow-wrap: anywhere;
}
.error-line {
color: var(--red);
font-size: 13px;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
border: 3px solid transparent;
border-radius: 999px;
background: var(--panel-3);
background-clip: padding-box;
}