Refine DICOM classification labels and loading
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# PACS DICOM 网页端
|
# DICOM 阅片分类系统
|
||||||
|
|
||||||
本目录提供本地 DICOM 检查浏览、序列查看和序列部位标注界面。
|
本目录提供本地 DICOM 检查浏览、序列查看和序列分类标注界面。
|
||||||
|
|
||||||
## Docker 启动
|
## Docker 启动
|
||||||
|
|
||||||
@@ -40,10 +40,10 @@ uvicorn app:app --host 127.0.0.1 --port 8107
|
|||||||
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。
|
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息。
|
||||||
- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。
|
- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。
|
||||||
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
|
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
|
||||||
- 图像叠层:可显示/隐藏患者、检查、方向、张数、窗宽窗位和缩放信息。
|
- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。
|
||||||
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
|
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
|
||||||
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过序列固定在最下方,并在略过分组内继续按当前排序规则排列。
|
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。
|
||||||
- 序列标注:选择略过、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期或无法判别。
|
- 序列标注:选择略过/不采用、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过/不采用可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。
|
||||||
- AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。
|
- AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。
|
||||||
- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。
|
- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。
|
||||||
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。
|
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。
|
||||||
|
|||||||
@@ -64,14 +64,15 @@ WINDOWS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BODY_PARTS = {"head_neck", "chest", "upper_abdomen", "lower_abdomen", "pelvis"}
|
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 = {
|
ROLES = {
|
||||||
"管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"],
|
"管理员": ["查看DICOM", "编辑标注", "AI识别", "用户创建", "权限控制", "系统设置"],
|
||||||
"阅片员": ["查看DICOM", "编辑标注", "AI识别"],
|
"阅片员": ["查看DICOM", "编辑标注", "AI识别"],
|
||||||
"访客": ["查看DICOM"],
|
"访客": ["查看DICOM"],
|
||||||
}
|
}
|
||||||
|
|
||||||
app = FastAPI(title="PACS DICOM Viewer")
|
app = FastAPI(title="DICOM 阅片分类系统")
|
||||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
|
|
||||||
TOKENS: dict[str, str] = {}
|
TOKENS: dict[str, str] = {}
|
||||||
@@ -91,6 +92,9 @@ class AnnotationIn(BaseModel):
|
|||||||
upper_abdomen_phase: str = ""
|
upper_abdomen_phase: str = ""
|
||||||
manual_upper_abdomen_phase: str = ""
|
manual_upper_abdomen_phase: str = ""
|
||||||
ai_upper_abdomen_phase: str = ""
|
ai_upper_abdomen_phase: str = ""
|
||||||
|
chest_window: str = ""
|
||||||
|
manual_chest_window: str = ""
|
||||||
|
ai_chest_window: str = ""
|
||||||
plain_ct: bool = False
|
plain_ct: bool = False
|
||||||
manual_plain_ct: bool | None = None
|
manual_plain_ct: bool | None = None
|
||||||
ai_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 '',
|
upper_abdomen_phase text NOT NULL DEFAULT '',
|
||||||
manual_upper_abdomen_phase text NOT NULL DEFAULT '',
|
manual_upper_abdomen_phase text NOT NULL DEFAULT '',
|
||||||
ai_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,
|
plain_ct boolean NOT NULL DEFAULT false,
|
||||||
manual_plain_ct boolean,
|
manual_plain_ct boolean,
|
||||||
ai_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 '';
|
ADD COLUMN IF NOT EXISTS manual_upper_abdomen_phase text NOT NULL DEFAULT '';
|
||||||
ALTER TABLE public.pacs_dicom_series_annotations
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
ADD COLUMN IF NOT EXISTS ai_upper_abdomen_phase text NOT NULL DEFAULT '';
|
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
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
ADD COLUMN IF NOT EXISTS plain_ct boolean NOT NULL DEFAULT false;
|
ADD COLUMN IF NOT EXISTS plain_ct boolean NOT NULL DEFAULT false;
|
||||||
ALTER TABLE public.pacs_dicom_series_annotations
|
ALTER TABLE public.pacs_dicom_series_annotations
|
||||||
@@ -475,6 +488,10 @@ def valid_phase(value: Any) -> str:
|
|||||||
return value if value in PHASES else ""
|
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:
|
def bool_or_none(value: Any) -> bool | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return 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)
|
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:
|
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:
|
if manual_plain_ct is not None:
|
||||||
return manual_plain_ct
|
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 "")
|
ai_phase = valid_phase(row.get("ai_upper_abdomen_phase") or "")
|
||||||
if not manual_phase and not ai_phase:
|
if not manual_phase and not ai_phase:
|
||||||
manual_phase = valid_phase(row.get("upper_abdomen_phase") or "")
|
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)
|
body_parts = merge_parts(manual_parts, ai_parts, skipped)
|
||||||
plain_ct = effective_plain_ct(
|
plain_ct = effective_plain_ct(
|
||||||
bool_or_none(row.get("manual_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),
|
"upper_abdomen_phase": effective_phase(manual_phase, ai_phase, body_parts, skipped),
|
||||||
"manual_upper_abdomen_phase": manual_phase,
|
"manual_upper_abdomen_phase": manual_phase,
|
||||||
"ai_upper_abdomen_phase": ai_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,
|
"plain_ct": plain_ct,
|
||||||
"manual_plain_ct": bool_or_none(row.get("manual_plain_ct")),
|
"manual_plain_ct": bool_or_none(row.get("manual_plain_ct")),
|
||||||
"ai_plain_ct": bool_or_none(row.get("ai_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()
|
marker = f"{meta.get('BodyPartExamined', '')} {meta.get('SeriesDescription', '')}".upper()
|
||||||
parts: list[str] = []
|
parts: list[str] = []
|
||||||
phase = ""
|
phase = ""
|
||||||
|
chest_window = ""
|
||||||
if "CHEST" in marker and "chest" not in parts:
|
if "CHEST" in marker and "chest" not in parts:
|
||||||
parts.append("chest")
|
parts.append("chest")
|
||||||
|
chest_window = "unknown"
|
||||||
if "ABDOMEN" in marker and "upper_abdomen" not in parts:
|
if "ABDOMEN" in marker and "upper_abdomen" not in parts:
|
||||||
parts.append("upper_abdomen")
|
parts.append("upper_abdomen")
|
||||||
phase = "unknown"
|
phase = "unknown"
|
||||||
return parts, phase
|
return parts, phase, chest_window
|
||||||
|
|
||||||
|
|
||||||
def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]:
|
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
|
SELECT
|
||||||
series_instance_uid, body_parts, manual_body_parts, ai_body_parts,
|
series_instance_uid, body_parts, manual_body_parts, ai_body_parts,
|
||||||
upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase,
|
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
|
plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, updated_at, ai_model, updated_by
|
||||||
FROM public.pacs_dicom_series_annotations
|
FROM public.pacs_dicom_series_annotations
|
||||||
WHERE ct_number = {sql_literal(ct_number)}
|
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]
|
file_map[uid] = [path for path, _ in items]
|
||||||
raw_annotation = annotations.get(uid)
|
raw_annotation = annotations.get(uid)
|
||||||
annotation = normalize_annotation(raw_annotation)
|
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")
|
can_apply_auto = bool(auto_parts) and not annotation.get("body_parts")
|
||||||
if can_apply_auto:
|
if can_apply_auto:
|
||||||
annotation["manual_body_parts"] = auto_parts
|
annotation["manual_body_parts"] = auto_parts
|
||||||
@@ -617,7 +650,10 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
if auto_phase:
|
if auto_phase:
|
||||||
annotation["manual_upper_abdomen_phase"] = auto_phase
|
annotation["manual_upper_abdomen_phase"] = auto_phase
|
||||||
annotation["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")
|
default_skipped = len(items) < 80 and (not raw_annotation or annotation.get("updated_by") == "system")
|
||||||
if default_skipped:
|
if default_skipped:
|
||||||
annotation["skipped"] = True
|
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:
|
try:
|
||||||
pg_scalar(
|
pg_scalar(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO public.pacs_dicom_series_annotations (
|
INSERT INTO public.pacs_dicom_series_annotations (
|
||||||
ct_number, study_instance_uid, series_instance_uid, series_description,
|
ct_number, study_instance_uid, series_instance_uid, series_description,
|
||||||
body_parts, manual_body_parts, upper_abdomen_phase, manual_upper_abdomen_phase,
|
body_parts, manual_body_parts, upper_abdomen_phase, manual_upper_abdomen_phase,
|
||||||
|
chest_window, manual_chest_window,
|
||||||
updated_by, updated_at
|
updated_by, updated_at
|
||||||
)
|
)
|
||||||
VALUES (
|
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(json.dumps(auto_parts, ensure_ascii=False))}::jsonb,
|
||||||
{sql_literal(auto_phase)},
|
{sql_literal(auto_phase)},
|
||||||
{sql_literal(auto_phase)},
|
{sql_literal(auto_phase)},
|
||||||
|
{sql_literal(auto_chest_window)},
|
||||||
|
{sql_literal(auto_chest_window)},
|
||||||
'system',
|
'system',
|
||||||
now()
|
now()
|
||||||
)
|
)
|
||||||
@@ -683,6 +722,8 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
manual_body_parts = EXCLUDED.manual_body_parts,
|
manual_body_parts = EXCLUDED.manual_body_parts,
|
||||||
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
|
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
|
||||||
manual_upper_abdomen_phase = EXCLUDED.manual_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_by = 'system',
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE pacs_dicom_series_annotations.updated_by = 'system'
|
WHERE pacs_dicom_series_annotations.updated_by = 'system'
|
||||||
@@ -713,7 +754,7 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
'',
|
'',
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
{sql_literal(f'少于80张默认略过({count}张)')},
|
{sql_literal(f'少于80张默认略过/不采用({count}张)')},
|
||||||
'system',
|
'system',
|
||||||
now()
|
now()
|
||||||
)
|
)
|
||||||
@@ -1008,6 +1049,8 @@ def save_annotation_payload(
|
|||||||
ai_parts: list[str],
|
ai_parts: list[str],
|
||||||
manual_phase: str,
|
manual_phase: str,
|
||||||
ai_phase: str,
|
ai_phase: str,
|
||||||
|
manual_chest_window: str,
|
||||||
|
ai_chest_window: str,
|
||||||
manual_plain_ct: bool | None,
|
manual_plain_ct: bool | None,
|
||||||
ai_plain_ct: bool | None,
|
ai_plain_ct: bool | None,
|
||||||
skipped: bool,
|
skipped: bool,
|
||||||
@@ -1023,6 +1066,12 @@ def save_annotation_payload(
|
|||||||
manual_phase = valid_phase(manual_phase)
|
manual_phase = valid_phase(manual_phase)
|
||||||
ai_phase = valid_phase(ai_phase)
|
ai_phase = valid_phase(ai_phase)
|
||||||
phase = effective_phase(manual_phase, ai_phase, body_parts, skipped)
|
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)
|
plain_ct = effective_plain_ct(manual_plain_ct, ai_plain_ct, False, skipped)
|
||||||
ensure_annotation_table()
|
ensure_annotation_table()
|
||||||
pg_scalar(
|
pg_scalar(
|
||||||
@@ -1031,6 +1080,7 @@ def save_annotation_payload(
|
|||||||
ct_number, study_instance_uid, series_instance_uid, series_description,
|
ct_number, study_instance_uid, series_instance_uid, series_description,
|
||||||
body_parts, manual_body_parts, ai_body_parts,
|
body_parts, manual_body_parts, ai_body_parts,
|
||||||
upper_abdomen_phase, manual_upper_abdomen_phase, ai_upper_abdomen_phase,
|
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,
|
plain_ct, manual_plain_ct, ai_plain_ct,
|
||||||
skipped, ai_skipped, notes, ai_result, ai_model, updated_by, updated_at
|
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(phase)},
|
||||||
{sql_literal(manual_phase)},
|
{sql_literal(manual_phase)},
|
||||||
{sql_literal(ai_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'},
|
{'true' if plain_ct else 'false'},
|
||||||
{sql_bool_or_null(manual_plain_ct)},
|
{sql_bool_or_null(manual_plain_ct)},
|
||||||
{sql_bool_or_null(ai_plain_ct)},
|
{sql_bool_or_null(ai_plain_ct)},
|
||||||
@@ -1065,6 +1118,9 @@ def save_annotation_payload(
|
|||||||
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
|
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
|
||||||
manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase,
|
manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase,
|
||||||
ai_upper_abdomen_phase = EXCLUDED.ai_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,
|
plain_ct = EXCLUDED.plain_ct,
|
||||||
manual_plain_ct = EXCLUDED.manual_plain_ct,
|
manual_plain_ct = EXCLUDED.manual_plain_ct,
|
||||||
ai_plain_ct = EXCLUDED.ai_plain_ct,
|
ai_plain_ct = EXCLUDED.ai_plain_ct,
|
||||||
@@ -1085,6 +1141,9 @@ def save_annotation_payload(
|
|||||||
"upper_abdomen_phase": phase,
|
"upper_abdomen_phase": phase,
|
||||||
"manual_upper_abdomen_phase": manual_phase,
|
"manual_upper_abdomen_phase": manual_phase,
|
||||||
"ai_upper_abdomen_phase": ai_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,
|
"plain_ct": plain_ct,
|
||||||
"manual_plain_ct": manual_plain_ct,
|
"manual_plain_ct": manual_plain_ct,
|
||||||
"ai_plain_ct": ai_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,
|
ai_parts=data.ai_body_parts,
|
||||||
manual_phase=data.manual_upper_abdomen_phase or data.upper_abdomen_phase,
|
manual_phase=data.manual_upper_abdomen_phase or data.upper_abdomen_phase,
|
||||||
ai_phase=data.ai_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),
|
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,
|
ai_plain_ct=data.ai_plain_ct,
|
||||||
skipped=data.skipped,
|
skipped=data.skipped,
|
||||||
@@ -1168,7 +1229,7 @@ def parse_ai_json(content: str) -> dict[str, Any]:
|
|||||||
try:
|
try:
|
||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
except Exception:
|
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")
|
@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(盆腔)。一个序列可包含多个部位。"
|
"lower_abdomen(下腹部), pelvis(盆腔)。一个序列可包含多个部位。"
|
||||||
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。"
|
"如果不是可用于标注的平扫CT影像、定位像、剂量报告或无法判断,请 skipped=true。"
|
||||||
"请同时判断 plain_ct 是否为平扫CT。"
|
"请同时判断 plain_ct 是否为平扫CT。"
|
||||||
"如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、unknown(无法判别)。"
|
"如果包含上腹部,请判断期相: arterial(动脉期)、portal_venous(门静脉期)、delayed(延迟期)、unknown(无法判别)。"
|
||||||
"只返回JSON: {\"body_parts\":[],\"upper_abdomen_phase\":\"\",\"plain_ct\":false,\"skipped\":false,\"notes\":\"\"}。"
|
"如果包含胸部,请判断窗位: 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"PACS张数: {series_row.get('count', 0)}。"
|
||||||
f"序列描述: {series_row.get('description','')};DICOM部位: {series_row.get('body_part_dicom','')}。"
|
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", "")
|
phase = suggestion.get("upper_abdomen_phase", "")
|
||||||
if phase not in PHASES or "upper_abdomen" not in body_parts:
|
if phase not in PHASES or "upper_abdomen" not in body_parts:
|
||||||
phase = ""
|
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", {}))
|
current = normalize_annotation(series_row.get("annotation", {}))
|
||||||
annotation = save_annotation_payload(
|
annotation = save_annotation_payload(
|
||||||
ct_number=ct_number,
|
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,
|
ai_parts=[] if skipped else body_parts,
|
||||||
manual_phase=current.get("manual_upper_abdomen_phase", ""),
|
manual_phase=current.get("manual_upper_abdomen_phase", ""),
|
||||||
ai_phase="" if skipped else 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"),
|
manual_plain_ct=current.get("manual_plain_ct"),
|
||||||
ai_plain_ct=None if skipped else plain_ct,
|
ai_plain_ct=None if skipped else plain_ct,
|
||||||
skipped=skipped,
|
skipped=skipped,
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const app = {
|
|||||||
drag: null,
|
drag: null,
|
||||||
seriesSort: "timeAsc",
|
seriesSort: "timeAsc",
|
||||||
showOverlay: true,
|
showOverlay: true,
|
||||||
|
thumbUrls: new Map(),
|
||||||
|
thumbObserver: null,
|
||||||
|
thumbSeries: new WeakMap(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
@@ -97,7 +100,11 @@ function timeKey(series) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function phaseLabel(value) {
|
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) {
|
function partLabel(value) {
|
||||||
@@ -254,7 +261,6 @@ async function selectStudy(ctNumber) {
|
|||||||
setSeries(data.series || []);
|
setSeries(data.series || []);
|
||||||
refreshStudyAnnotationSummary();
|
refreshStudyAnnotationSummary();
|
||||||
renderStudies();
|
renderStudies();
|
||||||
if (app.series.length) selectSeries(app.series[0].series_uid);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
|
$("seriesGrid").innerHTML = `<p class="error-line">${escapeHtml(err.message)}</p>`;
|
||||||
}
|
}
|
||||||
@@ -270,7 +276,7 @@ function seriesTags(series) {
|
|||||||
const annotation = series.annotation || {};
|
const annotation = series.annotation || {};
|
||||||
const tags = [];
|
const tags = [];
|
||||||
if (annotation.skipped) {
|
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) {
|
if (annotation.plain_ct) {
|
||||||
tags.push({ label: "平扫CT", source: annotation.manual_plain_ct !== null && annotation.manual_plain_ct !== undefined ? "manual" : "ai" });
|
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);
|
const phase = phaseLabel(annotation.upper_abdomen_phase);
|
||||||
if (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: "" });
|
if (series.body_part_dicom) tags.unshift({ label: series.body_part_dicom, source: "" });
|
||||||
return tags;
|
return tags;
|
||||||
@@ -288,6 +306,7 @@ function seriesTags(series) {
|
|||||||
|
|
||||||
function renderSeries() {
|
function renderSeries() {
|
||||||
const grid = $("seriesGrid");
|
const grid = $("seriesGrid");
|
||||||
|
if (app.thumbObserver) app.thumbObserver.disconnect();
|
||||||
grid.innerHTML = "";
|
grid.innerHTML = "";
|
||||||
for (const series of app.series) {
|
for (const series of app.series) {
|
||||||
const skipped = Boolean(series.annotation?.skipped);
|
const skipped = Boolean(series.annotation?.skipped);
|
||||||
@@ -300,18 +319,18 @@ function renderSeries() {
|
|||||||
<div class="thumb">
|
<div class="thumb">
|
||||||
<img alt="" />
|
<img alt="" />
|
||||||
<b>${Number(series.count || 0)} 张</b>
|
<b>${Number(series.count || 0)} 张</b>
|
||||||
${skipped ? "<i>略过</i>" : ""}
|
${skipped ? "<i>略过/不采用</i>" : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="series-copy">
|
<div class="series-copy">
|
||||||
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
<strong>${escapeHtml(series.description || "未命名序列")}</strong>
|
||||||
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
|
<span class="shot-time">拍摄 ${escapeHtml(timeRange(series))}</span>
|
||||||
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
|
<small>序列 ${escapeHtml(series.series_number || "-")} · ${escapeHtml(series.rows || "-")}×${escapeHtml(series.columns || "-")} · ${escapeHtml(series.modality || "")}</small>
|
||||||
<div class="tag-line">${tags.length ? tags.map((tag) => `<em class="${tag.source ? `tag-${tag.source}` : ""}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
|
<div class="tag-line">${tags.length ? tags.map((tag) => `<em class="${[tag.source ? `tag-${tag.source}` : "", tag.warn ? "tag-warn" : ""].filter(Boolean).join(" ")}">${escapeHtml(tag.label)}</em>`).join("") : "<em>未标注</em>"}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
card.onclick = () => selectSeries(series.series_uid);
|
card.onclick = () => selectSeries(series.series_uid);
|
||||||
grid.appendChild(card);
|
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) {
|
async function loadThumb(series, img) {
|
||||||
try {
|
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 index = Math.floor((Number(series.count) || 1) / 2);
|
||||||
const blob = await request(imageUrlFor(series, index, "soft", "axial")).then((res) => res.blob());
|
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 (_) {
|
} catch (_) {
|
||||||
img.removeAttribute("src");
|
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() {
|
function maxSlice() {
|
||||||
if (!app.activeSeries) return 0;
|
if (!app.activeSeries) return 0;
|
||||||
if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1);
|
if (app.plane === "sagittal") return Math.max(0, Number(app.activeSeries.columns || 1) - 1);
|
||||||
@@ -367,6 +425,7 @@ function resetViewer() {
|
|||||||
$("imageEmpty").classList.remove("hidden");
|
$("imageEmpty").classList.remove("hidden");
|
||||||
$("sliceText").textContent = "0 / 0";
|
$("sliceText").textContent = "0 / 0";
|
||||||
$("saveState").textContent = "已保存";
|
$("saveState").textContent = "已保存";
|
||||||
|
clearAnnotationControls();
|
||||||
app.dirty = false;
|
app.dirty = false;
|
||||||
app.saving = false;
|
app.saving = false;
|
||||||
resetViewState();
|
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() {
|
function updateOverlay() {
|
||||||
const overlay = $("imageOverlay");
|
const overlay = $("imageOverlay");
|
||||||
overlay.classList.toggle("hidden", !app.showOverlay || !app.activeSeries);
|
overlay.classList.toggle("hidden", !app.showOverlay || !app.activeSeries);
|
||||||
$("overlayToggle").textContent = app.showOverlay ? "隐藏信息" : "显示信息";
|
$("overlayToggle").textContent = app.showOverlay ? "隐藏信息" : "显示信息";
|
||||||
if (!app.activeSeries) return;
|
if (!app.activeSeries) return;
|
||||||
const s = app.activeSeries;
|
const s = app.activeSeries;
|
||||||
const orient = orientationLabels();
|
|
||||||
const win = windowInfo();
|
const win = windowInfo();
|
||||||
const age = ageText(s.patient_birth_date, s.study_date || app.study?.study_date);
|
const age = ageText(s.patient_birth_date, s.study_date || app.study?.study_date);
|
||||||
const sex = s.patient_sex || "";
|
const sex = s.patient_sex || "";
|
||||||
@@ -412,10 +464,6 @@ function updateOverlay() {
|
|||||||
s.institution_name || "",
|
s.institution_name || "",
|
||||||
s.manufacturer || "",
|
s.manufacturer || "",
|
||||||
].filter(Boolean).map(escapeHtml).join("<br />");
|
].filter(Boolean).map(escapeHtml).join("<br />");
|
||||||
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-left-bottom").textContent = `Img:${app.slice + 1}/${maxSlice() + 1}`;
|
||||||
document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}<br />WL:${Math.round(win.wl)}<br />Zoom:${app.zoom.toFixed(2)}`;
|
document.querySelector(".ov-right-bottom").innerHTML = `WW:${Math.round(win.ww)}<br />WL:${Math.round(win.wl)}<br />Zoom:${app.zoom.toFixed(2)}`;
|
||||||
}
|
}
|
||||||
@@ -451,6 +499,9 @@ function cloneAnnotation(annotation = {}) {
|
|||||||
upper_abdomen_phase: annotation.upper_abdomen_phase || "",
|
upper_abdomen_phase: annotation.upper_abdomen_phase || "",
|
||||||
manual_upper_abdomen_phase: annotation.manual_upper_abdomen_phase || "",
|
manual_upper_abdomen_phase: annotation.manual_upper_abdomen_phase || "",
|
||||||
ai_upper_abdomen_phase: annotation.ai_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),
|
plain_ct: Boolean(annotation.plain_ct),
|
||||||
manual_plain_ct: annotation.manual_plain_ct ?? null,
|
manual_plain_ct: annotation.manual_plain_ct ?? null,
|
||||||
ai_plain_ct: annotation.ai_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 || "";
|
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() {
|
function effectivePlainCt() {
|
||||||
if (!app.draft) return false;
|
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);
|
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");
|
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() {
|
function applyAnnotationControls() {
|
||||||
if (!app.draft) return;
|
if (!app.draft) return;
|
||||||
const parts = new Set(effectiveParts());
|
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" : "");
|
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"));
|
$("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() {
|
function hydrateAnnotation() {
|
||||||
@@ -525,6 +600,7 @@ function updateLocalAnnotation(annotation) {
|
|||||||
const normalized = cloneAnnotation(annotation);
|
const normalized = cloneAnnotation(annotation);
|
||||||
normalized.body_parts = asList(annotation.body_parts);
|
normalized.body_parts = asList(annotation.body_parts);
|
||||||
normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || "";
|
normalized.upper_abdomen_phase = annotation.upper_abdomen_phase || "";
|
||||||
|
normalized.chest_window = annotation.chest_window || "";
|
||||||
app.draft = normalized;
|
app.draft = normalized;
|
||||||
app.activeSeries.annotation = normalized;
|
app.activeSeries.annotation = normalized;
|
||||||
const index = app.series.findIndex((item) => item.series_uid === app.activeSeries.series_uid);
|
const index = app.series.findIndex((item) => item.series_uid === app.activeSeries.series_uid);
|
||||||
@@ -545,6 +621,9 @@ function annotationPayload() {
|
|||||||
upper_abdomen_phase: effectivePhase(),
|
upper_abdomen_phase: effectivePhase(),
|
||||||
manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase,
|
manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase,
|
||||||
ai_upper_abdomen_phase: app.draft.ai_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(),
|
plain_ct: effectivePlainCt(),
|
||||||
manual_plain_ct: app.draft.manual_plain_ct,
|
manual_plain_ct: app.draft.manual_plain_ct,
|
||||||
ai_plain_ct: app.draft.ai_plain_ct,
|
ai_plain_ct: app.draft.ai_plain_ct,
|
||||||
@@ -610,6 +689,9 @@ function handlePartChange(event) {
|
|||||||
} else if (BODY_PARTS.includes(value)) {
|
} else if (BODY_PARTS.includes(value)) {
|
||||||
if (input.checked) {
|
if (input.checked) {
|
||||||
app.draft.manual_body_parts = uniq([...app.draft.manual_body_parts, value]);
|
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 {
|
} else {
|
||||||
app.draft.manual_body_parts = app.draft.manual_body_parts.filter((part) => part !== value);
|
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);
|
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.manual_upper_abdomen_phase = "";
|
||||||
app.draft.ai_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.body_parts = effectiveParts();
|
||||||
app.draft.upper_abdomen_phase = effectivePhase();
|
app.draft.upper_abdomen_phase = effectivePhase();
|
||||||
|
app.draft.chest_window = effectiveChestWindow();
|
||||||
app.draft.plain_ct = effectivePlainCt();
|
app.draft.plain_ct = effectivePlainCt();
|
||||||
applyAnnotationControls();
|
applyAnnotationControls();
|
||||||
markDirty();
|
markDirty();
|
||||||
@@ -635,6 +722,15 @@ function handlePhaseChange(event) {
|
|||||||
markDirty();
|
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() {
|
async function runAI() {
|
||||||
if (!app.study || !app.activeSeries) return;
|
if (!app.study || !app.activeSeries) return;
|
||||||
const button = $("aiClassify");
|
const button = $("aiClassify");
|
||||||
@@ -866,6 +962,7 @@ function wire() {
|
|||||||
$("resetView").addEventListener("click", resetViewState);
|
$("resetView").addEventListener("click", resetViewState);
|
||||||
document.querySelectorAll(".part-grid input").forEach((input) => input.addEventListener("change", handlePartChange));
|
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=phase]").forEach((input) => input.addEventListener("change", handlePhaseChange));
|
||||||
|
document.querySelectorAll("input[name=chestWindow]").forEach((input) => input.addEventListener("change", handleChestWindowChange));
|
||||||
$("annotationNotes").addEventListener("input", () => {
|
$("annotationNotes").addEventListener("input", () => {
|
||||||
if (!app.draft) return;
|
if (!app.draft) return;
|
||||||
app.draft.notes = $("annotationNotes").value;
|
app.draft.notes = $("annotationNotes").value;
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>PACS DICOM Viewer</title>
|
<title>DICOM 阅片分类系统</title>
|
||||||
<link rel="stylesheet" href="/static/styles.css" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="loginOverlay" class="login-overlay">
|
<div id="loginOverlay" class="login-overlay">
|
||||||
<form id="loginForm" class="login-panel">
|
<form id="loginForm" class="login-panel">
|
||||||
<div class="brand-mark">PACS</div>
|
<div class="brand-mark">PACS</div>
|
||||||
<h1>DICOM 阅片标注</h1>
|
<h1>DICOM 阅片分类系统</h1>
|
||||||
<label>
|
<label>
|
||||||
<span>账号</span>
|
<span>账号</span>
|
||||||
<input id="username" autocomplete="username" value="admin" />
|
<input id="username" autocomplete="username" value="admin" />
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<div class="product">
|
<div class="product">
|
||||||
<div class="logo-dot"></div>
|
<div class="logo-dot"></div>
|
||||||
<div>
|
<div>
|
||||||
<strong>PACS DICOM Viewer</strong>
|
<strong>DICOM 阅片分类系统</strong>
|
||||||
<span id="activeStudyLabel">未选择检查</span>
|
<span id="activeStudyLabel">未选择检查</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -92,14 +92,8 @@
|
|||||||
<div class="image-wrap">
|
<div class="image-wrap">
|
||||||
<img id="dicomImage" alt="" />
|
<img id="dicomImage" alt="" />
|
||||||
<div id="imageOverlay" class="image-overlay">
|
<div id="imageOverlay" class="image-overlay">
|
||||||
<div class="crosshair crosshair-v"></div>
|
|
||||||
<div class="crosshair crosshair-h"></div>
|
|
||||||
<div class="ov ov-left-top"></div>
|
<div class="ov ov-left-top"></div>
|
||||||
<div class="ov ov-right-top"></div>
|
<div class="ov ov-right-top"></div>
|
||||||
<div class="ov ov-left-mid"></div>
|
|
||||||
<div class="ov ov-right-mid"></div>
|
|
||||||
<div class="ov ov-top-mid"></div>
|
|
||||||
<div class="ov ov-bottom-mid"></div>
|
|
||||||
<div class="ov ov-left-bottom"></div>
|
<div class="ov ov-left-bottom"></div>
|
||||||
<div class="ov ov-right-bottom"></div>
|
<div class="ov ov-right-bottom"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,7 +117,7 @@
|
|||||||
<span><i class="ai-dot"></i>AI</span>
|
<span><i class="ai-dot"></i>AI</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="part-grid">
|
<div class="part-grid">
|
||||||
<label class="skip-option"><input type="checkbox" value="skip" />略过</label>
|
<label class="skip-option"><input type="checkbox" value="skip" />略过/不采用</label>
|
||||||
<label class="plain-option"><input type="checkbox" value="plain_ct" />平扫CT</label>
|
<label class="plain-option"><input type="checkbox" value="plain_ct" />平扫CT</label>
|
||||||
<label><input type="checkbox" value="head_neck" />头颈部</label>
|
<label><input type="checkbox" value="head_neck" />头颈部</label>
|
||||||
<label><input type="checkbox" value="chest" />胸部</label>
|
<label><input type="checkbox" value="chest" />胸部</label>
|
||||||
@@ -136,7 +130,16 @@
|
|||||||
<div class="phase-options">
|
<div class="phase-options">
|
||||||
<label><input name="phase" type="radio" value="arterial" />动脉期</label>
|
<label><input name="phase" type="radio" value="arterial" />动脉期</label>
|
||||||
<label><input name="phase" type="radio" value="portal_venous" />门静脉期</label>
|
<label><input name="phase" type="radio" value="portal_venous" />门静脉期</label>
|
||||||
<label><input name="phase" type="radio" value="unknown" />无法判别</label>
|
<label><input name="phase" type="radio" value="delayed" />延迟期</label>
|
||||||
|
<label class="warn-option"><input name="phase" type="radio" value="unknown" />无法判别</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chestWindowBox" class="phase-box chest-window-box">
|
||||||
|
<span>胸部窗位</span>
|
||||||
|
<div class="phase-options chest-window-options">
|
||||||
|
<label><input name="chestWindow" type="radio" value="lung" />肺窗</label>
|
||||||
|
<label><input name="chestWindow" type="radio" value="mediastinal" />纵隔窗</label>
|
||||||
|
<label class="warn-option"><input name="chestWindow" type="radio" value="unknown" />无法判别</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="annotation-actions">
|
<div class="annotation-actions">
|
||||||
|
|||||||
@@ -471,6 +471,12 @@ button:disabled {
|
|||||||
background: rgba(240, 181, 78, 0.1);
|
background: rgba(240, 181, 78, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-line .tag-warn {
|
||||||
|
border-color: rgba(251, 113, 133, 0.72);
|
||||||
|
color: #fecdd3;
|
||||||
|
background: rgba(251, 113, 133, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.viewer-pane {
|
.viewer-pane {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -556,27 +562,6 @@ button:disabled {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.crosshair {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0.72;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crosshair-v {
|
|
||||||
top: 4%;
|
|
||||||
bottom: 4%;
|
|
||||||
left: 50%;
|
|
||||||
width: 1px;
|
|
||||||
background: #ff3b30;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crosshair-h {
|
|
||||||
left: 3%;
|
|
||||||
right: 3%;
|
|
||||||
top: 50%;
|
|
||||||
height: 1px;
|
|
||||||
background: #21c55d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ov {
|
.ov {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
max-width: 42%;
|
max-width: 42%;
|
||||||
@@ -594,30 +579,6 @@ button:disabled {
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ov-left-mid {
|
|
||||||
left: 12px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ov-right-mid {
|
|
||||||
right: 54px;
|
|
||||||
top: 50%;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ov-top-mid {
|
|
||||||
top: 12px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ov-bottom-mid {
|
|
||||||
bottom: 12px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ov-left-bottom {
|
.ov-left-bottom {
|
||||||
left: 14px;
|
left: 14px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
@@ -747,6 +708,12 @@ button:disabled {
|
|||||||
box-shadow: inset 3px 0 0 rgba(240, 181, 78, 0.95);
|
box-shadow: inset 3px 0 0 rgba(240, 181, 78, 0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.phase-options label.warn-option:has(input:checked) {
|
||||||
|
border-color: rgba(251, 113, 133, 0.78);
|
||||||
|
color: #fecdd3;
|
||||||
|
background: rgba(251, 113, 133, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.part-grid .skip-option:has(input:checked) {
|
.part-grid .skip-option:has(input:checked) {
|
||||||
border-color: rgba(240, 181, 78, 0.72);
|
border-color: rgba(240, 181, 78, 0.72);
|
||||||
background: rgba(240, 181, 78, 0.12);
|
background: rgba(240, 181, 78, 0.12);
|
||||||
@@ -780,10 +747,14 @@ button:disabled {
|
|||||||
|
|
||||||
.phase-options {
|
.phase-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
grid-template-columns: repeat(4, minmax(100px, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chest-window-options {
|
||||||
|
grid-template-columns: repeat(3, minmax(110px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
.annotation-actions {
|
.annotation-actions {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 118px minmax(0, 1fr);
|
grid-template-columns: 118px minmax(0, 1fr);
|
||||||
|
|||||||
Reference in New Issue
Block a user