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) => ` -
DICOM 序列、STL family 与人工位姿锁定
+完整关联 CT · STL 搭配 · 位姿参数