Add study body part filters
This commit is contained in:
@@ -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", ""),
|
||||
|
||||
Reference in New Issue
Block a user