Refine DICOM classification labels and loading

This commit is contained in:
Codex
2026-05-27 11:07:27 +08:00
parent 0f06b04bf7
commit 3c954cd274
5 changed files with 232 additions and 92 deletions

View File

@@ -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,