From 3c954cd274b432646e3605c47489b4e4054d1e0e Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 27 May 2026 11:07:27 +0800 Subject: [PATCH] Refine DICOM classification labels and loading --- PACS_DICOM处理/数据处理网页端/README.md | 10 +- PACS_DICOM处理/数据处理网页端/app.py | 91 ++++++++++-- PACS_DICOM处理/数据处理网页端/static/app.js | 135 +++++++++++++++--- .../数据处理网页端/static/index.html | 25 ++-- .../数据处理网页端/static/styles.css | 63 +++----- 5 files changed, 232 insertions(+), 92 deletions(-) diff --git a/PACS_DICOM处理/数据处理网页端/README.md b/PACS_DICOM处理/数据处理网页端/README.md index d00c7a4..f1801e5 100644 --- a/PACS_DICOM处理/数据处理网页端/README.md +++ b/PACS_DICOM处理/数据处理网页端/README.md @@ -1,6 +1,6 @@ -# PACS DICOM 网页端 +# DICOM 阅片分类系统 -本目录提供本地 DICOM 检查浏览、序列查看和序列部位标注界面。 +本目录提供本地 DICOM 检查浏览、序列查看和序列分类标注界面。 ## Docker 启动 @@ -40,10 +40,10 @@ uvicorn app:app --host 127.0.0.1 --port 8107 - 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。 - 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。 - 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。 -- 图像叠层:可显示/隐藏患者、检查、方向、张数、窗宽窗位和缩放信息。 +- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。 - DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。 -- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过序列固定在最下方,并在略过分组内继续按当前排序规则排列。 -- 序列标注:选择略过、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期或无法判别。 +- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。 +- 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过/不采用可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。 - AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。 - 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。 - 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。 diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 51fde37..7512806 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -64,14 +64,15 @@ WINDOWS = { } BODY_PARTS = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"} -PHASES = {"arterial", "portal_venous", "unknown", ""} +PHASES = {"arterial", "portal_venous", "delayed", "unknown", ""} +CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} ROLES = { "管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"], "阅片员": ["查看DICOM", "编辑标注", "AI识别"], "访客": ["查看DICOM"], } -app = FastAPI(title="PACS DICOM Viewer") +app = FastAPI(title="DICOM 阅片分类系统") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") TOKENS: dict[str, str] = {} @@ -91,6 +92,9 @@ class AnnotationIn(BaseModel): upper_abdomen_phase: str = "" manual_upper_abdomen_phase: str = "" ai_upper_abdomen_phase: str = "" + chest_window: str = "" + manual_chest_window: str = "" + ai_chest_window: str = "" plain_ct: bool = False manual_plain_ct: bool | None = None ai_plain_ct: bool | None = None @@ -187,6 +191,9 @@ def ensure_annotation_table() -> None: upper_abdomen_phase text NOT NULL DEFAULT '', manual_upper_abdomen_phase text NOT NULL DEFAULT '', ai_upper_abdomen_phase text NOT NULL DEFAULT '', + chest_window text NOT NULL DEFAULT '', + manual_chest_window text NOT NULL DEFAULT '', + ai_chest_window text NOT NULL DEFAULT '', plain_ct boolean NOT NULL DEFAULT false, manual_plain_ct boolean, ai_plain_ct boolean, @@ -208,6 +215,12 @@ def ensure_annotation_table() -> None: 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 chest_window text NOT NULL DEFAULT ''; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS manual_chest_window text NOT NULL DEFAULT ''; + ALTER TABLE public.pacs_dicom_series_annotations + ADD COLUMN IF NOT EXISTS ai_chest_window 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 @@ -475,6 +488,10 @@ def valid_phase(value: Any) -> str: return value if value in PHASES else "" +def valid_chest_window(value: Any) -> str: + return value if value in CHEST_WINDOWS else "" + + def bool_or_none(value: Any) -> bool | None: if value is None: return None @@ -502,6 +519,12 @@ def effective_phase(manual_phase: str, ai_phase: str, body_parts: list[str], ski return valid_phase(manual_phase) or valid_phase(ai_phase) +def effective_chest_window(manual_window: str, ai_window: str, body_parts: list[str], skipped: bool) -> str: + if "chest" not in body_parts: + return "" + return valid_chest_window(manual_window) or valid_chest_window(ai_window) or "unknown" + + def effective_plain_ct(manual_plain_ct: bool | None, ai_plain_ct: bool | None, fallback: bool, skipped: bool) -> bool: if manual_plain_ct is not None: return manual_plain_ct @@ -522,6 +545,10 @@ def normalize_annotation(row: dict[str, Any] | None, default_skipped: bool = Fal 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 "") + manual_chest_window = valid_chest_window(row.get("manual_chest_window") or "") + ai_chest_window = valid_chest_window(row.get("ai_chest_window") or "") + if not manual_chest_window and not ai_chest_window: + manual_chest_window = valid_chest_window(row.get("chest_window") or "") body_parts = merge_parts(manual_parts, ai_parts, skipped) plain_ct = effective_plain_ct( bool_or_none(row.get("manual_plain_ct")), @@ -536,6 +563,9 @@ def normalize_annotation(row: dict[str, Any] | None, default_skipped: bool = Fal "upper_abdomen_phase": effective_phase(manual_phase, ai_phase, body_parts, skipped), "manual_upper_abdomen_phase": manual_phase, "ai_upper_abdomen_phase": ai_phase, + "chest_window": effective_chest_window(manual_chest_window, ai_chest_window, body_parts, skipped), + "manual_chest_window": manual_chest_window, + "ai_chest_window": ai_chest_window, "plain_ct": plain_ct, "manual_plain_ct": bool_or_none(row.get("manual_plain_ct")), "ai_plain_ct": bool_or_none(row.get("ai_plain_ct")), @@ -548,16 +578,18 @@ def normalize_annotation(row: dict[str, Any] | None, default_skipped: bool = Fal } -def dicom_auto_annotation(meta: dict[str, str]) -> tuple[list[str], str]: +def dicom_auto_annotation(meta: dict[str, str]) -> tuple[list[str], str, str]: marker = f"{meta.get('BodyPartExamined', '')} {meta.get('SeriesDescription', '')}".upper() parts: list[str] = [] phase = "" + chest_window = "" if "CHEST" in marker and "chest" not in parts: parts.append("chest") + chest_window = "unknown" if "ABDOMEN" in marker and "upper_abdomen" not in parts: parts.append("upper_abdomen") phase = "unknown" - return parts, phase + return parts, phase, chest_window def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: @@ -568,6 +600,7 @@ def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]: SELECT series_instance_uid, body_parts, manual_body_parts, ai_body_parts, upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase, + chest_window, manual_chest_window, ai_chest_window, plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, updated_at, ai_model, updated_by FROM public.pacs_dicom_series_annotations WHERE ct_number = {sql_literal(ct_number)} @@ -609,7 +642,7 @@ def scan_study(ct_number: str) -> dict[str, Any]: file_map[uid] = [path for path, _ in items] raw_annotation = annotations.get(uid) annotation = normalize_annotation(raw_annotation) - auto_parts, auto_phase = dicom_auto_annotation(first) + auto_parts, auto_phase, auto_chest_window = dicom_auto_annotation(first) can_apply_auto = bool(auto_parts) and not annotation.get("body_parts") if can_apply_auto: annotation["manual_body_parts"] = auto_parts @@ -617,7 +650,10 @@ def scan_study(ct_number: str) -> dict[str, Any]: if auto_phase: annotation["manual_upper_abdomen_phase"] = auto_phase annotation["upper_abdomen_phase"] = auto_phase - auto_annotation_rows.append((uid, first, auto_parts, auto_phase)) + if auto_chest_window: + annotation["manual_chest_window"] = auto_chest_window + annotation["chest_window"] = auto_chest_window + auto_annotation_rows.append((uid, first, auto_parts, auto_phase, auto_chest_window)) default_skipped = len(items) < 80 and (not raw_annotation or annotation.get("updated_by") == "system") if default_skipped: annotation["skipped"] = True @@ -657,13 +693,14 @@ def scan_study(ct_number: str) -> dict[str, Any]: } ) - for uid, first, auto_parts, auto_phase in auto_annotation_rows: + for uid, first, auto_parts, auto_phase, auto_chest_window in auto_annotation_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, upper_abdomen_phase, manual_upper_abdomen_phase, + chest_window, manual_chest_window, updated_by, updated_at ) VALUES ( @@ -675,6 +712,8 @@ def scan_study(ct_number: str) -> dict[str, Any]: {sql_literal(json.dumps(auto_parts, ensure_ascii=False))}::jsonb, {sql_literal(auto_phase)}, {sql_literal(auto_phase)}, + {sql_literal(auto_chest_window)}, + {sql_literal(auto_chest_window)}, 'system', now() ) @@ -683,6 +722,8 @@ def scan_study(ct_number: str) -> dict[str, Any]: manual_body_parts = EXCLUDED.manual_body_parts, upper_abdomen_phase = EXCLUDED.upper_abdomen_phase, manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase, + chest_window = EXCLUDED.chest_window, + manual_chest_window = EXCLUDED.manual_chest_window, updated_by = 'system', updated_at = now() WHERE pacs_dicom_series_annotations.updated_by = 'system' @@ -713,7 +754,7 @@ def scan_study(ct_number: str) -> dict[str, Any]: '', false, true, - {sql_literal(f'少于80张默认略过({count}张)')}, + {sql_literal(f'少于80张默认略过/不采用({count}张)')}, 'system', now() ) @@ -1008,6 +1049,8 @@ def save_annotation_payload( ai_parts: list[str], manual_phase: str, ai_phase: str, + manual_chest_window: str, + ai_chest_window: str, manual_plain_ct: bool | None, ai_plain_ct: bool | None, skipped: bool, @@ -1023,6 +1066,12 @@ def save_annotation_payload( manual_phase = valid_phase(manual_phase) ai_phase = valid_phase(ai_phase) phase = effective_phase(manual_phase, ai_phase, body_parts, skipped) + manual_chest_window = valid_chest_window(manual_chest_window) + ai_chest_window = valid_chest_window(ai_chest_window) + if "chest" not in body_parts: + manual_chest_window = "" + ai_chest_window = "" + chest_window = effective_chest_window(manual_chest_window, ai_chest_window, body_parts, skipped) plain_ct = effective_plain_ct(manual_plain_ct, ai_plain_ct, False, skipped) ensure_annotation_table() pg_scalar( @@ -1031,6 +1080,7 @@ def save_annotation_payload( ct_number, study_instance_uid, series_instance_uid, series_description, body_parts, manual_body_parts, ai_body_parts, upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase, + chest_window, manual_chest_window, ai_chest_window, plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, ai_result, ai_model, updated_by, updated_at ) @@ -1045,6 +1095,9 @@ def save_annotation_payload( {sql_literal(phase)}, {sql_literal(manual_phase)}, {sql_literal(ai_phase)}, + {sql_literal(chest_window)}, + {sql_literal(manual_chest_window)}, + {sql_literal(ai_chest_window)}, {'true' if plain_ct else 'false'}, {sql_bool_or_null(manual_plain_ct)}, {sql_bool_or_null(ai_plain_ct)}, @@ -1065,6 +1118,9 @@ def save_annotation_payload( 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, + chest_window = EXCLUDED.chest_window, + manual_chest_window = EXCLUDED.manual_chest_window, + ai_chest_window = EXCLUDED.ai_chest_window, plain_ct = EXCLUDED.plain_ct, manual_plain_ct = EXCLUDED.manual_plain_ct, ai_plain_ct = EXCLUDED.ai_plain_ct, @@ -1085,6 +1141,9 @@ def save_annotation_payload( "upper_abdomen_phase": phase, "manual_upper_abdomen_phase": manual_phase, "ai_upper_abdomen_phase": ai_phase, + "chest_window": chest_window, + "manual_chest_window": manual_chest_window, + "ai_chest_window": ai_chest_window, "plain_ct": plain_ct, "manual_plain_ct": manual_plain_ct, "ai_plain_ct": ai_plain_ct, @@ -1110,6 +1169,8 @@ def save_annotation(ct_number: str, series_uid: str, data: AnnotationIn, user: s 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_chest_window=data.manual_chest_window or data.chest_window, + ai_chest_window=data.ai_chest_window, 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, @@ -1168,7 +1229,7 @@ def parse_ai_json(content: str) -> dict[str, Any]: try: return json.loads(text) except Exception: - return {"body_parts": [], "upper_abdomen_phase": "", "skipped": False, "notes": content[:800]} + return {"body_parts": [], "upper_abdomen_phase": "", "chest_window": "", "plain_ct": False, "skipped": False, "notes": content[:800]} @app.post("/api/series/{ct_number}/{series_uid}/ai") @@ -1189,8 +1250,9 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen "lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。" "如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。" "请同时判断 plain_ct 是否为平扫CT。" - "如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。" - "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"plain_ct\":false,\"skipped\":false,\"notes\":\"\"}。" + "如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。" + "如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、unknown(无法判别)。" + "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"chest_window\":\"\",\"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','')}。" ), @@ -1232,6 +1294,11 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen phase = suggestion.get("upper_abdomen_phase", "") if phase not in PHASES or "upper_abdomen" not in body_parts: phase = "" + chest_window = valid_chest_window(suggestion.get("chest_window", "")) + if "chest" not in body_parts: + chest_window = "" + elif not chest_window: + chest_window = "unknown" current = normalize_annotation(series_row.get("annotation", {})) annotation = save_annotation_payload( ct_number=ct_number, @@ -1241,6 +1308,8 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen ai_parts=[] if skipped else body_parts, manual_phase=current.get("manual_upper_abdomen_phase", ""), ai_phase="" if skipped else phase, + manual_chest_window=current.get("manual_chest_window", ""), + ai_chest_window="" if skipped else chest_window, manual_plain_ct=current.get("manual_plain_ct"), ai_plain_ct=None if skipped else plain_ct, skipped=skipped, diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index d3f340f..3b42bdb 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -22,6 +22,9 @@ const app = { drag: null, seriesSort: "timeAsc", showOverlay: true, + thumbUrls: new Map(), + thumbObserver: null, + thumbSeries: new WeakMap(), }; const $ = (id) => document.getElementById(id); @@ -97,7 +100,11 @@ function timeKey(series) { } function phaseLabel(value) { - return { arterial: "动脉期", portal_venous: "门静脉期", unknown: "无法判别" }[value] || ""; + return { arterial: "动脉期", portal_venous: "门静脉期", delayed: "延迟期", unknown: "无法判别" }[value] || ""; +} + +function chestWindowLabel(value) { + return { lung: "肺窗", mediastinal: "纵隔窗", unknown: "无法判别" }[value] || ""; } function partLabel(value) { @@ -254,7 +261,6 @@ async function selectStudy(ctNumber) { setSeries(data.series || []); refreshStudyAnnotationSummary(); renderStudies(); - if (app.series.length) selectSeries(app.series[0].series_uid); } catch (err) { $("seriesGrid").innerHTML = `

