Refine DICOM classification labels and loading
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user