Refine skip and manual save annotations
This commit is contained in:
@@ -43,7 +43,7 @@ uvicorn app:app --host 127.0.0.1 --port 8107
|
|||||||
- 图像叠层:可显示/隐藏患者、检查、方向、张数、窗宽窗位和缩放信息。
|
- 图像叠层:可显示/隐藏患者、检查、方向、张数、窗宽窗位和缩放信息。
|
||||||
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
|
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
|
||||||
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过序列固定在最下方,并在略过分组内继续按当前排序规则排列。
|
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过序列固定在最下方,并在略过分组内继续按当前排序规则排列。
|
||||||
- 序列标注:选择略过、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;点击后自动保存;上腹部需继续选择动脉期、门静脉期或无法判别。
|
- 序列标注:选择略过、平扫 CT、头颈部、胸部、上腹部、下腹部、盆腔;略过可与部位共存;DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期或无法判别。
|
||||||
- AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。
|
- AI 识别:配置 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI,自动给出部位、期相和备注建议。
|
||||||
- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。
|
- 设置:支持用户创建、角色权限展示、数据库状态和 AI 配置查看。
|
||||||
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。
|
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。
|
||||||
|
|||||||
@@ -483,8 +483,6 @@ def bool_or_none(value: Any) -> bool | None:
|
|||||||
|
|
||||||
|
|
||||||
def merge_parts(manual_parts: list[str], ai_parts: list[str], skipped: bool) -> list[str]:
|
def merge_parts(manual_parts: list[str], ai_parts: list[str], skipped: bool) -> list[str]:
|
||||||
if skipped:
|
|
||||||
return []
|
|
||||||
merged: list[str] = []
|
merged: list[str] = []
|
||||||
for part in manual_parts + ai_parts:
|
for part in manual_parts + ai_parts:
|
||||||
if part in BODY_PARTS and part not in merged:
|
if part in BODY_PARTS and part not in merged:
|
||||||
@@ -493,14 +491,12 @@ def merge_parts(manual_parts: list[str], ai_parts: list[str], skipped: bool) ->
|
|||||||
|
|
||||||
|
|
||||||
def effective_phase(manual_phase: str, ai_phase: str, body_parts: list[str], skipped: bool) -> str:
|
def effective_phase(manual_phase: str, ai_phase: str, body_parts: list[str], skipped: bool) -> str:
|
||||||
if skipped or "upper_abdomen" not in body_parts:
|
if "upper_abdomen" not in body_parts:
|
||||||
return ""
|
return ""
|
||||||
return valid_phase(manual_phase) or valid_phase(ai_phase)
|
return valid_phase(manual_phase) or valid_phase(ai_phase)
|
||||||
|
|
||||||
|
|
||||||
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 skipped:
|
|
||||||
return False
|
|
||||||
if manual_plain_ct is not None:
|
if manual_plain_ct is not None:
|
||||||
return manual_plain_ct
|
return manual_plain_ct
|
||||||
if ai_plain_ct is not None:
|
if ai_plain_ct is not None:
|
||||||
@@ -529,22 +525,35 @@ def normalize_annotation(row: dict[str, Any] | None, default_skipped: bool = Fal
|
|||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"body_parts": body_parts,
|
"body_parts": body_parts,
|
||||||
"manual_body_parts": [] if skipped else manual_parts,
|
"manual_body_parts": manual_parts,
|
||||||
"ai_body_parts": [] if skipped else ai_parts,
|
"ai_body_parts": ai_parts,
|
||||||
"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": "" if skipped else manual_phase,
|
"manual_upper_abdomen_phase": manual_phase,
|
||||||
"ai_upper_abdomen_phase": "" if skipped else ai_phase,
|
"ai_upper_abdomen_phase": ai_phase,
|
||||||
"plain_ct": plain_ct,
|
"plain_ct": plain_ct,
|
||||||
"manual_plain_ct": None if skipped else bool_or_none(row.get("manual_plain_ct")),
|
"manual_plain_ct": bool_or_none(row.get("manual_plain_ct")),
|
||||||
"ai_plain_ct": None if skipped else bool_or_none(row.get("ai_plain_ct")),
|
"ai_plain_ct": bool_or_none(row.get("ai_plain_ct")),
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"ai_skipped": ai_skipped,
|
"ai_skipped": ai_skipped,
|
||||||
"notes": row.get("notes", ""),
|
"notes": row.get("notes", ""),
|
||||||
"updated_at": row.get("updated_at", ""),
|
"updated_at": row.get("updated_at", ""),
|
||||||
"ai_model": row.get("ai_model", ""),
|
"ai_model": row.get("ai_model", ""),
|
||||||
|
"updated_by": row.get("updated_by", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dicom_auto_annotation(meta: dict[str, str]) -> tuple[list[str], str]:
|
||||||
|
marker = f"{meta.get('BodyPartExamined', '')} {meta.get('SeriesDescription', '')}".upper()
|
||||||
|
parts: list[str] = []
|
||||||
|
phase = ""
|
||||||
|
if "CHEST" in marker and "chest" not in parts:
|
||||||
|
parts.append("chest")
|
||||||
|
if "ABDOMEN" in marker and "upper_abdomen" not in parts:
|
||||||
|
parts.append("upper_abdomen")
|
||||||
|
phase = "unknown"
|
||||||
|
return parts, phase
|
||||||
|
|
||||||
|
|
||||||
def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]:
|
def get_annotations(ct_number: str) -> dict[str, dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
ensure_annotation_table()
|
ensure_annotation_table()
|
||||||
@@ -553,7 +562,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,
|
||||||
plain_ct, manual_plain_ct, ai_plain_ct, skipped, ai_skipped, notes, updated_at, ai_model
|
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)}
|
||||||
"""
|
"""
|
||||||
@@ -586,6 +595,7 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
series_list = []
|
series_list = []
|
||||||
file_map = {}
|
file_map = {}
|
||||||
default_skip_rows = []
|
default_skip_rows = []
|
||||||
|
auto_annotation_rows = []
|
||||||
for uid, items in grouped.items():
|
for uid, items in grouped.items():
|
||||||
items.sort(key=sort_key)
|
items.sort(key=sort_key)
|
||||||
first = items[0][1]
|
first = items[0][1]
|
||||||
@@ -593,13 +603,18 @@ 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)
|
||||||
default_skipped = len(items) < 80 and not annotation.get("body_parts") and not annotation.get("plain_ct")
|
auto_parts, auto_phase = 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
|
||||||
|
annotation["body_parts"] = auto_parts
|
||||||
|
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))
|
||||||
|
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
|
||||||
annotation["manual_body_parts"] = []
|
|
||||||
annotation["ai_body_parts"] = []
|
|
||||||
annotation["body_parts"] = []
|
|
||||||
annotation["plain_ct"] = False
|
|
||||||
if default_skipped:
|
if default_skipped:
|
||||||
default_skip_rows.append((uid, first, len(items)))
|
default_skip_rows.append((uid, first, len(items)))
|
||||||
series_list.append(
|
series_list.append(
|
||||||
@@ -636,6 +651,42 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for uid, first, auto_parts, auto_phase 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,
|
||||||
|
updated_by, updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
{sql_literal(ct_number)},
|
||||||
|
{sql_literal(first.get('StudyInstanceUID', ''))},
|
||||||
|
{sql_literal(uid)},
|
||||||
|
{sql_literal(first.get('SeriesDescription', '') or '未命名序列')},
|
||||||
|
{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)},
|
||||||
|
'system',
|
||||||
|
now()
|
||||||
|
)
|
||||||
|
ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET
|
||||||
|
body_parts = EXCLUDED.body_parts,
|
||||||
|
manual_body_parts = EXCLUDED.manual_body_parts,
|
||||||
|
upper_abdomen_phase = EXCLUDED.upper_abdomen_phase,
|
||||||
|
manual_upper_abdomen_phase = EXCLUDED.manual_upper_abdomen_phase,
|
||||||
|
updated_by = 'system',
|
||||||
|
updated_at = now()
|
||||||
|
WHERE pacs_dicom_series_annotations.updated_by = 'system'
|
||||||
|
AND jsonb_array_length(pacs_dicom_series_annotations.body_parts) = 0
|
||||||
|
""",
|
||||||
|
timeout=8,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
for uid, first, count in default_skip_rows:
|
for uid, first, count in default_skip_rows:
|
||||||
try:
|
try:
|
||||||
pg_scalar(
|
pg_scalar(
|
||||||
@@ -662,13 +713,6 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
)
|
)
|
||||||
ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET
|
ON CONFLICT (ct_number, series_instance_uid) DO UPDATE SET
|
||||||
skipped = true,
|
skipped = true,
|
||||||
body_parts = '[]'::jsonb,
|
|
||||||
manual_body_parts = '[]'::jsonb,
|
|
||||||
ai_body_parts = '[]'::jsonb,
|
|
||||||
upper_abdomen_phase = '',
|
|
||||||
manual_upper_abdomen_phase = '',
|
|
||||||
ai_upper_abdomen_phase = '',
|
|
||||||
plain_ct = false,
|
|
||||||
notes = CASE
|
notes = CASE
|
||||||
WHEN pacs_dicom_series_annotations.notes = ''
|
WHEN pacs_dicom_series_annotations.notes = ''
|
||||||
THEN EXCLUDED.notes
|
THEN EXCLUDED.notes
|
||||||
@@ -676,8 +720,7 @@ def scan_study(ct_number: str) -> dict[str, Any]:
|
|||||||
END,
|
END,
|
||||||
updated_by = 'system',
|
updated_by = 'system',
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE jsonb_array_length(pacs_dicom_series_annotations.body_parts) = 0
|
WHERE pacs_dicom_series_annotations.updated_by = 'system'
|
||||||
AND pacs_dicom_series_annotations.plain_ct IS NOT TRUE
|
|
||||||
""",
|
""",
|
||||||
timeout=8,
|
timeout=8,
|
||||||
)
|
)
|
||||||
@@ -991,14 +1034,14 @@ def save_annotation_payload(
|
|||||||
{sql_literal(series_uid)},
|
{sql_literal(series_uid)},
|
||||||
{sql_literal(series_row.get('description', ''))},
|
{sql_literal(series_row.get('description', ''))},
|
||||||
{sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb,
|
{sql_literal(json.dumps(body_parts, ensure_ascii=False))}::jsonb,
|
||||||
{sql_literal(json.dumps([] if skipped else manual_parts, ensure_ascii=False))}::jsonb,
|
{sql_literal(json.dumps(manual_parts, ensure_ascii=False))}::jsonb,
|
||||||
{sql_literal(json.dumps([] if skipped else ai_parts, ensure_ascii=False))}::jsonb,
|
{sql_literal(json.dumps(ai_parts, ensure_ascii=False))}::jsonb,
|
||||||
{sql_literal(phase)},
|
{sql_literal(phase)},
|
||||||
{sql_literal('' if skipped else manual_phase)},
|
{sql_literal(manual_phase)},
|
||||||
{sql_literal('' if skipped else ai_phase)},
|
{sql_literal(ai_phase)},
|
||||||
{'true' if plain_ct else 'false'},
|
{'true' if plain_ct else 'false'},
|
||||||
{sql_bool_or_null(None if skipped else manual_plain_ct)},
|
{sql_bool_or_null(manual_plain_ct)},
|
||||||
{sql_bool_or_null(None if skipped else ai_plain_ct)},
|
{sql_bool_or_null(ai_plain_ct)},
|
||||||
{'true' if skipped else 'false'},
|
{'true' if skipped else 'false'},
|
||||||
{'true' if ai_skipped else 'false'},
|
{'true' if ai_skipped else 'false'},
|
||||||
{sql_literal(notes)},
|
{sql_literal(notes)},
|
||||||
@@ -1031,14 +1074,14 @@ def save_annotation_payload(
|
|||||||
return normalize_annotation(
|
return normalize_annotation(
|
||||||
{
|
{
|
||||||
"body_parts": body_parts,
|
"body_parts": body_parts,
|
||||||
"manual_body_parts": [] if skipped else manual_parts,
|
"manual_body_parts": manual_parts,
|
||||||
"ai_body_parts": [] if skipped else ai_parts,
|
"ai_body_parts": ai_parts,
|
||||||
"upper_abdomen_phase": phase,
|
"upper_abdomen_phase": phase,
|
||||||
"manual_upper_abdomen_phase": "" if skipped else manual_phase,
|
"manual_upper_abdomen_phase": manual_phase,
|
||||||
"ai_upper_abdomen_phase": "" if skipped else ai_phase,
|
"ai_upper_abdomen_phase": ai_phase,
|
||||||
"plain_ct": plain_ct,
|
"plain_ct": plain_ct,
|
||||||
"manual_plain_ct": None if skipped else manual_plain_ct,
|
"manual_plain_ct": manual_plain_ct,
|
||||||
"ai_plain_ct": None if skipped else ai_plain_ct,
|
"ai_plain_ct": ai_plain_ct,
|
||||||
"skipped": skipped,
|
"skipped": skipped,
|
||||||
"ai_skipped": ai_skipped,
|
"ai_skipped": ai_skipped,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const app = {
|
|||||||
searchTimer: null,
|
searchTimer: null,
|
||||||
statusTimer: null,
|
statusTimer: null,
|
||||||
saveTimer: null,
|
saveTimer: null,
|
||||||
|
dirty: false,
|
||||||
|
saving: false,
|
||||||
pendingImage: null,
|
pendingImage: null,
|
||||||
drag: null,
|
drag: null,
|
||||||
seriesSort: "timeAsc",
|
seriesSort: "timeAsc",
|
||||||
@@ -147,7 +149,8 @@ async function login(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function logout() {
|
async function logout() {
|
||||||
|
await confirmSaveIfDirty();
|
||||||
app.token = "";
|
app.token = "";
|
||||||
localStorage.removeItem("pacs_web_token");
|
localStorage.removeItem("pacs_web_token");
|
||||||
showLogin(true);
|
showLogin(true);
|
||||||
@@ -216,6 +219,7 @@ function renderStudies() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function selectStudy(ctNumber) {
|
async function selectStudy(ctNumber) {
|
||||||
|
if (app.study?.ct_number && app.study.ct_number !== ctNumber) await confirmSaveIfDirty();
|
||||||
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
|
app.study = app.studies.find((item) => item.ct_number === ctNumber) || { ct_number: ctNumber };
|
||||||
app.series = [];
|
app.series = [];
|
||||||
app.activeSeries = null;
|
app.activeSeries = null;
|
||||||
@@ -243,10 +247,10 @@ function sourceTag(value, annotation) {
|
|||||||
|
|
||||||
function seriesTags(series) {
|
function seriesTags(series) {
|
||||||
const annotation = series.annotation || {};
|
const annotation = series.annotation || {};
|
||||||
if (annotation.skipped) {
|
|
||||||
return [{ label: "略过", source: annotation.ai_skipped ? "ai" : "manual" }];
|
|
||||||
}
|
|
||||||
const tags = [];
|
const tags = [];
|
||||||
|
if (annotation.skipped) {
|
||||||
|
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" });
|
||||||
}
|
}
|
||||||
@@ -319,7 +323,8 @@ function resetViewState() {
|
|||||||
applyTransform();
|
applyTransform();
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectSeries(seriesUid) {
|
async function selectSeries(seriesUid) {
|
||||||
|
if (app.activeSeries?.series_uid && app.activeSeries.series_uid !== seriesUid) await confirmSaveIfDirty();
|
||||||
const series = app.series.find((item) => item.series_uid === seriesUid);
|
const series = app.series.find((item) => item.series_uid === seriesUid);
|
||||||
if (!series) return;
|
if (!series) return;
|
||||||
app.activeSeries = series;
|
app.activeSeries = series;
|
||||||
@@ -340,7 +345,9 @@ function resetViewer() {
|
|||||||
$("dicomImage").removeAttribute("src");
|
$("dicomImage").removeAttribute("src");
|
||||||
$("imageEmpty").classList.remove("hidden");
|
$("imageEmpty").classList.remove("hidden");
|
||||||
$("sliceText").textContent = "0 / 0";
|
$("sliceText").textContent = "0 / 0";
|
||||||
$("saveState").textContent = "自动保存";
|
$("saveState").textContent = "已保存";
|
||||||
|
app.dirty = false;
|
||||||
|
app.saving = false;
|
||||||
resetViewState();
|
resetViewState();
|
||||||
updateOverlay();
|
updateOverlay();
|
||||||
}
|
}
|
||||||
@@ -434,7 +441,7 @@ function cloneAnnotation(annotation = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function effectiveParts() {
|
function effectiveParts() {
|
||||||
if (!app.draft || app.draft.skipped) return [];
|
if (!app.draft) return [];
|
||||||
return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]);
|
return uniq([...app.draft.manual_body_parts, ...app.draft.ai_body_parts]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,7 +451,7 @@ function effectivePhase() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function effectivePlainCt() {
|
function effectivePlainCt() {
|
||||||
if (!app.draft || app.draft.skipped) 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);
|
||||||
if (app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined) return Boolean(app.draft.ai_plain_ct);
|
if (app.draft.ai_plain_ct !== null && app.draft.ai_plain_ct !== undefined) return Boolean(app.draft.ai_plain_ct);
|
||||||
return Boolean(app.draft.plain_ct);
|
return Boolean(app.draft.plain_ct);
|
||||||
@@ -469,22 +476,22 @@ function applyAnnotationControls() {
|
|||||||
}
|
}
|
||||||
if (value === "plain_ct") {
|
if (value === "plain_ct") {
|
||||||
input.checked = effectivePlainCt();
|
input.checked = effectivePlainCt();
|
||||||
input.disabled = app.draft.skipped;
|
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" : "");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
input.checked = parts.has(value);
|
input.checked = parts.has(value);
|
||||||
input.disabled = app.draft.skipped;
|
input.disabled = false;
|
||||||
setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : "");
|
setLabelSource(input, app.draft.manual_body_parts.includes(value) ? "manual" : app.draft.ai_body_parts.includes(value) ? "ai" : "");
|
||||||
});
|
});
|
||||||
|
|
||||||
const phase = effectivePhase();
|
const phase = effectivePhase();
|
||||||
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
document.querySelectorAll("input[name=phase]").forEach((input) => {
|
||||||
input.checked = input.value === phase;
|
input.checked = input.value === phase;
|
||||||
input.disabled = app.draft.skipped;
|
input.disabled = false;
|
||||||
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", !app.draft.skipped && parts.has("upper_abdomen"));
|
$("phaseBox").classList.toggle("visible", parts.has("upper_abdomen"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateAnnotation() {
|
function hydrateAnnotation() {
|
||||||
@@ -507,17 +514,17 @@ function updateLocalAnnotation(annotation) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function annotationPayload() {
|
function annotationPayload() {
|
||||||
const bodyParts = app.draft.skipped ? [] : effectiveParts();
|
const bodyParts = effectiveParts();
|
||||||
return {
|
return {
|
||||||
body_parts: bodyParts,
|
body_parts: bodyParts,
|
||||||
manual_body_parts: app.draft.skipped ? [] : app.draft.manual_body_parts,
|
manual_body_parts: app.draft.manual_body_parts,
|
||||||
ai_body_parts: app.draft.skipped ? [] : app.draft.ai_body_parts,
|
ai_body_parts: app.draft.ai_body_parts,
|
||||||
upper_abdomen_phase: app.draft.skipped ? "" : effectivePhase(),
|
upper_abdomen_phase: effectivePhase(),
|
||||||
manual_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.manual_upper_abdomen_phase,
|
manual_upper_abdomen_phase: app.draft.manual_upper_abdomen_phase,
|
||||||
ai_upper_abdomen_phase: app.draft.skipped ? "" : app.draft.ai_upper_abdomen_phase,
|
ai_upper_abdomen_phase: app.draft.ai_upper_abdomen_phase,
|
||||||
plain_ct: effectivePlainCt(),
|
plain_ct: effectivePlainCt(),
|
||||||
manual_plain_ct: app.draft.skipped ? null : app.draft.manual_plain_ct,
|
manual_plain_ct: app.draft.manual_plain_ct,
|
||||||
ai_plain_ct: app.draft.skipped ? null : app.draft.ai_plain_ct,
|
ai_plain_ct: app.draft.ai_plain_ct,
|
||||||
skipped: app.draft.skipped,
|
skipped: app.draft.skipped,
|
||||||
ai_skipped: app.draft.ai_skipped,
|
ai_skipped: app.draft.ai_skipped,
|
||||||
notes: $("annotationNotes").value,
|
notes: $("annotationNotes").value,
|
||||||
@@ -526,6 +533,8 @@ function annotationPayload() {
|
|||||||
|
|
||||||
async function saveAnnotationNow() {
|
async function saveAnnotationNow() {
|
||||||
if (!app.study || !app.activeSeries || !app.draft) return;
|
if (!app.study || !app.activeSeries || !app.draft) return;
|
||||||
|
if (app.saving) return;
|
||||||
|
app.saving = true;
|
||||||
$("saveState").textContent = "保存中";
|
$("saveState").textContent = "保存中";
|
||||||
const uid = app.activeSeries.series_uid;
|
const uid = app.activeSeries.series_uid;
|
||||||
try {
|
try {
|
||||||
@@ -534,16 +543,30 @@ async function saveAnnotationNow() {
|
|||||||
body: JSON.stringify(annotationPayload()),
|
body: JSON.stringify(annotationPayload()),
|
||||||
});
|
});
|
||||||
$("saveState").textContent = "已保存";
|
$("saveState").textContent = "已保存";
|
||||||
|
app.dirty = false;
|
||||||
updateLocalAnnotation(data);
|
updateLocalAnnotation(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("saveState").textContent = err.message;
|
$("saveState").textContent = err.message;
|
||||||
|
} finally {
|
||||||
|
app.saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueSave(delay = 450) {
|
function markDirty() {
|
||||||
clearTimeout(app.saveTimer);
|
clearTimeout(app.saveTimer);
|
||||||
$("saveState").textContent = "待保存";
|
app.dirty = true;
|
||||||
app.saveTimer = setTimeout(saveAnnotationNow, delay);
|
$("saveState").textContent = "未保存";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSaveIfDirty() {
|
||||||
|
if (!app.dirty) return;
|
||||||
|
const shouldSave = window.confirm("当前标注尚未保存,是否先保存?");
|
||||||
|
if (shouldSave) {
|
||||||
|
await saveAnnotationNow();
|
||||||
|
} else {
|
||||||
|
app.dirty = false;
|
||||||
|
$("saveState").textContent = "未保存";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePartChange(event) {
|
function handlePartChange(event) {
|
||||||
@@ -553,14 +576,6 @@ function handlePartChange(event) {
|
|||||||
if (value === "skip") {
|
if (value === "skip") {
|
||||||
app.draft.skipped = input.checked;
|
app.draft.skipped = input.checked;
|
||||||
if (!input.checked) app.draft.ai_skipped = false;
|
if (!input.checked) app.draft.ai_skipped = false;
|
||||||
if (input.checked) {
|
|
||||||
app.draft.manual_body_parts = [];
|
|
||||||
app.draft.ai_body_parts = [];
|
|
||||||
app.draft.manual_upper_abdomen_phase = "";
|
|
||||||
app.draft.ai_upper_abdomen_phase = "";
|
|
||||||
app.draft.manual_plain_ct = null;
|
|
||||||
app.draft.ai_plain_ct = null;
|
|
||||||
}
|
|
||||||
} else if (value === "plain_ct") {
|
} else if (value === "plain_ct") {
|
||||||
if (input.checked) {
|
if (input.checked) {
|
||||||
app.draft.manual_plain_ct = true;
|
app.draft.manual_plain_ct = true;
|
||||||
@@ -585,7 +600,7 @@ function handlePartChange(event) {
|
|||||||
app.draft.upper_abdomen_phase = effectivePhase();
|
app.draft.upper_abdomen_phase = effectivePhase();
|
||||||
app.draft.plain_ct = effectivePlainCt();
|
app.draft.plain_ct = effectivePlainCt();
|
||||||
applyAnnotationControls();
|
applyAnnotationControls();
|
||||||
queueSave();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePhaseChange(event) {
|
function handlePhaseChange(event) {
|
||||||
@@ -594,7 +609,7 @@ function handlePhaseChange(event) {
|
|||||||
if (app.draft.ai_upper_abdomen_phase !== event.target.value) app.draft.ai_upper_abdomen_phase = "";
|
if (app.draft.ai_upper_abdomen_phase !== event.target.value) app.draft.ai_upper_abdomen_phase = "";
|
||||||
app.draft.upper_abdomen_phase = effectivePhase();
|
app.draft.upper_abdomen_phase = effectivePhase();
|
||||||
applyAnnotationControls();
|
applyAnnotationControls();
|
||||||
queueSave();
|
markDirty();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runAI() {
|
async function runAI() {
|
||||||
@@ -610,6 +625,7 @@ async function runAI() {
|
|||||||
updateLocalAnnotation(data);
|
updateLocalAnnotation(data);
|
||||||
$("annotationNotes").value = data.notes || "";
|
$("annotationNotes").value = data.notes || "";
|
||||||
$("saveState").textContent = "AI 结果已保存";
|
$("saveState").textContent = "AI 结果已保存";
|
||||||
|
app.dirty = false;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
$("saveState").textContent = err.message;
|
$("saveState").textContent = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -618,6 +634,7 @@ async function runAI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openInfo() {
|
async function openInfo() {
|
||||||
|
await confirmSaveIfDirty();
|
||||||
if (!app.study || !app.activeSeries) return;
|
if (!app.study || !app.activeSeries) return;
|
||||||
const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`);
|
const data = await json(`/api/dicom-info?ct_number=${encodeURIComponent(app.study.ct_number)}&series_uid=${encodeURIComponent(app.activeSeries.series_uid)}&index=${app.slice}`);
|
||||||
const content = $("infoContent");
|
const content = $("infoContent");
|
||||||
@@ -660,6 +677,7 @@ function roleCards(roles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openSettings() {
|
async function openSettings() {
|
||||||
|
await confirmSaveIfDirty();
|
||||||
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
|
const [settings, status] = await Promise.all([json("/api/settings"), fetch("/api/status").then((res) => res.json())]);
|
||||||
$("settingsContent").innerHTML = `
|
$("settingsContent").innerHTML = `
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
@@ -828,7 +846,13 @@ function wire() {
|
|||||||
$("annotationNotes").addEventListener("input", () => {
|
$("annotationNotes").addEventListener("input", () => {
|
||||||
if (!app.draft) return;
|
if (!app.draft) return;
|
||||||
app.draft.notes = $("annotationNotes").value;
|
app.draft.notes = $("annotationNotes").value;
|
||||||
queueSave(900);
|
markDirty();
|
||||||
|
});
|
||||||
|
$("saveAnnotation").addEventListener("click", saveAnnotationNow);
|
||||||
|
window.addEventListener("beforeunload", (event) => {
|
||||||
|
if (!app.dirty) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
});
|
});
|
||||||
document.querySelectorAll("[data-close]").forEach((button) => {
|
document.querySelectorAll("[data-close]").forEach((button) => {
|
||||||
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
|
button.addEventListener("click", () => $(button.dataset.close).classList.add("hidden"));
|
||||||
|
|||||||
@@ -114,7 +114,7 @@
|
|||||||
<div class="annotation-head">
|
<div class="annotation-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>部位标注</h2>
|
<h2>部位标注</h2>
|
||||||
<span id="saveState">自动保存</span>
|
<span id="saveState">已保存</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
|
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,6 +140,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="annotation-actions">
|
<div class="annotation-actions">
|
||||||
|
<button id="saveAnnotation" class="primary-btn save-btn">保存标注</button>
|
||||||
<input id="annotationNotes" class="note-input" placeholder="备注" />
|
<input id="annotationNotes" class="note-input" placeholder="备注" />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -785,6 +785,9 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.annotation-actions {
|
.annotation-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 118px minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -792,6 +795,10 @@ button:disabled {
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user