${escapeHtml(err.message)}

`; } @@ -270,7 +276,7 @@ function seriesTags(series) { const annotation = series.annotation || {}; const tags = []; if (annotation.skipped) { - tags.push({ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" }); + tags.push({ label: "略过/不采用", source: annotation.ai_skipped ? "ai" : "manual" }); } if (annotation.plain_ct) { tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" }); @@ -280,7 +286,19 @@ function seriesTags(series) { } const phase = phaseLabel(annotation.upper_abdomen_phase); if (phase) { - tags.push({ label: phase, source: annotation.manual_upper_abdomen_phase ? "manual" : "ai" }); + tags.push({ + label: phase, + source: annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : "", + warn: annotation.upper_abdomen_phase === "unknown", + }); + } + const chestWindow = chestWindowLabel(annotation.chest_window); + if (chestWindow) { + tags.push({ + label: chestWindow, + source: annotation.manual_chest_window ? "manual" : annotation.ai_chest_window ? "ai" : "", + warn: annotation.chest_window === "unknown", + }); } if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" }); return tags; @@ -288,6 +306,7 @@ function seriesTags(series) { function renderSeries() { const grid = $("seriesGrid"); + if (app.thumbObserver) app.thumbObserver.disconnect(); grid.innerHTML = ""; for (const series of app.series) { const skipped = Boolean(series.annotation?.skipped); @@ -300,18 +319,18 @@ function renderSeries() {
${Number(series.count || 0)} 张 - ${skipped ? "略过" : ""} + ${skipped ? "略过/不采用" : ""}
${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.label)}`).join("") : "未标注"}
+
${tags.length ? tags.map((tag) => `${escapeHtml(tag.label)}`).join("") : "未标注"}
`; card.onclick = () => selectSeries(series.series_uid); grid.appendChild(card); - loadThumb(series, card.querySelector("img")); + queueThumb(series, card.querySelector("img")); } } @@ -321,14 +340,53 @@ function imageUrlFor(series = app.activeSeries, index = app.slice, windowName = async function loadThumb(series, img) { try { + const key = thumbKey(series); + if (app.thumbUrls.has(key)) { + img.src = app.thumbUrls.get(key); + return; + } const index = Math.floor((Number(series.count) || 1) / 2); const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob()); - img.src = URL.createObjectURL(blob); + const url = URL.createObjectURL(blob); + app.thumbUrls.set(key, url); + img.src = url; } catch (_) { img.removeAttribute("src"); } } +function thumbKey(series) { + return `${series.ct_number || app.study?.ct_number || ""}:${series.series_uid}`; +} + +function queueThumb(series, img) { + const key = thumbKey(series); + if (app.thumbUrls.has(key)) { + img.src = app.thumbUrls.get(key); + return; + } + if (!("IntersectionObserver" in window)) { + loadThumb(series, img); + return; + } + if (!app.thumbObserver) { + app.thumbObserver = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const target = entry.target; + app.thumbObserver.unobserve(target); + const queuedSeries = app.thumbSeries.get(target); + if (queuedSeries) loadThumb(queuedSeries, target); + } + }, + { rootMargin: "320px 0px" }, + ); + } + app.thumbSeries.set(img, series); + app.thumbObserver.observe(img); +} + function maxSlice() { if (!app.activeSeries) return 0; if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1); @@ -367,6 +425,7 @@ function resetViewer() { $("imageEmpty").classList.remove("hidden"); $("sliceText").textContent = "0 / 0"; $("saveState").textContent = "已保存"; + clearAnnotationControls(); app.dirty = false; app.saving = false; resetViewState(); @@ -386,19 +445,12 @@ function windowInfo() { }; } -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 || ""; @@ -412,10 +464,6 @@ function updateOverlay() { 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)}`; } @@ -451,6 +499,9 @@ function cloneAnnotation(annotation = {}) { 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 || "", + chest_window: annotation.chest_window || "", + manual_chest_window: annotation.manual_chest_window || "", + ai_chest_window: annotation.ai_chest_window || "", plain_ct: Boolean(annotation.plain_ct), manual_plain_ct: annotation.manual_plain_ct ?? null, ai_plain_ct: annotation.ai_plain_ct ?? null, @@ -471,6 +522,11 @@ function effectivePhase() { return app.draft.manual_upper_abdomen_phase || app.draft.ai_upper_abdomen_phase || ""; } +function effectiveChestWindow() { + if (!effectiveParts().includes("chest")) return ""; + return app.draft.manual_chest_window || app.draft.ai_chest_window || "unknown"; +} + function effectivePlainCt() { if (!app.draft) return false; if (app.draft.manual_plain_ct !== null && app.draft.manual_plain_ct !== undefined) return Boolean(app.draft.manual_plain_ct); @@ -484,6 +540,17 @@ function setLabelSource(input, source) { label.classList.toggle("ai-selected", source === "ai"); } +function clearAnnotationControls() { + $("annotationNotes").value = ""; + document.querySelectorAll(".part-grid input, input[name=phase], input[name=chestWindow]").forEach((input) => { + input.checked = false; + input.disabled = true; + setLabelSource(input, ""); + }); + $("phaseBox").classList.remove("visible"); + $("chestWindowBox").classList.remove("visible"); +} + function applyAnnotationControls() { if (!app.draft) return; const parts = new Set(effectiveParts()); @@ -513,6 +580,14 @@ function applyAnnotationControls() { setLabelSource(input, app.draft.manual_upper_abdomen_phase === input.value ? "manual" : app.draft.ai_upper_abdomen_phase === input.value ? "ai" : ""); }); $("phaseBox").classList.toggle("visible", parts.has("upper_abdomen")); + + const chestWindow = effectiveChestWindow(); + document.querySelectorAll("input[name=chestWindow]").forEach((input) => { + input.checked = input.value === chestWindow; + input.disabled = false; + setLabelSource(input, app.draft.manual_chest_window === input.value ? "manual" : app.draft.ai_chest_window === input.value ? "ai" : ""); + }); + $("chestWindowBox").classList.toggle("visible", parts.has("chest")); } function hydrateAnnotation() { @@ -525,6 +600,7 @@ function updateLocalAnnotation(annotation) { const normalized = cloneAnnotation(annotation); normalized.body_parts = asList(annotation.body_parts); normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || ""; + normalized.chest_window = annotation.chest_window || ""; app.draft = normalized; app.activeSeries.annotation = normalized; const index = app.series.findIndex((item) => item.series_uid === app.activeSeries.series_uid); @@ -545,6 +621,9 @@ function annotationPayload() { upper_abdomen_phase: effectivePhase(), manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase, ai_upper_abdomen_phase: app.draft.ai_upper_abdomen_phase, + chest_window: effectiveChestWindow(), + manual_chest_window: app.draft.manual_chest_window, + ai_chest_window: app.draft.ai_chest_window, plain_ct: effectivePlainCt(), manual_plain_ct: app.draft.manual_plain_ct, ai_plain_ct: app.draft.ai_plain_ct, @@ -610,6 +689,9 @@ function handlePartChange(event) { } else if (BODY_PARTS.includes(value)) { if (input.checked) { app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]); + if (value === "chest" && !app.draft.manual_chest_window && !app.draft.ai_chest_window) { + app.draft.manual_chest_window = "unknown"; + } } 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); @@ -617,10 +699,15 @@ function handlePartChange(event) { app.draft.manual_upper_abdomen_phase = ""; app.draft.ai_upper_abdomen_phase = ""; } + if (value === "chest") { + app.draft.manual_chest_window = ""; + app.draft.ai_chest_window = ""; + } } } app.draft.body_parts = effectiveParts(); app.draft.upper_abdomen_phase = effectivePhase(); + app.draft.chest_window = effectiveChestWindow(); app.draft.plain_ct = effectivePlainCt(); applyAnnotationControls(); markDirty(); @@ -635,6 +722,15 @@ function handlePhaseChange(event) { markDirty(); } +function handleChestWindowChange(event) { + if (!app.draft) return; + app.draft.manual_chest_window = event.target.value; + if (app.draft.ai_chest_window !== event.target.value) app.draft.ai_chest_window = ""; + app.draft.chest_window = effectiveChestWindow(); + applyAnnotationControls(); + markDirty(); +} + async function runAI() { if (!app.study || !app.activeSeries) return; const button = $("aiClassify"); @@ -866,6 +962,7 @@ function wire() { $("resetView").addEventListener("click", resetViewState); document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", handlePartChange)); document.querySelectorAll("input[name=phase]").forEach((input) => input.addEventListener("change", handlePhaseChange)); + document.querySelectorAll("input[name=chestWindow]").forEach((input) => input.addEventListener("change", handleChestWindowChange)); $("annotationNotes").addEventListener("input", () => { if (!app.draft) return; app.draft.notes = $("annotationNotes").value; diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index 893f043..4266c1e 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -3,14 +3,14 @@ - PACS DICOM Viewer + DICOM 阅片分类系统