diff --git a/PACS_DICOM处理/数据处理网页端/README.md b/PACS_DICOM处理/数据处理网页端/README.md index 996a5ff..b5056fd 100644 --- a/PACS_DICOM处理/数据处理网页端/README.md +++ b/PACS_DICOM处理/数据处理网页端/README.md @@ -38,13 +38,15 @@ uvicorn app:app --host 127.0.0.1 --port 8107 - 左侧检查列表:来自 PostgreSQL `pacs_dicom_files`。 - 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。 -- 右侧查看器:支持横断面、矢状面、冠状面,窗宽窗位、旋转、切片进度条。 +- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。 - 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。 +- 图像叠层:可显示/隐藏患者、检查、方向、张数、窗宽窗位和缩放信息。 - DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 -- 序列标注:选择略过、头颈部、胸部、上腹部、下腹部、盆腔;上腹部需继续选择动脉期、门静脉期或无法判别。 -- AI 识别:配置 Kimi 后,可把序列张数及横断面、矢状面、冠状面代表图像传入 AI,自动给出部位、期相和备注建议。 +- 序列列表:默认按拍摄时间升序排序,可切换降序;少于 80 张且未标注部位的序列默认略过。 +- 序列标注:选择略过、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;点击后自动保存;上腹部需继续选择动脉期、门静脉期或无法判别。 +- AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。 - 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。 -- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`。 +- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。 ## 数据安全 diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 71711f1..215155d 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -76,7 +76,7 @@ app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") TOKENS: dict[str, str] = {} STUDY_CACHE: dict[str, dict[str, Any]] = {} -STACK_CACHE: dict[str, tuple[float, np.ndarray]] = {} +STACK_CACHE: dict[str, tuple[float, dict[str, Any]]] = {} class LoginIn(BaseModel): @@ -86,9 +86,17 @@ class LoginIn(BaseModel): class AnnotationIn(BaseModel): body_parts: list[str] = [] + manual_body_parts: list[str] = [] + ai_body_parts: list[str] = [] upper_abdomen_phase: str = "" + manual_upper_abdomen_phase: str = "" + ai_upper_abdomen_phase: str = "" + plain_ct: bool = False + manual_plain_ct: bool | None = None + ai_plain_ct: bool | None = None notes: str = "" skipped: bool = False + ai_skipped: bool = False class AIRequest(BaseModel): @@ -174,8 +182,16 @@ def ensure_annotation_table() -> None: series_instance_uid text NOT NULL, series_description text, body_parts jsonb NOT NULL DEFAULT '[]'::jsonb, + manual_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb, + ai_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb, upper_abdomen_phase text NOT NULL DEFAULT '', + manual_upper_abdomen_phase text NOT NULL DEFAULT '', + ai_upper_abdomen_phase text NOT NULL DEFAULT '', + plain_ct boolean NOT NULL DEFAULT false, + manual_plain_ct boolean, + ai_plain_ct boolean, skipped boolean NOT NULL DEFAULT false, + ai_skipped boolean NOT NULL DEFAULT false, notes text NOT NULL DEFAULT '', ai_result jsonb, ai_model text, @@ -184,12 +200,34 @@ def ensure_annotation_table() -> None: updated_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY (ct_number, series_instance_uid) ); + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS manual_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_body_parts jsonb NOT NULL DEFAULT '[]'::jsonb; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS manual_upper_abdomen_phase text NOT NULL DEFAULT ''; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_upper_abdomen_phase text NOT NULL DEFAULT ''; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS plain_ct boolean NOT NULL DEFAULT false; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS manual_plain_ct boolean; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_plain_ct boolean; ALTER TABLE public.pacs_dicom_series_annotations ADD COLUMN IF NOT EXISTS skipped boolean NOT NULL DEFAULT false; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_skipped boolean NOT NULL DEFAULT false; ALTER TABLE public.pacs_dicom_series_annotations ADD COLUMN IF NOT EXISTS ai_result jsonb; ALTER TABLE public.pacs_dicom_series_annotations ADD COLUMN IF NOT EXISTS ai_model text; + UPDATE public.pacs_dicom_series_annotations + SET manual_body_parts = body_parts, + manual_upper_abdomen_phase = upper_abdomen_phase + WHERE jsonb_array_length(body_parts) > 0 + AND jsonb_array_length(manual_body_parts) = 0 + AND jsonb_array_length(ai_body_parts) = 0; """ pg_scalar(sql) @@ -372,6 +410,13 @@ def read_header(path: Path) -> dict[str, str]: tags = [ "SeriesInstanceUID", "StudyInstanceUID", + "AccessionNumber", + "PatientName", + "PatientID", + "PatientBirthDate", + "PatientSex", + "InstitutionName", + "StudyDate", "SeriesNumber", "SeriesDescription", "InstanceNumber", @@ -414,11 +459,101 @@ def sort_key(item: tuple[Path, dict[str, str]]) -> tuple[float, float, str]: return (z, instance, str(path)) +def valid_parts(parts: Any) -> list[str]: + if not isinstance(parts, list): + return [] + return [part for part in parts if part in BODY_PARTS] + + +def valid_phase(value: Any) -> str: + return value if value in PHASES else "" + + +def bool_or_none(value: Any) -> bool | None: + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, str): + if value.lower() in {"true", "t", "1", "yes", "y"}: + return True + if value.lower() in {"false", "f", "0", "no", "n"}: + return False + return bool(value) + + +def merge_parts(manual_parts: list[str], ai_parts: list[str], skipped: bool) -> list[str]: + if skipped: + return [] + merged: list[str] = [] + for part in manual_parts + ai_parts: + if part in BODY_PARTS and part not in merged: + merged.append(part) + return merged + + +def effective_phase(manual_phase: str, ai_phase: str, body_parts: list[str], skipped: bool) -> str: + if skipped or "upper_abdomen" not in body_parts: + return "" + return valid_phase(manual_phase) or valid_phase(ai_phase) + + +def effective_plain_ct(manual_plain_ct: bool | None, ai_plain_ct: bool | None, fallback: bool, skipped: bool) -> bool: + if skipped: + return False + if manual_plain_ct is not None: + return manual_plain_ct + if ai_plain_ct is not None: + return ai_plain_ct + return bool(fallback) + + +def normalize_annotation(row: dict[str, Any] | None, default_skipped: bool = False) -> dict[str, Any]: + row = row or {} + manual_parts = valid_parts(row.get("manual_body_parts") or []) + ai_parts = valid_parts(row.get("ai_body_parts") or []) + if not manual_parts and not ai_parts: + manual_parts = valid_parts(row.get("body_parts") or []) + skipped = bool(row.get("skipped", default_skipped)) + ai_skipped = bool(row.get("ai_skipped", False)) + manual_phase = valid_phase(row.get("manual_upper_abdomen_phase") or "") + ai_phase = valid_phase(row.get("ai_upper_abdomen_phase") or "") + if not manual_phase and not ai_phase: + manual_phase = valid_phase(row.get("upper_abdomen_phase") or "") + body_parts = merge_parts(manual_parts, ai_parts, skipped) + plain_ct = effective_plain_ct( + bool_or_none(row.get("manual_plain_ct")), + bool_or_none(row.get("ai_plain_ct")), + bool(row.get("plain_ct", False)), + skipped, + ) + return { + "body_parts": body_parts, + "manual_body_parts": [] if skipped else manual_parts, + "ai_body_parts": [] if skipped else ai_parts, + "upper_abdomen_phase": effective_phase(manual_phase, ai_phase, body_parts, skipped), + "manual_upper_abdomen_phase": "" if skipped else manual_phase, + "ai_upper_abdomen_phase": "" if skipped else ai_phase, + "plain_ct": plain_ct, + "manual_plain_ct": None if skipped else bool_or_none(row.get("manual_plain_ct")), + "ai_plain_ct": None if skipped else bool_or_none(row.get("ai_plain_ct")), + "skipped": skipped, + "ai_skipped": ai_skipped, + "notes": row.get("notes", ""), + "updated_at": row.get("updated_at", ""), + "ai_model": row.get("ai_model", ""), + } + + def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: try: + ensure_annotation_table() rows = pg_json_rows( f""" - SELECT series_instance_uid, body_parts, upper_abdomen_phase, skipped, notes, updated_at, ai_model + SELECT + series_instance_uid, body_parts, manual_body_parts, ai_body_parts, + upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase, + plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, updated_at, ai_model FROM public.pacs_dicom_series_annotations WHERE ct_number = {sql_literal(ct_number)} """ @@ -450,17 +585,35 @@ def scan_study(ct_number: str) -> dict[str, Any]: annotations = get_annotations(ct_number) series_list = [] file_map = {} + default_skip_rows = [] for uid, items in grouped.items(): items.sort(key=sort_key) first = items[0][1] last = items[-1][1] file_map[uid] = [path for path, _ in items] - annotation = annotations.get(uid, {}) + raw_annotation = annotations.get(uid) + annotation = normalize_annotation(raw_annotation) + default_skipped = len(items) < 80 and not annotation.get("body_parts") and not annotation.get("plain_ct") + if default_skipped: + annotation["skipped"] = True + annotation["manual_body_parts"] = [] + annotation["ai_body_parts"] = [] + annotation["body_parts"] = [] + annotation["plain_ct"] = False + if default_skipped: + default_skip_rows.append((uid, first, len(items))) series_list.append( { "ct_number": ct_number, "series_uid": uid, "study_uid": first.get("StudyInstanceUID", ""), + "accession_number": first.get("AccessionNumber", ""), + "patient_name": first.get("PatientName", ""), + "patient_id": first.get("PatientID", ""), + "patient_birth_date": first.get("PatientBirthDate", ""), + "patient_sex": first.get("PatientSex", ""), + "institution_name": first.get("InstitutionName", ""), + "study_date": first.get("StudyDate", "") or study.get("study_date", ""), "series_number": first.get("SeriesNumber", ""), "description": first.get("SeriesDescription", "") or "未命名序列", "count": len(items), @@ -476,24 +629,75 @@ def scan_study(ct_number: str) -> dict[str, Any]: "pixel_spacing": first.get("PixelSpacing", ""), "slice_thickness": first.get("SliceThickness", ""), "spacing_between_slices": first.get("SpacingBetweenSlices", ""), - "annotation": { - "body_parts": annotation.get("body_parts", []), - "upper_abdomen_phase": annotation.get("upper_abdomen_phase", ""), - "skipped": bool(annotation.get("skipped", False)), - "notes": annotation.get("notes", ""), - "updated_at": annotation.get("updated_at", ""), - "ai_model": annotation.get("ai_model", ""), - }, + "window_center": first.get("WindowCenter", ""), + "window_width": first.get("WindowWidth", ""), + "default_skipped": default_skipped, + "annotation": annotation, } ) + for uid, first, count in default_skip_rows: + try: + pg_scalar( + f""" + INSERT INTO public.pacs_dicom_series_annotations ( + ct_number, study_instance_uid, series_instance_uid, series_description, + body_parts, manual_body_parts, ai_body_parts, upper_abdomen_phase, + plain_ct, skipped, notes, updated_by, updated_at + ) + VALUES ( + {sql_literal(ct_number)}, + {sql_literal(first.get('StudyInstanceUID', ''))}, + {sql_literal(uid)}, + {sql_literal(first.get('SeriesDescription', '') or '未命名序列')}, + '[]'::jsonb, + '[]'::jsonb, + '[]'::jsonb, + '', + false, + true, + {sql_literal(f'少于80张默认略过({count}张)')}, + 'system', + now() + ) + ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET + skipped = true, + body_parts = '[]'::jsonb, + manual_body_parts = '[]'::jsonb, + ai_body_parts = '[]'::jsonb, + upper_abdomen_phase = '', + manual_upper_abdomen_phase = '', + ai_upper_abdomen_phase = '', + plain_ct = false, + notes = CASE + WHEN pacs_dicom_series_annotations.notes = '' + THEN EXCLUDED.notes + ELSE pacs_dicom_series_annotations.notes + END, + updated_by = 'system', + updated_at = now() + WHERE jsonb_array_length(pacs_dicom_series_annotations.body_parts) = 0 + AND pacs_dicom_series_annotations.plain_ct IS NOT TRUE + """, + timeout=8, + ) + except Exception: + pass + + def series_time_key(row: dict[str, Any]) -> tuple[str, int, str]: + return ( + row.get("series_time") or row.get("first_time") or row.get("study_time") or "", + numeric(row.get("series_number", "")), + row.get("description", ""), + ) + def numeric(value: str) -> int: try: return int(float(value)) except Exception: return 999999 - series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, numeric(row["series_number"]), row["description"])) + series_list.sort(key=lambda row: (1 if row["annotation"].get("skipped") else 0, *series_time_key(row))) cached = {"cached_at": time.time(), "study": study, "series": series_list, "files": file_map} STUDY_CACHE[ct_number] = cached return cached @@ -535,13 +739,91 @@ def dicom_to_hu(ds: pydicom.Dataset) -> np.ndarray: return arr * slope + intercept -def render_array(arr: np.ndarray, center: float, width: float, invert: bool = False, rotate: int = 0, max_size: int = 900) -> bytes: +def as_float(value: Any, default: float = 1.0) -> float: + try: + return float(value) + except Exception: + return default + + +def pixel_spacing_from_ds(ds: pydicom.Dataset) -> tuple[float, float]: + spacing = getattr(ds, "PixelSpacing", None) + if spacing and len(spacing) >= 2: + row_spacing = as_float(spacing[0], 1.0) + col_spacing = as_float(spacing[1], row_spacing) + return max(row_spacing, 0.001), max(col_spacing, 0.001) + 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([as_float(position[0]), as_float(position[1]), as_float(position[2])], dtype=np.float32)) + for first, second in zip(positions, positions[1:]): + distance = float(np.linalg.norm(second - first)) + if distance > 0.001: + distances.append(distance) + if distances: + return max(float(np.median(distances)), 0.001) + + locations = [] + for ds in datasets: + value = getattr(ds, "SliceLocation", None) + if value is not None: + locations.append(as_float(value, 0.0)) + for first, second in zip(locations, locations[1:]): + distance = abs(second - first) + if distance > 0.001: + distances.append(distance) + if distances: + return max(float(np.median(distances)), 0.001) + + sample = datasets[0] if datasets else None + if sample is not None: + spacing = as_float(getattr(sample, "SpacingBetweenSlices", 0), 0.0) + if spacing > 0.001: + return spacing + thickness = as_float(getattr(sample, "SliceThickness", 0), 0.0) + if thickness > 0.001: + return thickness + return 1.0 + + +def resize_for_spacing(pil: Image.Image, row_spacing: float, col_spacing: float) -> Image.Image: + row_spacing = max(row_spacing, 0.001) + col_spacing = max(col_spacing, 0.001) + 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))) + max_edge = 2200 + scale = min(1.0, max_edge / max(target_w, target_h)) + target_size = (max(1, int(round(target_w * scale))), max(1, int(round(target_h * scale)))) + if target_size == pil.size: + return pil + return pil.resize(target_size, Image.Resampling.BILINEAR) + + +def render_array( + arr: np.ndarray, + center: float, + width: float, + invert: bool = False, + rotate: int = 0, + max_size: int = 900, + pixel_spacing: tuple[float, float] = (1.0, 1.0), +) -> bytes: low = center - width / 2.0 high = center + width / 2.0 img = ((np.clip(arr, low, high) - low) / max(high - low, 1.0) * 255.0).astype(np.uint8) if invert: img = 255 - img pil = Image.fromarray(img) + pil = resize_for_spacing(pil, pixel_spacing[0], pixel_spacing[1]) if rotate: pil = pil.rotate(-rotate, expand=True) if max(pil.size) > max_size: @@ -551,7 +833,7 @@ def render_array(arr: np.ndarray, center: float, width: float, invert: bool = Fa return output.getvalue() -def load_stack(ct_number: str, series_uid: str) -> np.ndarray: +def load_stack_data(ct_number: str, series_uid: str) -> dict[str, Any]: key = f"{ct_number}|{series_uid}" cached = STACK_CACHE.get(key) if cached: @@ -559,15 +841,24 @@ def load_stack(ct_number: str, series_uid: str) -> np.ndarray: return cached[1] files = get_series_files(ct_number, series_uid) arrays = [] + datasets = [] for path in files: ds = pydicom.dcmread(str(path), force=True) + datasets.append(ds) arrays.append(dicom_to_hu(ds)) stack = np.stack(arrays, axis=0) - STACK_CACHE[key] = (time.time(), stack) + row_spacing, col_spacing = pixel_spacing_from_ds(datasets[min(len(datasets) - 1, len(datasets) // 2)]) + payload = { + "stack": stack, + "row_spacing": row_spacing, + "col_spacing": col_spacing, + "slice_spacing": slice_spacing_from_datasets(datasets), + } + STACK_CACHE[key] = (time.time(), payload) if len(STACK_CACHE) > 2: oldest = sorted(STACK_CACHE.items(), key=lambda item: item[1][0])[0][0] STACK_CACHE.pop(oldest, None) - return stack + return payload @app.get("/api/image") @@ -586,21 +877,31 @@ def image( index = min(index, len(files) - 1) ds = pydicom.dcmread(str(files[index]), force=True) center, width = window_values(ds, window) - payload = render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", rotate) + payload = render_array( + dicom_to_hu(ds), + center, + width, + getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", + rotate, + pixel_spacing=pixel_spacing_from_ds(ds), + ) return Response(payload, media_type="image/png") - stack = load_stack(ct_number, series_uid) + 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, rotate) + payload = render_array(np.flipud(arr), center, width, False, rotate, pixel_spacing=spacing) return Response(payload, media_type="image/png") @@ -644,22 +945,45 @@ def dicom_info(ct_number: str, series_uid: str, index: int = 0, _: str = Depends return {"path": str(path), "fields": fields} -@app.put("/api/series/{ct_number}/{series_uid}/annotation") -def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: str = Depends(require_auth)) -> dict[str, Any]: - body_parts = [] if data.skipped else [part for part in data.body_parts if part in BODY_PARTS] - phase = data.upper_abdomen_phase if data.upper_abdomen_phase in PHASES else "" - if "upper_abdomen" not in body_parts: - phase = "" - study = scan_study(ct_number) - series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None) - if not series_row: - raise HTTPException(status_code=404, detail="series not found") +def sql_bool_or_null(value: bool | None) -> str: + if value is None: + return "NULL" + return "true" if value else "false" + + +def save_annotation_payload( + ct_number: str, + series_uid: str, + series_row: dict[str, Any], + manual_parts: list[str], + ai_parts: list[str], + manual_phase: str, + ai_phase: str, + manual_plain_ct: bool | None, + ai_plain_ct: bool | None, + skipped: bool, + ai_skipped: bool, + notes: str, + user: str, + ai_result: dict[str, Any] | None = None, + ai_model: str | None = None, +) -> dict[str, Any]: + manual_parts = valid_parts(manual_parts) + ai_parts = valid_parts(ai_parts) + body_parts = merge_parts(manual_parts, ai_parts, skipped) + manual_phase = valid_phase(manual_phase) + ai_phase = valid_phase(ai_phase) + phase = effective_phase(manual_phase, ai_phase, body_parts, skipped) + plain_ct = effective_plain_ct(manual_plain_ct, ai_plain_ct, False, skipped) ensure_annotation_table() pg_scalar( f""" INSERT INTO public.pacs_dicom_series_annotations ( ct_number, study_instance_uid, series_instance_uid, series_description, - body_parts, upper_abdomen_phase, skipped, notes, updated_by, updated_at + body_parts, manual_body_parts, ai_body_parts, + upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase, + plain_ct, manual_plain_ct, ai_plain_ct, + skipped, ai_skipped, notes, ai_result, ai_model, updated_by, updated_at ) VALUES ( {sql_literal(ct_number)}, @@ -667,9 +991,19 @@ def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: s {sql_literal(series_uid)}, {sql_literal(series_row.get('description', ''))}, {sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb, + {sql_literal(json.dumps([] if skipped else manual_parts, ensure_ascii=False))}::jsonb, + {sql_literal(json.dumps([] if skipped else ai_parts, ensure_ascii=False))}::jsonb, {sql_literal(phase)}, - {'true' if data.skipped else 'false'}, - {sql_literal(data.notes)}, + {sql_literal('' if skipped else manual_phase)}, + {sql_literal('' if skipped else ai_phase)}, + {'true' if plain_ct else 'false'}, + {sql_bool_or_null(None if skipped else manual_plain_ct)}, + {sql_bool_or_null(None if skipped else ai_plain_ct)}, + {'true' if skipped else 'false'}, + {'true' if ai_skipped else 'false'}, + {sql_literal(notes)}, + {sql_literal(json.dumps(ai_result, ensure_ascii=False)) + '::jsonb' if ai_result is not None else 'NULL'}, + {sql_literal(ai_model) if ai_model is not None else 'NULL'}, {sql_literal(user)}, now() ) @@ -677,15 +1011,65 @@ def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: s study_instance_uid = EXCLUDED.study_instance_uid, series_description = EXCLUDED.series_description, body_parts = EXCLUDED.body_parts, + manual_body_parts = EXCLUDED.manual_body_parts, + ai_body_parts = EXCLUDED.ai_body_parts, upper_abdomen_phase = EXCLUDED.upper_abdomen_phase, + manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase, + ai_upper_abdomen_phase = EXCLUDED.ai_upper_abdomen_phase, + plain_ct = EXCLUDED.plain_ct, + manual_plain_ct = EXCLUDED.manual_plain_ct, + ai_plain_ct = EXCLUDED.ai_plain_ct, skipped = EXCLUDED.skipped, + ai_skipped = EXCLUDED.ai_skipped, notes = EXCLUDED.notes, + ai_result = COALESCE(EXCLUDED.ai_result, pacs_dicom_series_annotations.ai_result), + ai_model = COALESCE(EXCLUDED.ai_model, pacs_dicom_series_annotations.ai_model), updated_by = EXCLUDED.updated_by, updated_at = now() """ ) + return normalize_annotation( + { + "body_parts": body_parts, + "manual_body_parts": [] if skipped else manual_parts, + "ai_body_parts": [] if skipped else ai_parts, + "upper_abdomen_phase": phase, + "manual_upper_abdomen_phase": "" if skipped else manual_phase, + "ai_upper_abdomen_phase": "" if skipped else ai_phase, + "plain_ct": plain_ct, + "manual_plain_ct": None if skipped else manual_plain_ct, + "ai_plain_ct": None if skipped else ai_plain_ct, + "skipped": skipped, + "ai_skipped": ai_skipped, + "notes": notes, + "ai_model": ai_model or series_row.get("annotation", {}).get("ai_model", ""), + } + ) + + +@app.put("/api/series/{ct_number}/{series_uid}/annotation") +def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: str = Depends(require_auth)) -> dict[str, Any]: + study = scan_study(ct_number) + series_row = next((row for row in study["series"] if row["series_uid"] == series_uid), None) + if not series_row: + raise HTTPException(status_code=404, detail="series not found") + annotation = save_annotation_payload( + ct_number=ct_number, + series_uid=series_uid, + series_row=series_row, + manual_parts=data.manual_body_parts or data.body_parts, + ai_parts=data.ai_body_parts, + manual_phase=data.manual_upper_abdomen_phase or data.upper_abdomen_phase, + ai_phase=data.ai_upper_abdomen_phase, + manual_plain_ct=data.manual_plain_ct if data.manual_plain_ct is not None else (data.plain_ct if data.plain_ct and data.ai_plain_ct is None else None), + ai_plain_ct=data.ai_plain_ct, + skipped=data.skipped, + ai_skipped=data.ai_skipped, + notes=data.notes, + user=user, + ) STUDY_CACHE.pop(ct_number, None) - return {"ok": True, "body_parts": body_parts, "upper_abdomen_phase": phase, "skipped": data.skipped} + return {"ok": True, **annotation} def representative_images(ct_number: str, series_uid: str) -> list[tuple[str, bytes]]: @@ -693,17 +1077,30 @@ def representative_images(ct_number: str, series_uid: str) -> list[tuple[str, by axial_index = max(0, min(len(files) - 1, len(files) // 2)) ds = pydicom.dcmread(str(files[axial_index]), force=True) center, width = window_values(ds, "soft") - images = [("横断面", render_array(dicom_to_hu(ds), center, width, getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", max_size=720))] + images = [ + ( + "轴位原始", + render_array( + dicom_to_hu(ds), + center, + width, + getattr(ds, "PhotometricInterpretation", "") == "MONOCHROME1", + max_size=720, + pixel_spacing=pixel_spacing_from_ds(ds), + ), + ) + ] if len(files) >= 3: try: - stack = load_stack(ct_number, series_uid) + stack_data = load_stack_data(ct_number, series_uid) + stack = stack_data["stack"] sample_ds = pydicom.dcmread(str(files[axial_index]), stop_before_pixels=True, force=True) center, width = window_values(sample_ds, "soft") coronal = np.flipud(stack[:, stack.shape[1] // 2, :]) sagittal = np.flipud(stack[:, :, stack.shape[2] // 2]) - images.append(("矢状面", render_array(sagittal, center, width, False, max_size=720))) - images.append(("冠状面", render_array(coronal, center, width, False, max_size=720))) + images.append(("矢状位重建", render_array(sagittal, center, width, False, max_size=720, pixel_spacing=(stack_data["slice_spacing"], stack_data["row_spacing"])))) + images.append(("冠状位重建", render_array(coronal, center, width, False, max_size=720, pixel_spacing=(stack_data["slice_spacing"], stack_data["col_spacing"])))) except Exception: pass return images @@ -742,8 +1139,9 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen "可选部位键: head_neck(头颈部), chest(胸部), upper_abdomen(上腹部), " "lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。" "如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。" + "请同时判断 plain_ct 是否为平扫CT。" "如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。" - "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"skipped\":false,\"notes\":\"\"}。" + "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"plain_ct\":false,\"skipped\":false,\"notes\":\"\"}。" f"PACS张数: {series_row.get('count', 0)}。" f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。" ), @@ -781,36 +1179,27 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen suggestion = parse_ai_json(message) body_parts = [part for part in suggestion.get("body_parts", []) if part in BODY_PARTS] skipped = bool(suggestion.get("skipped", False)) + plain_ct = bool(suggestion.get("plain_ct", False)) phase = suggestion.get("upper_abdomen_phase", "") if phase not in PHASES or "upper_abdomen" not in body_parts: phase = "" - ensure_annotation_table() - pg_scalar( - f""" - INSERT INTO public.pacs_dicom_series_annotations ( - ct_number, study_instance_uid, series_instance_uid, series_description, - body_parts, upper_abdomen_phase, skipped, notes, ai_result, ai_model, updated_by, updated_at - ) - VALUES ( - {sql_literal(ct_number)}, - {sql_literal(series_row.get('study_uid', ''))}, - {sql_literal(series_uid)}, - {sql_literal(series_row.get('description', ''))}, - '[]'::jsonb, - '', - false, - '', - {sql_literal(json.dumps(suggestion, ensure_ascii=False))}::jsonb, - {sql_literal(KIMI_MODEL)}, - {sql_literal(user)}, - now() - ) - ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET - ai_result = EXCLUDED.ai_result, - ai_model = EXCLUDED.ai_model, - updated_by = EXCLUDED.updated_by, - updated_at = now() - """ + current = normalize_annotation(series_row.get("annotation", {})) + annotation = save_annotation_payload( + ct_number=ct_number, + series_uid=series_uid, + series_row=series_row, + manual_parts=current.get("manual_body_parts", []), + ai_parts=[] if skipped else body_parts, + manual_phase=current.get("manual_upper_abdomen_phase", ""), + ai_phase="" if skipped else phase, + manual_plain_ct=current.get("manual_plain_ct"), + ai_plain_ct=None if skipped else plain_ct, + skipped=skipped, + ai_skipped=skipped, + notes=str(suggestion.get("notes", "")) or current.get("notes", ""), + user=user, + ai_result=suggestion, + ai_model=KIMI_MODEL, ) STUDY_CACHE.pop(ct_number, None) return { @@ -818,10 +1207,7 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen "provider": "Kimi", "name": KIMI_API_NAME, "model": KIMI_MODEL, - "body_parts": [] if skipped else body_parts, - "upper_abdomen_phase": "" if skipped else phase, - "skipped": skipped, - "notes": str(suggestion.get("notes", "")), + **annotation, "raw": suggestion, } diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index 18648b5..9d10d1b 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -4,6 +4,7 @@ const app = { study: null, series: [], activeSeries: null, + draft: null, slice: 0, plane: "axial", window: "default", @@ -14,12 +15,21 @@ const app = { imageUrl: "", searchTimer: null, statusTimer: null, + saveTimer: null, pendingImage: null, drag: null, + seriesSort: "asc", + showOverlay: true, }; const $ = (id) => document.getElementById(id); const clamp = (value, min, max) => Math.max(min, Math.min(max, value)); +const BODY_PARTS = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"]; +const WINDOW_PRESETS = { + bone: { wl: 500, ww: 1800 }, + soft: { wl: 50, ww: 360 }, + contrast: { wl: 90, ww: 140 }, +}; function escapeHtml(value) { return String(value ?? "") @@ -80,6 +90,10 @@ function timeRange(series) { return first || last || "未记录"; } +function timeKey(series) { + return String(series.series_time || series.first_time || series.study_time || "").replace(/\D/g, "").padEnd(6, "0"); +} + function phaseLabel(value) { return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || ""; } @@ -94,6 +108,28 @@ function partLabel(value) { }[value] || value; } +function asList(value) { + return Array.isArray(value) ? value : []; +} + +function uniq(list) { + return Array.from(new Set(list.filter(Boolean))); +} + +function parseFirstNumber(value) { + const match = String(value || "").match(/-?\d+(\.\d+)?/); + return match ? Number(match[0]) : null; +} + +function ageText(birthDate, studyDate) { + const birth = String(birthDate || ""); + const study = String(studyDate || ""); + if (birth.length !== 8 || study.length !== 8) return ""; + let age = Number(study.slice(0, 4)) - Number(birth.slice(0, 4)); + if (study.slice(4) < birth.slice(4)) age -= 1; + return age > 0 ? `${age}Y` : ""; +} + async function login(event) { event.preventDefault(); $("loginError").textContent = ""; @@ -132,6 +168,22 @@ async function refreshStatus() { } } +function sortSeries(list) { + return [...list].sort((a, b) => { + const skipA = a.annotation?.skipped ? 1 : 0; + const skipB = b.annotation?.skipped ? 1 : 0; + if (skipA !== skipB) return skipA - skipB; + const result = timeKey(a).localeCompare(timeKey(b)) || String(a.series_number || "").localeCompare(String(b.series_number || ""), undefined, { numeric: true }); + return app.seriesSort === "asc" ? result : -result; + }); +} + +function setSeries(list) { + app.series = sortSeries(list || []); + $("seriesCount").textContent = String(app.series.length); + renderSeries(); +} + async function loadStudies() { const q = encodeURIComponent($("studySearch").value.trim()); app.studies = await json(`/api/studies?q=${q}&limit=500`); @@ -163,6 +215,7 @@ async function selectStudy(ctNumber) { app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber }; app.series = []; app.activeSeries = null; + app.draft = null; $("activeStudyLabel").textContent = ctNumber; $("seriesCount").textContent = "读取中"; $("seriesGrid").innerHTML = ""; @@ -171,24 +224,45 @@ async function selectStudy(ctNumber) { try { const data = await json(`/api/studies/${encodeURIComponent(ctNumber)}/series`); app.study = data.study; - app.series = data.series || []; - $("seriesCount").textContent = String(app.series.length); - renderSeries(); + setSeries(data.series || []); if (app.series.length) selectSeries(app.series[0].series_uid); } catch (err) { $("seriesGrid").innerHTML = `

