From 92126c566490ec3c8c16040982d0b49a18e6bec4 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 19:46:01 +0800 Subject: [PATCH] Rebuild DICOM UPP registration workspace --- DICOM_and_UPP配准/README.md | 16 +- DICOM_and_UPP配准/app.py | 884 ++++++------- DICOM_and_UPP配准/static/app.js | 1861 ++++++++++++--------------- DICOM_and_UPP配准/static/index.html | 215 ++-- DICOM_and_UPP配准/static/styles.css | 1055 ++++++++------- 5 files changed, 1849 insertions(+), 2182 deletions(-) diff --git a/DICOM_and_UPP配准/README.md b/DICOM_and_UPP配准/README.md index 2266578..76967ca 100644 --- a/DICOM_and_UPP配准/README.md +++ b/DICOM_and_UPP配准/README.md @@ -1,10 +1,10 @@ -# DICOM_and_UPP 配准工作台 +# DICOM / UPP 配准工作台 -用于在浏览器中人工核对 DICOM 序列与 UPP STL 模型的配准关系。页面会读取 PACS DICOM、UPP STL 资产和 DICOM 阅片分类结果,配准位姿单独写入 `dicom_upp_registrations` 表。 +面向 `DICOM / UPP 数据库关联可视化` 中的完整关联 CT,维护 STL 模型与 DICOM 序列的搭配关系及位姿参数。 -配准维护表按 `ct_number + algorithm_model` 建唯一约束:同一个 CT、同一个算法模型只保留一条 STL 配准记录;再次保存会更新原记录。 +此工作台不内置示例项目;进入后直接从数据库完整关联 CT 列表读取 DICOM 和 STL。旧版“锁定”概念已改为 `未配准 / 已配准` 状态。 -默认端口: +默认地址: - 本机:`http://127.0.0.1:8109/` - 局域网:`http://192.168.3.11:8109/` @@ -14,10 +14,14 @@ - 账号:`admin` - 密码:`123456` +保存表: + +- `public.dicom_upp_registrations` +- 唯一键:`ct_number + algorithm_model` +- 主要字段:`registration_status`、`series_instance_uid`、`selected_stl_files`、`transform`、`model_reference`、`dicom_reference` + 启动: ```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 index 74a5458..c29565e 100644 --- a/DICOM_and_UPP配准/app.py +++ b/DICOM_and_UPP配准/app.py @@ -1,16 +1,15 @@ #!/usr/bin/env python3 from __future__ import annotations +import base64 import io import json import os import re import secrets import subprocess -import tempfile import time -import zipfile -from collections import defaultdict +from collections import Counter, defaultdict from pathlib import Path from typing import Any @@ -21,7 +20,6 @@ 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 @@ -61,22 +59,43 @@ PROCESSED_ROOT = Path(os.getenv("PACS_PROCESSED_ROOT", "/home/wkmgc/Desktop/Data 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), + "bone": (500.0, 2000.0), + "soft": (40.0, 400.0), + "contrast": (80.0, 180.0), } DEFAULT_POSE = { - "translateX": 0.0, - "translateY": 0.0, - "translateZ": 0.0, "rotateX": 0.0, "rotateY": 0.0, "rotateZ": 0.0, + "translateX": 0.0, + "translateY": 0.0, + "translateZ": 0.0, "scale": 1.0, "flipX": False, "flipY": False, "flipZ": False, } +DICOM_TAGS = [ + "SeriesInstanceUID", + "StudyInstanceUID", + "SeriesNumber", + "SeriesDescription", + "InstanceNumber", + "SliceLocation", + "ImagePositionPatient", + "AcquisitionTime", + "ContentTime", + "SeriesTime", + "Modality", + "BodyPartExamined", + "Rows", + "Columns", + "PixelSpacing", + "SliceThickness", + "SpacingBetweenSlices", + "WindowCenter", + "WindowWidth", +] def ident(name: str) -> str: @@ -92,7 +111,7 @@ 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 = FastAPI(title="DICOM UPP Registration Workspace") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") TOKENS: dict[str, str] = {} STUDY_CACHE: dict[str, dict[str, Any]] = {} @@ -106,29 +125,16 @@ class LoginPayload(BaseModel): class RegistrationPayload(BaseModel): ct_number: str + algorithm_model: str = "未指定模型" + registration_status: str = "unregistered" 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] = {} + module_styles: 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]: @@ -175,46 +181,51 @@ def normalize_ct(value: Any) -> str: return re.sub(r"\s+", "", str(value or "")).upper() +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 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, + algorithm_model text NOT NULL DEFAULT '未指定模型', + registration_status text NOT NULL DEFAULT 'unregistered', 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, + module_styles 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, algorithm_model) ); ALTER TABLE public.{REGISTRATION_TABLE_SQL} + ADD COLUMN IF NOT EXISTS algorithm_model text NOT NULL DEFAULT '未指定模型', + ADD COLUMN IF NOT EXISTS registration_status text NOT NULL DEFAULT 'unregistered', ADD COLUMN IF NOT EXISTS series_instance_uid text NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS series_description text NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS algorithm_model text NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS stl_family text NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS view_mode text NOT NULL DEFAULT 'family', ADD COLUMN IF NOT EXISTS selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb, ADD COLUMN IF NOT EXISTS transform jsonb NOT NULL DEFAULT '{{}}'::jsonb, + ADD COLUMN IF NOT EXISTS module_styles jsonb NOT NULL DEFAULT '{{}}'::jsonb, ADD COLUMN IF NOT EXISTS dicom_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb, ADD COLUMN IF NOT EXISTS model_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb, - ADD COLUMN IF NOT EXISTS segmentation jsonb NOT NULL DEFAULT '{{}}'::jsonb, ADD COLUMN IF NOT EXISTS notes text NOT NULL DEFAULT '', ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS locked_by text, - ADD COLUMN IF NOT EXISTS locked_at timestamptz, ADD COLUMN IF NOT EXISTS updated_by text NOT NULL DEFAULT 'admin', ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(), ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); @@ -222,22 +233,25 @@ def ensure_registration_table() -> None: SET ct_number = upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g')), algorithm_model = COALESCE(NULLIF(btrim(algorithm_model), ''), '未指定模型'), - stl_family = COALESCE(NULLIF(btrim(stl_family), ''), '全部STL') + registration_status = CASE + WHEN registration_status IN ('registered', 'unregistered') THEN registration_status + WHEN COALESCE(locked, false) IS TRUE THEN 'registered' + ELSE 'unregistered' + END WHERE ct_number <> upper(regexp_replace(COALESCE(ct_number, ''), '\\s+', '', 'g')) OR algorithm_model = '' - OR stl_family = ''; + OR registration_status NOT IN ('registered', 'unregistered'); CREATE TABLE IF NOT EXISTS public.{REGISTRATION_TABLE_SQL}_duplicate_archive ( archived_at timestamptz NOT NULL DEFAULT now(), reason text NOT NULL, row_data jsonb NOT NULL ); WITH ranked AS ( - SELECT - id, - row_number() OVER ( - PARTITION BY ct_number, algorithm_model - ORDER BY locked DESC, updated_at DESC NULLS LAST, id DESC - ) AS rn + SELECT id, + row_number() OVER ( + PARTITION BY ct_number, algorithm_model + ORDER BY registration_status = 'registered' DESC, updated_at DESC NULLS LAST, id DESC + ) AS rn FROM public.{REGISTRATION_TABLE_SQL} ), duplicates AS ( @@ -250,12 +264,11 @@ def ensure_registration_table() -> None: SELECT 'ct_algorithm_unique_migration', row_to_json(duplicates)::jsonb FROM duplicates; WITH ranked AS ( - SELECT - id, - row_number() OVER ( - PARTITION BY ct_number, algorithm_model - ORDER BY locked DESC, updated_at DESC NULLS LAST, id DESC - ) AS rn + SELECT id, + row_number() OVER ( + PARTITION BY ct_number, algorithm_model + ORDER BY registration_status = 'registered' DESC, updated_at DESC NULLS LAST, id DESC + ) AS rn FROM public.{REGISTRATION_TABLE_SQL} ) DELETE FROM public.{REGISTRATION_TABLE_SQL} t @@ -263,16 +276,16 @@ def ensure_registration_table() -> None: WHERE t.id = r.id AND r.rn > 1; DO $$ DECLARE - old_constraint record; + item record; BEGIN - FOR old_constraint IN + FOR item IN SELECT conname FROM pg_constraint WHERE conrelid = 'public.{REGISTRATION_TABLE_SQL}'::regclass AND contype = 'u' - AND pg_get_constraintdef(oid) LIKE '%(ct_number, series_instance_uid, algorithm_model, stl_family)%' + AND pg_get_constraintdef(oid) NOT LIKE '%(ct_number, algorithm_model)%' LOOP - EXECUTE format('ALTER TABLE public.{REGISTRATION_TABLE_SQL} DROP CONSTRAINT %I', old_constraint.conname); + EXECUTE format('ALTER TABLE public.{REGISTRATION_TABLE_SQL} DROP CONSTRAINT %I', item.conname); END LOOP; IF NOT EXISTS ( SELECT 1 @@ -286,10 +299,10 @@ def ensure_registration_table() -> None: END IF; END $$; 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}_status ON public.{REGISTRATION_TABLE_SQL}(registration_status); CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_algorithm ON public.{REGISTRATION_TABLE_SQL}(algorithm_model); - CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_locked ON public.{REGISTRATION_TABLE_SQL}(locked); """, - timeout=12, + timeout=14, ) @@ -299,7 +312,7 @@ def db_available() -> tuple[bool, str]: try: pg_scalar("SELECT 1", timeout=4) return True, "connected" - except Exception as exc: # noqa: BLE001 + except Exception as exc: return False, str(exc) @@ -307,8 +320,6 @@ def require_auth(authorization: str | None = Header(default=None), access_token: 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") @@ -351,105 +362,69 @@ def me(user: str = Depends(require_auth)) -> dict[str, str]: @app.get("/api/status") def status(_: str = Depends(require_auth)) -> dict[str, Any]: ok, message = db_available() - registration_count = None - locked_count = None + counts = {"complete_cases": 0, "registered": 0, "unregistered": 0} 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, + ensure_registration_table() + rows = pg_json_rows( + f""" + WITH complete AS ( + SELECT DISTINCT p.ct_number, COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型') AS algorithm_model + FROM public.{PACS_TABLE_SQL} p + JOIN public.{UPP_ASSET_TABLE_SQL} u ON upper(u.ct_number) = upper(p.ct_number) + LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(p.ct_number) + WHERE COALESCE(u.stl_present, false) OR COALESCE(u.stl_file_count, 0) > 0 OR s.ct_number IS NOT NULL ) - if rows: - registration_count = rows[0].get("registration_count") - locked_count = rows[0].get("locked_count") - except Exception: - pass + SELECT + count(*)::int AS complete_cases, + count(*) FILTER (WHERE r.registration_status = 'registered')::int AS registered, + count(*) FILTER (WHERE COALESCE(r.registration_status, 'unregistered') <> 'registered')::int AS unregistered + FROM complete c + LEFT JOIN public.{REGISTRATION_TABLE_SQL} r + ON r.ct_number = c.ct_number AND r.algorithm_model = c.algorithm_model + """, + timeout=10, + ) + if rows: + counts = rows[0] return { "database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE}, - "registration": {"table": REGISTRATION_TABLE, "count": registration_count, "locked": locked_count}, + "counts": counts, "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: +def annotation_labels_sql() -> 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 + a.ct_number, + COALESCE(jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_keys, + COALESCE(jsonb_agg(DISTINCT + CASE part.value + WHEN 'head_neck' THEN '头颈部' + WHEN 'chest' THEN '胸部' + WHEN 'upper_abdomen' THEN '上腹部' + WHEN 'lower_abdomen' THEN '下腹部' + WHEN 'pelvis' THEN '盆腔' + ELSE NULL + END + ) FILTER (WHERE part.value IS NOT NULL), '[]'::jsonb) AS body_part_labels + FROM public.{PACS_ANNOTATION_TABLE_SQL} a + LEFT JOIN LATERAL jsonb_array_elements_text(COALESCE(a.body_parts, '[]'::jsonb)) AS part(value) ON true + WHERE COALESCE(a.skipped, false) IS NOT TRUE + GROUP BY a.ct_number + """ + + +def relation_select(where_sql: str = "") -> str: + return f""" + WITH ann AS ({annotation_labels_sql()}), + complete 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, + upper(p.ct_number) AS ct_key, + p.ct_number, + COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型') AS algorithm_model, + p.batch_name, COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) AS patient_name, p.patient_id, p.study_date, @@ -458,90 +433,127 @@ def relation_base_cte() -> str: 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, + u.upp_status, + COALESCE(u.processed_stl_dir, '') AS processed_stl_dir, 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 + COALESCE(ann.body_part_keys, ps.body_parts, '[]'::jsonb) AS body_part_keys, + COALESCE(ann.body_part_labels, '[]'::jsonb) AS body_part_labels + FROM public.{PACS_TABLE_SQL} p + JOIN public.{UPP_ASSET_TABLE_SQL} u ON upper(u.ct_number) = upper(p.ct_number) + LEFT JOIN public.{UPP_STL_TABLE_SQL} s ON upper(s.ct_number) = upper(p.ct_number) + LEFT JOIN public.{PACS_SUMMARY_TABLE_SQL} ps ON ps.ct_number = p.ct_number + LEFT JOIN ann ON ann.ct_number = p.ct_number + WHERE COALESCE(u.stl_present, false) OR COALESCE(u.stl_file_count, 0) > 0 OR s.ct_number IS NOT NULL ) + SELECT + c.*, + COALESCE(r.registration_status, 'unregistered') AS registration_status, + r.series_instance_uid, + r.series_description, + r.transform, + r.selected_stl_files, + r.notes, + r.updated_at AS registration_updated_at + FROM complete c + LEFT JOIN public.{REGISTRATION_TABLE_SQL} r + ON r.ct_number = c.ct_number AND r.algorithm_model = c.algorithm_model + {where_sql} """ @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]]: +def cases( + q: str = "", + status_filter: str = Query(default="", alias="status"), + body_part: str = "", + limit: int = Query(default=120, ge=1, le=400), + _: str = Depends(require_auth), +) -> list[dict[str, Any]]: ensure_registration_table() - clauses = ["stl_present IS TRUE"] + clauses = [] 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"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)}", + f"upp_patient_name ILIKE {sql_literal(like)}", ] ) + ")" ) - where = "WHERE " + " AND ".join(clauses) + if status_filter in {"registered", "unregistered"}: + clauses.append(f"registration_status = {sql_literal(status_filter)}") + if body_part in {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}: + clauses.append(f"body_part_keys ? {sql_literal(body_part)}") + where_sql = "WHERE " + " AND ".join(clauses) if clauses else "" return pg_json_rows( f""" - {relation_base_cte()} SELECT * - FROM relation - {where} + FROM ({relation_select()}) relation + {where_sql} ORDER BY - locked_count DESC, - registration_count DESC, + registration_status = 'registered', COALESCE(study_date, '') DESC, COALESCE(study_time, '') DESC, ct_key LIMIT {int(limit)} """, - timeout=24, + timeout=20, ) @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) +def case_detail(ct_number: str, algorithm_model: str = "未指定模型", _: str = Depends(require_auth)) -> dict[str, Any]: rows = pg_json_rows( f""" - {relation_base_cte()} SELECT * - FROM relation - WHERE ct_key = {sql_literal(ct_key)} + FROM ({relation_select()}) relation + WHERE ct_key = {sql_literal(normalize_ct(ct_number))} + AND algorithm_model = {sql_literal(algorithm_model or '未指定模型')} LIMIT 1 """, - timeout=24, + timeout=16, ) if not rows: - raise HTTPException(status_code=404, detail="CT not found") + raise HTTPException(status_code=404, detail="未找到完整关联 CT") return rows[0] +def numeric(value: Any, fallback: float = 0.0) -> float: + try: + return float(str(value).strip().split("\\")[0]) + except Exception: + return fallback + + +def text(value: Any) -> str: + return "" if value is None else str(value).strip() + + +def read_header(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 sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]: + path, meta = item + z = numeric(meta.get("SliceLocation"), 0.0) + position = meta.get("ImagePositionPatient", "") + if position and not meta.get("SliceLocation"): + try: + z = float(str(position).strip("[]").split(",")[-1]) + except Exception: + z = 0.0 + return (z, numeric(meta.get("InstanceNumber"), 0.0), str(path)) + + def get_study_record(ct_number: str) -> dict[str, Any]: rows = pg_json_rows( f""" @@ -553,7 +565,7 @@ def get_study_record(ct_number: str) -> dict[str, Any]: timeout=12, ) if not rows: - raise HTTPException(status_code=404, detail="study not found") + raise HTTPException(status_code=404, detail="DICOM 检查不存在") return rows[0] @@ -563,89 +575,12 @@ def resolve_study_root(study: dict[str, Any]) -> Path: 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 + found = next(PROCESSED_ROOT.rglob(target_folder), None) + if found: + return found 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( @@ -661,23 +596,20 @@ def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: 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: +def annotation_labels(row: dict[str, Any]) -> list[str]: + if not row: return [] - if annotation.get("skipped"): + if row.get("skipped"): return ["略过/不采用"] - labels: list[str] = [] - parts = parse_json_list(annotation.get("body_parts")) - for part in parts: + labels = [] + for part in parse_json_list(row.get("body_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 '纵隔窗'}") + labels.append("胸部") 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}") + phase = {"plain": "平扫", "arterial": "动脉期", "portal_venous": "门脉期", "delayed": "延迟期", "unknown": "无法判别"}.get(row.get("upper_abdomen_phase") or "unknown", "无法判别") + labels.append(f"上腹部-{phase}") elif part == "lower_abdomen": labels.append("下腹部") elif part == "pelvis": @@ -685,47 +617,15 @@ def annotation_labels(annotation: dict[str, Any]) -> list[str]: 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) + key = normalize_ct(ct_number) + cached = STUDY_CACHE.get(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}") + raise HTTPException(status_code=404, detail=f"DICOM 目录不存在:{root}") grouped: dict[str, list[tuple[Path, dict[str, str]]]] = defaultdict(list) for path in root.rglob("*.dcm"): try: @@ -735,47 +635,36 @@ def scan_study(ct_number: str) -> dict[str, Any]: 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 = [] + series = [] 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( + annotation = annotations.get(uid) or {} + series.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 "未命名序列", + "description": first.get("SeriesDescription") or "未命名序列", + "series_number": first.get("SeriesNumber") 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": []}), + "modality": first.get("Modality") or "", + "rows": first.get("Rows") or "", + "columns": first.get("Columns") or "", + "body_part_dicom": first.get("BodyPartExamined") or "", + "series_time": first.get("SeriesTime") or first.get("AcquisitionTime") or first.get("ContentTime") or "", + "first_time": first.get("AcquisitionTime") or first.get("ContentTime") or "", + "last_time": last.get("AcquisitionTime") or last.get("ContentTime") or "", + "pixel_spacing": first.get("PixelSpacing") or "", + "slice_thickness": first.get("SliceThickness") or "", + "spacing_between_slices": first.get("SpacingBetweenSlices") or "", + "annotation_labels": annotation_labels(annotation), } ) - 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 + series.sort(key=lambda item: (str(item.get("first_time") or item.get("series_time") or ""), numeric(item.get("series_number"), 999999), item.get("description") or "")) + payload = {"cached_at": time.time(), "study": study, "root": str(root), "series": series, "files": file_map} + STUDY_CACHE[key] = payload return payload @@ -789,23 +678,27 @@ 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") + raise HTTPException(status_code=404, detail="DICOM 序列不存在") 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) + center = getattr(ds, "WindowCenter", 40) + width = getattr(ds, "WindowWidth", 400) 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 + return numeric(center, 40.0), numeric(width, 400.0) + + +def pixel_spacing_from_ds(ds: pydicom.Dataset) -> tuple[float, float]: + spacing = getattr(ds, "PixelSpacing", None) + if spacing and len(spacing) >= 2: + return max(numeric(spacing[0], 1.0), 0.001), max(numeric(spacing[1], 1.0), 0.001) + return 1.0, 1.0 def dicom_to_hu(ds: pydicom.Dataset) -> np.ndarray: @@ -815,34 +708,21 @@ def dicom_to_hu(ds: pydicom.Dataset) -> np.ndarray: 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) + distances = [float(np.linalg.norm(b - a)) for a, b in zip(positions, positions[1:])] + distances = [item for item in distances if item > 0.001] 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 datasets: + spacing = numeric(getattr(datasets[0], "SpacingBetweenSlices", 0), 0.0) if spacing > 0.001: return spacing - thickness = numeric(getattr(sample, "SliceThickness", 0), 0.0) + thickness = numeric(getattr(datasets[0], "SliceThickness", 0), 0.0) if thickness > 0.001: return thickness return 1.0 @@ -851,22 +731,22 @@ def slice_spacing_from_datasets(datasets: list[pydicom.Dataset]) -> float: 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) + unit = min(row_spacing, col_spacing) + width = max(1, int(round(pil.width * col_spacing / unit))) + height = max(1, int(round(pil.height * row_spacing / unit))) + scale = min(1.0, 1800 / max(width, height)) + target = (max(1, int(round(width * scale))), max(1, int(round(height * scale)))) + return pil if target == pil.size else pil.resize(target, 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: +def render_array(arr: np.ndarray, center: float, width: float, max_size: int = 900, spacing: tuple[float, float] = (1.0, 1.0), invert: bool = False) -> 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]) + pil = resize_for_spacing(pil, spacing[0], spacing[1]) if max(pil.size) > max_size: pil.thumbnail((max_size, max_size), Image.Resampling.BILINEAR) output = io.BytesIO() @@ -889,7 +769,7 @@ def load_stack_data(ct_number: str, series_uid: str) -> dict[str, Any]: 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)} + payload = {"stack": stack, "datasets": datasets, "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] @@ -897,46 +777,78 @@ def load_stack_data(ct_number: str, series_uid: str) -> dict[str, Any]: return payload -@app.get("/api/image") -def image( +@app.get("/api/dicom/image") +def dicom_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) + index = min(max(0, 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, spacing=pixel_spacing_from_ds(ds), invert=getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1") return Response(payload, media_type="image/png") -def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]: +@app.get("/api/dicom/fusion-volume") +def dicom_fusion_volume( + ct_number: str, + series_uid: str, + center_index: int = 0, + window: str = "soft", + radius: int = Query(default=24, ge=4, le=48), + _: str = Depends(require_auth), +) -> dict[str, Any]: + stack_data = load_stack_data(ct_number, series_uid) + stack = stack_data["stack"] + datasets = stack_data["datasets"] + total = int(stack.shape[0]) + center_index = min(max(0, center_index), total - 1) + start = max(0, center_index - radius) + end = min(total - 1, center_index + radius) + if end - start + 1 > 48: + step = int(np.ceil((end - start + 1) / 48)) + indices = list(range(start, end + 1, step)) + else: + indices = list(range(start, end + 1)) + sample_ds = datasets[center_index] + center, width = window_values(sample_ds, window) + frames = [] + frame_width = 0 + frame_height = 0 + for index in indices: + png = render_array(stack[index], center, width, max_size=256, spacing=(stack_data["row_spacing"], stack_data["col_spacing"])) + frames.append("data:image/png;base64," + base64.b64encode(png).decode("ascii")) + if not frame_width: + with Image.open(io.BytesIO(png)) as pil: + frame_width, frame_height = pil.size + return { + "frames": frames, + "indices": indices, + "start": start, + "end": end, + "center": center_index, + "total": total, + "width": frame_width, + "height": frame_height, + "spacing": {"row": stack_data["row_spacing"], "column": stack_data["col_spacing"], "slice": stack_data["slice_spacing"]}, + "physicalSize": { + "width": int(stack.shape[2]) * stack_data["col_spacing"], + "height": int(stack.shape[1]) * stack_data["row_spacing"], + "depth": total * stack_data["slice_spacing"], + "unit": "mm", + }, + } + + +def stl_rows_for_ct(ct_number: str, algorithm_model: str = "") -> list[dict[str, Any]]: rows = pg_json_rows( f""" SELECT - COALESCE(u.algorithm_model, '未指定模型') AS algorithm_model, + COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型') AS algorithm_model, u.processed_stl_dir, CASE WHEN jsonb_typeof(u.stl_files) = 'array' AND jsonb_array_length(u.stl_files) > 0 THEN u.stl_files @@ -945,7 +857,8 @@ def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]: 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 COALESCE(u.algorithm_model, '未指定模型'), u.updated_at DESC NULLS LAST + AND ({sql_literal(algorithm_model)} = '' OR COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型') = {sql_literal(algorithm_model or '未指定模型')}) + ORDER BY COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型'), u.updated_at DESC NULLS LAST """, timeout=14, ) @@ -954,18 +867,18 @@ def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]: 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"]) + base = Path(str(row["processed_stl_dir"])) if base.exists(): files = [ { - "file_name": item.name, - "segment_name": item.stem, - "family": item.stem, + "file_name": path.name, + "segment_name": path.stem, + "family": path.stem, "category": "未分类", - "processed_file_path": str(item), - "size_bytes": item.stat().st_size, + "processed_file_path": str(path), + "size_bytes": path.stat().st_size, } - for item in sorted(base.glob("*.stl")) + for path in sorted(base.glob("*.stl")) ] for file_info in files: path = file_info.get("processed_file_path") or file_info.get("source_file_path") @@ -982,7 +895,6 @@ def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]: "category": file_info.get("category") or "未分类", "size_bytes": int(file_info.get("size_bytes") or 0), "path": path, - "visible": True, } ) index += 1 @@ -990,39 +902,49 @@ def stl_rows_for_ct(ct_number: str) -> list[dict[str, Any]]: @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} +def stl_assets(ct_number: str, algorithm_model: str = "", _: str = Depends(require_auth)) -> dict[str, Any]: + files = stl_rows_for_ct(ct_number, algorithm_model) + return { + "ct_number": ct_number, + "files": files, + "families": sorted({str(item.get("family") or "") for item in files if item.get("family")}), + "algorithm_models": sorted({str(item.get("algorithm_model") or "") for item in files if item.get("algorithm_model")}), + } -@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) +@app.get("/api/stl/file") +def stl_file(ct_number: str, file_id: int, algorithm_model: str = "", _: str = Depends(require_auth)) -> FileResponse: + files = stl_rows_for_ct(ct_number, algorithm_model) 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") + raise HTTPException(status_code=404, detail="STL 文件不存在") 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}") + raise HTTPException(status_code=404, detail=f"STL 路径不存在:{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]: +def get_registration(ct_number: str, algorithm_model: 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 + WHERE ct_number = {sql_literal(normalize_ct(ct_number))} + AND algorithm_model = {sql_literal(algorithm_model or '未指定模型')} + LIMIT 1 """, timeout=12, ) - return {"ct_number": ct_number, "registrations": rows} + return rows[0] if rows else { + "ct_number": normalize_ct(ct_number), + "algorithm_model": algorithm_model or "未指定模型", + "registration_status": "unregistered", + "transform": DEFAULT_POSE, + "selected_stl_files": [], + "notes": "", + } @app.post("/api/registrations") @@ -1031,118 +953,44 @@ def save_registration(payload: RegistrationPayload, user: str = Depends(require_ 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 algorithm_model = {sql_literal(algorithm_model)} - 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" + registration_status = payload.registration_status if payload.registration_status in {"registered", "unregistered"} else "unregistered" 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 + ct_number, algorithm_model, registration_status, series_instance_uid, series_description, + selected_stl_files, transform, module_styles, dicom_reference, model_reference, + notes, 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')}, + {sql_literal(registration_status)}, + {sql_literal(payload.series_instance_uid.strip())}, + {sql_literal(payload.series_description.strip())}, {json_sql(payload.selected_stl_files)}, {json_sql(transform)}, + {json_sql(payload.module_styles)}, {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, algorithm_model) DO UPDATE SET + registration_status = EXCLUDED.registration_status, series_instance_uid = EXCLUDED.series_instance_uid, series_description = EXCLUDED.series_description, - stl_family = EXCLUDED.stl_family, - view_mode = EXCLUDED.view_mode, selected_stl_files = EXCLUDED.selected_stl_files, transform = EXCLUDED.transform, + module_styles = EXCLUDED.module_styles, 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, + timeout=14, ) - 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)) + return {"ok": True, "ct_number": ct_number, "algorithm_model": algorithm_model, "registration_status": registration_status} diff --git a/DICOM_and_UPP配准/static/app.js b/DICOM_and_UPP配准/static/app.js index 1fa7971..e7e5d7f 100644 --- a/DICOM_and_UPP配准/static/app.js +++ b/DICOM_and_UPP配准/static/app.js @@ -2,43 +2,9 @@ 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(), - selectedAlgorithmModel: "", - 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 = { +const BODY_LABELS = { head_neck: "头颈部", chest: "胸部", upper_abdomen: "上腹部", @@ -46,1066 +12,887 @@ const bodyPartLabels = { pelvis: "盆腔", }; -const familyColors = [ - "#38d7c7", - "#f4a261", - "#d88cf6", - "#54df8c", - "#60a5fa", - "#f87171", - "#facc15", - "#a3e635", - "#22d3ee", - "#f472b6", +const DEFAULT_POSE = { + rotateX: 0, + rotateY: 0, + rotateZ: 0, + translateX: 0, + translateY: 0, + translateZ: 0, + scale: 1, + flipX: false, + flipY: false, + flipZ: false, +}; + +const POSE_CONTROLS = [ + { key: "rotateX", label: "旋转 X", min: -180, max: 180, step: 1, suffix: "°" }, + { key: "rotateY", label: "旋转 Y", min: -180, max: 180, step: 1, suffix: "°" }, + { key: "rotateZ", label: "旋转 Z", min: -180, max: 180, step: 1, suffix: "°" }, + { key: "translateX", label: "平移 X", min: -4, max: 4, step: 0.01, suffix: "" }, + { key: "translateY", label: "平移 Y", min: -4, max: 4, step: 0.01, suffix: "" }, + { key: "translateZ", label: "平移 Z", min: -4, max: 4, step: 0.01, suffix: "" }, + { key: "scale", label: "缩放", min: 0.2, max: 3, step: 0.01, suffix: "x" }, ]; -function defaultPose() { - return { - translateX: 0, - translateY: 0, - translateZ: 0, - rotateX: 0, - rotateY: 0, - rotateZ: 0, - scale: 1, - flipX: false, - flipY: false, - flipZ: false, - }; -} +const STL_COLORS = [0x18d8c9, 0x3f82ff, 0xf5b84b, 0xf87171, 0x9bd76f, 0xd1a6ff, 0x66e0ff, 0xff9f7a]; + +const state = { + token: localStorage.getItem("dicom_upp_registration_token") || "", + user: null, + links: { pacs_viewer_url: "http://127.0.0.1:8107", relation_viewer_url: "http://127.0.0.1:8108" }, + statusFilter: "", + partFilter: "", + search: "", + cases: [], + activeCase: null, + series: [], + selectedSeriesUid: "", + stlFiles: [], + selectedStlIds: new Set(), + algorithmModel: "", + registrationStatus: "unregistered", + pose: { ...DEFAULT_POSE }, + windowMode: "default", + sliceIndex: 0, + dirty: false, + saving: false, + fusionVersion: 0, + volumeMeta: null, + sceneReady: false, + renderer: null, + camera: null, + scene: null, + controls: null, + dicomGroup: null, + modelGroup: null, + rawModelGroup: null, + baseModelScale: 1, + resizeObserver: null, +}; function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); + .replaceAll('"', """); } -function asList(value) { - return Array.isArray(value) ? value : []; +function cls(value) { + return value ? "active" : ""; } -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 setTopLoading(visible) { + $("topLoading").classList.toggle("hidden", !visible); } -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 setFusionLoading(visible, text = "正在构建融合视图") { + $("fusionLoading").classList.toggle("hidden", !visible); + $("fusionLoading").querySelector("span").textContent = text; } -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 setSaveState(text, tone = "") { + const el = $("saveState"); + el.textContent = text; + el.className = tone; } -function authParams(params = {}) { - const output = new URLSearchParams(params); - if (state.token) output.set("access_token", state.token); - return output; +function markDirty() { + state.dirty = true; + setSaveState("未保存", "warn"); } -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"); +function resetDirty() { + state.dirty = false; + setSaveState("已保存", "ok"); +} + +async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (state.token) headers.set("Authorization", `Bearer ${state.token}`); + if (options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json"); + const response = await fetch(path, { ...options, headers }); + if (response.status === 401) { + logout(false); + throw new Error("登录已过期"); } - if (!res.ok) { - let detail = res.statusText; + if (!response.ok) { + let message = response.statusText; try { - const data = await res.json(); - detail = data.detail || detail; - } catch (_) { - detail = await res.text(); + const payload = await response.json(); + message = payload.detail || message; + } catch { + message = await response.text(); } - throw new Error(detail); + throw new Error(message || "请求失败"); } - return res; + const type = response.headers.get("content-type") || ""; + return type.includes("application/json") ? response.json() : response.text(); } -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) => ` -
- - - -
- `, - ) - .join("")} -
- - - -
- `; - document.querySelectorAll("[data-pose-range], [data-pose-number]").forEach((input) => { - input.addEventListener("input", () => { - const key = input.dataset.poseRange || input.dataset.poseNumber; - state.pose[key] = Number(input.value); - syncPoseInputs(); - markUnsaved(); - updateScenesPose(); - }); - }); - document.querySelectorAll("[data-flip]").forEach((button) => { - button.addEventListener("click", () => { - const key = button.dataset.flip; - state.pose[key] = !state.pose[key]; - syncPoseInputs(); - markUnsaved(); - updateScenesPose(); - }); - }); - syncPoseInputs(); -} - -function syncPoseInputs() { - poseFields.forEach((field) => { - const value = Number(state.pose[field.key] ?? defaultPose()[field.key]); - document.querySelectorAll(`[data-pose-range="${field.key}"], [data-pose-number="${field.key}"]`).forEach((input) => { - input.value = field.step < 1 ? value.toFixed(3) : String(Math.round(value)); - }); - }); - document.querySelectorAll("[data-flip]").forEach((button) => { - button.classList.toggle("active", Boolean(state.pose[button.dataset.flip])); - }); -} - -function markUnsaved() { - $("saveState").textContent = "未保存"; - $("fusionMeta").textContent = "位姿未保存"; -} - -function setDbStatus(data) { - state.links = data.links || state.links; - const pill = $("dbStatus"); - if (data.database?.ok) { - pill.textContent = `${data.database.database} 已连接`; - pill.className = "status-pill online"; - } else { - pill.textContent = "数据库异常"; - pill.className = "status-pill offline"; - } -} - -async function loadStatus() { - const data = await json("/api/status"); - setDbStatus(data); -} - -async function loadCases() { - setLoading(true); - try { - const q = $("caseSearch").value.trim(); - const params = new URLSearchParams(); - if (q) params.set("q", q); - params.set("limit", "120"); - state.cases = await json(`/api/cases?${params.toString()}`); - renderCases(); - const targetCt = new URLSearchParams(location.search).get("ct_number"); - const next = targetCt - ? state.cases.find((row) => String(row.ct_key).toUpperCase() === targetCt.toUpperCase() || String(row.pacs_ct_number).toUpperCase() === targetCt.toUpperCase()) - : state.cases[0]; - if (next) await selectCase(next.ct_key); - } finally { - setLoading(false); - } -} - -function renderPartTags(row) { - const parts = asList(row.body_parts).map((part) => bodyPartLabels[part] || part); - return parts.map((part) => `${escapeHtml(part)}`).join(""); -} - -function renderCases() { - $("caseCount").textContent = String(state.cases.length); - if (!state.cases.length) { - $("caseList").innerHTML = `
没有 DICOM + STL 匹配检查
`; - return; - } - $("caseList").innerHTML = state.cases - .map((row) => { - const active = state.activeCase?.ct_key === row.ct_key ? "active" : ""; - const regLabel = row.registration_count ? `已配准 ${row.registered_series || 0}` : "未配准"; - const lockLabel = row.locked_count ? `${Number(row.locked_count)} 已锁` : ""; - return ` - - `; - }) - .join(""); - document.querySelectorAll("[data-case]").forEach((button) => { - button.addEventListener("click", () => selectCase(button.dataset.case)); - }); -} - -async function selectCase(ctKey) { - if (!ctKey) return; - setLoading(true); - try { - const [detail, seriesPayload, stlPayload, regPayload] = await Promise.all([ - json(`/api/cases/${encodeURIComponent(ctKey)}`), - json(`/api/cases/${encodeURIComponent(ctKey)}/series`), - json(`/api/cases/${encodeURIComponent(ctKey)}/stl`), - json(`/api/registrations/${encodeURIComponent(ctKey)}`), - ]); - state.activeCase = detail; - state.series = seriesPayload.series || []; - state.stlFiles = stlPayload.files || []; - state.registrations = regPayload.registrations || []; - chooseDefaultStl(); - const diagnosticSeries = [...state.series] - .filter((item) => Number(item.count || 0) >= 80 && !asList(item.annotation?.labels).includes("略过/不采用")) - .sort((left, right) => Number(right.count || 0) - Number(left.count || 0)); - const largestSeries = [...state.series].sort((left, right) => Number(right.count || 0) - Number(left.count || 0)); - state.selectedSeries = state.series.find((item) => item.registration?.registered) || diagnosticSeries[0] || largestSeries[0] || null; - state.sliceIndex = Math.max(0, Math.floor((state.selectedSeries?.count || 1) / 2)); - applyLatestRegistration(); - renderAll(); - await reloadVisuals(); - } catch (err) { - alert(err.message); - } finally { - setLoading(false); - } -} - -function chooseDefaultStl() { - state.selectedStlIds.clear(); - state.selectedFamilies.clear(); - if (!state.stlFiles.length) { - state.selectedAlgorithmModel = ""; - return; - } - const models = algorithmModels(); - const nextModel = models.includes(state.selectedAlgorithmModel) ? state.selectedAlgorithmModel : models[0] || ""; - selectDefaultStlForModel(nextModel); -} - -function algorithmModels() { - return Array.from(new Set(state.stlFiles.map((item) => item.algorithm_model || "未指定模型").filter(Boolean))).sort(); -} - -function currentAlgorithmModel() { - const selected = selectedStlFiles(); - const selectedModel = selected.find((item) => item.algorithm_model)?.algorithm_model; - if (selectedModel) return selectedModel; - if (state.selectedAlgorithmModel) return state.selectedAlgorithmModel; - return algorithmModels()[0] || state.activeCase?.algorithm_model || "未指定模型"; -} - -function stlFilesForSelectedModel() { - const model = currentAlgorithmModel(); - return model ? state.stlFiles.filter((item) => (item.algorithm_model || "未指定模型") === model) : state.stlFiles; -} - -function selectDefaultStlForModel(model) { - state.selectedStlIds.clear(); - state.selectedFamilies.clear(); - state.selectedAlgorithmModel = model || algorithmModels()[0] || ""; - const candidates = stlFilesForSelectedModel(); - if (!candidates.length) return; - const families = Array.from(new Set(candidates.map((item) => item.family).filter(Boolean))); - const preferred = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen"]; - const selectedFamily = preferred.find((family) => families.includes(family)) || families[0]; - if (selectedFamily) { - state.selectedFamilies.add(selectedFamily); - candidates.filter((item) => item.family === selectedFamily).forEach((item) => state.selectedStlIds.add(Number(item.id))); - } else { - state.selectedStlIds.add(Number(candidates[0].id)); - } -} - -function renderAll() { - renderCases(); - renderHero(); - renderSeries(); - renderStl(); - renderPoseControls(); - updateDICOMControls(); - renderMaskPreview(); -} - -function renderHero() { - const row = state.activeCase; - $("activeTitle").textContent = row?.ct_key || "未选择 CT"; - $("activeMeta").textContent = row - ? `${row.patient_name || row.upp_patient_name || "无姓名"} · ${fmtDate(row.study_date)} ${fmtTime(row.study_time)} · ${Number(row.series_count || 0)} 序列` - : "从左侧选择一个 DICOM + STL 匹配检查"; - const labels = [ - row?.algorithm_model, - ...asList(row?.body_parts).map((part) => bodyPartLabels[part] || part), - row?.registration_count ? `配准 ${row.registered_series || 0}` : "未配准", - row?.locked_count ? `锁定 ${row.locked_count}` : "", - ].filter(Boolean); - $("activeTags").innerHTML = labels.map((label) => `${escapeHtml(label)}`).join(""); - $("brandSubtitle").textContent = row?.ct_key ? `${row.ct_key} · STL ${Number(row.stl_file_count || 0)} 个` : "DICOM 序列、STL family 与人工位姿锁定"; -} - -function seriesTime(row) { - const start = fmtTime(row.first_time || row.series_time); - const end = fmtTime(row.last_time || ""); - return start && end && start !== end ? `${start}-${end}` : start || end || "-"; -} - -function renderSeries() { - $("seriesCount").textContent = String(state.series.length); - if (!state.series.length) { - $("seriesList").innerHTML = `
没有可用 DICOM 序列
`; - return; - } - $("seriesList").innerHTML = state.series - .map((row) => { - const active = state.selectedSeries?.series_uid === row.series_uid ? "active" : ""; - const reg = row.registration || {}; - const labels = asList(row.annotation?.labels); - return ` - - `; - }) - .join(""); - document.querySelectorAll("[data-series]").forEach((button) => { - button.addEventListener("click", async () => { - const next = state.series.find((item) => item.series_uid === button.dataset.series); - if (!next) return; - state.selectedSeries = next; - state.sliceIndex = Math.max(0, Math.floor((next.count || 1) / 2)); - applyLatestRegistration(); - renderAll(); - await reloadVisuals(); - }); - }); -} - -function renderStl() { - $("stlCount").textContent = String(state.stlFiles.length); - $("modeFile").classList.toggle("active", state.mode === "file"); - $("modeFamily").classList.toggle("active", state.mode === "family"); - const models = algorithmModels(); - if (models.length && !models.includes(state.selectedAlgorithmModel)) { - state.selectedAlgorithmModel = models[0]; - } - $("modelRail").innerHTML = models - .map((model) => ``) - .join(""); - const visibleFiles = stlFilesForSelectedModel(); - const families = Array.from(new Set(visibleFiles.map((item) => item.family).filter(Boolean))); - $("familyRail").innerHTML = families - .map((family) => ``) - .join(""); - document.querySelectorAll("[data-model]").forEach((button) => { - button.addEventListener("click", async () => { - selectDefaultStlForModel(button.dataset.model); - markUnsaved(); - renderStl(); - await reloadStlVisuals(); - }); - }); - document.querySelectorAll("[data-family]").forEach((button) => { - button.addEventListener("click", async () => { - const family = button.dataset.family; - if (state.selectedFamilies.has(family)) state.selectedFamilies.delete(family); - else state.selectedFamilies.add(family); - state.selectedStlIds.clear(); - visibleFiles.filter((item) => state.selectedFamilies.has(item.family)).forEach((item) => state.selectedStlIds.add(Number(item.id))); - markUnsaved(); - renderStl(); - await reloadStlVisuals(); - }); - }); - $("stlList").innerHTML = visibleFiles.length - ? visibleFiles - .map((row) => { - const checked = state.selectedStlIds.has(Number(row.id)); - return ` - - `; - }) - .join("") - : `
没有 STL 文件
`; - document.querySelectorAll("[data-stl]").forEach((input) => { - input.addEventListener("change", async () => { - const id = Number(input.dataset.stl); - if (input.checked) state.selectedStlIds.add(id); - else state.selectedStlIds.delete(id); - state.selectedFamilies = new Set( - Array.from(new Set(selectedStlFiles().map((item) => item.family).filter(Boolean))), - ); - markUnsaved(); - renderStl(); - await reloadStlVisuals(); - }); - }); -} - -function selectedStlFiles() { - return state.stlFiles.filter((item) => state.selectedStlIds.has(Number(item.id))); -} - -function currentStlLabel() { - const selected = selectedStlFiles(); - if (!selected.length) return "未选择STL"; - const families = Array.from(new Set(selected.map((item) => item.family).filter(Boolean))); - if (state.mode === "family" && families.length) return families.join("+"); - return selected.map((item) => item.segment_name || item.file_name).slice(0, 4).join("+") + (selected.length > 4 ? `等${selected.length}个` : ""); -} - -function updateDICOMControls() { - const series = state.selectedSeries; - $("dicomMeta").textContent = series ? `${series.description} · ${series.count} 张` : "未选择序列"; - $("sliceSlider").max = String(Math.max(0, Number(series?.count || 1) - 1)); - $("sliceSlider").value = String(Math.min(state.sliceIndex, Number($("sliceSlider").max || 0))); - $("sliceLabel").textContent = series ? `${state.sliceIndex + 1} / ${Number(series.count || 0)}` : "0 / 0"; -} - -function imageUrl() { - if (!state.activeCase || !state.selectedSeries) return ""; - return `/api/image?${authParams({ - ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key, - series_uid: state.selectedSeries.series_uid, - index: String(state.sliceIndex), - plane: state.plane, - window: state.window, - }).toString()}`; -} - -async function reloadVisuals() { - updateDICOMControls(); - await Promise.all([reloadDicomImage(), reloadStlVisuals()]); -} - -async function reloadDicomImage() { - const url = imageUrl(); - $("dicomEmpty").classList.toggle("hidden", Boolean(url)); - $("segmentationEmpty").classList.toggle("hidden", Boolean(url && selectedStlFiles().length)); - if (!url) { - $("dicomImage").removeAttribute("src"); - $("segmentationImage").removeAttribute("src"); - return; - } - $("dicomImage").src = url; - $("segmentationImage").src = url; - if (state.scenes.fusion) state.scenes.fusion.setDicomTexture(url, state.selectedSeries); - renderMaskPreview(); -} - -async function reloadStlVisuals() { - const selected = selectedStlFiles(); - $("stlMeta").textContent = selected.length ? `${selected.length} 个构件 · ${currentStlLabel()}` : "未选择模型"; - $("fusionMeta").textContent = $("saveState").textContent === "已保存" ? `已保存 · ${currentStlLabel()}` : `当前选择 · ${currentStlLabel()}`; - if (state.scenes.stl) state.scenes.stl.loadMeshes(selected); - if (state.scenes.fusion) state.scenes.fusion.loadMeshes(selected); - renderMaskPreview(); -} - -function applyLatestRegistration() { - const seriesUid = state.selectedSeries?.series_uid || ""; - const model = currentAlgorithmModel(); - const latest = - state.registrations.find((item) => item.algorithm_model === model) || - state.registrations.find((item) => item.series_instance_uid === seriesUid) || - null; - if (latest?.transform) { - state.pose = { ...defaultPose(), ...latest.transform }; - $("registrationNotes").value = latest.notes || ""; - $("saveState").textContent = latest.locked ? "已锁定" : "已保存"; - $("fusionMeta").textContent = latest.locked ? "配准已锁定" : "已载入已保存位姿"; - } else { - state.pose = defaultPose(); - $("registrationNotes").value = ""; - $("saveState").textContent = "未保存"; - $("fusionMeta").textContent = "位姿未保存"; - } - updateScenesPose(); -} - -async function saveRegistration(locked = false, forceUnlock = false) { - if (!state.activeCase || !state.selectedSeries) return; - const selected = selectedStlFiles(); - if (!selected.length) { - alert("请至少选择一个 STL 构件"); - return; - } - const selectedModels = Array.from(new Set(selected.map((item) => item.algorithm_model || "未指定模型"))); - if (selectedModels.length > 1) { - alert("同一次配准只能选择同一个算法模型的 STL"); - return; - } - const payload = { - ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key, - series_instance_uid: state.selectedSeries.series_uid, - series_description: state.selectedSeries.description || "", - algorithm_model: selectedModels[0] || currentAlgorithmModel(), - stl_family: currentStlLabel(), - view_mode: state.mode, - selected_stl_files: selected.map((item) => ({ - id: item.id, - file_name: item.file_name, - segment_name: item.segment_name, - family: item.family, - category: item.category, - algorithm_model: item.algorithm_model, - })), - transform: state.pose, - dicom_reference: { - series_number: state.selectedSeries.series_number, - slice_index: state.sliceIndex, - plane: state.plane, - window: state.window, - rows: state.selectedSeries.rows, - columns: state.selectedSeries.columns, - pixel_spacing: state.selectedSeries.pixel_spacing, - slice_thickness: state.selectedSeries.slice_thickness, - spacing_between_slices: state.selectedSeries.spacing_between_slices, - }, - model_reference: { algorithm_model: selectedModels[0] || currentAlgorithmModel(), selected_count: selected.length, families: Array.from(state.selectedFamilies) }, - segmentation: { preview: state.maskVisible, source: "visible_stl_preview" }, - notes: $("registrationNotes").value.trim(), - locked, - force_unlock: forceUnlock, - }; - try { - await json("/api/registrations", { method: "POST", body: JSON.stringify(payload) }); - $("saveState").textContent = locked ? "已锁定" : "已保存"; - $("fusionMeta").textContent = locked ? "配准已保存并锁定" : "配准已保存"; - const regPayload = await json(`/api/registrations/${encodeURIComponent(payload.ct_number)}`); - state.registrations = regPayload.registrations || []; - renderSeries(); - } catch (err) { - alert(err.message); - } -} - -async function exportStl() { - if (!state.activeCase) return; - const selected = selectedStlFiles(); - const payload = { - ct_number: state.activeCase.pacs_ct_number || state.activeCase.ct_key, - mode: state.mode, - file_ids: selected.map((item) => Number(item.id)), - families: Array.from(state.selectedFamilies), - include_pose: true, - transform: state.pose, - label: currentStlLabel(), - }; - const res = await request("/api/export/stl", { method: "POST", body: JSON.stringify(payload) }); - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = `DICOM_UPP_STL_${payload.ct_number}.zip`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); -} - -function colorFor(item, index) { - const key = item.family || item.segment_name || String(index); - let hash = 0; - for (let i = 0; i < key.length; i += 1) hash = (hash * 31 + key.charCodeAt(i)) % 997; - return familyColors[hash % familyColors.length]; -} - -function createFallbackScene(container, { fusion = false } = {}) { - const canvas = document.createElement("canvas"); - const ctx = canvas.getContext("2d"); - container.appendChild(canvas); - let files = []; - let dicomImage = null; - - function resize() { - const dpr = Math.min(window.devicePixelRatio || 1, 2); - const width = Math.max(1, container.clientWidth); - const height = Math.max(1, container.clientHeight); - canvas.width = Math.floor(width * dpr); - canvas.height = Math.floor(height * dpr); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - draw(); - } - - function draw() { - const width = container.clientWidth || 1; - const height = container.clientHeight || 1; - ctx.clearRect(0, 0, width, height); - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, width, height); - if (fusion && dicomImage?.complete && dicomImage.naturalWidth) { - const scale = Math.min(width / dicomImage.naturalWidth, height / dicomImage.naturalHeight) * 0.92; - const w = dicomImage.naturalWidth * scale; - const h = dicomImage.naturalHeight * scale; - ctx.globalAlpha = 0.72; - ctx.drawImage(dicomImage, (width - w) / 2, (height - h) / 2, w, h); - ctx.globalAlpha = 1; - } - const pose = state.pose; - const cx = width / 2 + Number(pose.translateX || 0) * width * 0.14; - const cy = height / 2 - Number(pose.translateY || 0) * height * 0.14; - const base = Math.max(20, Math.min(width, height) * 0.11 * Number(pose.scale || 1)); - ctx.save(); - ctx.translate(cx, cy); - ctx.rotate(THREE.MathUtils.degToRad(Number(pose.rotateZ || 0))); - files.slice(0, 12).forEach((file, index) => { - const angle = (index / Math.max(files.length, 1)) * Math.PI * 2; - const radius = base * (1.1 + (index % 4) * 0.16); - const x = Math.cos(angle) * radius; - const y = Math.sin(angle) * radius * 0.72; - ctx.fillStyle = `${colorFor(file, index)}99`; - ctx.strokeStyle = colorFor(file, index); - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(x, y, base * (0.42 + (index % 3) * 0.07), base * (0.26 + (index % 2) * 0.05), angle, 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - }); - ctx.restore(); - ctx.fillStyle = "rgba(234,242,251,0.72)"; - ctx.font = "12px ui-sans-serif, system-ui"; - ctx.fillText("WebGL不可用,已启用2D预览", 14, 22); - ctx.fillText(`${files.length} 个 STL 构件`, 14, height - 18); - } - - resize(); - window.addEventListener("resize", resize); - return { - loadMeshes(nextFiles) { - files = nextFiles || []; - draw(); - }, - setDicomTexture(url) { - if (!fusion || !url) return; - dicomImage = new Image(); - dicomImage.onload = draw; - dicomImage.src = url; - }, - updatePose: draw, - fit: draw, - dispose() { - window.removeEventListener("resize", resize); - container.innerHTML = ""; - }, - }; -} - -function createScene(container, { fusion = false } = {}) { - const scene = new THREE.Scene(); - scene.background = new THREE.Color("#000000"); - const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 2000); - camera.position.set(0, -5.5, 3.2); - camera.up.set(0, 0, 1); - let renderer; - try { - renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); - } catch (error) { - console.warn("WebGL不可用,切换到2D预览", error); - return createFallbackScene(container, { fusion }); - } - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); - container.appendChild(renderer.domElement); - - const controls = new OrbitControls(camera, renderer.domElement); - controls.enableDamping = true; - controls.dampingFactor = 0.08; - controls.target.set(0, 0, 0); - - scene.add(new THREE.AmbientLight(0xffffff, 0.72)); - const keyLight = new THREE.DirectionalLight(0xffffff, 1.15); - keyLight.position.set(4, -5, 5); - scene.add(keyLight); - const fillLight = new THREE.DirectionalLight(0x8fb8ff, 0.5); - fillLight.position.set(-4, 3, 2); - scene.add(fillLight); - - const modelGroup = new THREE.Group(); - const pivot = new THREE.Group(); - modelGroup.add(pivot); - scene.add(modelGroup); - - let dicomPlane = null; - let dicomTexture = null; - if (fusion) { - const geometry = new THREE.PlaneGeometry(4.2, 4.2); - const material = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.76, side: THREE.DoubleSide }); - dicomPlane = new THREE.Mesh(geometry, material); - dicomPlane.position.z = -0.02; - scene.add(dicomPlane); - } - - let baseScale = 1; - let disposed = false; - const loader = new STLLoader(); - - function resize() { - const width = Math.max(1, container.clientWidth); - const height = Math.max(1, container.clientHeight); - camera.aspect = width / height; - camera.updateProjectionMatrix(); - renderer.setSize(width, height, false); - } - - function fit() { - camera.position.set(0, -5.5, 3.2); - controls.target.set(0, 0, 0); - controls.update(); - } - - function clearMeshes() { - while (pivot.children.length) { - const child = pivot.children.pop(); - if (child.geometry) child.geometry.dispose(); - if (child.material) child.material.dispose(); - } - } - - function loadMeshes(files) { - clearMeshes(); - if (!files.length || !state.activeCase) return; - const ct = state.activeCase.pacs_ct_number || state.activeCase.ct_key; - const promises = files.map( - (file, index) => - new Promise((resolve) => { - const url = `/api/stl?${authParams({ ct_number: ct, file_id: String(file.id) }).toString()}`; - loader.load( - url, - (geometry) => { - geometry.computeBoundingBox(); - geometry.computeVertexNormals(); - const material = new THREE.MeshStandardMaterial({ - color: new THREE.Color(colorFor(file, index)), - roughness: 0.52, - metalness: 0.02, - transparent: fusion, - opacity: fusion ? 0.72 : 1, - side: THREE.DoubleSide, - }); - const mesh = new THREE.Mesh(geometry, material); - mesh.userData = file; - pivot.add(mesh); - resolve(mesh); - }, - undefined, - () => resolve(null), - ); - }), - ); - Promise.all(promises).then(() => { - if (disposed || !pivot.children.length) return; - const box = new THREE.Box3().setFromObject(pivot); - const center = box.getCenter(new THREE.Vector3()); - const size = box.getSize(new THREE.Vector3()); - const maxSize = Math.max(size.x, size.y, size.z, 1); - pivot.children.forEach((child) => { - child.geometry.translate(-center.x, -center.y, -center.z); - child.geometry.computeBoundingBox(); - child.geometry.computeBoundingSphere(); - }); - baseScale = 4.2 / maxSize; - fit(); - updatePose(); - }); - } - - function updatePose() { - const pose = state.pose; - modelGroup.rotation.set( - THREE.MathUtils.degToRad(Number(pose.rotateX || 0)), - THREE.MathUtils.degToRad(Number(pose.rotateY || 0)), - THREE.MathUtils.degToRad(Number(pose.rotateZ || 0)), - ); - modelGroup.position.set(Number(pose.translateX || 0), Number(pose.translateY || 0), Number(pose.translateZ || 0)); - const scale = baseScale * Number(pose.scale || 1); - modelGroup.scale.set(pose.flipX ? -scale : scale, pose.flipY ? -scale : scale, pose.flipZ ? -scale : scale); - } - - function setDicomTexture(url, series) { - if (!fusion || !dicomPlane || !url) return; - new THREE.TextureLoader().load(url, (texture) => { - if (dicomTexture) dicomTexture.dispose(); - dicomTexture = texture; - dicomPlane.material.map = texture; - dicomPlane.material.needsUpdate = true; - const rows = Number(series?.rows || 512); - const cols = Number(series?.columns || 512); - const width = 4.2; - const height = rows && cols ? width * (rows / cols) : width; - dicomPlane.geometry.dispose(); - dicomPlane.geometry = new THREE.PlaneGeometry(width, height); - }); - } - - function animate() { - if (disposed) return; - resize(); - updatePose(); - controls.update(); - renderer.render(scene, camera); - requestAnimationFrame(animate); - } - animate(); - - window.addEventListener("resize", resize); - return { - loadMeshes, - setDicomTexture, - updatePose, - fit, - dispose() { - disposed = true; - window.removeEventListener("resize", resize); - clearMeshes(); - if (dicomTexture) dicomTexture.dispose(); - renderer.dispose(); - container.innerHTML = ""; - }, - }; -} - -function updateScenesPose() { - Object.values(state.scenes).forEach((scene) => scene?.updatePose?.()); - renderMaskPreview(); -} - -function renderMaskPreview() { - const canvas = $("maskCanvas"); - const ctx = canvas.getContext("2d"); - const rect = canvas.getBoundingClientRect(); - const dpr = Math.min(window.devicePixelRatio || 1, 2); - canvas.width = Math.max(1, Math.floor(rect.width * dpr)); - canvas.height = Math.max(1, Math.floor(rect.height * dpr)); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - ctx.clearRect(0, 0, rect.width, rect.height); - if (!state.maskVisible || !state.selectedSeries || !selectedStlFiles().length) return; - const files = selectedStlFiles().slice(0, 9); - files.forEach((file, index) => { - const color = colorFor(file, index); - const x = rect.width * (0.36 + (index % 3) * 0.12 + Number(state.pose.translateX || 0) * 0.08); - const y = rect.height * (0.38 + Math.floor(index / 3) * 0.12 - Number(state.pose.translateY || 0) * 0.08); - const radius = Math.max(18, Math.min(rect.width, rect.height) * (0.07 + (Number(state.pose.scale || 1) - 1) * 0.015)); - ctx.fillStyle = `${color}66`; - ctx.strokeStyle = color; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.ellipse(x, y, radius * (1.15 - (index % 2) * 0.24), radius * (0.76 + (index % 3) * 0.09), THREE.MathUtils.degToRad(Number(state.pose.rotateZ || 0) + index * 18), 0, Math.PI * 2); - ctx.fill(); - ctx.stroke(); - }); - ctx.fillStyle = "rgba(234,242,251,0.78)"; - ctx.font = "12px ui-sans-serif, system-ui"; - ctx.fillText(`${selectedStlFiles().length} 个可见 STL · 预览叠加`, 14, rect.height - 16); +function loginVisible(visible) { + $("loginOverlay").classList.toggle("hidden", !visible); } async function login(event) { event.preventDefault(); $("loginError").textContent = ""; try { - const data = await json("/api/auth/login", { + const payload = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }), }); - state.token = data.token; - state.user = { username: data.username, role: data.role }; + state.token = payload.token; + state.user = payload; localStorage.setItem("dicom_upp_registration_token", state.token); - applyAuthUi(); - showLogin(false); - await refreshAll(); - } catch (_) { - $("loginError").textContent = "登录失败"; + loginVisible(false); + await bootstrap(); + } catch (error) { + $("loginError").textContent = error.message; } } -function logout() { +function logout(clearToken = true) { + if (clearToken) localStorage.removeItem("dicom_upp_registration_token"); state.token = ""; state.user = null; - localStorage.removeItem("dicom_upp_registration_token"); - applyAuthUi(); - showLogin(true); + loginVisible(true); + $("userBadge").textContent = "未登录"; } -function applyAuthUi() { - $("userBadge").textContent = state.user ? `${state.user.username} · ${state.user.role}` : "未登录"; -} - -async function loadCurrentUser() { - if (!state.token) { - showLogin(true); - return false; - } - try { - state.user = await json("/api/auth/me"); - applyAuthUi(); - showLogin(false); - return true; - } catch (_) { - state.token = ""; - localStorage.removeItem("dicom_upp_registration_token"); - applyAuthUi(); - showLogin(true); - return false; - } -} - -async function refreshAll() { - setLoading(true); +async function bootstrap() { + setTopLoading(true); try { + const me = await api("/api/auth/me"); + state.user = me; + $("userBadge").textContent = `${me.username} · ${me.role || "管理员"}`; + loginVisible(false); + try { + initScene(); + } catch (error) { + $("fusionStatus").textContent = `WebGL 初始化失败:${error.message}`; + console.warn(error); + } + buildPoseControls(); await loadStatus(); await loadCases(); - } catch (err) { - $("dbStatus").textContent = err.message; - $("dbStatus").className = "status-pill offline"; + } catch (error) { + console.warn(error); + if (!state.token) logout(false); + else { + $("dbStatus").textContent = error.message || "启动失败"; + $("dbStatus").className = "status-pill offline"; + loginVisible(false); + } } finally { - setLoading(false); + setTopLoading(false); } } -function wire() { +async function loadStatus() { + try { + const payload = await api("/api/status"); + state.links = payload.links || state.links; + const db = payload.database || {}; + $("dbStatus").textContent = db.ok ? `数据库已连接 · ${payload.counts?.complete_cases || 0}` : "数据库异常"; + $("dbStatus").className = `status-pill ${db.ok ? "online" : "offline"}`; + } catch (error) { + $("dbStatus").textContent = "数据库异常"; + $("dbStatus").className = "status-pill offline"; + } +} + +async function loadCases(selectFirst = true) { + setTopLoading(true); + try { + const params = new URLSearchParams(); + if (state.search) params.set("q", state.search); + if (state.statusFilter) params.set("status", state.statusFilter); + if (state.partFilter) params.set("body_part", state.partFilter); + const rows = await api(`/api/cases?${params.toString()}`); + state.cases = rows; + renderCases(); + if (selectFirst && !state.activeCase && rows.length) { + await selectCase(rows[0].ct_number, rows[0].algorithm_model || "未指定模型"); + } else if (state.activeCase && !rows.some((row) => sameCase(row, state.activeCase))) { + state.activeCase = null; + clearDetail(); + } + } finally { + setTopLoading(false); + } +} + +function sameCase(a, b) { + return normalize(a?.ct_number) === normalize(b?.ct_number) && (a?.algorithm_model || "未指定模型") === (b?.algorithm_model || "未指定模型"); +} + +function normalize(value) { + return String(value || "").trim().toUpperCase(); +} + +function fmtDateTime(date, time) { + const d = String(date || "").replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3"); + const t = fmtTime(time); + return [d, t].filter(Boolean).join(" "); +} + +function fmtTime(value) { + const raw = String(value || "").replace(/[^\d.]/g, ""); + if (!raw) return ""; + const main = raw.split(".")[0].padEnd(6, "0").slice(0, 6); + return `${main.slice(0, 2)}:${main.slice(2, 4)}:${main.slice(4, 6)}`; +} + +function statusText(value) { + return value === "registered" ? "已配准" : "未配准"; +} + +function bodyPartTags(row) { + const labels = Array.isArray(row?.body_part_labels) ? row.body_part_labels : []; + const keys = Array.isArray(row?.body_part_keys) ? row.body_part_keys : []; + if (labels.length) return labels; + return keys.map((key) => BODY_LABELS[key]).filter(Boolean); +} + +function renderCases() { + $("caseCount").textContent = state.cases.length; + const active = state.activeCase; + $("caseList").innerHTML = state.cases.length + ? state.cases + .map((row) => { + const tags = bodyPartTags(row).slice(0, 3); + const status = row.registration_status || "unregistered"; + const selected = active && sameCase(row, active); + const title = escapeHtml(row.ct_number || row.ct_key); + const patient = escapeHtml(row.patient_name || row.upp_patient_name || "-"); + const meta = `${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`; + return ` + + `; + }) + .join("") + : `
没有符合条件的完整关联 CT
`; + $("caseList").querySelectorAll(".case-card").forEach((button) => { + button.addEventListener("click", () => selectCase(button.dataset.ct, button.dataset.algorithm || "未指定模型")); + }); +} + +function clearDetail() { + state.series = []; + state.stlFiles = []; + state.selectedStlIds = new Set(); + state.selectedSeriesUid = ""; + $("activeTitle").textContent = "未选择 CT"; + $("activeMeta").textContent = "从左侧选择一个完整关联检查"; + $("activeTags").innerHTML = ""; + $("seriesList").innerHTML = `
未选择 CT
`; + $("stlList").innerHTML = `
未选择 STL
`; + $("modelRail").innerHTML = ""; + clearFusion(); +} + +async function confirmChange() { + if (!state.dirty) return true; + return window.confirm("当前配准参数还未保存,确定切换吗?"); +} + +async function selectCase(ctNumber, algorithmModel = "未指定模型") { + if (!ctNumber || !(await confirmChange())) return; + setTopLoading(true); + setFusionLoading(true, "正在读取 CT 关联数据"); + try { + const params = new URLSearchParams({ algorithm_model: algorithmModel || "未指定模型" }); + const [detail, seriesPayload, stlPayload, registration] = await Promise.all([ + api(`/api/cases/${encodeURIComponent(ctNumber)}?${params.toString()}`), + api(`/api/cases/${encodeURIComponent(ctNumber)}/series`), + api(`/api/cases/${encodeURIComponent(ctNumber)}/stl?${params.toString()}`), + api(`/api/registrations/${encodeURIComponent(ctNumber)}?${params.toString()}`), + ]); + state.activeCase = detail; + state.algorithmModel = detail.algorithm_model || algorithmModel || "未指定模型"; + state.series = seriesPayload.series || []; + state.stlFiles = stlPayload.files || []; + state.registrationStatus = registration.registration_status || "unregistered"; + state.pose = { ...DEFAULT_POSE, ...(registration.transform || {}) }; + state.sliceIndex = 0; + $("notes").value = registration.notes || ""; + + const savedIds = new Set((registration.selected_stl_files || []).map((item) => Number(item.id)).filter(Number.isFinite)); + state.selectedStlIds = savedIds.size ? savedIds : pickDefaultStlIds(state.stlFiles); + state.selectedSeriesUid = registration.series_instance_uid || pickDefaultSeries(state.series)?.series_uid || ""; + const selected = currentSeries(); + state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; + + renderCases(); + renderActiveHeader(); + renderSeries(); + renderStl(); + renderPoseControls(); + resetDirty(); + await loadFusion(); + } catch (error) { + $("fusionStatus").textContent = error.message; + clearFusion(); + } finally { + setTopLoading(false); + setFusionLoading(false); + } +} + +function pickDefaultSeries(series) { + return [...series] + .filter((item) => Number(item.count || 0) > 1) + .sort((a, b) => Number(b.count || 0) - Number(a.count || 0))[0] || series[0] || null; +} + +function pickDefaultStlIds(files) { + const priority = ["liver", "portal_vein", "liver_artery", "liver_vein", "bile_duct", "pancreas", "spleen", "vertebrae"]; + const ranked = [...files].sort((a, b) => { + const af = String(a.family || a.segment_name || a.file_name || "").toLowerCase(); + const bf = String(b.family || b.segment_name || b.file_name || "").toLowerCase(); + const ai = priority.findIndex((key) => af === key || af.includes(key)); + const bi = priority.findIndex((key) => bf === key || bf.includes(key)); + const ar = ai < 0 ? 999 : ai; + const br = bi < 0 ? 999 : bi; + if (ar !== br) return ar - br; + return Number(a.id || 0) - Number(b.id || 0); + }); + return new Set(ranked.slice(0, Math.min(6, ranked.length)).map((file) => Number(file.id))); +} + +function currentSeries() { + return state.series.find((item) => item.series_uid === state.selectedSeriesUid) || null; +} + +function renderActiveHeader() { + const row = state.activeCase; + if (!row) return; + $("activeTitle").textContent = `${row.ct_number} · ${row.algorithm_model || "未指定模型"}`; + $("activeMeta").textContent = `${row.patient_name || row.upp_patient_name || "-"} · ${fmtDateTime(row.study_date, row.study_time) || "检查时间未知"} · ${Number(row.series_count || 0)} 个 DICOM 序列 · STL ${Number(row.stl_file_count || 0)} 个`; + const tags = [ + `${statusText(state.registrationStatus)}`, + ...bodyPartTags(row).map((tag) => `${escapeHtml(tag)}`), + ]; + $("activeTags").innerHTML = tags.join(""); + $("statusBtn").textContent = state.registrationStatus === "registered" ? "标为未配准" : "标为已配准"; + $("statusBtn").classList.toggle("unregistered", state.registrationStatus !== "registered"); +} + +function renderSeries() { + $("seriesCount").textContent = state.series.length; + $("seriesList").innerHTML = state.series.length + ? state.series + .map((item) => { + const active = item.series_uid === state.selectedSeriesUid; + const labels = (item.annotation_labels || []).map((label) => `${escapeHtml(label)}`).join(""); + const time = [fmtTime(item.first_time || item.series_time), fmtTime(item.last_time)].filter(Boolean); + return ` + + `; + }) + .join("") + : `
未读取到 DICOM 序列
`; + $("seriesList").querySelectorAll(".series-card").forEach((button) => { + button.addEventListener("click", () => selectSeries(button.dataset.series)); + }); + updateSliceControl(); +} + +async function selectSeries(uid) { + if (!uid || uid === state.selectedSeriesUid) return; + if (!(await confirmChange())) return; + state.selectedSeriesUid = uid; + const selected = currentSeries(); + state.sliceIndex = selected ? Math.max(0, Math.floor((Number(selected.count) || 1) / 2)) : 0; + markDirty(); + renderSeries(); + await loadFusion(); +} + +function renderStl() { + $("stlCount").textContent = state.stlFiles.length; + const models = [...new Set(state.stlFiles.map((item) => item.algorithm_model || state.algorithmModel || "未指定模型"))]; + $("modelRail").innerHTML = models.map((model) => `${escapeHtml(model)}`).join(""); + $("stlList").innerHTML = state.stlFiles.length + ? state.stlFiles + .map((file, index) => { + const checked = state.selectedStlIds.has(Number(file.id)); + return ` + + `; + }) + .join("") + : `
当前算法模型没有 STL 文件
`; + $("stlList").querySelectorAll("input[data-stl]").forEach((input) => { + input.addEventListener("change", async () => { + const id = Number(input.dataset.stl); + if (input.checked) state.selectedStlIds.add(id); + else state.selectedStlIds.delete(id); + markDirty(); + renderStl(); + await loadFusion(); + }); + }); +} + +function cssColor(index) { + return `#${STL_COLORS[index % STL_COLORS.length].toString(16).padStart(6, "0")}`; +} + +function formatBytes(value) { + const n = Number(value || 0); + if (!n) return "大小未知"; + if (n > 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`; + return `${(n / 1024).toFixed(1)} KB`; +} + +function buildPoseControls() { + $("poseGrid").innerHTML = POSE_CONTROLS.map( + (item) => ` +
+ + + +
+ `, + ).join(""); + $("poseGrid").querySelectorAll(".pose-control").forEach((row) => { + const key = row.dataset.pose; + const range = row.querySelector("input[type='range']"); + const number = row.querySelector("input[type='number']"); + const sync = (value, source) => { + const numeric = Number(value); + state.pose[key] = Number.isFinite(numeric) ? numeric : DEFAULT_POSE[key]; + if (source !== range) range.value = state.pose[key]; + if (source !== number) number.value = state.pose[key]; + applyPose(); + markDirty(); + }; + range.addEventListener("input", () => sync(range.value, range)); + number.addEventListener("input", () => sync(number.value, number)); + }); + document.querySelectorAll("[data-flip]").forEach((button) => { + button.addEventListener("click", () => { + const key = button.dataset.flip; + state.pose[key] = !state.pose[key]; + renderPoseControls(); + applyPose(); + markDirty(); + }); + }); +} + +function renderPoseControls() { + POSE_CONTROLS.forEach((item) => { + const row = $(`poseGrid`).querySelector(`[data-pose="${item.key}"]`); + if (!row) return; + const value = Number(state.pose[item.key] ?? DEFAULT_POSE[item.key]); + row.querySelector("input[type='range']").value = value; + row.querySelector("input[type='number']").value = Number.isInteger(value) ? value : value.toFixed(2); + }); + document.querySelectorAll("[data-flip]").forEach((button) => { + button.classList.toggle("active", Boolean(state.pose[button.dataset.flip])); + }); + renderActiveHeader(); +} + +function updateSliceControl() { + const selected = currentSeries(); + const count = Number(selected?.count || 0); + $("sliceSlider").max = Math.max(0, count - 1); + $("sliceSlider").value = Math.min(state.sliceIndex, Math.max(0, count - 1)); + $("sliceLabel").textContent = count ? `切片 ${Number($("sliceSlider").value) + 1} / ${count}` : "切片 0 / 0"; +} + +function initScene() { + if (state.sceneReady) return; + const viewport = $("fusionViewport"); + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); + renderer.setClearColor(0x000000, 1); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + viewport.appendChild(renderer.domElement); + + const scene = new THREE.Scene(); + const camera = new THREE.PerspectiveCamera(38, 1, 0.01, 1000); + camera.position.set(0, -8.5, 4.4); + camera.up.set(0, 0, 1); + + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.target.set(0, 0, 0); + + const ambient = new THREE.AmbientLight(0xffffff, 0.82); + const directional = new THREE.DirectionalLight(0xffffff, 1.8); + directional.position.set(3.5, -4, 6); + scene.add(ambient, directional); + + const dicomGroup = new THREE.Group(); + const modelGroup = new THREE.Group(); + const rawModelGroup = new THREE.Group(); + modelGroup.add(rawModelGroup); + scene.add(dicomGroup, modelGroup); + + Object.assign(state, { renderer, camera, scene, controls, dicomGroup, modelGroup, rawModelGroup, sceneReady: true }); + + const resize = () => { + const rect = viewport.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + renderer.setSize(rect.width, rect.height, false); + camera.aspect = rect.width / rect.height; + camera.updateProjectionMatrix(); + }; + state.resizeObserver = new ResizeObserver(resize); + state.resizeObserver.observe(viewport); + resize(); + + const animate = () => { + requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + }; + animate(); +} + +function clearGroup(group) { + if (!group) return; + while (group.children.length) { + const child = group.children.pop(); + child.traverse?.((node) => { + node.geometry?.dispose?.(); + if (node.material) { + if (Array.isArray(node.material)) node.material.forEach((material) => material.dispose?.()); + else node.material.dispose?.(); + } + node.material?.map?.dispose?.(); + }); + } +} + +function clearFusion() { + clearGroup(state.dicomGroup); + clearGroup(state.rawModelGroup); + state.volumeMeta = null; + $("fusionMeta").textContent = "DICOM - · STL -"; + $("fusionStatus").textContent = "等待选择 DICOM 与 STL"; + updateSliceControl(); +} + +async function loadFusion() { + const version = ++state.fusionVersion; + const selected = currentSeries(); + if (!state.sceneReady) { + $("fusionStatus").textContent = "当前浏览器未能启用 WebGL,无法显示三维融合视图"; + return; + } + if (!state.activeCase || !selected) { + clearFusion(); + return; + } + setFusionLoading(true, "正在按需加载 DICOM 切片与 STL"); + updateSliceControl(); + try { + const params = new URLSearchParams({ + ct_number: state.activeCase.ct_number, + series_uid: selected.series_uid, + center_index: String(state.sliceIndex), + window: state.windowMode, + radius: "24", + }); + const volume = await api(`/api/dicom/fusion-volume?${params.toString()}`); + if (version !== state.fusionVersion) return; + state.volumeMeta = volume; + await buildDicomVolume(volume); + if (version !== state.fusionVersion) return; + await buildStlModels(volume); + if (version !== state.fusionVersion) return; + fitCamera(); + $("fusionStatus").textContent = `${selected.description || "DICOM 序列"} · ${Number(selected.count || 0)} 张`; + $("fusionMeta").textContent = `DICOM ${volume.indices?.length || 0} 张局部体 · STL ${state.selectedStlIds.size} 个`; + } catch (error) { + clearFusion(); + $("fusionStatus").textContent = error.message; + } finally { + if (version === state.fusionVersion) setFusionLoading(false); + } +} + +async function loadTexture(url) { + return new Promise((resolve, reject) => { + new THREE.TextureLoader().load(url, resolve, undefined, reject); + }); +} + +async function buildDicomVolume(volume) { + clearGroup(state.dicomGroup); + const physical = volume.physicalSize || { width: 1, height: 1, depth: 1 }; + const maxPhysical = Math.max(physical.width || 1, physical.height || 1, physical.depth || 1, 1); + const sceneScale = 5.2 / maxPhysical; + const width = (physical.width || 1) * sceneScale; + const height = (physical.height || 1) * sceneScale; + const depth = (physical.depth || 1) * sceneScale; + const centerIndex = Number(volume.center || 0); + const indices = volume.indices || []; + const centerPosition = indices.length ? Math.floor(indices.length / 2) : 0; + const textures = await Promise.all((volume.frames || []).map((frame) => loadTexture(frame))); + textures.forEach((texture, index) => { + texture.colorSpace = THREE.SRGBColorSpace; + const material = new THREE.MeshBasicMaterial({ + map: texture, + transparent: true, + opacity: index === centerPosition ? 0.78 : 0.14, + side: THREE.DoubleSide, + depthWrite: false, + }); + const plane = new THREE.Mesh(new THREE.PlaneGeometry(width, height), material); + const z = (Number(indices[index] ?? centerIndex) - centerIndex) * Number(volume.spacing?.slice || 1) * sceneScale; + plane.position.z = z; + state.dicomGroup.add(plane); + }); + const box = new THREE.BoxGeometry(width, height, Math.max(depth, 0.02)); + const edges = new THREE.EdgesGeometry(box); + const lines = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0x1cd9c7, transparent: true, opacity: 0.42 })); + state.dicomGroup.add(lines); + state.baseModelScale = sceneScale; +} + +async function buildStlModels(volume) { + clearGroup(state.rawModelGroup); + const files = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id))); + if (!files.length) { + applyPose(); + return; + } + const loader = new STLLoader(); + const token = encodeURIComponent(state.token); + for (const [index, file] of files.entries()) { + const params = new URLSearchParams({ + ct_number: state.activeCase.ct_number, + file_id: String(file.id), + algorithm_model: state.algorithmModel, + access_token: state.token, + }); + const geometry = await loader.loadAsync(`/api/stl/file?${params.toString()}`); + geometry.computeVertexNormals(); + geometry.computeBoundingBox(); + const material = new THREE.MeshStandardMaterial({ + color: STL_COLORS[index % STL_COLORS.length], + metalness: 0.08, + roughness: 0.5, + transparent: true, + opacity: 0.58, + side: THREE.DoubleSide, + depthWrite: false, + }); + const mesh = new THREE.Mesh(geometry, material); + mesh.name = file.segment_name || file.file_name; + state.rawModelGroup.add(mesh); + } + const bbox = new THREE.Box3().setFromObject(state.rawModelGroup); + const center = new THREE.Vector3(); + bbox.getCenter(center); + state.rawModelGroup.position.set(-center.x, -center.y, -center.z); + applyPose(); +} + +function applyPose() { + if (!state.modelGroup) return; + const pose = { ...DEFAULT_POSE, ...state.pose }; + state.modelGroup.rotation.set( + THREE.MathUtils.degToRad(Number(pose.rotateX) || 0), + THREE.MathUtils.degToRad(Number(pose.rotateY) || 0), + THREE.MathUtils.degToRad(Number(pose.rotateZ) || 0), + ); + state.modelGroup.position.set(Number(pose.translateX) || 0, Number(pose.translateY) || 0, Number(pose.translateZ) || 0); + const scale = Math.max(0.001, Number(pose.scale) || 1) * state.baseModelScale; + state.modelGroup.scale.set(scale * (pose.flipX ? -1 : 1), scale * (pose.flipY ? -1 : 1), scale * (pose.flipZ ? -1 : 1)); +} + +function fitCamera() { + if (!state.camera || !state.controls) return; + const box = new THREE.Box3(); + box.expandByObject(state.dicomGroup); + box.expandByObject(state.modelGroup); + const size = new THREE.Vector3(); + const center = new THREE.Vector3(); + box.getSize(size); + box.getCenter(center); + const distance = Math.max(size.x, size.y, size.z, 1) * 1.8; + state.camera.position.set(center.x, center.y - distance, center.z + distance * 0.45); + state.controls.target.copy(center); + state.controls.update(); +} + +async function saveRegistration(nextStatus = null) { + if (!state.activeCase || !currentSeries() || state.saving) return; + state.saving = true; + $("saveBtn").disabled = true; + $("statusBtn").disabled = true; + setSaveState("保存中", "warn"); + const selectedSeries = currentSeries(); + const status = nextStatus || state.registrationStatus; + const selectedFiles = state.stlFiles.filter((file) => state.selectedStlIds.has(Number(file.id))); + try { + await api("/api/registrations", { + method: "POST", + body: JSON.stringify({ + ct_number: state.activeCase.ct_number, + algorithm_model: state.algorithmModel, + registration_status: status, + series_instance_uid: selectedSeries.series_uid, + series_description: selectedSeries.description || "", + selected_stl_files: selectedFiles.map((file) => ({ + id: file.id, + file_name: file.file_name, + segment_name: file.segment_name, + family: file.family, + category: file.category, + algorithm_model: file.algorithm_model, + })), + transform: state.pose, + module_styles: Object.fromEntries(selectedFiles.map((file, index) => [file.file_name, { color: cssColor(index), opacity: 0.58 }])), + dicom_reference: { + series_uid: selectedSeries.series_uid, + series_number: selectedSeries.series_number, + description: selectedSeries.description, + slice_index: state.sliceIndex, + slice_count: selectedSeries.count, + window: state.windowMode, + volume: state.volumeMeta?.physicalSize || null, + }, + model_reference: { + algorithm_model: state.algorithmModel, + selected_count: selectedFiles.length, + families: [...new Set(selectedFiles.map((file) => file.family).filter(Boolean))], + }, + notes: $("notes").value, + }), + }); + state.registrationStatus = status; + if (state.activeCase) state.activeCase.registration_status = status; + const existing = state.cases.find((row) => sameCase(row, state.activeCase)); + if (existing) existing.registration_status = status; + resetDirty(); + renderCases(); + renderActiveHeader(); + } catch (error) { + setSaveState(error.message, "error"); + } finally { + state.saving = false; + $("saveBtn").disabled = false; + $("statusBtn").disabled = false; + } +} + +function sameHostUrl(raw, targetPort) { + try { + const url = new URL(raw); + const here = new URL(window.location.href); + if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname)) url.hostname = here.hostname; + if (targetPort) url.port = String(targetPort); + return url.toString(); + } catch { + const here = new URL(window.location.href); + here.port = String(targetPort || here.port); + here.pathname = "/"; + here.search = ""; + return here.toString(); + } +} + +function openLinkedApp(kind) { + const ct = state.activeCase?.ct_number || ""; + const model = state.algorithmModel || ""; + if (kind === "viewer") { + const url = new URL(sameHostUrl(state.links.pacs_viewer_url, 8107)); + if (ct) url.searchParams.set("ct_number", ct); + window.open(url.toString(), "_blank"); + } else { + const url = new URL(sameHostUrl(state.links.relation_viewer_url, 8108)); + if (ct) url.searchParams.set("ct_number", ct); + if (model) url.searchParams.set("algorithm_model", model); + window.open(url.toString(), "_blank"); + } +} + +function wireEvents() { $("loginForm").addEventListener("submit", login); - $("logoutBtn").addEventListener("click", logout); - $("refreshBtn").addEventListener("click", refreshAll); - $("relationBtn").addEventListener("click", () => window.open(relationBaseUrl(), "_blank", "noopener")); - $("viewerBtn").addEventListener("click", () => { - const ct = state.activeCase?.pacs_ct_number || state.activeCase?.ct_key || ""; - const suffix = ct ? `/?ct_number=${encodeURIComponent(ct)}` : ""; - window.open(`${viewerBaseUrl()}${suffix}`, "_blank", "noopener"); + $("logoutBtn").addEventListener("click", () => logout()); + $("refreshBtn").addEventListener("click", async () => { + await loadStatus(); + await loadCases(false); }); + $("relationBtn").addEventListener("click", () => openLinkedApp("relation")); + $("viewerBtn").addEventListener("click", () => openLinkedApp("viewer")); + $("fitBtn").addEventListener("click", fitCamera); + $("saveBtn").addEventListener("click", () => saveRegistration()); + $("statusBtn").addEventListener("click", () => saveRegistration(state.registrationStatus === "registered" ? "unregistered" : "registered")); + $("notes").addEventListener("input", markDirty); $("caseSearch").addEventListener("input", () => { - clearTimeout(state.searchTimer); - state.searchTimer = setTimeout(loadCases, 280); + state.search = $("caseSearch").value.trim(); + window.clearTimeout(state.searchTimer); + state.searchTimer = window.setTimeout(() => loadCases(false), 240); }); - $("modeFile").addEventListener("click", async () => { - state.mode = "file"; - markUnsaved(); - renderStl(); + document.querySelectorAll(".filter[data-status]").forEach((button) => { + button.addEventListener("click", async () => { + state.statusFilter = button.dataset.status || ""; + document.querySelectorAll(".filter[data-status]").forEach((item) => item.classList.toggle("active", item === button)); + await loadCases(false); + }); }); - $("modeFamily").addEventListener("click", async () => { - state.mode = "family"; - markUnsaved(); - renderStl(); + document.querySelectorAll(".filter[data-part]").forEach((button) => { + button.addEventListener("click", async () => { + state.partFilter = button.dataset.part || ""; + document.querySelectorAll(".filter[data-part]").forEach((item) => item.classList.toggle("active", item === button)); + await loadCases(false); + }); }); - $("sliceSlider").addEventListener("input", async () => { + document.querySelectorAll(".segmented button").forEach((button) => { + button.addEventListener("click", async () => { + state.windowMode = button.dataset.window || "default"; + document.querySelectorAll(".segmented button").forEach((item) => item.classList.toggle("active", item === button)); + await loadFusion(); + }); + }); + $("sliceSlider").addEventListener("input", () => { state.sliceIndex = Number($("sliceSlider").value || 0); - updateDICOMControls(); - await reloadDicomImage(); - markUnsaved(); - }); - $("planeSelect").addEventListener("change", async () => { - state.plane = $("planeSelect").value; - await reloadDicomImage(); - }); - $("windowSelect").addEventListener("change", async () => { - state.window = $("windowSelect").value; - await reloadDicomImage(); - }); - $("registrationNotes").addEventListener("input", markUnsaved); - $("resetPoseBtn").addEventListener("click", () => { - state.pose = defaultPose(); - syncPoseInputs(); - markUnsaved(); - updateScenesPose(); - }); - $("saveRegistrationBtn").addEventListener("click", () => saveRegistration(false, false)); - $("lockRegistrationBtn").addEventListener("click", () => saveRegistration(true, false)); - $("unlockRegistrationBtn").addEventListener("click", () => saveRegistration(false, true)); - $("exportStlBtn").addEventListener("click", exportStl); - $("fitStlBtn").addEventListener("click", () => state.scenes.stl?.fit()); - $("fitFusionBtn").addEventListener("click", () => state.scenes.fusion?.fit()); - $("toggleMaskBtn").addEventListener("click", () => { - state.maskVisible = !state.maskVisible; - $("toggleMaskBtn").textContent = state.maskVisible ? "隐藏" : "显示"; - renderMaskPreview(); + updateSliceControl(); }); + $("sliceSlider").addEventListener("change", () => loadFusion()); } -async function boot() { - state.scenes.stl = createScene($("stlViewport"), { fusion: false }); - state.scenes.fusion = createScene($("fusionViewport"), { fusion: true }); - renderPoseControls(); - wire(); - applyAuthUi(); - const ok = await loadCurrentUser(); - if (ok) await refreshAll(); -} +wireEvents(); -boot(); +if (state.token) { + bootstrap(); +} else { + loginVisible(true); +} diff --git a/DICOM_and_UPP配准/static/index.html b/DICOM_and_UPP配准/static/index.html index a8703f5..47302f9 100644 --- a/DICOM_and_UPP配准/static/index.html +++ b/DICOM_and_UPP配准/static/index.html @@ -14,167 +14,138 @@ +
+ +
+

DICOM / UPP 配准工作台

-

DICOM 序列、STL family 与人工位姿锁定

+

完整关联 CT · STL 搭配 · 位姿参数

- - + + 数据库 - + 未登录 - +
- -
-
+
+

未选择 CT

-

从左侧选择一个 DICOM + STL 匹配检查

+

从左侧选择一个完整关联检查

-
+
-
-
-
-
-

DICOM

- 未选择序列 +
+ + +
+
+
+ + + +
-
- - +
+ + +
-
- DICOM切片 -
等待选择 DICOM 序列
+
+
+
等待选择 DICOM 与 STL
+
DICOM - · STL -
+
+ 切片 0 / 0 - 0 / 0
-
+
-
-
-
-

STL拼接后

- 未加载模型 +
-
-
+
+
+
-
-
-
-

STL和DICOM融合

- 位姿未保存 +
+
+

位姿参数

+ 未保存
- -
-
-
- -
-
-
-

DICOM分割

- 基于当前可见 STL 的预览 +
+
+ + +
- -
-
- DICOM分割预览 - -
选择序列和 STL 后显示叠加预览
-
-
+ +
+
-
- -
- diff --git a/DICOM_and_UPP配准/static/styles.css b/DICOM_and_UPP配准/static/styles.css index 11cf136..ab54144 100644 --- a/DICOM_and_UPP配准/static/styles.css +++ b/DICOM_and_UPP配准/static/styles.css @@ -1,19 +1,19 @@ :root { color-scheme: dark; - --bg: #070b10; - --panel: #101720; - --panel-2: #0b1118; - --panel-3: #070b10; - --line: #243344; - --line-strong: #3a5773; - --text: #eaf2fb; - --muted: #93a8c0; + --bg: #070a0f; + --panel: #0f1620; + --panel-soft: #131d29; + --panel-strong: #0a1018; + --line: #253548; + --line-soft: rgba(148, 163, 184, 0.2); + --text: #e8f1fb; + --muted: #93a8bf; --blue: #3378f6; - --cyan: #17d6c1; - --green: #21c58a; - --amber: #efb84d; + --cyan: #19d6c3; + --green: #24c58d; + --amber: #f2b84d; --red: #fb7185; - --shadow: 0 18px 44px rgba(0, 0, 0, 0.32); + --shadow: 0 20px 52px rgba(0, 0, 0, 0.34); } * { @@ -24,11 +24,10 @@ body { margin: 0; overflow: hidden; background: - linear-gradient(90deg, rgba(51, 120, 246, 0.07) 1px, transparent 1px), - linear-gradient(180deg, rgba(23, 214, 193, 0.055) 1px, transparent 1px), - radial-gradient(circle at 16% 0%, rgba(23, 214, 193, 0.12), transparent 27%), - var(--bg); - background-size: 80px 80px, 80px 80px, auto; + linear-gradient(90deg, rgba(51, 120, 246, 0.05) 1px, transparent 1px), + linear-gradient(180deg, rgba(25, 214, 195, 0.045) 1px, transparent 1px), + #070a0f; + background-size: 72px 72px; color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing: 0; @@ -36,7 +35,6 @@ body { button, input, -select, textarea { font: inherit; } @@ -54,318 +52,255 @@ button { display: flex; align-items: center; justify-content: space-between; - gap: 18px; - padding: 0 20px; + gap: 16px; + padding: 0 18px; border-bottom: 1px solid var(--line); - background: rgba(7, 11, 16, 0.93); + background: rgba(7, 10, 15, 0.95); } .brand, -.top-actions { +.top-actions, +.viewer-tools { display: flex; align-items: center; - gap: 12px; + gap: 10px; min-width: 0; } .brand-mark { - width: 30px; - height: 30px; + width: 31px; + height: 31px; flex: 0 0 auto; - border-radius: 8px; + border-radius: 9px; background: linear-gradient(135deg, var(--cyan), var(--blue)); - box-shadow: 0 0 24px rgba(23, 214, 193, 0.24); + box-shadow: 0 0 22px rgba(25, 214, 195, 0.22); } -.brand h1 { +.brand h1, +.login-panel h1 { margin: 0; font-size: 18px; - line-height: 1.2; + line-height: 1.15; } -.brand p { - margin: 3px 0 0; +.brand p, +.login-panel p { + margin: 4px 0 0; color: var(--muted); font-size: 12px; } .top-actions { justify-content: flex-end; - gap: 9px; } -.top-loading { - position: fixed; - top: 66px; - left: 0; - right: 0; - z-index: 20; - height: 3px; - overflow: hidden; - background: rgba(36, 51, 68, 0.75); +.viewer-tools { + flex: 0 0 auto; } -.top-loading span { - position: absolute; - inset-block: 0; - width: 34%; - border-radius: 999px; - background: linear-gradient(90deg, transparent, var(--cyan), var(--blue), transparent); - animation: loading-sweep 1.15s ease-in-out infinite; -} - -@keyframes loading-sweep { - from { - transform: translateX(-110%); - } - to { - transform: translateX(320%); - } -} - -.dark-btn, -.primary-btn, -.warn-btn, -.icon-btn { - min-height: 34px; - border: 1px solid rgba(147, 168, 192, 0.36); - border-radius: 8px; - background: linear-gradient(180deg, rgba(24, 36, 52, 0.96), rgba(10, 17, 25, 0.96)); - color: var(--text); - font-weight: 700; -} - -.dark-btn { - padding: 0 13px; -} - -.dark-btn.accent { - border-color: rgba(23, 214, 193, 0.62); - color: #bffbf2; - background: linear-gradient(180deg, rgba(15, 57, 66, 0.96), rgba(8, 27, 37, 0.96)); -} - -.primary-btn { - padding: 0 14px; - border-color: rgba(51, 120, 246, 0.8); - background: var(--blue); - color: white; -} - -.warn-btn { - padding: 0 14px; - border-color: rgba(239, 184, 77, 0.6); - background: rgba(239, 184, 77, 0.14); - color: #ffe1a6; -} - -.icon-btn { - height: 32px; - padding: 0 12px; - font-size: 12px; -} - -.dark-btn:hover, -.icon-btn:hover { - border-color: rgba(51, 120, 246, 0.84); - background: #172334; -} - -.user-badge { - max-width: 150px; - overflow: hidden; - color: var(--muted); - font-size: 12px; - text-overflow: ellipsis; - white-space: nowrap; -} - -.status-pill { - min-width: 90px; - height: 30px; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 12px; - border: 1px solid var(--line); - border-radius: 999px; - color: var(--muted); - font-size: 12px; - font-weight: 800; - white-space: nowrap; -} - -.status-pill.online { - border-color: rgba(33, 197, 138, 0.48); - color: #a8f4d6; - background: rgba(33, 197, 138, 0.08); -} - -.status-pill.offline { - border-color: rgba(251, 113, 133, 0.5); - color: #fecdd3; - background: rgba(251, 113, 133, 0.08); +.top-actions .ghost-btn, +.viewer-tools .ghost-btn, +.viewer-tools .primary-btn, +.viewer-tools .state-btn { + box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.03), 0 8px 20px rgba(0, 0, 0, 0.18); } .workspace { height: calc(100vh - 66px); display: grid; - grid-template-columns: 410px minmax(0, 1fr); + grid-template-columns: 338px minmax(0, 1fr); gap: 12px; padding: 12px; } -.left-rail { +.panel { min-height: 0; - display: grid; - grid-template-rows: 210px minmax(160px, 0.82fr) minmax(250px, 1.12fr) minmax(300px, 0.9fr); - gap: 10px; - overflow: hidden; -} - -.viewer-shell { - min-height: 0; - display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 10px; -} - -.panel, -.viewport-card { - min-width: 0; border: 1px solid var(--line); - border-radius: 8px; - background: rgba(16, 23, 32, 0.92); + border-radius: 14px; + background: linear-gradient(180deg, rgba(15, 22, 32, 0.96), rgba(10, 16, 24, 0.96)); box-shadow: var(--shadow); } -.search-panel, -.stacked-panel, -.registration-panel, -.hero-strip { - padding: 12px; -} - -.stacked-panel { - min-height: 0; - display: flex; - flex-direction: column; - overflow: hidden; -} - -.panel-head, -.viewport-head, -.hero-strip { +.panel-head { + min-height: 52px; display: flex; align-items: center; justify-content: space-between; - gap: 10px; + gap: 12px; + padding: 14px; +} + +.panel-head.compact { + min-height: 42px; + padding: 10px 12px; } .panel-head h2, -.viewport-head h3, -.hero-strip h2 { +.case-strip h2 { margin: 0; - font-size: 15px; + font-size: 17px; + line-height: 1.2; } -.panel-head span, -.viewport-head span, -.hero-strip p { +.panel-head p, +.case-strip p { + margin: 4px 0 0; color: var(--muted); font-size: 12px; } -.hero-strip p { - margin: 4px 0 0; +.case-panel { + min-height: 0; + display: flex; + flex-direction: column; } .search-input, -.notes, -.tool-row select { - width: 100%; +.login-panel input, +.notes { + width: calc(100% - 28px); + margin: 0 14px 10px; border: 1px solid var(--line); - border-radius: 8px; - outline: 0; - background: #080d14; + border-radius: 10px; + background: rgba(6, 10, 15, 0.86); color: var(--text); + outline: none; } -.search-input { - height: 38px; - margin-top: 10px; +.search-input, +.login-panel input { + height: 40px; padding: 0 12px; } .notes { - min-height: 42px; - resize: vertical; - padding: 8px 10px; + min-height: 58px; + resize: none; + padding: 10px; } -.search-input:focus, -.notes:focus, -.tool-row select:focus { - border-color: rgba(51, 120, 246, 0.72); +.filter-row, +.part-filter, +.chip-rail, +.tag-line { + display: flex; + gap: 8px; + padding: 0 14px 10px; + overflow-x: auto; +} + +.filter, +.chip, +.tag, +.status-pill, +.ghost-btn, +.primary-btn, +.state-btn { + min-height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + white-space: nowrap; + border-radius: 10px; + font-weight: 800; +} + +.filter, +.chip, +.tag { + border: 1px solid var(--line); + background: rgba(8, 13, 20, 0.82); + color: #b7c7dc; + font-size: 12px; + padding: 0 10px; +} + +.filter.active, +.chip.active { + border-color: rgba(25, 214, 195, 0.78); + color: #ccfbf1; + background: rgba(20, 184, 166, 0.16); +} + +.tag { + min-height: 24px; + border-radius: 999px; + font-style: normal; +} + +.tag.green { + border-color: rgba(36, 197, 141, 0.58); + color: #b8f7da; + background: rgba(36, 197, 141, 0.11); +} + +.tag.amber { + border-color: rgba(242, 184, 77, 0.62); + color: #ffe1a6; + background: rgba(242, 184, 77, 0.13); +} + +.tag.red { + border-color: rgba(251, 113, 133, 0.62); + color: #fecdd3; + background: rgba(251, 113, 133, 0.12); } .case-list, .series-list, .stl-list { min-height: 0; - overflow: auto; - scrollbar-width: thin; + overflow-y: auto; + padding: 4px 12px 12px; } .case-list { - height: 138px; - margin-top: 10px; -} - -.series-list, -.stl-list { - flex: 1 1 auto; - height: auto; - margin-top: 10px; + flex: 1; } .case-card, .series-card, .stl-row { width: 100%; - display: grid; - gap: 6px; - margin-bottom: 8px; - padding: 10px; + display: block; + margin: 0 0 10px; border: 1px solid transparent; - border-radius: 8px; - background: #0b1118; + border-radius: 14px; + background: rgba(9, 15, 23, 0.88); color: var(--text); text-align: left; } +.case-card, +.series-card { + padding: 13px; +} + .case-card.active, .series-card.active, .stl-row.active { - border-color: rgba(23, 214, 193, 0.8); - background: rgba(18, 37, 46, 0.92); + border-color: rgba(51, 120, 246, 0.92); + background: linear-gradient(180deg, rgba(20, 45, 88, 0.96), rgba(10, 18, 31, 0.96)); } .case-card strong, -.series-card strong, -.stl-row strong { +.series-card strong { + display: block; + max-width: 100%; overflow: hidden; - font-size: 13px; + color: #f1f7ff; + font-size: 15px; text-overflow: ellipsis; white-space: nowrap; } .case-card span, -.case-card small, .series-card span, +.case-card small, .series-card small, -.stl-row span, .stl-row small { + display: block; + margin-top: 6px; overflow: hidden; color: var(--muted); font-size: 12px; @@ -373,6 +308,39 @@ button { white-space: nowrap; } +.mini-tags { + display: flex; + flex-wrap: nowrap; + gap: 6px; + max-width: 100%; + margin-top: 9px; + overflow-x: auto; + scrollbar-width: none; +} + +.mini-tags::-webkit-scrollbar { + display: none; +} + +.mini-tags i { + min-height: 22px; + display: inline-flex; + align-items: center; + flex: 0 0 auto; + border: 1px solid rgba(25, 214, 195, 0.5); + border-radius: 999px; + background: rgba(20, 184, 166, 0.12); + color: #ccfbf1; + font-size: 11px; + font-style: normal; + font-weight: 800; + padding: 0 8px; +} + +.case-card .tag { + flex: 0 0 auto; +} + .card-line { display: flex; align-items: center; @@ -380,343 +348,432 @@ button { gap: 8px; } -.tag-line, -.hero-tags, -.chip-rail { - display: flex; - gap: 6px; - overflow-x: auto; - padding-bottom: 2px; - scrollbar-width: thin; -} - -.stacked-panel .chip-rail { - flex: 0 0 auto; - min-height: 30px; - margin-top: 8px; -} - -.tag, -.chip { - flex: 0 0 auto; - max-width: 132px; - padding: 2px 7px; - overflow: hidden; - border: 1px solid rgba(23, 214, 193, 0.42); - border-radius: 999px; - color: #bffbf2; - background: rgba(23, 214, 193, 0.08); - font-size: 11px; - font-style: normal; - text-overflow: ellipsis; - white-space: nowrap; -} - -.tag.warn { - border-color: rgba(239, 184, 77, 0.55); - color: #ffe1a6; - background: rgba(239, 184, 77, 0.09); -} - -.tag.locked { - border-color: rgba(51, 120, 246, 0.56); - color: #cfe0ff; - background: rgba(51, 120, 246, 0.11); -} - -.chip { - cursor: pointer; -} - -.chip.active { - border-color: var(--cyan); - background: rgba(23, 214, 193, 0.18); -} - -.segmented { - display: flex; - gap: 6px; - padding: 4px; - border: 1px solid var(--line); - border-radius: 8px; - background: #080d14; -} - -.segmented.small { - margin-top: 10px; -} - -.segmented button { - flex: 1; - height: 28px; - border: 0; - border-radius: 6px; - background: transparent; - color: var(--muted); - font-weight: 800; -} - -.segmented button.active { - background: var(--blue); - color: white; -} - -.stl-row { - grid-template-columns: 18px minmax(0, 1fr) auto; - align-items: center; -} - -.stl-row input { - margin: 0; -} - -.registration-panel { - display: grid; - gap: 10px; - min-height: 0; - overflow: auto; -} - -.pose-grid { - display: grid; - gap: 7px; -} - -.pose-control { - display: grid; - grid-template-columns: 70px minmax(0, 1fr) 72px; - align-items: center; - gap: 8px; -} - -.pose-control label { - color: var(--muted); - font-size: 12px; -} - -.pose-control input[type="range"] { - width: 100%; - accent-color: var(--cyan); -} - -.pose-control input[type="number"] { - width: 72px; - height: 28px; - border: 1px solid var(--line); - border-radius: 7px; - background: #080d14; - color: var(--text); - text-align: right; -} - -.flip-row { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 6px; -} - -.flip-row button { - height: 28px; - border: 1px solid var(--line); - border-radius: 7px; - background: #080d14; - color: var(--muted); - font-weight: 800; -} - -.flip-row button.active { - border-color: rgba(23, 214, 193, 0.72); - color: #bffbf2; - background: rgba(23, 214, 193, 0.13); -} - -.action-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.viewer-grid { +.main-panel { + min-width: 0; min-height: 0; display: grid; - grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr); - grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); - gap: 10px; + grid-template-rows: auto minmax(0, 1fr); + gap: 12px; } -.viewport-card { +.case-strip { + min-height: 74px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 14px 16px; +} + +.case-strip .tag-line { + padding: 0; +} + +.work-grid { + min-height: 0; + display: grid; + grid-template-columns: 300px minmax(420px, 1fr) 330px; + gap: 12px; +} + +.series-panel, +.right-panel { + min-height: 0; +} + +.series-panel, +.stl-panel, +.pose-panel { + display: flex; + flex-direction: column; +} + +.right-panel { + display: grid; + grid-template-rows: minmax(220px, 0.88fr) minmax(320px, 1.12fr); + gap: 12px; +} + +.viewer-panel { + min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto; overflow: hidden; } -.fusion-card, -.segmentation-card { - grid-template-rows: auto minmax(0, 1fr); -} - -.viewport-head { - min-height: 52px; +.viewer-head { + min-height: 58px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--line); - background: rgba(11, 17, 24, 0.86); + overflow-x: auto; } -.tool-row { +.segmented { display: flex; - gap: 8px; + flex: 0 0 auto; + gap: 7px; + overflow-x: auto; } -.tool-row select { - width: 94px; - height: 32px; - padding: 0 8px; +.segmented button { + min-width: 72px; + min-height: 34px; + border: 1px solid var(--line); + border-radius: 10px; + background: rgba(18, 28, 42, 0.88); + color: #c6d4e5; + font-weight: 900; } -.image-stage, -.segmentation-stage, -.three-stage { +.segmented button.active { + border-color: rgba(51, 120, 246, 0.9); + background: var(--blue); + color: white; +} + +.ghost-btn, +.primary-btn, +.state-btn { + flex: 0 0 auto; + min-height: 34px; + border: 1px solid rgba(148, 163, 184, 0.34); + padding: 0 12px; + background: linear-gradient(180deg, rgba(25, 38, 56, 0.95), rgba(9, 15, 23, 0.95)); + color: var(--text); +} + +.ghost-btn:disabled, +.primary-btn:disabled, +.state-btn:disabled { + cursor: wait; + opacity: 0.62; +} + +.ghost-btn.accent { + border-color: rgba(25, 214, 195, 0.56); + color: #bffbf2; +} + +.primary-btn { + border-color: rgba(51, 120, 246, 0.85); + background: var(--blue); + color: white; +} + +.state-btn { + border-color: rgba(36, 197, 141, 0.62); + color: #b8f7da; + background: rgba(36, 197, 141, 0.14); +} + +.state-btn.unregistered { + border-color: rgba(242, 184, 77, 0.66); + color: #ffe1a6; + background: rgba(242, 184, 77, 0.12); +} + +.status-pill { + height: 31px; + border: 1px solid var(--line); + color: var(--muted); + padding: 0 12px; + font-size: 12px; +} + +.status-pill.online { + border-color: rgba(36, 197, 141, 0.54); + color: #b8f7da; + background: rgba(36, 197, 141, 0.1); +} + +.status-pill.offline { + border-color: rgba(251, 113, 133, 0.6); + color: #fecdd3; + background: rgba(251, 113, 133, 0.1); +} + +.user-badge { + max-width: 140px; + overflow: hidden; + color: var(--muted); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +#saveState.ok { + color: #b8f7da; +} + +#saveState.warn { + color: #ffe1a6; +} + +#saveState.error { + color: #fecdd3; +} + +.fusion-stage { position: relative; min-height: 0; - overflow: hidden; background: #000; } -.image-stage, -.segmentation-stage { - display: grid; - place-items: center; -} - -.image-stage img, -.segmentation-stage img { - max-width: 100%; - max-height: 100%; - object-fit: contain; - image-rendering: auto; -} - -.segmentation-stage canvas { +#fusionViewport { position: absolute; inset: 0; - width: 100%; - height: 100%; - pointer-events: none; } -.empty-state { +#fusionViewport canvas { + display: block; + width: 100% !important; + height: 100% !important; +} + +.fusion-hud { position: absolute; - inset: 0; - display: grid; - place-items: center; - padding: 20px; - color: rgba(234, 242, 251, 0.44); - font-size: 13px; + z-index: 4; + max-width: 46%; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: rgba(0, 0, 0, 0.58); + color: rgba(255, 255, 255, 0.72); + font-size: 11px; font-weight: 800; - text-align: center; + padding: 8px 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fusion-hud.left { + top: 14px; + left: 14px; +} + +.fusion-hud.right { + top: 14px; + right: 14px; +} + +.fusion-loading { + position: absolute; + left: 40px; + right: 40px; + bottom: 24px; + z-index: 5; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 13px; + background: rgba(0, 0, 0, 0.68); + padding: 12px; +} + +.fusion-loading span { + display: block; + margin-bottom: 8px; + color: rgba(255, 255, 255, 0.72); + font-size: 12px; + font-weight: 900; +} + +.fusion-loading div { + height: 4px; + overflow: hidden; + border-radius: 999px; + background: rgba(255, 255, 255, 0.12); +} + +.fusion-loading i, +.top-loading span { + display: block; + height: 100%; + width: 34%; + border-radius: 999px; + background: linear-gradient(90deg, transparent, var(--cyan), var(--blue), transparent); + animation: sweep 1.1s ease-in-out infinite; +} + +.top-loading { + position: fixed; + top: 66px; + left: 0; + right: 0; + z-index: 30; + height: 3px; + background: rgba(37, 53, 72, 0.82); + overflow: hidden; +} + +@keyframes sweep { + from { + transform: translateX(-110%); + } + to { + transform: translateX(320%); + } } .slice-row { + height: 48px; display: grid; - grid-template-columns: minmax(0, 1fr) 72px; - gap: 10px; + grid-template-columns: 98px minmax(0, 1fr); + gap: 12px; align-items: center; - height: 42px; - padding: 0 12px; + padding: 0 14px; border-top: 1px solid var(--line); - background: rgba(11, 17, 24, 0.86); -} - -.slice-row input { - width: 100%; - accent-color: var(--cyan); } .slice-row span { color: var(--muted); font-size: 12px; + font-weight: 800; +} + +input[type="range"] { + accent-color: var(--cyan); +} + +.stl-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 10px; +} + +.stl-row input { + margin-top: 3px; + accent-color: var(--cyan); +} + +.stl-row strong { + display: block; + overflow: hidden; + color: #e8f1fb; + font-size: 13px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.pose-grid { + display: grid; + gap: 10px; + padding: 0 12px 12px; +} + +.pose-control { + display: grid; + grid-template-columns: 72px minmax(0, 1fr) 72px; + gap: 8px; + align-items: center; +} + +.pose-control label { + color: var(--muted); + font-size: 12px; + font-weight: 900; +} + +.pose-control input[type="number"] { + width: 100%; + height: 30px; + border: 1px solid var(--line); + border-radius: 8px; + background: rgba(6, 10, 15, 0.86); + color: var(--text); text-align: right; + padding: 0 7px; +} + +.flip-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; + padding: 0 12px 12px; +} + +.flip-row button { + min-height: 32px; + border: 1px solid var(--line); + border-radius: 9px; + background: rgba(8, 13, 20, 0.82); + color: #c6d4e5; + font-weight: 900; +} + +.flip-row button.active { + border-color: rgba(25, 214, 195, 0.78); + color: #ccfbf1; + background: rgba(20, 184, 166, 0.16); +} + +.empty-state { + padding: 22px; + color: var(--muted); + font-size: 13px; + text-align: center; } .login-overlay { position: fixed; inset: 0; - z-index: 50; - display: grid; - place-items: center; - background: rgba(7, 11, 16, 0.78); - backdrop-filter: blur(8px); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(5, 8, 12, 0.92); + backdrop-filter: blur(12px); } .login-panel { - width: min(390px, calc(100vw - 32px)); - display: grid; - gap: 12px; - padding: 24px; - border: 1px solid var(--line-strong); - border-radius: 8px; - background: rgba(16, 23, 32, 0.96); + width: min(420px, calc(100vw - 36px)); + border: 1px solid var(--line); + border-radius: 18px; + background: linear-gradient(180deg, rgba(19, 29, 41, 0.98), rgba(10, 16, 24, 0.98)); box-shadow: var(--shadow); + padding: 26px; } -.login-panel h2 { - margin: 0; - font-size: 18px; +.login-panel .brand-mark { + margin-bottom: 14px; } -.login-panel p { - margin: 0; - color: var(--muted); - font-size: 13px; +.login-panel input { + width: 100%; + margin: 12px 0 0; } -.login-panel .search-input { - margin-top: 0; +.login-panel button { + width: 100%; + min-height: 40px; + margin-top: 14px; + border: 0; + border-radius: 10px; + background: var(--blue); + color: white; + font-weight: 900; } -.error-line { +#loginError { + display: block; min-height: 18px; - color: #fecdd3; + margin-top: 10px; + color: var(--red); font-size: 12px; + font-weight: 800; } -@media (max-width: 1320px) { +@media (max-width: 1500px) { .workspace { - grid-template-columns: 360px minmax(0, 1fr); + grid-template-columns: 310px minmax(0, 1fr); } - .viewer-grid { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: repeat(4, minmax(360px, 1fr)); - overflow: auto; - } -} - -@media (max-width: 900px) { - body { - overflow: auto; - } - - .topbar, - .workspace { - height: auto; - } - - .topbar, - .workspace, - .left-rail, - .viewer-shell { - display: grid; - grid-template-columns: 1fr; - } - - .top-actions { - flex-wrap: wrap; - justify-content: start; - } - - .left-rail { - grid-template-rows: auto; + .work-grid { + grid-template-columns: 270px minmax(360px, 1fr) 300px; } }