diff --git a/PACS_DICOM处理/数据处理网页端/app.py b/PACS_DICOM处理/数据处理网页端/app.py index 8256680..23b4818 100644 --- a/PACS_DICOM处理/数据处理网页端/app.py +++ b/PACS_DICOM处理/数据处理网页端/app.py @@ -65,7 +65,8 @@ WINDOWS = { "contrast": (90.0, 140.0), } -BODY_PARTS = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"} +BODY_PART_ORDER = ["head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"] +BODY_PARTS = set(BODY_PART_ORDER) PHASES = {"arterial", "portal_venous", "delayed", "unknown", ""} CHEST_WINDOWS = {"lung", "mediastinal", "unknown", ""} ROLES = { @@ -300,11 +301,14 @@ def ensure_study_summary_table() -> None: series_count integer NOT NULL DEFAULT 0, annotated_series integer NOT NULL DEFAULT 0, unannotated_series integer NOT NULL DEFAULT 0, + body_parts jsonb NOT NULL DEFAULT '[]'::jsonb, completed boolean NOT NULL DEFAULT false, completed_by text, completed_at timestamptz, refreshed_at timestamptz NOT NULL DEFAULT now() ); + ALTER TABLE public.pacs_dicom_study_summaries + ADD COLUMN IF NOT EXISTS body_parts jsonb NOT NULL DEFAULT '[]'::jsonb; ALTER TABLE public.pacs_dicom_study_summaries ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false; ALTER TABLE public.pacs_dicom_study_summaries @@ -480,24 +484,31 @@ def status() -> dict[str, Any]: } -def annotation_counts_by_study() -> dict[str, int]: +def annotation_overview_by_study() -> dict[str, dict[str, Any]]: try: annotations = pg_json_rows( """ - SELECT ct_number, count(*)::int AS annotated_series - FROM public.pacs_dicom_series_annotations - WHERE skipped IS TRUE - OR jsonb_array_length(body_parts) > 0 - OR plain_ct IS TRUE - OR NULLIF(notes, '') IS NOT NULL - OR ai_result IS NOT NULL - GROUP BY ct_number + SELECT + a.ct_number, + count(DISTINCT a.series_instance_uid) FILTER ( + WHERE a.skipped IS TRUE + OR jsonb_array_length(a.body_parts) > 0 + OR NULLIF(a.notes, '') IS NOT NULL + OR a.ai_result IS NOT NULL + )::int AS annotated_series, + COALESCE( + jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), + '[]'::jsonb + ) AS body_parts + FROM public.pacs_dicom_series_annotations a + LEFT JOIN LATERAL jsonb_array_elements_text(a.body_parts) AS part(value) ON true + GROUP BY a.ct_number """, timeout=8, ) except Exception: return {} - return {row["ct_number"]: int(row["annotated_series"] or 0) for row in annotations} + return {row["ct_number"]: row for row in annotations} def summary_rows_by_study() -> dict[str, dict[str, Any]]: @@ -507,7 +518,7 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]: """ SELECT ct_number, series_count, annotated_series, unannotated_series, - completed, completed_by, completed_at, refreshed_at + body_parts, completed, completed_by, completed_at, refreshed_at FROM public.pacs_dicom_study_summaries """, timeout=8, @@ -557,14 +568,16 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li LIMIT {int(limit)} """ ) - annotation_map = annotation_counts_by_study() + annotation_map = annotation_overview_by_study() summary_map = summary_rows_by_study() for row in rows: summary = summary_map.get(row["ct_number"]) + annotation_summary = annotation_map.get(row["ct_number"], {}) total_series = int((summary or {}).get("series_count") or row.get("series_count") or 0) row["series_count"] = total_series - row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_map.get(row["ct_number"], 0))) + row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_summary.get("annotated_series", 0))) row["unannotated_series"] = max(0, total_series - int(row["annotated_series"])) + row["body_parts"] = valid_parts((summary or {}).get("body_parts") or annotation_summary.get("body_parts") or []) row["completed"] = bool((summary or {}).get("completed", False)) row["completed_by"] = (summary or {}).get("completed_by", "") row["completed_at"] = (summary or {}).get("completed_at", "") @@ -978,11 +991,15 @@ def scan_study(ct_number: str) -> dict[str, Any]: def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]: series_list = data.get("series", []) total = len(series_list) + body_parts = [ + part + for part in BODY_PART_ORDER + if any(part in valid_parts(row.get("annotation", {}).get("body_parts") or []) for row in series_list) + ] annotated = sum( 1 for row in series_list if row.get("annotation", {}).get("skipped") - or row.get("annotation", {}).get("plain_ct") or row.get("annotation", {}).get("body_parts") or row.get("annotation", {}).get("notes") or row.get("annotation", {}).get("ai_model") @@ -992,6 +1009,7 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]: "series_count": total, "annotated_series": annotated, "unannotated_series": max(0, total - annotated), + "body_parts": body_parts, "summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), } @@ -1002,19 +1020,21 @@ def save_study_summary(summary: dict[str, Any]) -> None: pg_scalar( f""" INSERT INTO public.pacs_dicom_study_summaries ( - ct_number, series_count, annotated_series, unannotated_series, refreshed_at + ct_number, series_count, annotated_series, unannotated_series, body_parts, refreshed_at ) VALUES ( {sql_literal(summary.get('ct_number', ''))}, {int(summary.get('series_count') or 0)}, {int(summary.get('annotated_series') or 0)}, {int(summary.get('unannotated_series') or 0)}, + {sql_literal(json.dumps(valid_parts(summary.get('body_parts') or []), ensure_ascii=False))}::jsonb, now() ) ON CONFLICT (ct_number) DO UPDATE SET series_count = EXCLUDED.series_count, annotated_series = EXCLUDED.annotated_series, unannotated_series = EXCLUDED.unannotated_series, + body_parts = EXCLUDED.body_parts, refreshed_at = now() """, timeout=8, @@ -1539,7 +1559,7 @@ def parse_ai_json(content: str) -> dict[str, Any]: try: return json.loads(text) except Exception: - return {"body_parts": [], "upper_abdomen_phase": "", "chest_window": "", "plain_ct": False, "skipped": False, "notes": content[:800]} + return {"body_parts": [], "upper_abdomen_phase": "", "chest_window": "", "skipped": False, "notes": content[:800]} @app.post("/api/series/{ct_number}/{series_uid}/ai") @@ -1559,10 +1579,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(门静脉期)、delayed(延迟期)、unknown(无法判别)。" "如果包含胸部,请判断窗位: lung(肺窗)、mediastinal(纵隔窗)、unknown(无法判别)。" - "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"chest_window\":\"\",\"plain_ct\":false,\"skipped\":false,\"notes\":\"\"}。" + "只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"chest_window\":\"\",\"skipped\":false,\"notes\":\"\"}。" f"PACS张数: {series_row.get('count', 0)}。" f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。" ), @@ -1600,7 +1619,6 @@ 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 = "" @@ -1620,8 +1638,8 @@ def ai_classify(ct_number: str, series_uid: str, _: AIRequest, user: str = Depen 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, + manual_plain_ct=None, + ai_plain_ct=None, skipped=skipped, ai_skipped=skipped, notes=str(suggestion.get("notes", "")) or current.get("notes", ""), diff --git a/PACS_DICOM处理/数据处理网页端/static/app.js b/PACS_DICOM处理/数据处理网页端/static/app.js index 182c65d..6ba8b9b 100644 --- a/PACS_DICOM处理/数据处理网页端/static/app.js +++ b/PACS_DICOM处理/数据处理网页端/static/app.js @@ -27,6 +27,7 @@ const app = { studySortField: "time", studySortDir: "desc", studyFilter: "all", + studyPartFilter: "all", aiEnabled: true, seriesSortField: "time", seriesSortDir: "asc", @@ -254,8 +255,9 @@ function studyStatusLabel(status) { function visibleStudies() { return sortStudies(app.studies).filter((study) => { const status = studyStatus(study); - if (app.studyFilter === "complete") return status === "complete"; - if (app.studyFilter === "incomplete") return status !== "complete"; + if (app.studyFilter === "complete" && status !== "complete") return false; + if (app.studyFilter === "incomplete" && status === "complete") return false; + if (app.studyPartFilter !== "all" && !asList(study.body_parts).includes(app.studyPartFilter)) return false; return true; }); } @@ -276,6 +278,9 @@ function updateSortButtons() { document.querySelectorAll("[data-study-filter]").forEach((button) => { button.classList.toggle("active", button.dataset.studyFilter === app.studyFilter); }); + document.querySelectorAll("[data-study-part-filter]").forEach((button) => { + button.classList.toggle("active", button.dataset.studyPartFilter === app.studyPartFilter); + }); } function setSeries(list) { @@ -287,17 +292,21 @@ function setSeries(list) { function isSeriesAnnotated(series) { const annotation = series.annotation || {}; - return Boolean(annotation.skipped || annotation.plain_ct || asList(annotation.body_parts).length || annotation.notes || annotation.ai_model); + return Boolean(annotation.skipped || asList(annotation.body_parts).length || annotation.notes || annotation.ai_model); } function refreshStudyAnnotationSummary() { if (!app.study || !app.series.length) return; const annotated = app.series.filter(isSeriesAnnotated).length; const total = app.series.length; + const bodyParts = BODY_PARTS.filter((part) => + app.series.some((series) => asList(series.annotation?.body_parts).includes(part)), + ); const summary = { annotated_series: annotated, unannotated_series: Math.max(0, total - annotated), series_count: total, + body_parts: bodyParts, }; Object.assign(app.study, summary); const study = app.studies.find((item) => item.ct_number === app.study.ct_number); @@ -326,8 +335,15 @@ function renderStudies() { button.classList.toggle("active", app.study?.ct_number === study.ct_number); button.classList.toggle("complete", status === "complete"); const name = study.source_patient_name || study.patient_name_dicom || "无姓名"; + const partTags = asList(study.body_parts) + .filter((part) => BODY_PARTS.includes(part)) + .map((part) => `${escapeHtml(partLabel(part))}`) + .join(""); button.innerHTML = ` - ${studyStatusLabel(status)} +
+ ${studyStatusLabel(status)} +
${partTags}
+
${escapeHtml(study.ct_number)} ${escapeHtml(name)} · ${escapeHtml(study.patient_id || "无ID")} ${escapeHtml(fmtDate(study.study_date))} ${escapeHtml(fmtTime(study.study_time))} · ${Number(study.dicom_file_count || 0)} 张 @@ -416,9 +432,6 @@ function seriesTags(series) { if (annotation.skipped) { 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" }); - } const phase = phaseLabel(annotation.upper_abdomen_phase); const phaseSource = annotation.manual_upper_abdomen_phase ? "manual" : annotation.ai_upper_abdomen_phase ? "ai" : ""; const chestWindow = chestWindowLabel(annotation.chest_window); @@ -754,13 +767,6 @@ function effectiveChestWindow(draft = app.draft) { return draft.manual_chest_window || draft.ai_chest_window || "unknown"; } -function effectivePlainCt(draft = app.draft) { - if (!draft) return false; - if (draft.manual_plain_ct !== null && draft.manual_plain_ct !== undefined) return Boolean(draft.manual_plain_ct); - if (draft.ai_plain_ct !== null && draft.ai_plain_ct !== undefined) return Boolean(draft.ai_plain_ct); - return Boolean(draft.plain_ct); -} - function setLabelSource(input, source) { const label = input.closest("label"); label.classList.toggle("manual-selected", source === "manual"); @@ -789,12 +795,6 @@ function applyAnnotationControls() { setLabelSource(input, app.draft.skipped ? (app.draft.ai_skipped ? "ai" : "manual") : ""); return; } - if (value === "plain_ct") { - input.checked = effectivePlainCt(); - input.disabled = false; - 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 = false; setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : ""); @@ -858,9 +858,9 @@ function annotationPayload(draft = app.draft, notes = $("annotationNotes").value chest_window: effectiveChestWindow(draft), manual_chest_window: draft.manual_chest_window, ai_chest_window: draft.ai_chest_window, - plain_ct: effectivePlainCt(draft), - manual_plain_ct: draft.manual_plain_ct, - ai_plain_ct: draft.ai_plain_ct, + plain_ct: false, + manual_plain_ct: null, + ai_plain_ct: null, skipped: Boolean(draft.skipped), ai_skipped: Boolean(draft.ai_skipped), notes, @@ -944,14 +944,6 @@ function handlePartChange(event) { if (value === "skip") { app.draft.skipped = input.checked; if (!input.checked) app.draft.ai_skipped = false; - } 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]); @@ -974,7 +966,9 @@ function handlePartChange(event) { app.draft.body_parts = effectiveParts(); app.draft.upper_abdomen_phase = effectivePhase(); app.draft.chest_window = effectiveChestWindow(); - app.draft.plain_ct = effectivePlainCt(); + app.draft.plain_ct = false; + app.draft.manual_plain_ct = null; + app.draft.ai_plain_ct = null; applyAnnotationControls(); markDirty(); } @@ -1284,6 +1278,12 @@ function wire() { renderStudies(); }); }); + document.querySelectorAll("[data-study-part-filter]").forEach((button) => { + button.addEventListener("click", () => { + app.studyPartFilter = button.dataset.studyPartFilter; + renderStudies(); + }); + }); document.querySelectorAll("[data-series-sort]").forEach((button) => { button.addEventListener("click", () => setSeriesSort(button.dataset.seriesSort)); }); diff --git a/PACS_DICOM处理/数据处理网页端/static/index.html b/PACS_DICOM处理/数据处理网页端/static/index.html index efba69d..378d83a 100644 --- a/PACS_DICOM处理/数据处理网页端/static/index.html +++ b/PACS_DICOM处理/数据处理网页端/static/index.html @@ -55,6 +55,14 @@ +
+ + + + + + +
@@ -125,7 +133,6 @@
- diff --git a/PACS_DICOM处理/数据处理网页端/static/styles.css b/PACS_DICOM处理/数据处理网页端/static/styles.css index 3863e55..c69056b 100644 --- a/PACS_DICOM处理/数据处理网页端/static/styles.css +++ b/PACS_DICOM处理/数据处理网页端/static/styles.css @@ -262,7 +262,7 @@ button:disabled { .series-pane { padding: 14px; display: grid; - grid-template-rows: auto auto auto 1fr; + grid-template-rows: auto auto auto auto 1fr; gap: 12px; } @@ -336,8 +336,17 @@ button:disabled { gap: 6px; } -.study-filter button { +.study-part-filter { + display: flex; + gap: 6px; + overflow-x: auto; + padding-bottom: 2px; +} + +.study-filter button, +.study-part-filter button { height: 30px; + flex: 0 0 auto; border: 1px solid var(--stroke); border-radius: 7px; background: #0b0f16; @@ -345,7 +354,13 @@ button:disabled { font-size: 12px; } -.study-filter button.active { +.study-part-filter button { + min-width: 66px; + padding: 0 10px; +} + +.study-filter button.active, +.study-part-filter button.active { border-color: rgba(25, 212, 194, 0.62); color: #baf8ee; background: rgba(25, 212, 194, 0.08); @@ -361,7 +376,7 @@ button:disabled { .study-card { position: relative; width: 100%; - padding: 12px 42px 12px 12px; + padding: 12px 112px 12px 12px; margin-bottom: 10px; border: 1px solid transparent; border-radius: 8px; @@ -370,10 +385,17 @@ button:disabled { text-align: left; } -.study-status-badge { +.study-card-status-stack { position: absolute; top: 10px; right: 10px; + max-width: 96px; + display: grid; + justify-items: end; + gap: 6px; +} + +.study-status-badge { min-width: 48px; height: 22px; display: inline-grid; @@ -388,6 +410,28 @@ button:disabled { cursor: pointer; } +.study-part-tags { + display: flex; + justify-content: flex-end; + flex-wrap: wrap; + gap: 4px; +} + +.study-part-tags em { + max-width: 92px; + padding: 2px 6px; + overflow: hidden; + border: 1px solid rgba(25, 212, 194, 0.34); + border-radius: 999px; + color: #baf8ee; + background: rgba(25, 212, 194, 0.07); + font-size: 10px; + font-style: normal; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + .study-status-badge:hover { border-color: rgba(239, 245, 255, 0.58); color: var(--text); @@ -874,11 +918,6 @@ button:disabled { 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); }