${escapeHtml(err.message)}

`; } } +function sourceTag(value, annotation) { + if (asList(annotation.manual_body_parts).includes(value)) return "manual"; + if (asList(annotation.ai_body_parts).includes(value)) return "ai"; + return ""; +} + +function seriesTags(series) { + const annotation = series.annotation || {}; + if (annotation.skipped) { + return [{ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" }]; + } + const tags = []; + if (annotation.plain_ct) { + tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" }); + } + for (const part of asList(annotation.body_parts)) { + tags.push({ label: partLabel(part), source: sourceTag(part, annotation) }); + } + const phase = phaseLabel(annotation.upper_abdomen_phase); + if (phase) { + tags.push({ label: phase, source: annotation.manual_upper_abdomen_phase ? "manual" : "ai" }); + } + if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" }); + return tags; +} + function renderSeries() { const grid = $("seriesGrid"); grid.innerHTML = ""; for (const series of app.series) { const skipped = Boolean(series.annotation?.skipped); - const parts = series.annotation?.body_parts || []; - const tags = skipped - ? ["略过"] - : [series.body_part_dicom, ...parts.map(partLabel), phaseLabel(series.annotation?.upper_abdomen_phase)].filter(Boolean); + const tags = seriesTags(series); const card = document.createElement("button"); card.className = "series-card"; card.classList.toggle("active", app.activeSeries?.series_uid === series.series_uid); @@ -203,7 +277,7 @@ function renderSeries() { ${escapeHtml(series.description || "未命名序列")} 拍摄 ${escapeHtml(timeRange(series))} 序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")} -
${tags.length ? tags.map((tag) => `${escapeHtml(tag)}`).join("") : "未标注"}
+
${tags.length ? tags.map((tag) => `${escapeHtml(tag.label)}`).join("") : "未标注"}
`; card.onclick = () => selectSeries(series.series_uid); @@ -262,12 +336,56 @@ function resetViewer() { $("dicomImage").removeAttribute("src"); $("imageEmpty").classList.remove("hidden"); $("sliceText").textContent = "0 / 0"; - $("saveState").textContent = "未保存"; + $("saveState").textContent = "自动保存"; resetViewState(); + updateOverlay(); } function applyTransform() { $("dicomImage").style.transform = `translate(${app.panX}px, ${app.panY}px) scale(${app.zoom}) rotate(${app.rotate}deg)`; + updateOverlay(); +} + +function windowInfo() { + if (WINDOW_PRESETS[app.window]) return WINDOW_PRESETS[app.window]; + return { + wl: parseFirstNumber(app.activeSeries?.window_center) ?? 50, + ww: parseFirstNumber(app.activeSeries?.window_width) ?? 360, + }; +} + +function orientationLabels() { + if (app.plane === "sagittal") return { top: "H", bottom: "F", left: "A", right: "P" }; + if (app.plane === "coronal") return { top: "H", bottom: "F", left: "R", right: "L" }; + return { top: "A", bottom: "P", left: "R", right: "L" }; +} + +function updateOverlay() { + const overlay = $("imageOverlay"); + overlay.classList.toggle("hidden", !app.showOverlay || !app.activeSeries); + $("overlayToggle").textContent = app.showOverlay ? "隐藏信息" : "显示信息"; + if (!app.activeSeries) return; + const s = app.activeSeries; + const orient = orientationLabels(); + const win = windowInfo(); + const age = ageText(s.patient_birth_date, s.study_date || app.study?.study_date); + const sex = s.patient_sex || ""; + document.querySelector(".ov-left-top").innerHTML = [ + s.patient_name || app.study?.source_patient_name || "", + [fmtDate(s.patient_birth_date), age, sex].filter(Boolean).join(" "), + s.patient_id || app.study?.patient_id || "", + fmtDate(s.study_date || app.study?.study_date), + ].filter(Boolean).map(escapeHtml).join("
"); + document.querySelector(".ov-right-top").innerHTML = [ + s.institution_name || "", + s.manufacturer || "", + ].filter(Boolean).map(escapeHtml).join("
"); + document.querySelector(".ov-left-mid").textContent = orient.left; + document.querySelector(".ov-right-mid").textContent = orient.right; + document.querySelector(".ov-top-mid").textContent = orient.top; + document.querySelector(".ov-bottom-mid").textContent = orient.bottom; + document.querySelector(".ov-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`; + document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}
WL:${Math.round(win.wl)}
Zoom:${app.zoom.toFixed(2)}`; } async function updateImage() { @@ -278,6 +396,7 @@ async function updateImage() { $("sliceSlider").value = String(app.slice); $("sliceText").textContent = `${app.slice + 1} / ${max + 1}`; $("imageEmpty").classList.add("hidden"); + updateOverlay(); const ticket = Symbol("image"); app.pendingImage = ticket; try { @@ -292,95 +411,186 @@ async function updateImage() { } } -function isSkippedChecked() { - return Boolean(document.querySelector('.part-grid input[value="skip"]')?.checked); +function cloneAnnotation(annotation = {}) { + return { + body_parts: asList(annotation.body_parts), + manual_body_parts: asList(annotation.manual_body_parts), + ai_body_parts: asList(annotation.ai_body_parts), + upper_abdomen_phase: annotation.upper_abdomen_phase || "", + manual_upper_abdomen_phase: annotation.manual_upper_abdomen_phase || "", + ai_upper_abdomen_phase: annotation.ai_upper_abdomen_phase || "", + plain_ct: Boolean(annotation.plain_ct), + manual_plain_ct: annotation.manual_plain_ct ?? null, + ai_plain_ct: annotation.ai_plain_ct ?? null, + skipped: Boolean(annotation.skipped), + ai_skipped: Boolean(annotation.ai_skipped), + notes: annotation.notes || "", + ai_model: annotation.ai_model || "", + }; } -function selectedParts() { - return Array.from(document.querySelectorAll('.part-grid input:checked:not([value="skip"])')).map((input) => input.value); +function effectiveParts() { + if (!app.draft || app.draft.skipped) return []; + return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]); } -function syncPartState() { - const skipped = isSkippedChecked(); - document.querySelectorAll('.part-grid input:not([value="skip"])').forEach((input) => { - input.disabled = skipped; - if (skipped) input.checked = false; +function effectivePhase() { + if (!effectiveParts().includes("upper_abdomen")) return ""; + return app.draft.manual_upper_abdomen_phase || app.draft.ai_upper_abdomen_phase || ""; +} + +function effectivePlainCt() { + if (!app.draft || app.draft.skipped) return false; + if (app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined) return Boolean(app.draft.manual_plain_ct); + if (app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined) return Boolean(app.draft.ai_plain_ct); + return Boolean(app.draft.plain_ct); +} + +function setLabelSource(input, source) { + const label = input.closest("label"); + label.classList.toggle("manual-selected", source === "manual"); + label.classList.toggle("ai-selected", source === "ai"); +} + +function applyAnnotationControls() { + if (!app.draft) return; + const parts = new Set(effectiveParts()); + document.querySelectorAll(".part-grid input").forEach((input) => { + const value = input.value; + if (value === "skip") { + input.checked = app.draft.skipped; + input.disabled = false; + setLabelSource(input, app.draft.skipped ? (app.draft.ai_skipped ? "ai" : "manual") : ""); + return; + } + if (value === "plain_ct") { + input.checked = effectivePlainCt(); + input.disabled = app.draft.skipped; + setLabelSource(input, app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined ? "manual" : app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined ? "ai" : ""); + return; + } + input.checked = parts.has(value); + input.disabled = app.draft.skipped; + setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : ""); }); - if (skipped) { - document.querySelectorAll("input[name=phase]").forEach((input) => { - input.checked = false; - }); - } - const show = !skipped && selectedParts().includes("upper_abdomen"); - $("phaseBox").classList.toggle("visible", show); + + const phase = effectivePhase(); + document.querySelectorAll("input[name=phase]").forEach((input) => { + input.checked = input.value === phase; + input.disabled = app.draft.skipped; + setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : ""); + }); + $("phaseBox").classList.toggle("visible", !app.draft.skipped && parts.has("upper_abdomen")); } function hydrateAnnotation() { - const annotation = app.activeSeries?.annotation || {}; - const parts = new Set(annotation.body_parts || []); - document.querySelectorAll(".part-grid input").forEach((input) => { - if (input.value === "skip") { - input.checked = Boolean(annotation.skipped); - return; - } - input.checked = !annotation.skipped && parts.has(input.value); - input.disabled = Boolean(annotation.skipped); - }); - document.querySelectorAll("input[name=phase]").forEach((input) => { - input.checked = !annotation.skipped && input.value === (annotation.upper_abdomen_phase || ""); - }); - $("annotationNotes").value = annotation.notes || ""; - syncPartState(); + app.draft = cloneAnnotation(app.activeSeries?.annotation || {}); + $("annotationNotes").value = app.draft.notes || ""; + applyAnnotationControls(); } -async function reloadCurrentStudySeries(keepUid) { - const data = await json(`/api/studies/${encodeURIComponent(app.study.ct_number)}/series`); - app.study = data.study; - app.series = data.series || []; - app.activeSeries = app.series.find((item) => item.series_uid === keepUid) || app.series[0] || null; - $("seriesCount").textContent = String(app.series.length); +function updateLocalAnnotation(annotation) { + const normalized = cloneAnnotation(annotation); + normalized.body_parts = asList(annotation.body_parts); + normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || ""; + app.draft = normalized; + app.activeSeries.annotation = normalized; + const index = app.series.findIndex((item) => item.series_uid === app.activeSeries.series_uid); + if (index >= 0) app.series[index].annotation = normalized; + app.series = sortSeries(app.series); renderSeries(); - hydrateAnnotation(); + applyAnnotationControls(); } -async function saveAnnotation() { - if (!app.study || !app.activeSeries) return; - const skipped = isSkippedChecked(); - const parts = skipped ? [] : selectedParts(); - let phase = skipped ? "" : document.querySelector("input[name=phase]:checked")?.value || ""; - if (parts.includes("upper_abdomen") && !phase) { - $("saveState").textContent = "请选择上腹部期相"; - return; - } - if (!parts.includes("upper_abdomen")) phase = ""; +function annotationPayload() { + const bodyParts = app.draft.skipped ? [] : effectiveParts(); + return { + body_parts: bodyParts, + manual_body_parts: app.draft.skipped ? [] : app.draft.manual_body_parts, + ai_body_parts: app.draft.skipped ? [] : app.draft.ai_body_parts, + upper_abdomen_phase: app.draft.skipped ? "" : effectivePhase(), + manual_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.manual_upper_abdomen_phase, + ai_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.ai_upper_abdomen_phase, + plain_ct: effectivePlainCt(), + manual_plain_ct: app.draft.skipped ? null : app.draft.manual_plain_ct, + ai_plain_ct: app.draft.skipped ? null : app.draft.ai_plain_ct, + skipped: app.draft.skipped, + ai_skipped: app.draft.ai_skipped, + notes: $("annotationNotes").value, + }; +} + +async function saveAnnotationNow() { + if (!app.study || !app.activeSeries || !app.draft) return; $("saveState").textContent = "保存中"; const uid = app.activeSeries.series_uid; try { - await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, { + const data = await json(`/api/series/${encodeURIComponent(app.study.ct_number)}/${encodeURIComponent(uid)}/annotation`, { method: "PUT", - body: JSON.stringify({ body_parts: parts, upper_abdomen_phase: phase, skipped, notes: $("annotationNotes").value }), + body: JSON.stringify(annotationPayload()), }); $("saveState").textContent = "已保存"; - await reloadCurrentStudySeries(uid); + updateLocalAnnotation(data); } catch (err) { $("saveState").textContent = err.message; } } -function applyAiSuggestion(data) { - const parts = new Set(data.body_parts || []); - document.querySelectorAll(".part-grid input").forEach((input) => { - if (input.value === "skip") { - input.checked = Boolean(data.skipped); - return; +function queueSave(delay = 450) { + clearTimeout(app.saveTimer); + $("saveState").textContent = "待保存"; + app.saveTimer = setTimeout(saveAnnotationNow, delay); +} + +function handlePartChange(event) { + if (!app.draft) return; + const input = event.target; + const value = input.value; + if (value === "skip") { + app.draft.skipped = input.checked; + if (!input.checked) app.draft.ai_skipped = false; + if (input.checked) { + app.draft.manual_body_parts = []; + app.draft.ai_body_parts = []; + app.draft.manual_upper_abdomen_phase = ""; + app.draft.ai_upper_abdomen_phase = ""; + app.draft.manual_plain_ct = null; + app.draft.ai_plain_ct = null; } - input.checked = !data.skipped && parts.has(input.value); - }); - document.querySelectorAll("input[name=phase]").forEach((input) => { - input.checked = !data.skipped && input.value === (data.upper_abdomen_phase || ""); - }); - if (data.notes) $("annotationNotes").value = data.notes; - syncPartState(); + } else if (value === "plain_ct") { + if (input.checked) { + app.draft.manual_plain_ct = true; + app.draft.ai_plain_ct = null; + } else { + app.draft.manual_plain_ct = false; + app.draft.ai_plain_ct = null; + } + } else if (BODY_PARTS.includes(value)) { + if (input.checked) { + app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]); + } else { + app.draft.manual_body_parts = app.draft.manual_body_parts.filter((part) => part !== value); + app.draft.ai_body_parts = app.draft.ai_body_parts.filter((part) => part !== value); + if (value === "upper_abdomen") { + app.draft.manual_upper_abdomen_phase = ""; + app.draft.ai_upper_abdomen_phase = ""; + } + } + } + app.draft.body_parts = effectiveParts(); + app.draft.upper_abdomen_phase = effectivePhase(); + app.draft.plain_ct = effectivePlainCt(); + applyAnnotationControls(); + queueSave(); +} + +function handlePhaseChange(event) { + if (!app.draft) return; + app.draft.manual_upper_abdomen_phase = event.target.value; + if (app.draft.ai_upper_abdomen_phase !== event.target.value) app.draft.ai_upper_abdomen_phase = ""; + app.draft.upper_abdomen_phase = effectivePhase(); + applyAnnotationControls(); + queueSave(); } async function runAI() { @@ -393,8 +603,9 @@ async function runAI() { method: "POST", body: JSON.stringify({ sample_count: 3 }), }); - applyAiSuggestion(data); - $("saveState").textContent = "AI 建议已填入,确认后保存"; + updateLocalAnnotation(data); + $("annotationNotes").value = data.notes || ""; + $("saveState").textContent = "AI 结果已保存"; } catch (err) { $("saveState").textContent = err.message; } finally { @@ -530,7 +741,7 @@ function wireImageGestures() { { passive: false }, ); wrap.addEventListener("pointerdown", (event) => { - if (!app.activeSeries) return; + if (!app.activeSeries || event.target.closest(".slice-rail")) return; app.drag = { x: event.clientX, y: event.clientY, panX: app.panX, panY: app.panY }; wrap.classList.add("dragging"); wrap.setPointerCapture(event.pointerId); @@ -549,6 +760,17 @@ function wireImageGestures() { }); } +function setSort(direction) { + app.seriesSort = direction; + $("sortAsc").classList.toggle("active", direction === "asc"); + $("sortDesc").classList.toggle("active", direction === "desc"); + app.series = sortSeries(app.series); + renderSeries(); + if (app.activeSeries) { + app.activeSeries = app.series.find((item) => item.series_uid === app.activeSeries.series_uid) || app.activeSeries; + } +} + function wire() { $("loginForm").addEventListener("submit", login); $("logoutBtn").addEventListener("click", logout); @@ -557,6 +779,12 @@ function wire() { $("aiClassify").addEventListener("click", runAI); $("zoomIn").addEventListener("click", () => changeZoom(1.18)); $("zoomOut").addEventListener("click", () => changeZoom(1 / 1.18)); + $("overlayToggle").addEventListener("click", () => { + app.showOverlay = !app.showOverlay; + updateOverlay(); + }); + $("sortAsc").addEventListener("click", () => setSort("asc")); + $("sortDesc").addEventListener("click", () => setSort("desc")); $("studySearch").addEventListener("input", () => { clearTimeout(app.searchTimer); app.searchTimer = setTimeout(loadStudies, 250); @@ -589,8 +817,13 @@ function wire() { applyTransform(); }); $("resetView").addEventListener("click", resetViewState); - document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", syncPartState)); - $("saveAnnotation").addEventListener("click", saveAnnotation); + document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", handlePartChange)); + document.querySelectorAll("input[name=phase]").forEach((input) => input.addEventListener("change", handlePhaseChange)); + $("annotationNotes").addEventListener("input", () => { + if (!app.draft) return; + app.draft.notes = $("annotationNotes").value; + queueSave(900); + }); document.querySelectorAll("[data-close]").forEach((button) => { button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden")); }); diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index 3d0b319..f391944 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -53,7 +53,11 @@

序列

- 0 +
+ + + 0 +
@@ -61,9 +65,9 @@
- - - + + +
@@ -77,6 +81,7 @@ +
@@ -84,6 +89,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -95,12 +112,17 @@

部位标注

- 未保存 + 自动保存
+
+ 人工 + AI +
+ @@ -117,7 +139,6 @@
-
diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index a404cd7..de86b68 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -284,6 +284,28 @@ button:disabled { font-size: 12px; } +.series-head-tools { + display: flex; + align-items: center; + gap: 6px; +} + +.sort-btn { + height: 26px; + padding: 0 8px; + border: 1px solid var(--stroke); + border-radius: 7px; + background: #0b0f16; + color: var(--muted); + font-size: 12px; +} + +.sort-btn.active { + border-color: var(--blue); + background: rgba(52, 116, 246, 0.2); + color: var(--text); +} + .study-list, .series-grid { min-height: 0; @@ -433,6 +455,18 @@ button:disabled { white-space: nowrap; } +.tag-line .tag-manual { + border-color: rgba(25, 212, 194, 0.58); + color: #baf8ee; + background: rgba(25, 212, 194, 0.08); +} + +.tag-line .tag-ai { + border-color: rgba(240, 181, 78, 0.65); + color: #ffe0a3; + background: rgba(240, 181, 78, 0.1); +} + .viewer-pane { min-width: 0; display: grid; @@ -502,6 +536,95 @@ button:disabled { display: none; } +.image-overlay { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; + color: rgba(245, 248, 255, 0.86); + font-family: "Consolas", "Cascadia Mono", monospace; + font-size: 14px; + line-height: 1.25; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.95); +} + +.image-overlay.hidden { + display: none; +} + +.crosshair { + position: absolute; + opacity: 0.72; +} + +.crosshair-v { + top: 4%; + bottom: 4%; + left: 50%; + width: 1px; + background: #ff3b30; +} + +.crosshair-h { + left: 3%; + right: 3%; + top: 50%; + height: 1px; + background: #21c55d; +} + +.ov { + position: absolute; + max-width: 42%; + white-space: pre-line; +} + +.ov-left-top { + top: 12px; + left: 14px; +} + +.ov-right-top { + top: 12px; + right: 54px; + text-align: right; +} + +.ov-left-mid { + left: 12px; + top: 50%; + transform: translateY(-50%); +} + +.ov-right-mid { + right: 54px; + top: 50%; + transform: translateY(-50%); +} + +.ov-top-mid { + top: 12px; + left: 50%; + transform: translateX(-50%); +} + +.ov-bottom-mid { + bottom: 12px; + left: 50%; + transform: translateX(-50%); +} + +.ov-left-bottom { + left: 14px; + bottom: 12px; +} + +.ov-right-bottom { + right: 54px; + bottom: 12px; + text-align: right; +} + .slice-rail { position: absolute; top: 16px; @@ -550,9 +673,39 @@ button:disabled { color: #baf8ee; } +.source-legend { + display: flex; + gap: 12px; + margin: -2px 0 10px; + color: var(--muted); + font-size: 12px; +} + +.source-legend span { + display: flex; + align-items: center; + gap: 5px; +} + +.manual-dot, +.ai-dot { + width: 9px; + height: 9px; + display: inline-block; + border-radius: 999px; +} + +.manual-dot { + background: var(--cyan); +} + +.ai-dot { + background: var(--amber); +} + .part-grid { display: grid; - grid-template-columns: repeat(6, minmax(82px, 1fr)); + grid-template-columns: repeat(7, minmax(78px, 1fr)); gap: 8px; } @@ -577,11 +730,29 @@ button:disabled { background: rgba(25, 212, 194, 0.1); } +.part-grid label.manual-selected, +.phase-options label.manual-selected { + border-color: rgba(25, 212, 194, 0.72); + box-shadow: inset 3px 0 0 rgba(25, 212, 194, 0.95); +} + +.part-grid label.ai-selected, +.phase-options label.ai-selected { + border-color: rgba(240, 181, 78, 0.72); + background: rgba(240, 181, 78, 0.1); + box-shadow: inset 3px 0 0 rgba(240, 181, 78, 0.95); +} + .part-grid .skip-option:has(input:checked) { border-color: rgba(240, 181, 78, 0.72); background: rgba(240, 181, 78, 0.12); } +.part-grid .plain-option:has(input:checked) { + border-color: rgba(52, 116, 246, 0.72); + background: rgba(52, 116, 246, 0.12); +} + .part-grid input:disabled + * { color: var(--muted); } @@ -610,9 +781,6 @@ button:disabled { } .annotation-actions { - display: grid; - grid-template-columns: minmax(220px, 1fr) 160px; - gap: 10px; margin-top: 10px; }