Add DICOM UPP registration workspace
This commit is contained in:
@@ -8,7 +8,9 @@ PACS_SUMMARY_TABLE=pacs_dicom_study_summaries
|
||||
PACS_ANNOTATION_TABLE=pacs_dicom_series_annotations
|
||||
UPP_ASSET_TABLE=upp_exam_assets
|
||||
UPP_STL_TABLE=upp_stl_files
|
||||
REGISTRATION_TABLE=dicom_upp_registrations
|
||||
VISUALIZER_USER_TABLE=db_visualizer_users
|
||||
VISUALIZER_ADMIN_USER=admin
|
||||
VISUALIZER_ADMIN_PASSWORD=123456
|
||||
PACS_VIEWER_URL=http://127.0.0.1:8107
|
||||
REGISTRATION_URL=http://127.0.0.1:8109
|
||||
|
||||
@@ -35,10 +35,12 @@ PACS_SUMMARY_TABLE = os.getenv("PACS_SUMMARY_TABLE", "pacs_dicom_study_summaries
|
||||
PACS_ANNOTATION_TABLE = os.getenv("PACS_ANNOTATION_TABLE", "pacs_dicom_series_annotations")
|
||||
UPP_ASSET_TABLE = os.getenv("UPP_ASSET_TABLE", "upp_exam_assets")
|
||||
UPP_STL_TABLE = os.getenv("UPP_STL_TABLE", "upp_stl_files")
|
||||
REGISTRATION_TABLE = os.getenv("REGISTRATION_TABLE", "dicom_upp_registrations")
|
||||
USER_TABLE = os.getenv("VISUALIZER_USER_TABLE", "db_visualizer_users")
|
||||
WEB_ADMIN_USER = os.getenv("VISUALIZER_ADMIN_USER", "admin")
|
||||
WEB_ADMIN_PASSWORD = os.getenv("VISUALIZER_ADMIN_PASSWORD", "123456")
|
||||
PACS_VIEWER_URL = os.getenv("PACS_VIEWER_URL", "http://127.0.0.1:8107")
|
||||
REGISTRATION_URL = os.getenv("REGISTRATION_URL", "http://127.0.0.1:8109")
|
||||
|
||||
IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
CJK_RE = re.compile(r"[\u3400-\u9fff]")
|
||||
@@ -56,6 +58,7 @@ PACS_SUMMARY_TABLE_SQL = ident(PACS_SUMMARY_TABLE)
|
||||
PACS_ANNOTATION_TABLE_SQL = ident(PACS_ANNOTATION_TABLE)
|
||||
UPP_ASSET_TABLE_SQL = ident(UPP_ASSET_TABLE)
|
||||
UPP_STL_TABLE_SQL = ident(UPP_STL_TABLE)
|
||||
REGISTRATION_TABLE_SQL = ident(REGISTRATION_TABLE)
|
||||
USER_TABLE_SQL = ident(USER_TABLE)
|
||||
|
||||
app = FastAPI(title="DICOM UPP Database Visualizer")
|
||||
@@ -247,6 +250,38 @@ def ensure_user_table() -> None:
|
||||
)
|
||||
|
||||
|
||||
def ensure_registration_table() -> None:
|
||||
run_psql(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS public.{REGISTRATION_TABLE_SQL} (
|
||||
id bigserial PRIMARY KEY,
|
||||
ct_number text NOT NULL,
|
||||
series_instance_uid text NOT NULL DEFAULT '',
|
||||
series_description text NOT NULL DEFAULT '',
|
||||
algorithm_model text NOT NULL DEFAULT '',
|
||||
stl_family text NOT NULL DEFAULT '',
|
||||
view_mode text NOT NULL DEFAULT 'family',
|
||||
selected_stl_files jsonb NOT NULL DEFAULT '[]'::jsonb,
|
||||
transform jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
dicom_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
model_reference jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
segmentation jsonb NOT NULL DEFAULT '{{}}'::jsonb,
|
||||
notes text NOT NULL DEFAULT '',
|
||||
locked boolean NOT NULL DEFAULT false,
|
||||
locked_by text,
|
||||
locked_at timestamptz,
|
||||
updated_by text NOT NULL DEFAULT 'admin',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
UNIQUE (ct_number, series_instance_uid, algorithm_model, stl_family)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_ct ON public.{REGISTRATION_TABLE_SQL}(ct_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_{REGISTRATION_TABLE_SQL}_locked ON public.{REGISTRATION_TABLE_SQL}(locked);
|
||||
""",
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
|
||||
def fetch_user(username: str) -> dict[str, Any] | None:
|
||||
ensure_user_table()
|
||||
rows = pg_json_rows(
|
||||
@@ -281,6 +316,7 @@ def admin_user(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
|
||||
def startup() -> None:
|
||||
try:
|
||||
ensure_user_table()
|
||||
ensure_registration_table()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -488,6 +524,16 @@ def relation_cte() -> str:
|
||||
LEFT JOIN s_segments ON s_segments.ct_key = s_raw.ct_key
|
||||
GROUP BY s_raw.ct_key
|
||||
),
|
||||
r AS (
|
||||
SELECT
|
||||
upper(ct_number) AS ct_key,
|
||||
count(*)::int AS registration_count,
|
||||
count(DISTINCT series_instance_uid) FILTER (WHERE series_instance_uid <> '')::int AS registered_series,
|
||||
count(*) FILTER (WHERE locked)::int AS locked_count,
|
||||
max(updated_at) AS registration_updated_at
|
||||
FROM public.{REGISTRATION_TABLE_SQL}
|
||||
GROUP BY upper(ct_number)
|
||||
),
|
||||
keys AS (
|
||||
SELECT ct_key FROM p
|
||||
UNION
|
||||
@@ -573,6 +619,10 @@ def relation_cte() -> str:
|
||||
s.updated_at AS stl_updated_at,
|
||||
COALESCE(s.stl_asset_count, 0) AS stl_asset_count,
|
||||
COALESCE(s.stl_assets, '[]'::jsonb) AS stl_assets,
|
||||
COALESCE(r.registration_count, 0)::int AS registration_count,
|
||||
COALESCE(r.registered_series, 0)::int AS registered_series,
|
||||
COALESCE(r.locked_count, 0)::int AS locked_count,
|
||||
r.registration_updated_at,
|
||||
CASE
|
||||
WHEN COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) IS NULL
|
||||
OR NULLIF(u.patient_name, '') IS NULL THEN ''
|
||||
@@ -586,6 +636,7 @@ def relation_cte() -> str:
|
||||
LEFT JOIN a_labels ON a_labels.ct_number = p.ct_number
|
||||
LEFT JOIN u ON u.ct_key = k.ct_key
|
||||
LEFT JOIN s ON s.ct_key = k.ct_key
|
||||
LEFT JOIN r ON r.ct_key = k.ct_key
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -683,6 +734,7 @@ def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
|
||||
return {
|
||||
"user": user,
|
||||
"viewer_url": PACS_VIEWER_URL,
|
||||
"registration_url": REGISTRATION_URL,
|
||||
"users": rows,
|
||||
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE},
|
||||
"tables": {
|
||||
@@ -691,6 +743,7 @@ def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
|
||||
"dicom_annotations": PACS_ANNOTATION_TABLE,
|
||||
"upp_assets": UPP_ASSET_TABLE,
|
||||
"upp_stl": UPP_STL_TABLE,
|
||||
"registration": REGISTRATION_TABLE,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -750,6 +803,7 @@ def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
||||
ok, message = db_available()
|
||||
counts: dict[str, Any] = {}
|
||||
if ok:
|
||||
ensure_registration_table()
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
{relation_cte()}
|
||||
@@ -764,6 +818,10 @@ def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
||||
count(*) FILTER (WHERE relation_status = 'pacs_only')::int AS pacs_only_count,
|
||||
count(*) FILTER (WHERE relation_status = 'stl_only')::int AS stl_only_count,
|
||||
count(*) FILTER (WHERE relation_status = 'list_only')::int AS list_only_count,
|
||||
count(*) FILTER (WHERE registration_count > 0)::int AS registered_ct_count,
|
||||
count(*) FILTER (WHERE locked_count > 0)::int AS locked_ct_count,
|
||||
COALESCE(sum(registration_count), 0)::int AS registration_count,
|
||||
COALESCE(sum(locked_count), 0)::int AS locked_registration_count,
|
||||
count(*) FILTER (WHERE pacs_present AND COALESCE(completed, false) IS NOT TRUE)::int AS pending_dicom_count,
|
||||
COALESCE(sum(CASE WHEN pacs_present THEN COALESCE(undetermined_series, 0) ELSE 0 END), 0)::int AS pending_dicom_series
|
||||
FROM relation
|
||||
@@ -774,12 +832,14 @@ def status(user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
||||
return {
|
||||
"database": {"ok": ok, "message": message, "host": PGHOST, "database": PGDATABASE},
|
||||
"viewer_url": PACS_VIEWER_URL,
|
||||
"registration_url": REGISTRATION_URL,
|
||||
"tables": {
|
||||
"dicom": PACS_TABLE,
|
||||
"dicom_summary": PACS_SUMMARY_TABLE,
|
||||
"dicom_annotations": PACS_ANNOTATION_TABLE,
|
||||
"upp_assets": UPP_ASSET_TABLE,
|
||||
"upp_stl": UPP_STL_TABLE,
|
||||
"registration": REGISTRATION_TABLE,
|
||||
},
|
||||
"counts": counts,
|
||||
}
|
||||
@@ -795,6 +855,7 @@ def relations(
|
||||
offset: int = Query(default=0, ge=0),
|
||||
user: dict[str, str] = Depends(current_user),
|
||||
) -> list[dict[str, Any]]:
|
||||
ensure_registration_table()
|
||||
where = relation_where(q, status, algorithm_model, dicom_part)
|
||||
rows = pg_json_rows(
|
||||
f"""
|
||||
@@ -810,6 +871,7 @@ def relations(
|
||||
body_parts, dicom_body_parts, dicom_annotation_labels, algorithm_models,
|
||||
upp_asset_count, duplicate_model_asset_count, upp_assets, stl_asset_count, stl_assets,
|
||||
segment_categories, segment_families,
|
||||
registration_count, registered_series, locked_count, registration_updated_at,
|
||||
pacs_updated_at, upp_updated_at, stl_updated_at
|
||||
FROM relation
|
||||
{where}
|
||||
@@ -829,6 +891,7 @@ def relations(
|
||||
|
||||
@app.get("/api/relations/{ct_number}")
|
||||
def relation_detail(ct_number: str, user: dict[str, str] = Depends(current_user)) -> dict[str, Any]:
|
||||
ensure_registration_table()
|
||||
ct_key = normalize_ct(ct_number)
|
||||
if not ct_key:
|
||||
raise HTTPException(status_code=400, detail="invalid CT number")
|
||||
|
||||
@@ -8,6 +8,7 @@ const app = {
|
||||
algorithmModel: "",
|
||||
dicomPart: "",
|
||||
viewerUrl: "http://127.0.0.1:8107",
|
||||
registrationUrl: "http://127.0.0.1:8109",
|
||||
searchTimer: null,
|
||||
offset: 0,
|
||||
limit: 20,
|
||||
@@ -171,6 +172,7 @@ function assetSummary(row) {
|
||||
|
||||
function setDbStatus(data) {
|
||||
app.viewerUrl = data.viewer_url || app.viewerUrl;
|
||||
app.registrationUrl = data.registration_url || app.registrationUrl;
|
||||
const pill = $("dbStatus");
|
||||
if (data.database?.ok) {
|
||||
pill.textContent = `${data.database.database} 已连接`;
|
||||
@@ -187,6 +189,7 @@ function renderMetrics(counts = {}) {
|
||||
["DICOM", counts.pacs_count],
|
||||
["仅 DICOM", counts.pacs_only_count],
|
||||
["仅 STL", counts.stl_only_count],
|
||||
["已配准", counts.registered_ct_count],
|
||||
];
|
||||
$("metrics").innerHTML = items
|
||||
.map(([label, value]) => `<div class="metric"><span>${escapeHtml(label)}</span><strong>${Number(value || 0)}</strong></div>`)
|
||||
@@ -203,6 +206,7 @@ function cardTitle(row) {
|
||||
`UPP患者:${row.upp_patient_name || "无"}`,
|
||||
`重建模型:${row.algorithm_model || "无"}`,
|
||||
`部位标注:${annotationLabels(row).join("、") || "无"}`,
|
||||
`配准:${Number(row.registered_series || 0)} 序列,${Number(row.locked_count || 0)} 锁定`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -237,6 +241,11 @@ function renderList() {
|
||||
</div>
|
||||
<div class="card-status">
|
||||
<i class="mini-badge ${escapeHtml(row.relation_status)}">${escapeHtml(statusLabel(row.relation_status))}</i>
|
||||
${
|
||||
Number(row.registration_count || 0)
|
||||
? `<small class="registration-mini" title="${Number(row.registration_count || 0)} 条配准记录,${Number(row.locked_count || 0)} 条锁定">${Number(row.registered_series || 0)} 已配准</small>`
|
||||
: `<small class="registration-mini pending">未配准</small>`
|
||||
}
|
||||
${
|
||||
Number(row.duplicate_model_asset_count || 0)
|
||||
? `<small class="name-check-warning" title="同一CT同一算法模型只能对应一个STL,已按规则保留一组">${Number(row.duplicate_model_asset_count)} 组重复</small>`
|
||||
@@ -290,16 +299,39 @@ function viewerBaseUrl() {
|
||||
}
|
||||
}
|
||||
|
||||
function registrationBaseUrl() {
|
||||
try {
|
||||
const url = new URL(app.registrationUrl || `${location.protocol}//${location.hostname}:8109`, location.href);
|
||||
if (["127.0.0.1", "localhost", "0.0.0.0"].includes(url.hostname) && !["127.0.0.1", "localhost"].includes(location.hostname)) {
|
||||
url.hostname = location.hostname;
|
||||
}
|
||||
url.protocol = location.protocol;
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch (_) {
|
||||
return `${location.protocol}//${location.hostname}:8109`;
|
||||
}
|
||||
}
|
||||
|
||||
function dicomViewerUrl(row = app.active) {
|
||||
if (!row?.pacs_ct_number) return "";
|
||||
return `${viewerBaseUrl()}/?ct_number=${encodeURIComponent(row.pacs_ct_number)}`;
|
||||
}
|
||||
|
||||
function registrationViewerUrl(row = app.active) {
|
||||
const ct = row?.pacs_ct_number || row?.ct_key;
|
||||
if (!ct) return "";
|
||||
return `${registrationBaseUrl()}/?ct_number=${encodeURIComponent(ct)}`;
|
||||
}
|
||||
|
||||
function updateOpenDicomButton() {
|
||||
const button = $("openDicomBtn");
|
||||
const url = dicomViewerUrl();
|
||||
button.disabled = !url;
|
||||
button.title = url || "当前记录没有 DICOM 检查号";
|
||||
const registrationButton = $("openRegistrationBtn");
|
||||
const registration = registrationViewerUrl();
|
||||
registrationButton.disabled = !registration;
|
||||
registrationButton.title = registration || "当前记录没有可配准 CT 号";
|
||||
}
|
||||
|
||||
function clearDetail() {
|
||||
@@ -349,6 +381,7 @@ function renderDetail(row) {
|
||||
["标注", `${Number(row.annotated_series || 0)} 已标注`],
|
||||
["完成", row.completed ? "已完成" : "待处理"],
|
||||
["部位标注", labels.join("、")],
|
||||
["配准状态", row.registration_count ? `${Number(row.registered_series || 0)} 个序列已配准 / ${Number(row.locked_count || 0)} 条锁定` : "未配准"],
|
||||
]);
|
||||
|
||||
renderDl("stlDetails", [
|
||||
@@ -370,6 +403,7 @@ function renderDetail(row) {
|
||||
["目录", row.processed_stl_dir],
|
||||
["分割分类", uniq(asList(row.segment_categories)).join("、")],
|
||||
["更新时间", fmtDate(row.stl_updated_at || row.upp_updated_at)],
|
||||
["配准更新时间", fmtDate(row.registration_updated_at)],
|
||||
]);
|
||||
|
||||
updateOpenDicomButton();
|
||||
@@ -597,6 +631,15 @@ function openViewerHome() {
|
||||
window.open(viewerBaseUrl(), "_blank", "noopener");
|
||||
}
|
||||
|
||||
function openRegistrationViewer() {
|
||||
const url = registrationViewerUrl();
|
||||
if (url) window.open(url, "_blank", "noopener");
|
||||
}
|
||||
|
||||
function openRegistrationHome() {
|
||||
window.open(registrationBaseUrl(), "_blank", "noopener");
|
||||
}
|
||||
|
||||
function wire() {
|
||||
$("loginForm").addEventListener("submit", login);
|
||||
$("logoutBtn").addEventListener("click", logout);
|
||||
@@ -604,7 +647,9 @@ function wire() {
|
||||
$("backToViewer").addEventListener("click", showViewer);
|
||||
$("refreshBtn").addEventListener("click", refreshAll);
|
||||
$("viewerHomeBtn").addEventListener("click", openViewerHome);
|
||||
$("registrationHomeBtn").addEventListener("click", openRegistrationHome);
|
||||
$("openDicomBtn").addEventListener("click", openDicomViewer);
|
||||
$("openRegistrationBtn").addEventListener("click", openRegistrationViewer);
|
||||
$("relationList").addEventListener("scroll", () => {
|
||||
const list = $("relationList");
|
||||
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 140) loadRelations(false);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button id="viewerHomeBtn" class="dark-btn" type="button">进入阅片系统</button>
|
||||
<button id="registrationHomeBtn" class="dark-btn" type="button">进入配准系统</button>
|
||||
<span id="dbStatus" class="status-pill">数据库</span>
|
||||
<button id="refreshBtn" class="dark-btn" type="button">刷新</button>
|
||||
<span id="userBadge" class="user-badge">未登录</span>
|
||||
@@ -101,7 +102,10 @@
|
||||
<article class="panel detail-card">
|
||||
<div class="detail-title">
|
||||
<h3>DICOM 阅片分类系统</h3>
|
||||
<button id="openDicomBtn" class="link-btn" type="button">打开阅片系统</button>
|
||||
<div class="detail-actions">
|
||||
<button id="openDicomBtn" class="link-btn" type="button">打开阅片系统</button>
|
||||
<button id="openRegistrationBtn" class="link-btn" type="button">打开配准</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl id="pacsDetails"></dl>
|
||||
</article>
|
||||
|
||||
@@ -194,6 +194,12 @@ button {
|
||||
background: linear-gradient(180deg, rgba(17, 61, 70, 0.92), rgba(9, 30, 42, 0.98));
|
||||
}
|
||||
|
||||
.top-actions #registrationHomeBtn {
|
||||
border-color: rgba(240, 181, 78, 0.62);
|
||||
color: #ffe3ad;
|
||||
background: linear-gradient(180deg, rgba(66, 47, 17, 0.92), rgba(34, 23, 9, 0.98));
|
||||
}
|
||||
|
||||
.primary-btn,
|
||||
.link-btn {
|
||||
height: 34px;
|
||||
@@ -229,6 +235,11 @@ button {
|
||||
background: rgba(25, 212, 194, 0.14);
|
||||
}
|
||||
|
||||
.top-actions #registrationHomeBtn:hover {
|
||||
border-color: var(--amber);
|
||||
background: rgba(240, 181, 78, 0.14);
|
||||
}
|
||||
|
||||
.layout {
|
||||
height: calc(100vh - 66px);
|
||||
display: grid;
|
||||
@@ -451,6 +462,27 @@ button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.registration-mini {
|
||||
max-width: 94px;
|
||||
padding: 2px 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(25, 212, 194, 0.46);
|
||||
border-radius: 999px;
|
||||
color: #baf8ee;
|
||||
background: rgba(25, 212, 194, 0.08);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.registration-mini.pending {
|
||||
border-color: rgba(240, 181, 78, 0.52);
|
||||
color: #ffe0a3;
|
||||
background: rgba(240, 181, 78, 0.08);
|
||||
}
|
||||
|
||||
.mini-badge {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 7px;
|
||||
@@ -636,6 +668,12 @@ button {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-title h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user