Show undetermined study series count

This commit is contained in:
Codex
2026-05-27 21:22:15 +08:00
parent bfa2436327
commit e788f08037
2 changed files with 51 additions and 3 deletions

View File

@@ -313,6 +313,7 @@ def ensure_study_summary_table() -> None:
series_count integer NOT NULL DEFAULT 0, series_count integer NOT NULL DEFAULT 0,
annotated_series integer NOT NULL DEFAULT 0, annotated_series integer NOT NULL DEFAULT 0,
unannotated_series integer NOT NULL DEFAULT 0, unannotated_series integer NOT NULL DEFAULT 0,
undetermined_series integer NOT NULL DEFAULT 0,
body_parts jsonb NOT NULL DEFAULT '[]'::jsonb, body_parts jsonb NOT NULL DEFAULT '[]'::jsonb,
completed boolean NOT NULL DEFAULT false, completed boolean NOT NULL DEFAULT false,
completed_by text, completed_by text,
@@ -322,6 +323,8 @@ def ensure_study_summary_table() -> None:
); );
ALTER TABLE public.pacs_dicom_study_summaries ALTER TABLE public.pacs_dicom_study_summaries
ADD COLUMN IF NOT EXISTS body_parts jsonb NOT NULL DEFAULT '[]'::jsonb; ADD COLUMN IF NOT EXISTS body_parts jsonb NOT NULL DEFAULT '[]'::jsonb;
ALTER TABLE public.pacs_dicom_study_summaries
ADD COLUMN IF NOT EXISTS undetermined_series integer NOT NULL DEFAULT 0;
ALTER TABLE public.pacs_dicom_study_summaries ALTER TABLE public.pacs_dicom_study_summaries
ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false; ADD COLUMN IF NOT EXISTS completed boolean NOT NULL DEFAULT false;
ALTER TABLE public.pacs_dicom_study_summaries ALTER TABLE public.pacs_dicom_study_summaries
@@ -511,6 +514,19 @@ def annotation_overview_by_study() -> dict[str, dict[str, Any]]:
OR NULLIF(a.notes, '') IS NOT NULL OR NULLIF(a.notes, '') IS NOT NULL
OR a.ai_result IS NOT NULL OR a.ai_result IS NOT NULL
)::int AS annotated_series, )::int AS annotated_series,
count(DISTINCT a.series_instance_uid) FILTER (
WHERE a.skipped IS NOT TRUE
AND (
(
a.body_parts ? 'upper_abdomen'
AND COALESCE(NULLIF(a.upper_abdomen_phase, ''), 'unknown') = 'unknown'
)
OR (
a.body_parts ? 'chest'
AND COALESCE(NULLIF(a.chest_window, ''), 'unknown') = 'unknown'
)
)
)::int AS undetermined_series,
COALESCE( COALESCE(
jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL), jsonb_agg(DISTINCT part.value) FILTER (WHERE part.value IS NOT NULL),
'[]'::jsonb '[]'::jsonb
@@ -534,7 +550,7 @@ def summary_rows_by_study() -> dict[str, dict[str, Any]]:
""" """
SELECT SELECT
ct_number, series_count, annotated_series, unannotated_series, ct_number, series_count, annotated_series, unannotated_series,
body_parts, completed, completed_by, completed_at, completion_updated_at, refreshed_at undetermined_series, body_parts, completed, completed_by, completed_at, completion_updated_at, refreshed_at
FROM public.pacs_dicom_study_summaries FROM public.pacs_dicom_study_summaries
""", """,
timeout=8, timeout=8,
@@ -594,6 +610,8 @@ def studies(_: str = Depends(require_auth), q: str = "", limit: int = 200) -> li
row["series_count"] = total_series row["series_count"] = total_series
row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_summary.get("annotated_series", 0))) row["annotated_series"] = min(total_series, int((summary or {}).get("annotated_series") if summary else annotation_summary.get("annotated_series", 0)))
row["unannotated_series"] = max(0, total_series - int(row["annotated_series"])) row["unannotated_series"] = max(0, total_series - int(row["annotated_series"]))
undetermined_source = annotation_summary.get("undetermined_series", (summary or {}).get("undetermined_series", 0))
row["undetermined_series"] = min(total_series, int(undetermined_source or 0))
row["body_parts"] = valid_parts((summary or {}).get("body_parts") or annotation_summary.get("body_parts") or []) row["body_parts"] = valid_parts((summary or {}).get("body_parts") or annotation_summary.get("body_parts") or [])
row["completed"] = bool((summary or {}).get("completed", False)) row["completed"] = bool((summary or {}).get("completed", False))
row["completed_by"] = (summary or {}).get("completed_by", "") row["completed_by"] = (summary or {}).get("completed_by", "")
@@ -1122,11 +1140,27 @@ def study_summary_payload(data: dict[str, Any]) -> dict[str, Any]:
or row.get("annotation", {}).get("notes") or row.get("annotation", {}).get("notes")
or row.get("annotation", {}).get("ai_model") or row.get("annotation", {}).get("ai_model")
) )
undetermined = sum(
1
for row in series_list
if not row.get("annotation", {}).get("skipped")
and (
(
"upper_abdomen" in valid_parts(row.get("annotation", {}).get("body_parts") or [])
and row.get("annotation", {}).get("upper_abdomen_phase") == "unknown"
)
or (
"chest" in valid_parts(row.get("annotation", {}).get("body_parts") or [])
and row.get("annotation", {}).get("chest_window") == "unknown"
)
)
)
return { return {
"ct_number": data.get("study", {}).get("ct_number", ""), "ct_number": data.get("study", {}).get("ct_number", ""),
"series_count": total, "series_count": total,
"annotated_series": annotated, "annotated_series": annotated,
"unannotated_series": max(0, total - annotated), "unannotated_series": max(0, total - annotated),
"undetermined_series": undetermined,
"body_parts": body_parts, "body_parts": body_parts,
"summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"), "summary_updated_at": time.strftime("%Y-%m-%d %H:%M:%S"),
} }
@@ -1138,13 +1172,14 @@ def save_study_summary(summary: dict[str, Any]) -> None:
pg_scalar( pg_scalar(
f""" f"""
INSERT INTO public.pacs_dicom_study_summaries ( INSERT INTO public.pacs_dicom_study_summaries (
ct_number, series_count, annotated_series, unannotated_series, body_parts, refreshed_at ct_number, series_count, annotated_series, unannotated_series, undetermined_series, body_parts, refreshed_at
) )
VALUES ( VALUES (
{sql_literal(summary.get('ct_number', ''))}, {sql_literal(summary.get('ct_number', ''))},
{int(summary.get('series_count') or 0)}, {int(summary.get('series_count') or 0)},
{int(summary.get('annotated_series') or 0)}, {int(summary.get('annotated_series') or 0)},
{int(summary.get('unannotated_series') or 0)}, {int(summary.get('unannotated_series') or 0)},
{int(summary.get('undetermined_series') or 0)},
{sql_literal(json.dumps(valid_parts(summary.get('body_parts') or []), ensure_ascii=False))}::jsonb, {sql_literal(json.dumps(valid_parts(summary.get('body_parts') or []), ensure_ascii=False))}::jsonb,
now() now()
) )
@@ -1152,6 +1187,7 @@ def save_study_summary(summary: dict[str, Any]) -> None:
series_count = EXCLUDED.series_count, series_count = EXCLUDED.series_count,
annotated_series = EXCLUDED.annotated_series, annotated_series = EXCLUDED.annotated_series,
unannotated_series = EXCLUDED.unannotated_series, unannotated_series = EXCLUDED.unannotated_series,
undetermined_series = EXCLUDED.undetermined_series,
body_parts = EXCLUDED.body_parts, body_parts = EXCLUDED.body_parts,
refreshed_at = now() refreshed_at = now()
""", """,

View File

@@ -302,9 +302,20 @@ function isSeriesAnnotated(series) {
return Boolean(annotation.skipped || asList(annotation.body_parts).length || annotation.notes || annotation.ai_model); return Boolean(annotation.skipped || asList(annotation.body_parts).length || annotation.notes || annotation.ai_model);
} }
function isSeriesUndetermined(series) {
const annotation = series.annotation || {};
if (annotation.skipped) return false;
const parts = asList(annotation.body_parts);
return (
(parts.includes("upper_abdomen") && annotation.upper_abdomen_phase === "unknown") ||
(parts.includes("chest") && annotation.chest_window === "unknown")
);
}
function refreshStudyAnnotationSummary() { function refreshStudyAnnotationSummary() {
if (!app.study || !app.series.length) return; if (!app.study || !app.series.length) return;
const annotated = app.series.filter(isSeriesAnnotated).length; const annotated = app.series.filter(isSeriesAnnotated).length;
const undetermined = app.series.filter(isSeriesUndetermined).length;
const total = app.series.length; const total = app.series.length;
const bodyParts = BODY_PARTS.filter((part) => const bodyParts = BODY_PARTS.filter((part) =>
app.series.some((series) => asList(series.annotation?.body_parts).includes(part)), app.series.some((series) => asList(series.annotation?.body_parts).includes(part)),
@@ -312,6 +323,7 @@ function refreshStudyAnnotationSummary() {
const summary = { const summary = {
annotated_series: annotated, annotated_series: annotated,
unannotated_series: Math.max(0, total - annotated), unannotated_series: Math.max(0, total - annotated),
undetermined_series: undetermined,
series_count: total, series_count: total,
body_parts: bodyParts, body_parts: bodyParts,
}; };
@@ -458,7 +470,7 @@ function renderStudies() {
.map((label) => `<em title="${escapeHtml(label)}">${escapeHtml(label)}</em>`) .map((label) => `<em title="${escapeHtml(label)}">${escapeHtml(label)}</em>`)
.join(""); .join("");
const studyDateTime = `${fmtDate(study.study_date)} ${fmtTime(study.study_time)}`.trim() || "未记录时间"; const studyDateTime = `${fmtDate(study.study_date)} ${fmtTime(study.study_time)}`.trim() || "未记录时间";
const annotationSummary = `${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.unannotated_series || 0)} 个序列未标注`; const annotationSummary = `${Number(study.annotated_series || 0)} 个序列已标注,${Number(study.undetermined_series || 0)} 个序列待判别`;
const cardTitle = [ const cardTitle = [
`检查号:${study.ct_number}`, `检查号:${study.ct_number}`,
`患者:${name} · ${study.patient_id || "无ID"}`, `患者:${name} · ${study.patient_id || "无ID"}`,