未选择 CT
+从左侧选择一个 DICOM + STL 匹配检查
+diff --git a/DICOM_and_UPP配准/.dockerignore b/DICOM_and_UPP配准/.dockerignore new file mode 100644 index 0000000..06c00d0 --- /dev/null +++ b/DICOM_and_UPP配准/.dockerignore @@ -0,0 +1,4 @@ +.env +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/DICOM_and_UPP配准/.env.example b/DICOM_and_UPP配准/.env.example new file mode 100644 index 0000000..c4a9410 --- /dev/null +++ b/DICOM_and_UPP配准/.env.example @@ -0,0 +1,18 @@ +PGHOST=192.168.3.3 +PGPORT=5432 +PGUSER=his_user +PGPASSWORD=change_me +PGDATABASE=pacs_db + +PACS_TABLE=pacs_dicom_files +PACS_SUMMARY_TABLE=pacs_dicom_study_summaries +PACS_ANNOTATION_TABLE=pacs_dicom_series_annotations +UPP_ASSET_TABLE=upp_exam_assets +UPP_STL_TABLE=upp_stl_files +REGISTRATION_TABLE=dicom_upp_registrations + +REGISTRATION_WEB_USER=admin +REGISTRATION_WEB_PASSWORD=123456 +PACS_VIEWER_URL=http://127.0.0.1:8107 +RELATION_VIEWER_URL=http://127.0.0.1:8108 +PACS_PROCESSED_ROOT=/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据 diff --git a/DICOM_and_UPP配准/Dockerfile b/DICOM_and_UPP配准/Dockerfile new file mode 100644 index 0000000..6405774 --- /dev/null +++ b/DICOM_and_UPP配准/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY . /app +EXPOSE 8109 + +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8109"] diff --git a/DICOM_and_UPP配准/README.md b/DICOM_and_UPP配准/README.md new file mode 100644 index 0000000..3c6152c --- /dev/null +++ b/DICOM_and_UPP配准/README.md @@ -0,0 +1,21 @@ +# DICOM_and_UPP 配准工作台 + +用于在浏览器中人工核对 DICOM 序列与 UPP STL 模型的配准关系。页面会读取 PACS DICOM、UPP STL 资产和 DICOM 阅片分类结果,配准位姿单独写入 `dicom_upp_registrations` 表。 + +默认端口: + +- 本机:`http://127.0.0.1:8109/` +- 局域网:`http://192.168.3.11:8109/` + +登录: + +- 账号:`admin` +- 密码:`123456` + +启动: + +```bash +docker compose up -d --build +``` + +当前保存内容包括 CT 号、DICOM 序列 UID、重建算法模型、STL family/文件选择、位姿 transform、可选分割/备注、锁定状态和更新人。 diff --git a/DICOM_and_UPP配准/app.py b/DICOM_and_UPP配准/app.py new file mode 100644 index 0000000..2a1d3c6 --- /dev/null +++ b/DICOM_and_UPP配准/app.py @@ -0,0 +1,1059 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import io +import json +import os +import re +import secrets +import subprocess +import tempfile +import time +import zipfile +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 +from starlette.background import BackgroundTask + + +APP_DIR = Path(__file__).resolve().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") +PACS_TABLE = os.getenv("PACS_TABLE", "pacs_dicom_files") +PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries") +PACS_ANNOTATION_TABLE = os.getenv("PACS_ANNOTATION_TABLE", "pacs_dicom_series_annotations") +UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets") +UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files") +REGISTRATION_TABLE = os.getenv("REGISTRATION_TABLE", "dicom_upp_registrations") +WEB_USER = os.getenv("REGISTRATION_WEB_USER", "admin") +WEB_PASSWORD = os.getenv("REGISTRATION_WEB_PASSWORD", "123456") +PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107") +RELATION_VIEWER_URL = os.getenv("RELATION_VIEWER_URL", "http://127.0.0.1:8108") +PROCESSED_ROOT = Path(os.getenv("PACS_PROCESSED_ROOT", "/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据")) + +IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +WINDOWS = { + "default": None, + "bone": (500.0, 1800.0), + "soft": (50.0, 360.0), + "contrast": (90.0, 140.0), +} +DEFAULT_POSE = { + "translateX": 0.0, + "translateY": 0.0, + "translateZ": 0.0, + "rotateX": 0.0, + "rotateY": 0.0, + "rotateZ": 0.0, + "scale": 1.0, + "flipX": False, + "flipY": False, + "flipZ": False, +} + + +def ident(name: str) -> str: + if not IDENT_RE.fullmatch(name): + raise RuntimeError(f"invalid SQL identifier: {name}") + return name + + +PACS_TABLE_SQL = ident(PACS_TABLE) +PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE) +PACS_ANNOTATION_TABLE_SQL = ident(PACS_ANNOTATION_TABLE) +UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE) +UPP_STL_TABLE_SQL = ident(UPP_STL_TABLE) +REGISTRATION_TABLE_SQL = ident(REGISTRATION_TABLE) + +app = FastAPI(title="DICOM and UPP Registration") +app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") +TOKENS: dict[str, str] = {} +STUDY_CACHE: dict[str, dict[str, Any]] = {} +STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} + + +class LoginPayload(BaseModel): + username: str + password: str + + +class RegistrationPayload(BaseModel): + ct_number: str + series_instance_uid: str = "" + series_description: str = "" + algorithm_model: str = "" + stl_family: str = "" + view_mode: str = "family" + selected_stl_files: list[dict[str, Any]] = [] + transform: dict[str, Any] = {} + dicom_reference: dict[str, Any] = {} + model_reference: dict[str, Any] = {} + segmentation: dict[str, Any] = {} + notes: str = "" + locked: bool = False + force_unlock: bool = False + + +class ExportPayload(BaseModel): + ct_number: str + mode: str = "family" + file_ids: list[int] = [] + families: list[str] = [] + include_pose: bool = True + transform: dict[str, Any] = {} + label: str = "" + + +def pg_env() -> dict[str, str]: + env = os.environ.copy() + env.update({"PGHOST": PGHOST, "PGPORT": PGPORT, "PGUSER": PGUSER, "PGDATABASE": PGDATABASE}) + if os.getenv("PGPASSWORD"): + env["PGPASSWORD"] = os.environ["PGPASSWORD"] + return env + + +def run_psql(sql: str, timeout: int = 20) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["psql", "-X", "-q", "-t", "-A", "-v", "ON_ERROR_STOP=1", "-c", sql], + text=True, + capture_output=True, + timeout=timeout, + env=pg_env(), + ) + + +def pg_scalar(sql: str, timeout: int = 20) -> 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 = 24) -> 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 sql_literal(value: Any) -> str: + if value is None: + return "NULL" + return "'" + str(value).replace("'", "''") + "'" + + +def json_sql(value: Any) -> str: + return f"{sql_literal(json.dumps(value, ensure_ascii=False, separators=(',', ':')))}::jsonb" + + +def normalize_ct(value: Any) -> str: + return re.sub(r"\s+", "", str(value or "")).upper() + + +def ensure_registration_table() -> None: + pg_scalar( + f""" + CREATE TABLE IF NOT EXISTS public.{REGISTRATION_TABLE_SQL} ( + id bigserial PRIMARY KEY, + ct_number text NOT NULL, + series_instance_uid text NOT NULL DEFAULT '', + series_description text NOT NULL DEFAULT '', + algorithm_model text NOT NULL DEFAULT '', + stl_family text NOT NULL DEFAULT '', + view_mode text NOT NULL DEFAULT 'family', + selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb, + transform jsonb NOT NULL DEFAULT '{{}}'::jsonb, + dicom_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb, + model_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb, + segmentation jsonb NOT NULL DEFAULT '{{}}'::jsonb, + notes text NOT NULL DEFAULT '', + locked boolean NOT NULL DEFAULT false, + locked_by text, + locked_at timestamptz, + updated_by text NOT NULL DEFAULT 'admin', + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (ct_number, series_instance_uid, algorithm_model, stl_family) + ); + CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_ct ON public.{REGISTRATION_TABLE_SQL}(ct_number); + CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_locked ON public.{REGISTRATION_TABLE_SQL}(locked); + """, + timeout=12, + ) + + +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 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.on_event("startup") +def startup() -> None: + try: + ensure_registration_table() + except Exception: + pass + + +@app.get("/") +def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + +@app.get("/health") +def health() -> str: + return "ok" + + +@app.post("/api/auth/login") +def login(payload: LoginPayload) -> dict[str, str]: + username = payload.username.strip() + if username != WEB_USER or payload.password != WEB_PASSWORD: + raise HTTPException(status_code=401, detail="用户名或密码错误") + token = secrets.token_urlsafe(32) + TOKENS[token] = username + return {"token": token, "username": username, "role": "管理员"} + + +@app.get("/api/auth/me") +def me(user: str = Depends(require_auth)) -> dict[str, str]: + return {"username": user, "role": "管理员"} + + +@app.get("/api/status") +def status(_: str = Depends(require_auth)) -> dict[str, Any]: + ok, message = db_available() + registration_count = None + locked_count = None + if ok: + try: + ensure_registration_table() + rows = pg_json_rows( + f""" + SELECT count(*)::int AS registration_count, + count(*) FILTER (WHERE locked)::int AS locked_count + FROM public.{REGISTRATION_TABLE_SQL} + """, + timeout=8, + ) + if rows: + registration_count = rows[0].get("registration_count") + locked_count = rows[0].get("locked_count") + except Exception: + pass + return { + "database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE}, + "registration": {"table": REGISTRATION_TABLE, "count": registration_count, "locked": locked_count}, + "links": {"pacs_viewer_url": PACS_VIEWER_URL, "relation_viewer_url": RELATION_VIEWER_URL}, + "dicom": {"processed_root": str(PROCESSED_ROOT), "exists": PROCESSED_ROOT.exists()}, + "server_time": time.strftime("%Y-%m-%d %H:%M:%S"), + } + + +def relation_base_cte() -> str: + return f""" + WITH + p AS ( + SELECT p.*, upper(p.ct_number) AS ct_key + FROM public.{PACS_TABLE_SQL} p + ), + ps AS ( + SELECT * + FROM public.{PACS_SUMMARY_TABLE_SQL} + ), + a AS ( + SELECT + ct_number, + count(DISTINCT series_instance_uid)::int AS annotated_series, + count(DISTINCT series_instance_uid) FILTER ( + WHERE skipped IS NOT TRUE + AND ( + (body_parts ? 'upper_abdomen' AND COALESCE(NULLIF(upper_abdomen_phase, ''), 'unknown') = 'unknown') + OR + (body_parts ? 'chest' AND COALESCE(NULLIF(chest_window, ''), 'unknown') = 'unknown') + ) + )::int AS undetermined_series, + COALESCE(jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_parts + FROM public.{PACS_ANNOTATION_TABLE_SQL} + LEFT JOIN LATERAL jsonb_array_elements_text(body_parts) AS part(value) ON true + GROUP BY ct_number + ), + u AS ( + SELECT + upper(ct_number) AS ct_key, + (array_agg(ct_number ORDER BY updated_at DESC NULLS LAST))[1] AS ct_number, + bool_or(COALESCE(stl_present, false)) AS stl_present, + (array_agg(patient_name ORDER BY updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(patient_name, '') IS NOT NULL))[1] AS patient_name, + array_to_string(array_remove(array_agg(DISTINCT NULLIF(algorithm_model, '')), NULL), '、') AS algorithm_model, + COALESCE(sum(COALESCE(stl_file_count, 0)), 0)::int AS stl_file_count, + COALESCE(sum(COALESCE(stl_total_bytes, 0)), 0)::bigint AS stl_total_bytes, + max(updated_at) AS updated_at + FROM public.{UPP_ASSET_TABLE_SQL} + GROUP BY upper(ct_number) + ), + s AS ( + SELECT + upper(ct_number) AS ct_key, + (array_agg(ct_number ORDER BY updated_at DESC NULLS LAST))[1] AS ct_number, + COALESCE(sum(COALESCE(file_count, 0)), 0)::int AS file_count, + COALESCE(sum(COALESCE(total_bytes, 0)), 0)::bigint AS total_bytes, + COALESCE(jsonb_agg(DISTINCT family.value) FILTER (WHERE family.value IS NOT NULL), '[]'::jsonb) AS segment_families, + COALESCE(jsonb_agg(DISTINCT category.value) FILTER (WHERE category.value IS NOT NULL), '[]'::jsonb) AS segment_categories, + max(updated_at) AS updated_at + FROM public.{UPP_STL_TABLE_SQL} + LEFT JOIN LATERAL jsonb_array_elements_text(segment_families) AS family(value) ON true + LEFT JOIN LATERAL jsonb_array_elements_text(segment_categories) AS category(value) ON true + GROUP BY upper(ct_number) + ), + r AS ( + SELECT + upper(ct_number) AS ct_key, + count(*)::int AS registration_count, + count(DISTINCT series_instance_uid) FILTER (WHERE series_instance_uid <> '')::int AS registered_series, + count(*) FILTER (WHERE locked)::int AS locked_count, + max(updated_at) AS registration_updated_at + FROM public.{REGISTRATION_TABLE_SQL} + GROUP BY upper(ct_number) + ), + relation AS ( + SELECT + p.ct_key, + p.ct_number AS pacs_ct_number, + COALESCE(s.ct_number, u.ct_number) AS stl_ct_number, + p.source_patient_name, + p.patient_name_dicom, + COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) AS patient_name, + p.patient_id, + p.study_date, + p.study_time, + p.study_description, + p.series_count, + p.dicom_file_count, + p.processed_path, + p.updated_at AS pacs_updated_at, + COALESCE(ps.completed, false) AS completed, + COALESCE(ps.annotated_series, a.annotated_series, 0)::int AS annotated_series, + COALESCE(ps.undetermined_series, a.undetermined_series, 0)::int AS undetermined_series, + COALESCE(ps.body_parts, a.body_parts, '[]'::jsonb) AS body_parts, + u.patient_name AS upp_patient_name, + u.algorithm_model, + COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present, + COALESCE(u.stl_file_count, s.file_count, 0)::int AS stl_file_count, + COALESCE(u.stl_total_bytes, s.total_bytes, 0)::bigint AS stl_total_bytes, + COALESCE(s.segment_families, '[]'::jsonb) AS segment_families, + COALESCE(s.segment_categories, '[]'::jsonb) AS segment_categories, + COALESCE(r.registration_count, 0)::int AS registration_count, + COALESCE(r.registered_series, 0)::int AS registered_series, + COALESCE(r.locked_count, 0)::int AS locked_count, + r.registration_updated_at + FROM p + LEFT JOIN ps ON ps.ct_number = p.ct_number + LEFT JOIN a ON a.ct_number = p.ct_number + LEFT JOIN u ON u.ct_key = p.ct_key + LEFT JOIN s ON s.ct_key = p.ct_key + LEFT JOIN r ON r.ct_key = p.ct_key + ) + """ + + +@app.get("/api/cases") +def cases(q: str = "", limit: int = Query(default=80, ge=1, le=300), _: str = Depends(require_auth)) -> list[dict[str, Any]]: + ensure_registration_table() + clauses = ["stl_present IS TRUE"] + if q.strip(): + like = "%" + q.strip().replace("%", "").replace("_", "") + "%" + clauses.append( + "(" + + " OR ".join( + [ + f"ct_key ILIKE {sql_literal(like)}", + f"pacs_ct_number ILIKE {sql_literal(like)}", + f"patient_name ILIKE {sql_literal(like)}", + f"patient_id ILIKE {sql_literal(like)}", + f"algorithm_model ILIKE {sql_literal(like)}", + f"study_description ILIKE {sql_literal(like)}", + ] + ) + + ")" + ) + where = "WHERE " + " AND ".join(clauses) + return pg_json_rows( + f""" + {relation_base_cte()} + SELECT * + FROM relation + {where} + ORDER BY + locked_count DESC, + registration_count DESC, + COALESCE(study_date, '') DESC, + COALESCE(study_time, '') DESC, + ct_key + LIMIT {int(limit)} + """, + timeout=24, + ) + + +@app.get("/api/cases/{ct_number}") +def case_detail(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: + ensure_registration_table() + ct_key = normalize_ct(ct_number) + rows = pg_json_rows( + f""" + {relation_base_cte()} + SELECT * + FROM relation + WHERE ct_key = {sql_literal(ct_key)} + LIMIT 1 + """, + timeout=24, + ) + if not rows: + raise HTTPException(status_code=404, detail="CT not found") + return rows[0] + + +def get_study_record(ct_number: str) -> dict[str, Any]: + rows = pg_json_rows( + f""" + SELECT * + FROM public.{PACS_TABLE_SQL} + WHERE upper(ct_number) = {sql_literal(normalize_ct(ct_number))} + LIMIT 1 + """, + timeout=12, + ) + 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 and PROCESSED_ROOT.exists(): + direct = list(PROCESSED_ROOT.glob(f"*/{target_folder}")) + if direct: + return direct[0] + recursive = next(PROCESSED_ROOT.rglob(target_folder), None) + if recursive: + return recursive + ct_number = str(study.get("ct_number") or "") + if ct_number and PROCESSED_ROOT.exists(): + recursive = next(PROCESSED_ROOT.rglob(f"{ct_number}-*"), None) + if recursive: + return recursive + return root + + +def read_header(path: Path) -> dict[str, str]: + tags = [ + "SeriesInstanceUID", + "StudyInstanceUID", + "AccessionNumber", + "PatientName", + "PatientID", + "PatientBirthDate", + "PatientSex", + "InstitutionName", + "StudyDate", + "StudyTime", + "SeriesNumber", + "SeriesDescription", + "InstanceNumber", + "SliceLocation", + "ImagePositionPatient", + "AcquisitionTime", + "ContentTime", + "SeriesTime", + "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 numeric(value: Any, fallback: float = 0.0) -> float: + try: + return float(str(value).strip()) + except Exception: + return fallback + + +def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]: + path, meta = item + instance = numeric(meta.get("InstanceNumber"), 0.0) + z = 0.0 + position = meta.get("ImagePositionPatient", "") + if position: + try: + z = float(str(position).strip("[]").split(",")[-1]) + except Exception: + z = 0.0 + if meta.get("SliceLocation"): + z = numeric(meta.get("SliceLocation"), z) + return (z, instance, str(path)) + + +def parse_json_list(value: Any) -> list[Any]: + if isinstance(value, list): + return value + if isinstance(value, str) and value: + try: + parsed = json.loads(value) + return parsed if isinstance(parsed, list) else [] + except Exception: + return [] + return [] + + +def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: + try: + rows = pg_json_rows( + f""" + SELECT * + FROM public.{PACS_ANNOTATION_TABLE_SQL} + WHERE upper(ct_number) = {sql_literal(normalize_ct(ct_number))} + """, + timeout=12, + ) + except Exception: + return {} + return {str(row.get("series_instance_uid") or ""): row for row in rows} + + +def annotation_labels(annotation: dict[str, Any]) -> list[str]: + if not annotation: + return [] + if annotation.get("skipped"): + return ["略过/不采用"] + labels: list[str] = [] + parts = parse_json_list(annotation.get("body_parts")) + for part in parts: + if part == "head_neck": + labels.append("头颈部") + elif part == "chest": + window = annotation.get("chest_window") or "unknown" + labels.append("胸部" if not window or window == "unknown" else f"胸部-{'肺窗' if window == 'lung' else '纵隔窗'}") + elif part == "upper_abdomen": + phase = annotation.get("upper_abdomen_phase") or "unknown" + phase_label = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(phase, phase) + labels.append(f"上腹部-{phase_label}") + elif part == "lower_abdomen": + labels.append("下腹部") + elif part == "pelvis": + labels.append("盆腔") + return labels + + +def registration_map(ct_number: str) -> dict[str, dict[str, Any]]: + ensure_registration_table() + rows = pg_json_rows( + f""" + SELECT * + FROM public.{REGISTRATION_TABLE_SQL} + WHERE upper(ct_number) = {sql_literal(normalize_ct(ct_number))} + ORDER BY updated_at DESC + """, + timeout=12, + ) + mapped: dict[str, dict[str, Any]] = {} + for row in rows: + uid = str(row.get("series_instance_uid") or "") + existing = mapped.get(uid) + if not existing: + mapped[uid] = { + "registered": True, + "locked": bool(row.get("locked")), + "count": 1, + "latest_updated_at": row.get("updated_at", ""), + "labels": [row.get("stl_family") or row.get("algorithm_model") or "配准"], + } + else: + existing["count"] += 1 + existing["locked"] = existing["locked"] or bool(row.get("locked")) + label = row.get("stl_family") or row.get("algorithm_model") or "配准" + if label not in existing["labels"]: + existing["labels"].append(label) + return mapped + + +def scan_study(ct_number: str) -> dict[str, Any]: + ct_key = normalize_ct(ct_number) + cached = STUDY_CACHE.get(ct_key) + 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) + reg_map = registration_map(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] + annotation = annotations.get(uid) or {} + file_map[uid] = [path for path, _ in items] + series_list.append( + { + "ct_number": study.get("ct_number") or 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_date": first.get("StudyDate", "") or study.get("study_date", ""), + "study_time": first.get("StudyTime", "") or study.get("study_time", ""), + "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", ""), + "rows": first.get("Rows", ""), + "columns": first.get("Columns", ""), + "pixel_spacing": first.get("PixelSpacing", ""), + "slice_thickness": first.get("SliceThickness", ""), + "spacing_between_slices": first.get("SpacingBetweenSlices", ""), + "window_center": first.get("WindowCenter", ""), + "window_width": first.get("WindowWidth", ""), + "annotation": { + "labels": annotation_labels(annotation), + "raw": annotation, + }, + "registration": reg_map.get(uid, {"registered": False, "locked": False, "count": 0, "labels": []}), + } + ) + series_list.sort(key=lambda row: (str(row.get("first_time") or row.get("series_time") or ""), numeric(row.get("series_number"), 999999), row.get("description", ""))) + payload = {"cached_at": time.time(), "study": study, "root": str(root), "series": series_list, "files": file_map} + STUDY_CACHE[ct_key] = payload + return payload + + +@app.get("/api/cases/{ct_number}/series") +def series(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: + data = scan_study(ct_number) + return {"study": data["study"], "root": data["root"], "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 pixel_spacing_from_ds(ds: pydicom.Dataset) -> tuple[float, float]: + spacing = getattr(ds, "PixelSpacing", None) + if spacing and len(spacing) >= 2: + row_spacing = max(numeric(spacing[0], 1.0), 0.001) + col_spacing = max(numeric(spacing[1], row_spacing), 0.001) + return row_spacing, col_spacing + return 1.0, 1.0 + + +def slice_spacing_from_datasets(datasets: list[pydicom.Dataset]) -> float: + distances = [] + positions = [] + for ds in datasets: + position = getattr(ds, "ImagePositionPatient", None) + if position and len(position) >= 3: + positions.append(np.array([numeric(position[0]), numeric(position[1]), numeric(position[2])], dtype=np.float32)) + for first, second in zip(positions, positions[1:]): + distance = float(np.linalg.norm(second - first)) + if distance > 0.001: + distances.append(distance) + if distances: + return max(float(np.median(distances)), 0.001) + sample = datasets[0] if datasets else None + if sample is not None: + spacing = numeric(getattr(sample, "SpacingBetweenSlices", 0), 0.0) + if spacing > 0.001: + return spacing + thickness = numeric(getattr(sample, "SliceThickness", 0), 0.0) + if thickness > 0.001: + return thickness + return 1.0 + + +def resize_for_spacing(pil: Image.Image, row_spacing: float, col_spacing: float) -> Image.Image: + if abs(row_spacing - col_spacing) < 0.01: + return pil + base = min(row_spacing, col_spacing) + target_w = max(1, int(round(pil.width * col_spacing / base))) + target_h = max(1, int(round(pil.height * row_spacing / base))) + scale = min(1.0, 2200 / max(target_w, target_h)) + target_size = (max(1, int(round(target_w * scale))), max(1, int(round(target_h * scale)))) + return pil if target_size == pil.size else pil.resize(target_size, Image.Resampling.BILINEAR) + + +def render_array(arr: np.ndarray, center: float, width: float, invert: bool = False, max_size: int = 1100, pixel_spacing: tuple[float, float] = (1.0, 1.0)) -> bytes: + low = center - width / 2.0 + high = center + width / 2.0 + img = ((np.clip(arr, low, high) - low) / max(high - low, 1.0) * 255.0).astype(np.uint8) + if invert: + img = 255 - img + pil = Image.fromarray(img) + pil = resize_for_spacing(pil, pixel_spacing[0], pixel_spacing[1]) + if max(pil.size) > max_size: + pil.thumbnail((max_size, max_size), Image.Resampling.BILINEAR) + output = io.BytesIO() + pil.save(output, format="PNG", optimize=True) + return output.getvalue() + + +def load_stack_data(ct_number: str, series_uid: str) -> dict[str, Any]: + key = f"{normalize_ct(ct_number)}|{series_uid}" + cached = STACK_CACHE.get(key) + if cached: + STACK_CACHE[key] = (time.time(), cached[1]) + return cached[1] + files = get_series_files(ct_number, series_uid) + arrays = [] + datasets = [] + for path in files: + ds = pydicom.dcmread(str(path), force=True) + datasets.append(ds) + arrays.append(dicom_to_hu(ds)) + stack = np.stack(arrays, axis=0) + row_spacing, col_spacing = pixel_spacing_from_ds(datasets[min(len(datasets) - 1, len(datasets) // 2)]) + payload = {"stack": stack, "row_spacing": row_spacing, "col_spacing": col_spacing, "slice_spacing": slice_spacing_from_datasets(datasets)} + STACK_CACHE[key] = (time.time(), payload) + if len(STACK_CACHE) > 2: + oldest = sorted(STACK_CACHE.items(), key=lambda item: item[1][0])[0][0] + STACK_CACHE.pop(oldest, None) + return payload + + +@app.get("/api/image") +def image( + ct_number: str, + series_uid: str, + index: int = 0, + plane: str = "axial", + window: str = "default", + _: 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", pixel_spacing=pixel_spacing_from_ds(ds)) + return Response(payload, media_type="image/png") + stack_data = load_stack_data(ct_number, series_uid) + stack = stack_data["stack"] + sample_ds = pydicom.dcmread(str(files[min(len(files) - 1, len(files) // 2)]), stop_before_pixels=True, force=True) + center, width = window_values(sample_ds, window) + if plane == "coronal": + index = min(index, stack.shape[1] - 1) + arr = stack[:, index, :] + spacing = (stack_data["slice_spacing"], stack_data["col_spacing"]) + elif plane == "sagittal": + index = min(index, stack.shape[2] - 1) + arr = stack[:, :, index] + spacing = (stack_data["slice_spacing"], stack_data["row_spacing"]) + else: + raise HTTPException(status_code=400, detail="invalid plane") + payload = render_array(np.flipud(arr), center, width, False, pixel_spacing=spacing) + return Response(payload, media_type="image/png") + + +def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]: + rows = pg_json_rows( + f""" + SELECT + COALESCE(u.algorithm_model, '未指定模型') AS algorithm_model, + u.processed_stl_dir, + COALESCE(s.files, u.stl_files, '[]'::jsonb) AS files + FROM public.{UPP_ASSET_TABLE_SQL} u + LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(u.ct_number) + WHERE upper(u.ct_number) = {sql_literal(normalize_ct(ct_number))} + ORDER BY u.updated_at DESC NULLS LAST + """, + timeout=14, + ) + output: list[dict[str, Any]] = [] + index = 0 + for asset_index, row in enumerate(rows): + files = parse_json_list(row.get("files")) + if not files and row.get("processed_stl_dir"): + base = Path(row["processed_stl_dir"]) + if base.exists(): + files = [ + { + "file_name": item.name, + "segment_name": item.stem, + "family": item.stem, + "category": "未分类", + "processed_file_path": str(item), + "size_bytes": item.stat().st_size, + } + for item in sorted(base.glob("*.stl")) + ] + for file_info in files: + path = file_info.get("processed_file_path") or file_info.get("source_file_path") + if not path: + continue + output.append( + { + "id": index, + "asset_id": asset_index, + "algorithm_model": row.get("algorithm_model") or "未指定模型", + "file_name": file_info.get("file_name") or Path(path).name, + "segment_name": file_info.get("segment_name") or Path(path).stem, + "family": file_info.get("family") or file_info.get("segment_name") or Path(path).stem, + "category": file_info.get("category") or "未分类", + "size_bytes": int(file_info.get("size_bytes") or 0), + "path": path, + "visible": True, + } + ) + index += 1 + return output + + +@app.get("/api/cases/{ct_number}/stl") +def stl_assets(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: + files = stl_rows_for_ct(ct_number) + families = sorted({str(item.get("family") or "") for item in files if item.get("family")}) + categories = sorted({str(item.get("category") or "") for item in files if item.get("category")}) + models = sorted({str(item.get("algorithm_model") or "") for item in files if item.get("algorithm_model")}) + return {"ct_number": ct_number, "files": files, "families": families, "categories": categories, "algorithm_models": models} + + +@app.get("/api/stl") +def stl_file(ct_number: str, file_id: int, _: str = Depends(require_auth)) -> FileResponse: + files = stl_rows_for_ct(ct_number) + selected = next((item for item in files if int(item["id"]) == int(file_id)), None) + if not selected: + raise HTTPException(status_code=404, detail="STL file not found") + path = Path(str(selected["path"])) + if not path.exists() or not path.is_file(): + raise HTTPException(status_code=404, detail=f"STL path not found: {path}") + return FileResponse(path, media_type="model/stl", filename=path.name) + + +@app.get("/api/registrations/{ct_number}") +def registrations(ct_number: str, _: str = Depends(require_auth)) -> dict[str, Any]: + ensure_registration_table() + rows = pg_json_rows( + f""" + SELECT * + FROM public.{REGISTRATION_TABLE_SQL} + WHERE upper(ct_number) = {sql_literal(normalize_ct(ct_number))} + ORDER BY updated_at DESC + """, + timeout=12, + ) + return {"ct_number": ct_number, "registrations": rows} + + +@app.post("/api/registrations") +def save_registration(payload: RegistrationPayload, user: str = Depends(require_auth)) -> dict[str, Any]: + ensure_registration_table() + ct_number = normalize_ct(payload.ct_number) + if not ct_number: + raise HTTPException(status_code=400, detail="CT号不能为空") + series_uid = payload.series_instance_uid.strip() + algorithm_model = payload.algorithm_model.strip() or "未指定模型" + stl_family = payload.stl_family.strip() or "全部STL" + existing = pg_json_rows( + f""" + SELECT locked + FROM public.{REGISTRATION_TABLE_SQL} + WHERE ct_number = {sql_literal(ct_number)} + AND series_instance_uid = {sql_literal(series_uid)} + AND algorithm_model = {sql_literal(algorithm_model)} + AND stl_family = {sql_literal(stl_family)} + LIMIT 1 + """, + timeout=8, + ) + was_locked = bool(existing and existing[0].get("locked")) + if was_locked and not payload.force_unlock and not payload.locked: + raise HTTPException(status_code=423, detail="该配准结果已锁定,请先解锁") + locked_by_sql = sql_literal(user) if payload.locked else "NULL" + locked_at_sql = "now()" if payload.locked else "NULL" + transform = {**DEFAULT_POSE, **(payload.transform or {})} + pg_scalar( + f""" + INSERT INTO public.{REGISTRATION_TABLE_SQL} ( + ct_number, series_instance_uid, series_description, algorithm_model, stl_family, + view_mode, selected_stl_files, transform, dicom_reference, model_reference, + segmentation, notes, locked, locked_by, locked_at, updated_by, updated_at + ) + VALUES ( + {sql_literal(ct_number)}, + {sql_literal(series_uid)}, + {sql_literal(payload.series_description.strip())}, + {sql_literal(algorithm_model)}, + {sql_literal(stl_family)}, + {sql_literal(payload.view_mode.strip() or 'family')}, + {json_sql(payload.selected_stl_files)}, + {json_sql(transform)}, + {json_sql(payload.dicom_reference)}, + {json_sql(payload.model_reference)}, + {json_sql(payload.segmentation)}, + {sql_literal(payload.notes.strip())}, + {'true' if payload.locked else 'false'}, + {locked_by_sql}, + {locked_at_sql}, + {sql_literal(user)}, + now() + ) + ON CONFLICT (ct_number, series_instance_uid, algorithm_model, stl_family) DO UPDATE SET + series_description = EXCLUDED.series_description, + view_mode = EXCLUDED.view_mode, + selected_stl_files = EXCLUDED.selected_stl_files, + transform = EXCLUDED.transform, + dicom_reference = EXCLUDED.dicom_reference, + model_reference = EXCLUDED.model_reference, + segmentation = EXCLUDED.segmentation, + notes = EXCLUDED.notes, + locked = EXCLUDED.locked, + locked_by = EXCLUDED.locked_by, + locked_at = EXCLUDED.locked_at, + updated_by = EXCLUDED.updated_by, + updated_at = now() + """, + timeout=12, + ) + STUDY_CACHE.pop(ct_number, None) + return {"ok": True, "ct_number": ct_number, "locked": payload.locked, "updated_by": user} + + +def remove_file(path: str) -> None: + try: + Path(path).unlink(missing_ok=True) + except Exception: + pass + + +def safe_archive_name(value: Any, fallback: str = "file") -> str: + text = str(value or "").strip() + cleaned = "".join("_" if char in '<>:"/\\|?*\0' else char for char in text).strip().strip(".") + return cleaned[:160] or fallback + + +@app.post("/api/export/stl") +def export_stl(payload: ExportPayload, _: str = Depends(require_auth)) -> FileResponse: + ct_number = normalize_ct(payload.ct_number) + files = stl_rows_for_ct(ct_number) + selected = [] + wanted_ids = {int(item) for item in payload.file_ids} + wanted_families = {str(item) for item in payload.families if str(item)} + for row in files: + if wanted_ids and int(row["id"]) in wanted_ids: + selected.append(row) + elif wanted_families and row.get("family") in wanted_families: + selected.append(row) + if not wanted_ids and not wanted_families: + selected = files + if not selected: + raise HTTPException(status_code=400, detail="没有可导出的STL") + temp = tempfile.NamedTemporaryFile(prefix="dicom_upp_registration_stl_", suffix=".zip", delete=False) + temp_path = temp.name + temp.close() + with zipfile.ZipFile(temp_path, mode="w", compression=zipfile.ZIP_STORED, allowZip64=True) as zip_file: + manifest = {"ct_number": ct_number, "mode": payload.mode, "label": payload.label, "transform": payload.transform, "files": []} + for row in selected: + path = Path(str(row["path"])) + if not path.exists(): + continue + family = safe_archive_name(row.get("family"), "family") + name = safe_archive_name(row.get("file_name"), path.name) + archive_name = Path(family) / name if payload.mode == "family" else Path(name) + zip_file.write(path, str(archive_name)) + manifest["files"].append({key: row.get(key) for key in ["file_name", "segment_name", "family", "category", "algorithm_model"]}) + if payload.include_pose: + zip_file.writestr("registration_pose.json", json.dumps(manifest, ensure_ascii=False, indent=2)) + filename = f"DICOM_UPP_STL_{safe_archive_name(ct_number)}_{time.strftime('%Y%m%d_%H%M%S')}.zip" + return FileResponse(temp_path, media_type="application/zip", filename=filename, background=BackgroundTask(remove_file, temp_path)) diff --git a/DICOM_and_UPP配准/docker-compose.yml b/DICOM_and_UPP配准/docker-compose.yml new file mode 100644 index 0000000..a3b561e --- /dev/null +++ b/DICOM_and_UPP配准/docker-compose.yml @@ -0,0 +1,14 @@ +name: dicom-upp-registration + +services: + dicom-upp-registration: + build: + context: . + container_name: dicom-upp-registration + restart: unless-stopped + env_file: + - .env + ports: + - "8109:8109" + volumes: + - /home/wkmgc/Desktop:/home/wkmgc/Desktop:ro diff --git a/DICOM_and_UPP配准/requirements.txt b/DICOM_and_UPP配准/requirements.txt new file mode 100644 index 0000000..5c2bdb8 --- /dev/null +++ b/DICOM_and_UPP配准/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/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js new file mode 100644 index 0000000..8637405 --- /dev/null +++ b/DICOM_and_UPP配准/static/app.js @@ -0,0 +1,1056 @@ +import * as THREE from "three"; +import { STLLoader } from "./vendor/STLLoader.js"; +import { OrbitControls } from "./vendor/OrbitControls.js"; + +const state = { + token: localStorage.getItem("dicom_upp_registration_token") || "", + user: null, + cases: [], + activeCase: null, + series: [], + selectedSeries: null, + stlFiles: [], + selectedStlIds: new Set(), + selectedFamilies: new Set(), + mode: "file", + registrations: [], + pose: defaultPose(), + maskVisible: true, + sliceIndex: 0, + plane: "axial", + window: "default", + loading: false, + searchTimer: null, + links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" }, + scenes: {}, +}; + +const $ = (id) => document.getElementById(id); + +const poseFields = [ + { key: "translateX", label: "平移 X", min: -2, max: 2, step: 0.001 }, + { key: "translateY", label: "平移 Y", min: -2, max: 2, step: 0.001 }, + { key: "translateZ", label: "平移 Z", min: -2, max: 2, step: 0.001 }, + { key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1 }, + { key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1 }, + { key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1 }, + { key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.001 }, +]; + +const bodyPartLabels = { + head_neck: "头颈部", + chest: "胸部", + upper_abdomen: "上腹部", + lower_abdomen: "下腹部", + pelvis: "盆腔", +}; + +const familyColors = [ + "#38d7c7", + "#f4a261", + "#d88cf6", + "#54df8c", + "#60a5fa", + "#f87171", + "#facc15", + "#a3e635", + "#22d3ee", + "#f472b6", +]; + +function defaultPose() { + return { + translateX: 0, + translateY: 0, + translateZ: 0, + rotateX: 0, + rotateY: 0, + rotateZ: 0, + scale: 1, + flipX: false, + flipY: false, + flipZ: false, + }; +} + +function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function asList(value) { + return Array.isArray(value) ? value : []; +} + +function fmtDate(value) { + const text = String(value || ""); + if (!text) return ""; + if (/^\d{8}$/.test(text)) return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`; + return text.replace("T", " ").slice(0, 19); +} + +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 fmtBytes(value) { + const size = Number(value || 0); + if (!size) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024))); + return `${(size / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`; +} + +function authParams(params = {}) { + const output = new URLSearchParams(params); + if (state.token) output.set("access_token", state.token); + return output; +} + +async function request(path, options = {}) { + const headers = { ...(options.headers || {}) }; + if (state.token) headers.Authorization = `Bearer ${state.token}`; + if (options.body && !(options.body instanceof FormData)) 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 setLoading(show) { + state.loading = show; + $("topLoading").classList.toggle("hidden", !show); +} + +function showLogin(show) { + $("loginOverlay").classList.toggle("hidden", !show); +} + +function sameHostUrl(rawUrl, fallbackPort) { + try { + const url = new URL(rawUrl || `${location.protocol}//${location.hostname}:${fallbackPort}`, location.href); + if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname) && !["127.0.0.1", "localhost"].includes(location.hostname)) { + url.hostname = location.hostname; + } + url.protocol = location.protocol; + return url.toString().replace(/\/$/, ""); + } catch (_) { + return `${location.protocol}//${location.hostname}:${fallbackPort}`; + } +} + +function relationBaseUrl() { + return sameHostUrl(state.links.relation_viewer_url, 8108); +} + +function viewerBaseUrl() { + return sameHostUrl(state.links.pacs_viewer_url, 8107); +} + +function renderPoseControls() { + $("poseGrid").innerHTML = ` + ${poseFields + .map( + (field) => ` +
DICOM 序列、STL family 与人工位姿锁定
+从左侧选择一个 DICOM + STL 匹配检查
+=r)){const a=e[1];t=r)break e}n=i,i=0;break i}break t}for(;i{const i=Pc[t];if(void 0===i)throw this.manager.itemError(t),e;delete Pc[t];for(let t=0,s=i.length;t{this.manager.itemEnd(t)}),this.manager.itemStart(t)}setResponseType(t){return this.responseType=t,this}setMimeType(t){return this.mimeType=t,this}abort(){return this._abortController.abort(),this._abortController=new AbortController,this}}class Vc extends Oc{constructor(t){super(t)}load(t,e,i,s){const r=this,n=new Nc(this.manager);n.setPath(this.path),n.setRequestHeader(this.requestHeader),n.setWithCredentials(this.withCredentials),n.load(t,function(i){try{e(r.parse(JSON.parse(i)))}catch(e){s?s(e):os(e),r.manager.itemError(t)}},i,s)}parse(t){const e=[];for(let i=0;i{let e=null,i=null;return void 0!==t.boundingBox&&(e=(new Qr).fromJSON(t.boundingBox)),void 0!==t.boundingSphere&&(i=(new Nn).fromJSON(t.boundingSphere)),{...t,boundingBox:e,boundingSphere:i}}),n._instanceInfo=t.instanceInfo,n._availableInstanceIds=t._availableInstanceIds,n._availableGeometryIds=t._availableGeometryIds,n._nextIndexStart=t.nextIndexStart,n._nextVertexStart=t.nextVertexStart,n._geometryCount=t.geometryCount,n._maxInstanceCount=t.maxInstanceCount,n._maxVertexCount=t.maxVertexCount,n._maxIndexCount=t.maxIndexCount,n._geometryInitialized=t.geometryInitialized,n._matricesTexture=c(t.matricesTexture.uuid),n._indirectTexture=c(t.indirectTexture.uuid),void 0!==t.colorsTexture&&(n._colorsTexture=c(t.colorsTexture.uuid)),void 0!==t.boundingSphere&&(n.boundingSphere=(new Nn).fromJSON(t.boundingSphere)),void 0!==t.boundingBox&&(n.boundingBox=(new Qr).fromJSON(t.boundingBox));break;case"LOD":n=new pa;break;case"Line":n=new Jo(h(t.geometry),l(t.material));break;case"LineLoop":n=new Go(h(t.geometry),l(t.material));break;case"LineSegments":n=new Zo(h(t.geometry),l(t.material));break;case"PointCloud":case"Points":n=new ih(h(t.geometry),l(t.material));break;case"Sprite":n=new la(l(t.material));break;case"Group":n=new Tr;break;case"Bone":n=new Xa;break;default:n=new Ar}if(n.uuid=t.uuid,void 0!==t.name&&(n.name=t.name),void 0!==t.matrix?(n.matrix.fromArray(t.matrix),void 0!==t.matrixAutoUpdate&&(n.matrixAutoUpdate=t.matrixAutoUpdate),n.matrixAutoUpdate&&n.matrix.decompose(n.position,n.quaternion,n.scale)):(void 0!==t.position&&n.position.fromArray(t.position),void 0!==t.rotation&&n.rotation.fromArray(t.rotation),void 0!==t.quaternion&&n.quaternion.fromArray(t.quaternion),void 0!==t.scale&&n.scale.fromArray(t.scale)),void 0!==t.up&&n.up.fromArray(t.up),void 0!==t.pivot&&(n.pivot=(new Ts).fromArray(t.pivot)),void 0!==t.morphTargetDictionary&&(n.morphTargetDictionary=Object.assign({},t.morphTargetDictionary)),void 0!==t.morphTargetInfluences&&(n.morphTargetInfluences=t.morphTargetInfluences.slice()),void 0!==t.castShadow&&(n.castShadow=t.castShadow),void 0!==t.receiveShadow&&(n.receiveShadow=t.receiveShadow),t.shadow&&(void 0!==t.shadow.intensity&&(n.shadow.intensity=t.shadow.intensity),void 0!==t.shadow.bias&&(n.shadow.bias=t.shadow.bias),void 0!==t.shadow.normalBias&&(n.shadow.normalBias=t.shadow.normalBias),void 0!==t.shadow.radius&&(n.shadow.radius=t.shadow.radius),void 0!==t.shadow.mapSize&&n.shadow.mapSize.fromArray(t.shadow.mapSize),void 0!==t.shadow.camera&&(n.shadow.camera=this.parseObject(t.shadow.camera))),void 0!==t.visible&&(n.visible=t.visible),void 0!==t.frustumCulled&&(n.frustumCulled=t.frustumCulled),void 0!==t.renderOrder&&(n.renderOrder=t.renderOrder),void 0!==t.static&&(n.static=t.static),void 0!==t.userData&&(n.userData=t.userData),void 0!==t.layers&&(n.layers.mask=t.layers),void 0!==t.children){const a=t.children;for(let t=0;t