Compare commits

62 Commits

Author SHA1 Message Date
Codex
5dc20cef56 Use fixed bright bone reward 2026-05-31 01:58:57 +08:00
Codex
2fa26320bc Add activated bone brightness scoring 2026-05-31 01:40:41 +08:00
Codex
747dd97949 Tighten bright bone scoring 2026-05-31 01:38:24 +08:00
Codex
e2be49677f Compact STL list and clarify auto iteration 2026-05-31 01:27:32 +08:00
Codex
5cac15fbed Group liver STL models by anatomy 2026-05-31 01:03:50 +08:00
Codex
b13715ac4e Refine STL visibility and pose panels 2026-05-30 16:51:44 +08:00
Codex
cad4257a4b Align registration status and crisp slice masks 2026-05-30 15:40:51 +08:00
Codex
30f73937aa Improve registration iteration feedback 2026-05-30 14:17:55 +08:00
Codex
40e18ed45d Update PACS DICOM navigation and summaries 2026-05-30 13:29:31 +08:00
Codex
c1fbdd8bac Improve auto pose refinement controls 2026-05-30 10:52:11 +08:00
Codex
bdd0db2a4c Update DICOM UPP fusion workspace view 2026-05-30 10:37:44 +08:00
Codex
86e25d0a51 Show automatic pose adjustment deltas 2026-05-29 17:16:02 +08:00
Codex
692c50e22c Optimize DICOM UPP registration lazy loading and fusion views 2026-05-29 16:53:01 +08:00
Codex
1d6f04061a Document UPP STL cleanup record 2026-05-29 14:22:14 +08:00
Codex
aecfa957ef Improve UPP list batch merge filtering 2026-05-29 13:56:42 +08:00
Codex
4bb482c4b0 Improve segmentation mapping render 2026-05-29 11:24:48 +08:00
Codex
adb2f41213 Improve registration fusion interaction 2026-05-29 11:00:43 +08:00
Codex
989303ec35 Cache STL workspace visibility toggles 2026-05-29 09:55:13 +08:00
Codex
45e719aab0 Refine registration DICOM selection and model centering 2026-05-29 09:35:44 +08:00
Codex
69a5926e3c Fix DICOM UPP model pivot 2026-05-29 03:44:24 +08:00
Codex
54bae18a21 Add PACS image mount cache refresh settings 2026-05-29 03:18:50 +08:00
Codex
dbf52d147a Tighten registration controls and fusion display 2026-05-29 02:28:07 +08:00
Codex
559266d5e6 Refine registration workspace controls 2026-05-29 01:46:52 +08:00
Codex
f9063b674c Refine registration series defaults and mapping controls 2026-05-29 00:33:40 +08:00
Codex
c4f0fd54d6 Migrate registration workspace fusion mapping views 2026-05-28 23:43:49 +08:00
Codex
7a38272e8b Refine DICOM UPP registration workstation layout 2026-05-28 22:38:27 +08:00
Codex
4ffdd28fbe Rebuild DICOM UPP registration workspace 2026-05-28 19:46:51 +08:00
Codex
1679ddb03a Align DICOM UPP registration uniqueness 2026-05-28 18:41:49 +08:00
Codex
cb18aabb4d Add DICOM UPP registration workspace 2026-05-28 11:58:59 +08:00
Codex
07df6ce446 Enforce one STL per CT algorithm 2026-05-28 10:09:57 +08:00
Codex
2c6274ac0d Aggregate multiple STL assets per CT 2026-05-28 10:03:15 +08:00
Codex
026f6ce83c Add DICOM UPP patient name matching 2026-05-28 09:52:17 +08:00
Codex
160790d38d Fix viewer link host resolution 2026-05-28 09:37:02 +08:00
Codex
5aabb300f2 Simplify relation card body part tags 2026-05-28 09:29:32 +08:00
Codex
92ca5e5b38 Refine DICOM UPP relation loading 2026-05-28 09:19:29 +08:00
Codex
4fe6466e20 Improve DICOM UPP relation visualizer 2026-05-28 08:49:16 +08:00
Codex
b6149a31eb Use exact CT links for PACS STL view 2026-05-28 08:17:54 +08:00
Codex
48b0e23d64 Add PACS UPP database visualizer 2026-05-28 00:46:41 +08:00
Codex
f34e40ec0a Align series sort with displayed capture time 2026-05-27 21:58:30 +08:00
Codex
e788f08037 Show undetermined study series count 2026-05-27 21:22:15 +08:00
Codex
bfa2436327 Refine study controls and slice rail 2026-05-27 15:23:22 +08:00
Codex
f5b40c5f5b Prevent viewer toolbar button wrapping 2026-05-27 14:51:23 +08:00
Codex
2c2838adf9 Align study export action with summary 2026-05-27 14:49:37 +08:00
Codex
8212d3b819 Tighten study card and phase layout 2026-05-27 14:46:08 +08:00
Codex
2a858be166 Export selected DICOM series 2026-05-27 14:43:17 +08:00
Codex
223e853bad Tighten study card export layout 2026-05-27 14:32:10 +08:00
Codex
555a04f337 Add admin DICOM export controls 2026-05-27 14:27:47 +08:00
Codex
fa38abf213 Improve settings contrast and add plain phase 2026-05-27 14:02:23 +08:00
Codex
6653c147c8 Add study body part filters 2026-05-27 13:42:53 +08:00
Codex
fc681fa08b Bind annotation saves to selected series 2026-05-27 13:34:37 +08:00
Codex
3a4bf5a486 Add manual study completion toggle 2026-05-27 13:22:03 +08:00
Codex
0cf7725dfc Move settings to page and cache study summaries 2026-05-27 13:10:18 +08:00
Codex
cfd2b379b5 Improve study status and sequence loading 2026-05-27 12:52:03 +08:00
Codex
ddfee82b0b Refine vertical slice rail orientation 2026-05-27 11:27:41 +08:00
Codex
806807f4bc Add compact study and series sorting 2026-05-27 11:13:28 +08:00
Codex
3c954cd274 Refine DICOM classification labels and loading 2026-05-27 11:07:27 +08:00
Codex
0f06b04bf7 Show annotated and unannotated series counts 2026-05-27 10:40:49 +08:00
Codex
bbf8466d56 Refine skip and manual save annotations 2026-05-27 10:38:03 +08:00
Codex
45017fbdfd Add series count sorting 2026-05-27 10:28:51 +08:00
Codex
ac75c37e3f Refine PACS viewer annotation workflow 2026-05-27 10:25:33 +08:00
Codex
9c478ed392 Add Docker PACS DICOM web viewer 2026-05-27 08:43:33 +08:00
Codex
f3e3cfff1d Add PACS DICOM preprocessing workflow 2026-05-27 00:54:14 +08:00
40 changed files with 19398 additions and 1 deletions

11
.gitignore vendored
View File

@@ -21,3 +21,14 @@ UPP列表处理/数据处理工作区/06_PostgreSQL建表结构.sql
UPP_STL处理/
UPP_数据库构建/UPP_STL资产同步报告.json
UPP_数据库构建/UPP_STL文件family顺序明细.csv
# PACS DICOM 实数据、软链接数据目录和批次处理结果不提交
PACS_DICOM处理/待处理_DICOM数据
PACS_DICOM处理/待处理_DICOM数据/
PACS_DICOM处理/已处理_DICOM数据
PACS_DICOM处理/已处理_DICOM数据/
PACS_DICOM处理/数据处理结果区/
# DICOM/UPP 配准运行缓存和共享盘缓存软链接不提交
DICOM_and_UPP配准/runtime/
DICOM_and_UPP配准/缓存数据

View File

@@ -0,0 +1,4 @@
.env
__pycache__/
*.pyc
.pytest_cache/

View File

@@ -0,0 +1,23 @@
PGHOST=192.168.3.3
PGPORT=5432
PGUSER=his_user
PGPASSWORD=change_me
PGDATABASE=pacs_db
PACS_TABLE=pacs_dicom_files
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
REGISTRATION_WEB_USER=admin
REGISTRATION_WEB_PASSWORD=123456
SMB_USERNAME=change_me
SMB_PASSWORD=change_me
PACS_VIEWER_URL=http://127.0.0.1:8107
RELATION_VIEWER_URL=http://127.0.0.1:8108
PACS_IMAGE_DB_ROOT=/home/wkmgc/Desktop/PACS影像数据库_192.168.3.3
PACS_PROCESSED_ROOT=/home/wkmgc/Desktop/PACS影像数据库_192.168.3.3/PACS数据/DICOM数据/已处理_DICOM数据
REGISTRATION_CACHE_ROOT=/home/wkmgc/Desktop/PACS影像数据库_192.168.3.3/PACS数据/DICOM与UPP配准数据
REGISTRATION_RUNTIME_DIR=/home/wkmgc/Desktop/PACS数据处理/DICOM_and_UPP配准/runtime

View File

@@ -0,0 +1,17 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
COPY . /app
EXPOSE 8109
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8109"]

View File

@@ -0,0 +1,27 @@
# DICOM / UPP 配准工作台
面向 `DICOM / UPP 数据库关联可视化` 中的完整关联 CT维护 STL 模型与 DICOM 序列的搭配关系及位姿参数。
此工作台不内置示例项目;进入后直接从数据库完整关联 CT 列表读取 DICOM 和 STL。旧版“锁定”概念已改为 `未配准 / 已配准` 状态。
默认地址:
- 本机:`http://127.0.0.1:8109/`
- 局域网:`http://192.168.3.11:8109/`
登录:
- 账号:`admin`
- 密码:`123456`
保存表:
- `public.dicom_upp_registrations`
- 唯一键:`ct_number + algorithm_model`
- 主要字段:`registration_status``series_instance_uid``selected_stl_files``transform``model_reference``dicom_reference`
启动:
```bash
docker compose up -d --build
```

1450
DICOM_and_UPP配准/app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,35 @@
name: dicom-upp-registration
services:
dicom-upp-registration:
build:
context: .
container_name: dicom-upp-registration
restart: unless-stopped
env_file:
- .env
environment:
PACS_IMAGE_DB_ROOT: /pacs-image-db
PACS_PROCESSED_ROOT: /pacs-image-db/PACS数据/DICOM数据/已处理_DICOM数据
REGISTRATION_CACHE_ROOT: /pacs-image-db/PACS数据/DICOM与UPP配准数据
REGISTRATION_RUNTIME_DIR: /app/runtime
ports:
- "8109:8109"
volumes:
- type: volume
source: pacs_image_db
target: /pacs-image-db
volume:
nocopy: true
- type: bind
source: /home/wkmgc/Desktop/PACS数据处理/DICOM_and_UPP配准/runtime
target: /app/runtime
read_only: false
volumes:
pacs_image_db:
driver: local
driver_opts:
type: cifs
device: //192.168.3.3/pacs影像数据库
o: username=${SMB_USERNAME},password=${SMB_PASSWORD},uid=1001,gid=1001,file_mode=0755,dir_mode=0755,iocharset=utf8,vers=3.0,soft,nounix,mapposix,noperm

View File

@@ -0,0 +1,5 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pydicom==2.4.5
numpy>=2.0
pillow>=11.0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,425 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DICOM / UPP 配准工作台</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='8' fill='%2319d6c3'/%3E%3Cpath d='M9 17h14M16 10v14' stroke='%233378f6' stroke-width='4' stroke-linecap='round'/%3E%3C/svg%3E" />
<link rel="stylesheet" href="/static/styles.css" />
<script type="importmap">
{
"imports": {
"three": "/static/vendor/three.module.min.js"
}
}
</script>
</head>
<body>
<div id="loginOverlay" class="login-overlay">
<form id="loginForm" class="login-panel">
<span class="brand-mark"></span>
<h1>DICOM / UPP 配准工作台</h1>
<p>登录后从完整关联 CT 中选择 DICOM 与 STL 配准关系</p>
<input id="username" autocomplete="username" placeholder="账号" />
<input id="password" type="password" autocomplete="current-password" placeholder="密码" />
<button type="submit">登录</button>
<span id="loginError"></span>
</form>
</div>
<header class="topbar">
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>DICOM / UPP 配准工作台</h1>
<p id="subtitle">完整关联 CT · STL 搭配 · 位姿参数</p>
</div>
</div>
<div class="top-actions">
<button id="relationBtn" class="ghost-btn accent" type="button">数据库关联可视化</button>
<button id="viewerBtn" class="ghost-btn" type="button">DICOM 阅片系统</button>
<span id="dbStatus" class="status-pill">数据库</span>
<button id="refreshBtn" class="ghost-btn" type="button">刷新</button>
<button id="settingsBtn" class="ghost-btn" type="button">设置</button>
<span id="userBadge" class="user-badge">未登录</span>
<button id="logoutBtn" class="ghost-btn" type="button">退出</button>
</div>
</header>
<div id="topLoading" class="top-loading hidden"><span></span></div>
<section id="settingsPanel" class="settings-panel hidden" aria-label="系统设置">
<div class="settings-shell">
<div class="settings-head">
<div>
<h2>系统设置</h2>
<p>DICOM / UPP 数据刷新与配准缓存</p>
</div>
<button id="settingsCloseBtn" class="ghost-btn" type="button">关闭</button>
</div>
<div class="settings-grid">
<section class="settings-card">
<div class="settings-card-head">
<h3>自动刷新</h3>
<label class="switch-line">
<input id="autoRefreshEnabled" type="checkbox" />
<span>启用</span>
</label>
</div>
<div class="settings-row">
<label for="refreshTime">每日刷新时间</label>
<input id="refreshTime" type="time" value="03:00" />
</div>
<div class="settings-actions">
<button id="saveSettingsBtn" class="primary-btn" type="button">保存设置</button>
<button id="manualCacheRefreshBtn" class="ghost-btn" type="button">立刻刷新缓存</button>
</div>
</section>
<section class="settings-card">
<div class="settings-card-head">
<h3>缓存状态</h3>
<span id="cacheStage" class="status-pill">未刷新</span>
</div>
<div class="cache-progress">
<span id="cacheProgressFill"></span>
</div>
<div id="cacheMessage" class="cache-message">等待刷新</div>
<div class="settings-metrics">
<span id="cacheCases">0 / 0 CT</span>
<span id="cacheFiles">STL 0</span>
<span id="cacheNextRun">下次 -</span>
</div>
<code id="cacheRootText" class="cache-root"></code>
</section>
</div>
</div>
</section>
<main class="workspace">
<aside class="case-panel panel">
<div class="panel-head">
<div class="panel-title-row">
<div>
<h2>完整关联 CT</h2>
<p>DICOM + STL</p>
</div>
<button id="caseToggleBtn" class="icon-btn" type="button" title="收起/展开完整关联 CT">
<span></span><span></span><span></span>
</button>
</div>
<span id="caseCount">0</span>
</div>
<input id="caseSearch" class="search-input" placeholder="搜索 CT号 / 姓名 / 模型" />
<div class="filter-row">
<button class="filter active" data-status="">全部</button>
<button class="filter" data-status="unregistered">未配准</button>
<button class="filter" data-status="registered">已配准</button>
</div>
<div class="part-filter">
<button class="filter active" data-part="">全部部位</button>
<button class="filter" data-part="head_neck">头颈部</button>
<button class="filter" data-part="chest">胸部</button>
<button class="filter" data-part="upper_abdomen">上腹部</button>
<button class="filter" data-part="abdomen_pelvis">腹盆部</button>
</div>
<div class="model-filter">
<button class="filter active" data-model-filter="">全部模型</button>
<button class="filter" data-model-filter="肝胆模型">肝胆模型</button>
<button class="filter" data-model-filter="泌尿模型">泌尿模型</button>
<button class="filter" data-model-filter="胸部模型">胸部模型</button>
</div>
<div id="caseList" class="case-list"></div>
</aside>
<section class="main-panel">
<section class="case-strip panel">
<div>
<h2 id="activeTitle">未选择 CT</h2>
<p id="activeMeta">从左侧选择一个完整关联检查</p>
</div>
<div id="activeTags" class="tag-line"></div>
</section>
<section class="registration-board">
<aside class="tool-dock panel">
<div class="tool-tabs">
<button class="active" data-tool-tab="series">序列</button>
<button data-tool-tab="models">模型可视</button>
<button data-tool-tab="pose">位姿</button>
</div>
<section class="tool-pane active" data-tool-pane="series">
<div class="display-segmented" id="fusionDetailControls">
<button data-fusion-detail="low" type="button">DICOM 低</button>
<button data-fusion-detail="medium" type="button">DICOM 中</button>
<button class="active" data-fusion-detail="high" type="button">DICOM 高</button>
</div>
<div class="tool-section-title">
<span>检查序列</span>
<em id="seriesCount">0</em>
</div>
<div id="seriesList" class="series-list compact-list"></div>
</section>
<section class="tool-pane" data-tool-pane="models">
<div class="display-segmented" id="modelDetailControls">
<button data-model-detail="standard" type="button">标准</button>
<button data-model-detail="fine" type="button">精细</button>
<button class="active" data-model-detail="ultra" type="button">超精细</button>
<button data-model-detail="solid" type="button">实体</button>
</div>
<div class="tool-section-title compact-title">
<span>STL</span>
<div class="title-actions model-visibility-actions">
<button id="cycleAllStlModeBtn" class="model-eye-btn" type="button" title="切换全部 STL 显示状态" aria-label="切换全部 STL 显示状态"><span></span></button>
</div>
<em id="stlCount">0</em>
</div>
<div id="modelRail" class="chip-rail"></div>
<div id="stlList" class="stl-list compact-list"></div>
</section>
<section class="tool-pane" data-tool-pane="pose">
<div class="tool-section-title">
<span>位姿手动调整</span>
<button id="poseManualToggle" class="section-toggle" type="button" title="收起/展开位姿手动调整" aria-label="收起或展开位姿手动调整"><span></span></button>
<em id="saveState">未保存</em>
</div>
<div id="poseManualBody" class="pose-section-body">
<div class="pose-actions">
<button id="resetRotationBtn" type="button">重置旋转</button>
<button id="resetTransformBtn" type="button">重置平移缩放</button>
<button id="resetFlipBtn" type="button">重置镜像</button>
</div>
<div class="flip-row">
<button data-flip="flipX">镜像 X</button>
<button data-flip="flipY">镜像 Y</button>
<button data-flip="flipZ">镜像 Z</button>
</div>
<div id="poseGrid" class="pose-grid"></div>
</div>
<div class="tool-section-title">
<span>位姿自动调整</span>
<button id="poseAutoToggle" class="section-toggle" type="button" title="收起/展开位姿自动调整" aria-label="收起或展开位姿自动调整"><span></span></button>
<em id="autoState">未运行</em>
</div>
<div id="poseAutoBody" class="pose-section-body auto-box compact-auto">
<button id="openAutoModalBtn" class="wide-action" type="button">恢复初始设置</button>
<div id="autoSettingsPanel" class="auto-settings-panel">
<div class="auto-options modal-options">
<label><input id="autoX" type="checkbox" checked /> X 方向</label>
<label><input id="autoY" type="checkbox" checked /> Y 方向</label>
<label><input id="autoZ" type="checkbox" checked /> Z 方向</label>
<label><input id="autoScale" type="checkbox" checked /> 缩放</label>
</div>
<div class="auto-config-grid">
<section>
<div class="modal-section-head">
<strong>STL 参考区域</strong>
<button id="suggestBoneBtn" type="button">建议选择</button>
</div>
<div id="autoBoneOptions" class="bone-option-list"></div>
</section>
<section>
<div class="modal-section-head">
<strong>采样切片</strong>
<button id="autoDefaultsBtn" type="button">默认数量</button>
</div>
<div class="modal-slider">
<input id="autoSampleSlices" type="range" min="3" max="60" value="9" />
<input id="autoSampleSlicesNumber" type="number" min="3" max="60" value="9" />
</div>
</section>
</div>
<div class="iteration-section-title">迭代参数</div>
<div class="auto-weight-grid">
<label>命中奖励<input id="autoBoneReward" type="number" step="0.1" value="1" /></label>
<label>区域惩罚<input id="autoOutsidePenalty" type="number" step="0.05" value="0.1" /></label>
<label>移动惩罚<input id="autoMovePenalty" type="number" step="0.01" value="0" /></label>
<label>缩放惩罚<input id="autoScalePenalty" type="number" step="0.01" value="0" /></label>
<label>迭代轮次<input id="autoIterations" type="number" min="1" max="100" value="30" /></label>
<label>每轮候选<input id="autoCandidates" type="number" min="6" max="96" value="36" /></label>
</div>
<div class="iteration-section-title">迭代操作区</div>
<div class="pose-actions modal-actions save-pose-actions">
<button id="savePoseBtn" type="button">保存位姿</button>
<button id="saveIterateBtn" type="button">保存并迭代</button>
<button id="restorePoseBtn" type="button">退回位姿</button>
</div>
<div id="autoPoseResult" class="auto-pose-result">
<span>旋转 X 0</span>
<span>旋转 Y 0</span>
<span>旋转 Z 0</span>
<span>平移 X 0</span>
<span>平移 Y 0</span>
<span>平移 Z 0</span>
<span>缩放 1</span>
</div>
</div>
<div id="autoResult" class="auto-result">选择 DICOM 序列和 STL 后可运行。</div>
<div id="autoProgress" class="auto-progress hidden">
<div class="auto-progress-head">
<b id="autoProgressTitle">准备迭代</b>
<span id="autoProgressText">0%</span>
</div>
<div class="auto-progress-track"><i id="autoProgressFill"></i></div>
<div id="autoProgressSteps" class="auto-progress-steps"></div>
<p id="autoProgressDetail">等待自动微调任务。</p>
</div>
</div>
<textarea id="notes" class="notes" rows="2" placeholder="配准备注"></textarea>
</section>
</aside>
<section class="viewer-panel fusion-panel panel">
<div class="viewer-head">
<div class="fusion-head-stack">
<div class="fusion-action-row">
<div class="segmented">
<button data-fusion-mode="fusion" class="active">融合结果</button>
<button data-fusion-mode="model">单独模型</button>
</div>
<div class="fusion-save-actions">
<button id="saveBtn" class="primary-btn">保存配准</button>
<button id="statusBtn" class="state-btn">标为已配准</button>
</div>
</div>
<div class="fusion-control-row">
<div class="segmented">
<button data-window="default" class="active">默认</button>
<button data-window="bone">骨窗</button>
<button data-window="soft">软组织</button>
<button data-window="contrast">高对比</button>
</div>
<div class="viewer-tools">
<button id="stretchXBtn" class="ghost-btn">X拉伸</button>
<button id="stretchYBtn" class="ghost-btn">Y拉伸</button>
<button id="stretchZBtn" class="ghost-btn">Z拉伸</button>
</div>
</div>
</div>
</div>
<div class="fusion-stage">
<div id="fusionViewport" aria-label="DICOM 与 STL 三维融合视图"></div>
<div id="fusionWebglError" class="fusion-webgl-error hidden">
<div>
<strong>三维融合视图无法启动</strong>
<p>请检查浏览器硬件加速、显卡驱动或远程桌面图形支持。二维 DICOM 与映射功能仍可继续使用。</p>
</div>
</div>
<div class="fusion-hud fusion-status" id="fusionStatus">等待选择 DICOM 与 STL</div>
<div class="fusion-hud fusion-meta" id="fusionMeta">DICOM - · STL -</div>
<button id="fitBtn" class="fusion-reset-btn" type="button" title="重置影像与模型融合视角位置">
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 12a9 9 0 0 1 15.2-6.5L21 8.3M21 4v4.3h-4.3M21 12a9 9 0 0 1-15.2 6.5L3 15.7M3 20v-4.3h4.3" />
</svg>
位置重置
</button>
<div class="fusion-axis-inset" title="当前视角下模型平移 XYZ 方向" aria-hidden="true">
<svg width="54" height="54" viewBox="0 0 54 54">
<defs>
<marker id="fusion-axis-arrow-x" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L5,2.5 L0,5 Z" fill="#ef4444" />
</marker>
<marker id="fusion-axis-arrow-y" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L5,2.5 L0,5 Z" fill="#22c55e" />
</marker>
<marker id="fusion-axis-arrow-z" markerWidth="5" markerHeight="5" refX="4.3" refY="2.5" orient="auto" markerUnits="strokeWidth">
<path d="M0,0 L5,2.5 L0,5 Z" fill="#38bdf8" />
</marker>
</defs>
<circle cx="25" cy="31" r="2.2" />
<g id="fusionAxisX">
<line id="fusionAxisXLine" x1="25" y1="31" x2="42" y2="31" class="axis-x" marker-end="url(#fusion-axis-arrow-x)" />
<text id="fusionAxisXText" x="46" y="28" class="axis-x-text" text-anchor="start">X</text>
</g>
<g id="fusionAxisY">
<line id="fusionAxisYLine" x1="25" y1="31" x2="15" y2="41" class="axis-y" marker-end="url(#fusion-axis-arrow-y)" />
<text id="fusionAxisYText" x="11" y="47" class="axis-y-text" text-anchor="end">Y</text>
</g>
<g id="fusionAxisZ">
<line id="fusionAxisZLine" x1="25" y1="31" x2="25" y2="14" class="axis-z" marker-end="url(#fusion-axis-arrow-z)" />
<text id="fusionAxisZText" x="29" y="11" class="axis-z-text" text-anchor="start">Z</text>
</g>
</svg>
</div>
<div id="fusionLoading" class="fusion-loading hidden">
<span>正在融合三维影像与模型</span>
<div><i id="fusionProgressFill"></i></div>
<em id="fusionProgressText">0%</em>
</div>
</div>
<div class="slice-range-panel">
<div class="slice-range-head">
<strong>DICOM 切片范围</strong>
<span id="sliceRangeLabel">0 - 0 / 0</span>
</div>
<div class="dual-range">
<input id="sliceRangeStart" type="range" min="0" max="0" value="0" />
<input id="sliceRangeEnd" type="range" min="0" max="0" value="0" />
</div>
<div class="slice-range-foot">
<span id="sliceStartLabel">起点 0</span>
<span>范围</span>
<span id="sliceEndLabel">终点 0</span>
</div>
</div>
</section>
<section class="viewer-panel dicom-panel panel">
<div class="viewer-head">
<div class="mapping-title-row">
<div class="segmented">
<button data-map-mode="result" class="active">分割结果</button>
<button data-map-mode="image">单独影像</button>
</div>
</div>
<div class="mapping-toolbar">
<div class="segmented mini-segmented">
<button data-map-window="default" class="active">默认</button>
<button data-map-window="bone">骨窗</button>
<button data-map-window="soft">软组织</button>
<button data-map-window="contrast">高对比</button>
</div>
<div class="viewer-tools">
<button id="mappingRotateLeftBtn" class="ghost-btn" type="button">左旋</button>
<button id="mappingRotateRightBtn" class="ghost-btn" type="button">右旋</button>
<button id="mappingResetBtn" class="ghost-btn" type="button">位置重置</button>
</div>
</div>
</div>
<div class="dicom-stage">
<div id="mappingViewport" class="mapping-viewport">
<div id="mappingLayer" class="mapping-layer">
<img id="dicomPreview" alt="DICOM 切片" />
<canvas id="mappingCanvas"></canvas>
</div>
</div>
<div class="mapping-slice-rail">
<input id="mappingSliceSlider" type="range" min="0" max="0" value="0" />
<span id="mappingSliceLabel">0 / 0</span>
</div>
<div class="dicom-overlay top-left" id="dicomInfoLeft">未选择序列</div>
<div class="dicom-overlay top-right" id="dicomInfoRight"></div>
<div class="dicom-overlay bottom-left" id="dicomInfoBottom">Img: -</div>
<div id="dicomLoading" class="fusion-loading hidden">
<span>正在加载分割结果</span>
<div><i id="dicomProgressFill"></i></div>
<em id="dicomProgressText">0%</em>
</div>
</div>
<div class="mapping-summary">
<div class="mapping-summary-head">
<span id="mappingSummaryTitle">当前分割构件</span>
<em id="mappingStats">0/0 构件 · 0 边 · 0 px</em>
</div>
<div id="mappingLegend" class="mapping-legend"></div>
</div>
</section>
</section>
</section>
</main>
<script type="module" src="/static/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,421 @@
import {
BufferAttribute,
BufferGeometry,
Color,
FileLoader,
Float32BufferAttribute,
Loader,
Vector3,
SRGBColorSpace
} from 'three';
/**
* A loader for the STL format, as created by Solidworks and other CAD programs.
*
* Supports both binary and ASCII encoded files. The loader returns a non-indexed buffer geometry.
*
* Limitations:
* - Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
* - There is perhaps some question as to how valid it is to always assume little-endian-ness.
* - ASCII decoding assumes file is UTF-8.
*
* ```js
* const loader = new STLLoader();
* const geometry = await loader.loadAsync( './models/stl/slotted_disk.stl' )
* scene.add( new THREE.Mesh( geometry ) );
* ```
* For binary STLs geometry might contain colors for vertices. To use it:
* ```js
* // use the same code to load STL as above
* if ( geometry.hasColors ) {
* material = new THREE.MeshPhongMaterial( { opacity: geometry.alpha, vertexColors: true } );
* }
* const mesh = new THREE.Mesh( geometry, material );
* ```
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
* Groups can be used to assign a different color by defining an array of materials with the same length of
* geometry.groups and passing it to the Mesh constructor:
*
* ```js
* const materials = [];
* const nGeometryGroups = geometry.groups.length;
*
* for ( let i = 0; i < nGeometryGroups; i ++ ) {
* const material = new THREE.MeshPhongMaterial( { color: colorMap[ i ], wireframe: false } );
* materials.push( material );
* }
*
* const mesh = new THREE.Mesh(geometry, materials);
* ```
*
* @augments Loader
* @three_import import { STLLoader } from 'three/addons/loaders/STLLoader.js';
*/
class STLLoader extends Loader {
/**
* Constructs a new STL loader.
*
* @param {LoadingManager} [manager] - The loading manager.
*/
constructor( manager ) {
super( manager );
}
/**
* Starts loading from the given URL and passes the loaded STL asset
* to the `onLoad()` callback.
*
* @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
* @param {function(BufferGeometry)} onLoad - Executed when the loading process has been finished.
* @param {onProgressCallback} onProgress - Executed while the loading is in progress.
* @param {onErrorCallback} onError - Executed when errors occur.
*/
load( url, onLoad, onProgress, onError ) {
const scope = this;
const loader = new FileLoader( this.manager );
loader.setPath( this.path );
loader.setResponseType( 'arraybuffer' );
loader.setRequestHeader( this.requestHeader );
loader.setWithCredentials( this.withCredentials );
loader.load( url, function ( text ) {
try {
onLoad( scope.parse( text ) );
} catch ( e ) {
if ( onError ) {
onError( e );
} else {
console.error( e );
}
scope.manager.itemError( url );
}
}, onProgress, onError );
}
/**
* Parses the given STL data and returns the resulting geometry.
*
* @param {ArrayBuffer} data - The raw STL data as an array buffer.
* @return {BufferGeometry} The parsed geometry.
*/
parse( data ) {
function isBinary( data ) {
const reader = new DataView( data );
const face_size = ( 32 / 8 * 3 ) + ( ( 32 / 8 * 3 ) * 3 ) + ( 16 / 8 );
const n_faces = reader.getUint32( 80, true );
const expect = 80 + ( 32 / 8 ) + ( n_faces * face_size );
if ( expect === reader.byteLength ) {
return true;
}
// An ASCII STL data must begin with 'solid ' as the first six bytes.
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
// plentiful. So, check the first 5 bytes for 'solid'.
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
// Search for "solid" to start anywhere after those prefixes.
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
const solid = [ 115, 111, 108, 105, 100 ];
for ( let off = 0; off < 5; off ++ ) {
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
if ( matchDataViewAt( solid, reader, off ) ) return false;
}
// Couldn't find "solid" text at the beginning; it is binary STL.
return true;
}
function matchDataViewAt( query, reader, offset ) {
// Check if each byte in query matches the corresponding byte from the current offset
for ( let i = 0, il = query.length; i < il; i ++ ) {
if ( query[ i ] !== reader.getUint8( offset + i ) ) return false;
}
return true;
}
function parseBinary( data ) {
const reader = new DataView( data );
const faces = reader.getUint32( 80, true );
let r, g, b, hasColors = false, colors;
let defaultR, defaultG, defaultB, alpha;
// process STL header
// check for default color in header ("COLOR=rgba" sequence).
for ( let index = 0; index < 80 - 10; index ++ ) {
if ( ( reader.getUint32( index, false ) == 0x434F4C4F /*COLO*/ ) &&
( reader.getUint8( index + 4 ) == 0x52 /*'R'*/ ) &&
( reader.getUint8( index + 5 ) == 0x3D /*'='*/ ) ) {
hasColors = true;
colors = new Float32Array( faces * 3 * 3 );
defaultR = reader.getUint8( index + 6 ) / 255;
defaultG = reader.getUint8( index + 7 ) / 255;
defaultB = reader.getUint8( index + 8 ) / 255;
alpha = reader.getUint8( index + 9 ) / 255;
}
}
const dataOffset = 84;
const faceLength = 12 * 4 + 2;
const geometry = new BufferGeometry();
const vertices = new Float32Array( faces * 3 * 3 );
const normals = new Float32Array( faces * 3 * 3 );
const color = new Color();
for ( let face = 0; face < faces; face ++ ) {
const start = dataOffset + face * faceLength;
const normalX = reader.getFloat32( start, true );
const normalY = reader.getFloat32( start + 4, true );
const normalZ = reader.getFloat32( start + 8, true );
if ( hasColors ) {
const packedColor = reader.getUint16( start + 48, true );
if ( ( packedColor & 0x8000 ) === 0 ) {
// facet has its own unique color
r = ( packedColor & 0x1F ) / 31;
g = ( ( packedColor >> 5 ) & 0x1F ) / 31;
b = ( ( packedColor >> 10 ) & 0x1F ) / 31;
} else {
r = defaultR;
g = defaultG;
b = defaultB;
}
}
for ( let i = 1; i <= 3; i ++ ) {
const vertexstart = start + i * 12;
const componentIdx = ( face * 3 * 3 ) + ( ( i - 1 ) * 3 );
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
normals[ componentIdx ] = normalX;
normals[ componentIdx + 1 ] = normalY;
normals[ componentIdx + 2 ] = normalZ;
if ( hasColors ) {
color.setRGB( r, g, b, SRGBColorSpace );
colors[ componentIdx ] = color.r;
colors[ componentIdx + 1 ] = color.g;
colors[ componentIdx + 2 ] = color.b;
}
}
}
geometry.setAttribute( 'position', new BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new BufferAttribute( normals, 3 ) );
if ( hasColors ) {
geometry.setAttribute( 'color', new BufferAttribute( colors, 3 ) );
geometry.hasColors = true;
geometry.alpha = alpha;
}
return geometry;
}
function parseASCII( data ) {
const geometry = new BufferGeometry();
const patternSolid = /solid([\s\S]*?)endsolid/g;
const patternFace = /facet([\s\S]*?)endfacet/g;
const patternName = /solid\s(.+)/;
let faceCounter = 0;
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
const vertices = [];
const normals = [];
const groupNames = [];
const normal = new Vector3();
let result;
let groupCount = 0;
let startVertex = 0;
let endVertex = 0;
while ( ( result = patternSolid.exec( data ) ) !== null ) {
startVertex = endVertex;
const solid = result[ 0 ];
const name = ( result = patternName.exec( solid ) ) !== null ? result[ 1 ] : '';
groupNames.push( name );
while ( ( result = patternFace.exec( solid ) ) !== null ) {
let vertexCountPerFace = 0;
let normalCountPerFace = 0;
const text = result[ 0 ];
while ( ( result = patternNormal.exec( text ) ) !== null ) {
normal.x = parseFloat( result[ 1 ] );
normal.y = parseFloat( result[ 2 ] );
normal.z = parseFloat( result[ 3 ] );
normalCountPerFace ++;
}
while ( ( result = patternVertex.exec( text ) ) !== null ) {
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
normals.push( normal.x, normal.y, normal.z );
vertexCountPerFace ++;
endVertex ++;
}
// every face have to own ONE valid normal
if ( normalCountPerFace !== 1 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
}
// each face have to own THREE valid vertices
if ( vertexCountPerFace !== 3 ) {
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
}
faceCounter ++;
}
const start = startVertex;
const count = endVertex - startVertex;
geometry.userData.groupNames = groupNames;
geometry.addGroup( start, count, groupCount );
groupCount ++;
}
geometry.setAttribute( 'position', new Float32BufferAttribute( vertices, 3 ) );
geometry.setAttribute( 'normal', new Float32BufferAttribute( normals, 3 ) );
return geometry;
}
function ensureString( buffer ) {
if ( typeof buffer !== 'string' ) {
return new TextDecoder().decode( buffer );
}
return buffer;
}
function ensureBinary( buffer ) {
if ( typeof buffer === 'string' ) {
const array_buffer = new Uint8Array( buffer.length );
for ( let i = 0; i < buffer.length; i ++ ) {
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
}
return array_buffer.buffer || array_buffer;
} else {
return buffer;
}
}
// start
const binData = ensureBinary( data );
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
}
}
export { STLLoader };

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,19 @@
# PACS DICOM 数据处理
本目录用于 PACS DICOM 批次数据的本地预处理和归档脚本管理。
## 目录约定
- `待处理_DICOM数据/`:本地待处理 DICOM 数据目录,通常为数据盘软链接,不提交到 Git。
- `已处理_DICOM数据/`:本地已处理 DICOM 数据目录,通常为数据盘软链接,不提交到 Git。
- `数据处理工作区/`:可提交的处理脚本、模板和说明。
- `数据处理结果区/`批次处理产生的清单、报告、SQL 输出等结果数据,不提交到 Git。
- `数据处理网页端/`:预留给后续网页端工具。
## 当前预处理逻辑
`数据处理工作区/preprocess_pacs_dicom_batch.py` 会读取 DICOM 元数据中的 `AccessionNumber` 作为真实 `ct_number`,用于修正 PACS 导出顶层目录名中的检查号。
脚本会生成检查级清单、文件级清单和改名计划。实际落地已处理数据时,优先使用硬链接,避免在同一数据盘上重复占用整份 DICOM 空间。
数据目录和处理结果包含 DICOM 或患者相关信息,已在 `.gitignore` 中排除。

View File

@@ -0,0 +1,567 @@
#!/usr/bin/env python3
"""Preprocess a PACS DICOM batch by normalizing study folders.
The top-level PACS export folder is assumed to contain one folder per study.
The exported folder name may contain the wrong CT number, so the canonical
ct_number is read from DICOM AccessionNumber (0008,0050).
"""
from __future__ import annotations
import argparse
import csv
import errno
import json
import os
import re
import shutil
import sys
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass
from datetime import datetime
from pathlib import Path
from typing import Iterable
import pydicom
DICOM_TAGS = [
"AccessionNumber",
"PatientName",
"PatientID",
"PatientBirthDate",
"PatientSex",
"StudyInstanceUID",
"StudyDate",
"StudyTime",
"StudyID",
"Modality",
"BodyPartExamined",
"ProtocolName",
"StudyDescription",
"SeriesInstanceUID",
"SOPInstanceUID",
]
CT_NUMBER_RE = re.compile(r"\bD?CT\d{6,}\b", re.IGNORECASE)
@dataclass
class StudyRow:
batch_name: str
source_folder_name: str
source_ct_number: str
source_patient_name: str
ct_number: str
target_folder_name: str
needs_ct_number_fix: bool
patient_name_dicom: str
patient_id: str
patient_birth_date: str
patient_sex: str
study_date: str
study_time: str
study_id: str
modality: str
body_part_examined: str
protocol_name: str
study_description: str
accession_numbers: str
raw_accession_numbers: str
study_instance_uids: str
series_count: int
dicom_file_count: int
total_file_count: int
total_bytes: int
source_path: str
processed_path: str
status: str
notes: str
def text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def most_common(counter: Counter[str]) -> str:
if not counter:
return ""
return counter.most_common(1)[0][0]
def sanitize_component(name: str) -> str:
cleaned = name.replace("/", "_").replace("\x00", "_").strip()
return cleaned or "UNKNOWN"
def parse_export_folder_name(name: str) -> tuple[str, str]:
if "-" not in name:
return name, ""
ct_number, patient_name = name.split("-", 1)
return ct_number.strip(), patient_name.strip()
def normalize_ct_text(value: str) -> str:
return value.strip().upper()
def extract_ct_numbers(*values: object) -> list[str]:
candidates: list[str] = []
for value in values:
for match in CT_NUMBER_RE.findall(text(value)):
normalized = normalize_ct_text(match)
if normalized not in candidates:
candidates.append(normalized)
return candidates
def canonical_ct_number(accession_number: str) -> str:
accession_number = normalize_ct_text(accession_number)
match = re.fullmatch(r"((?:D)?CT\d+)-\d+", accession_number)
if match:
return match.group(1)
return accession_number
def iter_files(folder: Path) -> Iterable[Path]:
for path in folder.rglob("*"):
if path.is_file():
yield path
def read_meta(path: Path) -> dict[str, str]:
ds = pydicom.dcmread(
str(path),
stop_before_pixels=True,
force=True,
specific_tags=DICOM_TAGS + ["SpecificCharacterSet"],
)
return {tag: text(getattr(ds, tag, "")) for tag in DICOM_TAGS}
def scan_study_folder(batch_name: str, folder: Path, processed_batch_root: Path) -> tuple[StudyRow, list[dict[str, str]]]:
source_ct_number, source_patient_name = parse_export_folder_name(folder.name)
files = sorted(iter_files(folder))
counters: dict[str, Counter[str]] = defaultdict(Counter)
file_rows: list[dict[str, str]] = []
dicom_file_count = 0
total_bytes = 0
errors = []
for path in files:
try:
size = path.stat().st_size
total_bytes += size
meta = read_meta(path)
dicom_file_count += 1
except Exception as exc: # noqa: BLE001 - keep scanning and report the file.
errors.append(f"{path}: {exc}")
continue
for key in DICOM_TAGS:
value = meta.get(key, "")
if value:
counters[key][value] += 1
file_rows.append(
{
"ct_number": "",
"source_folder_name": folder.name,
"source_relative_path": str(path.relative_to(folder)),
"processed_relative_path": "",
"sop_instance_uid": meta.get("SOPInstanceUID", ""),
"series_instance_uid": meta.get("SeriesInstanceUID", ""),
"study_instance_uid": meta.get("StudyInstanceUID", ""),
"bytes": str(size),
}
)
raw_accession_numbers = sorted(counters["AccessionNumber"])
accession_numbers = sorted({canonical_ct_number(value) for value in raw_accession_numbers if value})
fallback_ct_numbers = extract_ct_numbers(
source_ct_number,
folder.name,
*counters["StudyID"].keys(),
*counters["AccessionNumber"].keys(),
*counters["PatientID"].keys(),
)
study_instance_uids = sorted(counters["StudyInstanceUID"])
status = "ok"
notes: list[str] = []
if errors:
status = "error"
notes.append(f"metadata_read_errors={len(errors)}")
if raw_accession_numbers and raw_accession_numbers != accession_numbers:
notes.append("normalized_accession_suffixes")
if not accession_numbers:
if fallback_ct_numbers:
notes.append("missing_accession_number_used_folder_or_dicom_fallback")
ct_number = fallback_ct_numbers[0]
else:
status = "error"
notes.append("missing_accession_number")
ct_number = source_ct_number
elif len(accession_numbers) > 1:
status = "error"
notes.append("multiple_accession_numbers")
ct_number = most_common(counters["AccessionNumber"])
else:
ct_number = accession_numbers[0]
target_folder_name = sanitize_component(ct_number)
if source_patient_name:
target_folder_name = f"{target_folder_name}-{sanitize_component(source_patient_name)}"
processed_path = processed_batch_root / target_folder_name
needs_ct_number_fix = source_ct_number != ct_number
for row in file_rows:
row["ct_number"] = ct_number
row["processed_relative_path"] = str(Path(target_folder_name) / row["source_relative_path"])
row = StudyRow(
batch_name=batch_name,
source_folder_name=folder.name,
source_ct_number=source_ct_number,
source_patient_name=source_patient_name,
ct_number=ct_number,
target_folder_name=target_folder_name,
needs_ct_number_fix=needs_ct_number_fix,
patient_name_dicom=most_common(counters["PatientName"]),
patient_id=most_common(counters["PatientID"]),
patient_birth_date=most_common(counters["PatientBirthDate"]),
patient_sex=most_common(counters["PatientSex"]),
study_date=most_common(counters["StudyDate"]),
study_time=most_common(counters["StudyTime"]),
study_id=most_common(counters["StudyID"]),
modality=most_common(counters["Modality"]),
body_part_examined=most_common(counters["BodyPartExamined"]),
protocol_name=most_common(counters["ProtocolName"]),
study_description=most_common(counters["StudyDescription"]),
accession_numbers="|".join(accession_numbers),
raw_accession_numbers="|".join(raw_accession_numbers),
study_instance_uids="|".join(study_instance_uids),
series_count=len(counters["SeriesInstanceUID"]),
dicom_file_count=dicom_file_count,
total_file_count=len(files),
total_bytes=total_bytes,
source_path=str(folder),
processed_path=str(processed_path),
status=status,
notes=";".join(notes),
)
return row, file_rows
def write_csv(path: Path, rows: list[dict[str, object]], fieldnames: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(rows)
def hardlink_tree(source_folder: Path, target_folder: Path) -> tuple[int, int]:
linked = 0
already_ok = 0
for source in iter_files(source_folder):
rel = source.relative_to(source_folder)
target = target_folder / rel
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
try:
if os.path.samefile(source, target):
already_ok += 1
continue
except OSError:
pass
if target.stat().st_size == source.stat().st_size:
already_ok += 1
continue
else:
raise RuntimeError(f"target exists with different size: {target}")
try:
os.link(source, target)
except OSError as exc:
if exc.errno not in {errno.EXDEV, errno.EPERM, errno.EOPNOTSUPP}:
raise
shutil.copyfile(source, target)
try:
shutil.copystat(source, target)
except OSError as stat_exc:
if stat_exc.errno not in {errno.EPERM, errno.EOPNOTSUPP}:
raise
linked += 1
return linked, already_ok
def write_sync_sql(path: Path, manifest_path: Path) -> None:
sql = f"""BEGIN;
CREATE TABLE IF NOT EXISTS public.pacs_dicom_files (
ct_number text PRIMARY KEY,
batch_name text NOT NULL,
source_folder_name text,
source_ct_number text,
source_patient_name text,
target_folder_name text,
needs_ct_number_fix boolean NOT NULL DEFAULT false,
patient_name_dicom text,
patient_id text,
patient_birth_date text,
patient_sex text,
study_date text,
study_time text,
study_id text,
modality text,
body_part_examined text,
protocol_name text,
study_description text,
accession_numbers text,
raw_accession_numbers text,
study_instance_uids text,
series_count integer,
dicom_file_count integer,
total_file_count integer,
total_bytes bigint,
source_path text,
processed_path text,
status text,
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TEMP TABLE pacs_dicom_files_stage (
batch_name text,
source_folder_name text,
source_ct_number text,
source_patient_name text,
ct_number text,
target_folder_name text,
needs_ct_number_fix text,
patient_name_dicom text,
patient_id text,
patient_birth_date text,
patient_sex text,
study_date text,
study_time text,
study_id text,
modality text,
body_part_examined text,
protocol_name text,
study_description text,
accession_numbers text,
raw_accession_numbers text,
study_instance_uids text,
series_count text,
dicom_file_count text,
total_file_count text,
total_bytes text,
source_path text,
processed_path text,
status text,
notes text
) ON COMMIT DROP;
\\copy pacs_dicom_files_stage FROM '{manifest_path}' WITH (FORMAT csv, HEADER true, ENCODING 'UTF8')
INSERT INTO public.pacs_dicom_files (
ct_number, batch_name, source_folder_name, source_ct_number, source_patient_name,
target_folder_name, needs_ct_number_fix, patient_name_dicom, patient_id,
patient_birth_date, patient_sex, study_date, study_time, study_id, modality,
body_part_examined, protocol_name, study_description, accession_numbers,
raw_accession_numbers, study_instance_uids, series_count, dicom_file_count,
total_file_count, total_bytes, source_path, processed_path, status, notes, updated_at
)
SELECT
ct_number, batch_name, source_folder_name, source_ct_number, source_patient_name,
target_folder_name, COALESCE(NULLIF(needs_ct_number_fix, '')::boolean, false),
patient_name_dicom, patient_id, patient_birth_date, patient_sex, study_date,
study_time, study_id, modality, body_part_examined, protocol_name,
study_description, accession_numbers, raw_accession_numbers, study_instance_uids,
NULLIF(series_count, '')::integer,
NULLIF(dicom_file_count, '')::integer,
NULLIF(total_file_count, '')::integer,
NULLIF(total_bytes, '')::bigint,
source_path, processed_path, status, notes, now()
FROM pacs_dicom_files_stage
ON CONFLICT (ct_number) DO UPDATE SET
batch_name = EXCLUDED.batch_name,
source_folder_name = EXCLUDED.source_folder_name,
source_ct_number = EXCLUDED.source_ct_number,
source_patient_name = EXCLUDED.source_patient_name,
target_folder_name = EXCLUDED.target_folder_name,
needs_ct_number_fix = EXCLUDED.needs_ct_number_fix,
patient_name_dicom = EXCLUDED.patient_name_dicom,
patient_id = EXCLUDED.patient_id,
patient_birth_date = EXCLUDED.patient_birth_date,
patient_sex = EXCLUDED.patient_sex,
study_date = EXCLUDED.study_date,
study_time = EXCLUDED.study_time,
study_id = EXCLUDED.study_id,
modality = EXCLUDED.modality,
body_part_examined = EXCLUDED.body_part_examined,
protocol_name = EXCLUDED.protocol_name,
study_description = EXCLUDED.study_description,
accession_numbers = EXCLUDED.accession_numbers,
raw_accession_numbers = EXCLUDED.raw_accession_numbers,
study_instance_uids = EXCLUDED.study_instance_uids,
series_count = EXCLUDED.series_count,
dicom_file_count = EXCLUDED.dicom_file_count,
total_file_count = EXCLUDED.total_file_count,
total_bytes = EXCLUDED.total_bytes,
source_path = EXCLUDED.source_path,
processed_path = EXCLUDED.processed_path,
status = EXCLUDED.status,
notes = EXCLUDED.notes,
updated_at = now();
COMMIT;
"""
path.write_text(sql, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--source", type=Path, required=True)
parser.add_argument("--processed-root", type=Path, required=True)
parser.add_argument("--results-root", type=Path, required=True)
parser.add_argument("--batch-name", default="")
parser.add_argument("--apply", action="store_true", help="create processed hardlink tree")
parser.add_argument("--write-file-manifest", action="store_true")
parser.add_argument("--legacy-batch-layout", action="store_true", help="store processed studies under processed-root/batch-name")
args = parser.parse_args()
source_root = args.source.resolve()
batch_name = args.batch_name or source_root.name
processed_batch_root = (args.processed_root / batch_name if args.legacy_batch_layout else args.processed_root).absolute()
result_dir = (args.results_root / batch_name).resolve()
result_dir.mkdir(parents=True, exist_ok=True)
started_at = datetime.now().isoformat(timespec="seconds")
study_folders = sorted([p for p in source_root.iterdir() if p.is_dir()])
study_rows: list[StudyRow] = []
file_rows_all: list[dict[str, str]] = []
for index, folder in enumerate(study_folders, start=1):
print(f"[{index}/{len(study_folders)}] scan {folder.name}", flush=True)
study_row, file_rows = scan_study_folder(batch_name, folder, processed_batch_root)
study_rows.append(study_row)
if args.write_file_manifest:
file_rows_all.extend(file_rows)
ct_counts = Counter(row.ct_number for row in study_rows if row.ct_number)
target_counts = Counter(row.target_folder_name for row in study_rows if row.target_folder_name)
duplicate_ct_numbers = sorted([ct for ct, count in ct_counts.items() if count > 1])
duplicate_target_folders = sorted([name for name, count in target_counts.items() if count > 1])
for row in study_rows:
notes = [row.notes] if row.notes else []
if row.ct_number in duplicate_ct_numbers:
row.status = "error"
notes.append("duplicate_ct_number")
if row.target_folder_name in duplicate_target_folders:
row.status = "error"
notes.append("duplicate_target_folder")
row.notes = ";".join([note for note in notes if note])
study_dicts = [asdict(row) for row in study_rows]
study_fieldnames = list(asdict(study_rows[0]).keys()) if study_rows else list(StudyRow.__dataclass_fields__)
write_csv(result_dir / "study_manifest.csv", study_dicts, study_fieldnames)
write_sync_sql(result_dir / "sync_pacs_dicom_files.sql", (result_dir / "study_manifest.csv").resolve())
write_csv(
result_dir / "rename_plan.csv",
[
{
"source_folder_name": row.source_folder_name,
"source_ct_number": row.source_ct_number,
"ct_number": row.ct_number,
"target_folder_name": row.target_folder_name,
"needs_ct_number_fix": row.needs_ct_number_fix,
"status": row.status,
"notes": row.notes,
}
for row in study_rows
],
[
"source_folder_name",
"source_ct_number",
"ct_number",
"target_folder_name",
"needs_ct_number_fix",
"status",
"notes",
],
)
if args.write_file_manifest:
write_csv(
result_dir / "file_manifest.csv",
file_rows_all,
[
"ct_number",
"source_folder_name",
"source_relative_path",
"processed_relative_path",
"sop_instance_uid",
"series_instance_uid",
"study_instance_uid",
"bytes",
],
)
ok_rows = [row for row in study_rows if row.status == "ok"]
error_rows = [row for row in study_rows if row.status != "ok"]
summary = {
"batch_name": batch_name,
"source_root": str(source_root),
"processed_batch_root": str(processed_batch_root),
"result_dir": str(result_dir),
"started_at": started_at,
"finished_scan_at": datetime.now().isoformat(timespec="seconds"),
"apply": args.apply,
"source_study_folder_count": len(study_folders),
"ok_study_count": len(ok_rows),
"error_study_count": len(error_rows),
"needs_ct_number_fix_count": sum(1 for row in study_rows if row.needs_ct_number_fix),
"duplicate_ct_numbers": duplicate_ct_numbers,
"duplicate_target_folders": duplicate_target_folders,
"dicom_file_count": sum(row.dicom_file_count for row in study_rows),
"total_file_count": sum(row.total_file_count for row in study_rows),
"total_bytes": sum(row.total_bytes for row in study_rows),
"linked_file_count": 0,
"already_linked_file_count": 0,
}
if error_rows:
summary_path = result_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"ERROR: found {len(error_rows)} invalid study rows; see {summary_path}", file=sys.stderr)
return 2
if args.apply:
processed_batch_root.mkdir(parents=True, exist_ok=True)
linked_total = 0
already_total = 0
for index, row in enumerate(ok_rows, start=1):
print(f"[{index}/{len(ok_rows)}] link {row.source_folder_name} -> {row.target_folder_name}", flush=True)
linked, already_ok = hardlink_tree(Path(row.source_path), Path(row.processed_path))
linked_total += linked
already_total += already_ok
summary["linked_file_count"] = linked_total
summary["already_linked_file_count"] = already_total
summary["finished_apply_at"] = datetime.now().isoformat(timespec="seconds")
summary_path = result_dir / "summary.json"
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,7 @@
.env
__pycache__/
*.pyc
.pytest_cache/
data/
logs/
*.log

View File

@@ -0,0 +1,17 @@
PGHOST=192.168.3.3
PGPORT=5432
PGUSER=his_user
PGPASSWORD=change_me
PGDATABASE=pacs_db
PGTABLE=pacs_dicom_files
PG_ANNOTATION_TABLE=pacs_dicom_series_annotations
PACS_PROCESSED_ROOT=/dicom/已处理_DICOM数据
PACS_BATCH_NAME=2026_5_27_PACS下载数据
PACS_WEB_USER=admin
PACS_WEB_PASSWORD=123456
SMB_USERNAME=change_me
SMB_PASSWORD=change_me
KIMI_API_NAME=HIS_Check
KIMI_API_KEY=change_me
KIMI_API_URL=https://api.moonshot.cn/v1/chat/completions
KIMI_MODEL=kimi-latest

View File

@@ -0,0 +1,20 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ./app.py
COPY static ./static
EXPOSE 8107
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8107"]

View File

@@ -0,0 +1,53 @@
# DICOM 阅片分类系统
本目录提供本地 DICOM 检查浏览、序列查看和序列分类标注界面。
## Docker 启动
本网页端推荐用 Docker Compose 启动。DICOM 数据通过只读 volume 挂载到容器,不会打包进镜像。
```bash
cd /home/wkmgc/Desktop/PACS数据处理/PACS_DICOM处理/数据处理网页端
cp .env.example .env
# 编辑 .env填写本机 PostgreSQL 密码和可选 Kimi API Key
docker compose up -d --build
```
浏览器打开 `http://127.0.0.1:8107`
默认登录账号为 `admin`,密码为 `123456`。可以通过 `.env``docker-compose.yml` 中的 `PACS_WEB_USER``PACS_WEB_PASSWORD` 覆盖。
## 本机直接启动
先在当前 shell 设置 PostgreSQL 连接信息,再启动 FastAPI
```bash
export PGHOST=192.168.3.3
export PGPORT=5432
export PGUSER=his_user
export PGPASSWORD='请在本机填写'
export PGDATABASE=pacs_db
export PGTABLE=pacs_dicom_files
export PACS_PROCESSED_ROOT=/home/wkmgc/Desktop/Data_Disk_1/PACS数据/DICOM数据/已处理_DICOM数据
cd /home/wkmgc/Desktop/PACS数据处理/PACS_DICOM处理/数据处理网页端
uvicorn app:app --host 127.0.0.1 --port 8107
```
## 功能
- 左侧检查列表:来自 PostgreSQL `pacs_dicom_files` 与检查摘要缓存,支持按检查时间或未标注序列数升降序排序、按处理状态筛选;摘要每天 04:00 后台刷新,也会在进入单个检查时刷新该检查。
- 中间序列列表:从已处理 DICOM 目录扫描 DICOM 头信息,支持按时间或张数升降序排序;序列读取和缩略图未完成时显示加载状态。
- 右侧查看器:支持轴位原始、矢状位重建、冠状位重建,窗宽窗位、旋转、切片进度条。
- 影像操作:支持鼠标滚轮缩放、拖拽平移、按钮缩放和复位。
- 图像叠层:可显示/隐藏患者、检查、张数、窗宽窗位和缩放信息。
- DICOM 信息:查看患者、检查、序列、像素间距、切片间距等元数据。
- 序列列表:默认按拍摄时间升序排序,可切换时间升降序或张数升降序;略过/不采用序列固定在最下方,并在略过/不采用分组内继续按当前排序规则排列。
- 序列标注:选择略过/不采用、头颈部、胸部、上腹部、腹盆部;略过/不采用可与部位共存DICOM 标识含 CHEST/ABDOMEN 时会自动预选胸部/上腹部;切换界面前会提示保存;上腹部需继续选择动脉期、门静脉期、延迟期或无法判别,胸部需继续选择肺窗、纵隔窗或无法判别。
- AI 识别:配置并启用 Kimi 后,可把序列张数及轴位原始、矢状位重建、冠状位重建代表图像传入 AI自动给出部位、期相和备注建议。
- 设置管理员独立页面支持用户创建、角色权限展示、数据库状态、AI 开关、检查摘要立刻更新。
- 标注写入 PostgreSQL 表 `pacs_dicom_series_annotations`,人工标注和 AI 标注分别记录。
## 数据安全
网页端代码不提交 DICOM 原始数据、已处理数据、处理结果和 `.env`。数据库密码请只放在本机环境变量或本机 `.env` 中。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
name: pacs-dicom-web
services:
pacs-dicom-web:
build:
context: .
container_name: pacs-dicom-web
restart: unless-stopped
env_file:
- .env
environment:
PACS_PROCESSED_ROOT: /pacs-image-db/PACS数据/DICOM数据/已处理_DICOM数据
PACS_BATCH_NAME: 2026_5_27_PACS下载数据
PACS_WEB_USER: admin
PACS_WEB_PASSWORD: "123456"
PG_ANNOTATION_TABLE: pacs_dicom_series_annotations
ports:
- "8107:8107"
volumes:
- type: volume
source: pacs_image_db
target: /pacs-image-db
read_only: true
volume:
nocopy: true
volumes:
pacs_image_db:
driver: local
driver_opts:
type: cifs
device: //192.168.3.3/pacs影像数据库
o: username=${SMB_USERNAME},password=${SMB_PASSWORD},uid=1001,gid=1001,file_mode=0755,dir_mode=0755,iocharset=utf8,vers=3.0,soft,nounix,mapposix,noperm

View File

@@ -0,0 +1,5 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pydicom==2.4.5
numpy>=2.0
pillow>=11.0

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,220 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DICOM 阅片分类系统</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div id="loginOverlay" class="login-overlay">
<form id="loginForm" class="login-panel">
<div class="brand-mark">PACS</div>
<h1>DICOM 阅片分类系统</h1>
<label>
<span>账号</span>
<input id="username" autocomplete="username" value="admin" />
</label>
<label>
<span>密码</span>
<input id="password" type="password" autocomplete="current-password" value="123456" />
</label>
<button type="submit">登录</button>
<p id="loginError"></p>
</form>
</div>
<div class="app-shell">
<header class="topbar">
<div class="product">
<div class="logo-dot"></div>
<div>
<strong>DICOM 阅片分类系统</strong>
<span id="activeStudyLabel">未选择检查</span>
</div>
</div>
<div class="top-actions">
<button id="registrationLinkBtn" class="ghost-btn nav-btn" type="button">进入配准系统</button>
<button id="relationLinkBtn" class="ghost-btn nav-btn accent" type="button">数据库关联可视化</button>
<div id="dbStatus" class="status-pill offline">数据库</div>
<button id="settingsBtn" class="ghost-btn">设置</button>
<button id="logoutBtn" class="dark-btn">退出</button>
</div>
</header>
<main id="viewerPage" class="workspace">
<aside class="study-pane">
<div class="pane-head">
<h2>检查列表</h2>
<div class="sort-tools study-head-tools">
<button class="sort-btn active" data-study-sort="modified">修改时间 <span class="sort-arrow"></span></button>
<button class="sort-btn" data-study-sort="study_time">检查时间 <span class="sort-arrow"></span></button>
<span id="studyCount">0</span>
</div>
</div>
<div class="study-filter">
<button class="active" data-study-filter="all">全部</button>
<button data-study-filter="incomplete">待处理</button>
<button data-study-filter="complete">已完成</button>
</div>
<div class="study-part-filter">
<button class="active" data-study-part-filter="all">全部部位</button>
<button data-study-part-filter="head_neck">头颈部</button>
<button data-study-part-filter="chest">胸部</button>
<button data-study-part-filter="upper_abdomen">上腹部</button>
<button data-study-part-filter="abdomen_pelvis">腹盆部</button>
</div>
<input id="studySearch" class="search" placeholder="搜索检查号 / 姓名" />
<div id="studyList" class="study-list"></div>
</aside>
<section class="series-pane">
<div class="pane-head">
<h2>序列</h2>
<div class="sort-tools series-head-tools">
<button class="sort-btn active" data-series-sort="time">时间 <span class="sort-arrow"></span></button>
<button class="sort-btn" data-series-sort="count">张数 <span class="sort-arrow"></span></button>
<span id="seriesCount">0</span>
</div>
</div>
<div id="seriesGrid" class="series-grid"></div>
</section>
<section class="viewer-pane">
<div class="viewer-toolbar">
<div class="segmented" data-group="plane">
<button data-plane="axial" class="active">轴位原始</button>
<button data-plane="sagittal">矢状位重建</button>
<button data-plane="coronal">冠状位重建</button>
</div>
<div class="segmented" data-group="window">
<button data-window="default" class="active">默认</button>
<button data-window="bone">骨窗</button>
<button data-window="soft">软组织</button>
<button data-window="contrast">高对比</button>
</div>
<div class="tool-row">
<button id="rotateLeft">↶ 左转</button>
<button id="rotateRight">↷ 右转</button>
<button id="zoomOut"> 缩小</button>
<button id="zoomIn">+ 放大</button>
<button id="resetView">↺ 复位</button>
<button id="overlayToggle">隐藏信息</button>
<button id="infoBtn">DICOM 信息</button>
</div>
</div>
<div class="viewer-stage">
<div class="image-wrap">
<img id="dicomImage" alt="" />
<div id="imageOverlay" class="image-overlay">
<div class="ov ov-left-top"></div>
<div class="ov ov-right-top"></div>
<div class="ov ov-left-bottom"></div>
<div class="ov ov-right-bottom"></div>
</div>
<div id="imageEmpty" class="image-empty hidden"></div>
<div class="slice-rail">
<input id="sliceSlider" type="range" min="0" max="0" value="0" orient="vertical" />
<span id="sliceText">0 / 0</span>
</div>
</div>
<aside class="annotation-panel">
<div class="annotation-head">
<div>
<h2>部位标注</h2>
<span id="saveState">已保存</span>
</div>
<button id="aiClassify" class="ghost-btn ai-btn">AI 识别</button>
</div>
<div class="source-legend">
<span><i class="manual-dot"></i>人工</span>
<span><i class="ai-dot"></i>AI</span>
</div>
<div class="part-grid">
<label class="skip-option"><input type="checkbox" value="skip" />略过/不采用</label>
<label><input type="checkbox" value="head_neck" />头颈部</label>
<label><input type="checkbox" value="chest" />胸部</label>
<label><input type="checkbox" value="upper_abdomen" />上腹部</label>
<label><input type="checkbox" value="abdomen_pelvis" />腹盆部</label>
</div>
<div id="phaseBox" class="phase-box">
<span>上腹部期相</span>
<div class="phase-options">
<label><input name="phase" type="radio" value="plain" />平扫</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="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 class="annotation-actions">
<button id="saveAnnotation" class="primary-btn save-btn">保存标注</button>
<input id="annotationNotes" class="note-input" placeholder="备注" />
</div>
</aside>
</div>
</section>
</main>
<main id="settingsPage" class="settings-page hidden">
<section class="settings-page-head">
<div>
<h1>设置</h1>
<span>用户、权限、AI 与数据刷新</span>
</div>
<button id="backToViewer" class="dark-btn">返回阅片</button>
</section>
<div id="settingsContent" class="settings-content"></div>
</main>
</div>
<div id="infoModal" class="modal hidden">
<div class="modal-card">
<div class="modal-head">
<div>
<h2>DICOM 详细信息</h2>
<span>基础元数据、图像矩阵、空间距离</span>
</div>
<button class="icon-btn" data-close="infoModal">×</button>
</div>
<div id="infoContent" class="info-grid"></div>
</div>
</div>
<div id="exportModal" class="modal hidden">
<div class="modal-card export-modal-card">
<div class="modal-head">
<div>
<h2>选择导出序列</h2>
<span id="exportStudyLabel">未选择检查</span>
</div>
<button class="icon-btn" data-close="exportModal">×</button>
</div>
<div class="export-modal-tools">
<span id="exportHint">默认不选择略过/不采用序列</span>
<div>
<button id="selectExportableSeries" type="button">选择可用</button>
<button id="clearExportSeries" type="button">清空</button>
</div>
</div>
<div id="exportSeriesList" class="export-series-list"></div>
<div class="export-modal-foot">
<span id="exportModalCount">0 个序列</span>
<button id="downloadSelectedSeries" type="button">开始导出</button>
</div>
</div>
</div>
<script src="/static/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@
- 同一 CT 号有多个 STL 目录时,优先选择 STL 文件数更多的目录。
- 文件数相同,则选择病例名或 `*-STL` 目录中序号更大的目录。
- 规范化输出到 `UPP_STL处理/已处理STL数据/<CT号>/`
- 每个批次处理完成后,先核对待处理批次中的唯一 CT 号都已在 `已处理STL数据` 中生成对应 STL再删除 `待处理STL数据/<批次目录>`,避免后续重复扫描和重复入库。
- 数据库中 `upp_exam_assets.ct_number` 是主键,可用 list 的检查号查询对应 STL 路径;未来 CT 数据也可以继续挂在同一行。
运行:

View File

@@ -0,0 +1,11 @@
# UPP STL 处理记录
## 2026-05-29
- `UPP重建-肝胆外科-2026_5_22` 已核对完成:待处理批次含 853 个 STL 候选目录、726 个唯一 CT、21453 个 STL 文件;`UPP_STL处理/已处理STL数据` 中 726 个 CT 均有对应 STL缺失 0 个。
- 已删除 `UPP_STL处理/待处理STL数据/UPP重建-肝胆外科-2026_5_22`
## 后续约定
- 每次批次同步/整理完成后,先核对 `已处理STL数据` 覆盖待处理批次中的唯一 CT 号。
- 核对通过后删除 `待处理STL数据/<批次目录>`,避免重复处理。

View File

@@ -111,6 +111,16 @@
"contains": "肝胆外科",
"业务分类1": "PACS",
"业务分类2": "肝胆外科"
},
{
"contains": "泌尿外科",
"业务分类1": "PACS",
"业务分类2": "泌尿外科"
},
{
"contains": "胸外科",
"业务分类1": "PACS",
"业务分类2": "胸外科"
}
]
},

View File

@@ -176,11 +176,13 @@ def main() -> None:
config = load_config(Path(args.config))
root = Path(args.root)
suffix = args.result_suffix or config.get("result_suffix") or "-列表归档结果"
official_root = root / normalize_text(config.get("processed_input_root"))
search_root = official_root if official_root.exists() else root
archives: list[dict[str, Any]] = []
batch_summaries: list[dict[str, Any]] = []
raw_records: list[dict[str, Any]] = []
for result_dir in sorted(root.rglob(f"*{suffix}")):
for result_dir in sorted(search_root.rglob(f"*{suffix}")):
archive_path = result_dir / "图片表格_结构化.json"
if not archive_path.exists():
continue

View File

@@ -0,0 +1,16 @@
PGHOST=192.168.3.3
PGPORT=5432
PGUSER=his_user
PGPASSWORD=change_me
PGDATABASE=pacs_db
PACS_TABLE=pacs_dicom_files
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

View File

@@ -0,0 +1,20 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends postgresql-client \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py ./app.py
COPY static ./static
EXPOSE 8108
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8108"]

View File

@@ -0,0 +1,23 @@
# DICOM / UPP 数据库关联可视化
本网页端以 CT 号严格一致匹配,把 PostgreSQL 中的 DICOM 阅片分类结果与 UPP STL 重建资产放在同一视图中查看。UPP 列表字段作为 STL 资产的补充信息展示,不作为独立系统节点。
## 启动
```bash
cd /home/wkmgc/Desktop/PACS数据处理/数据库Web可视化
cp .env.example .env
# 编辑 .env填写本机 PostgreSQL 密码
docker compose up -d --build
```
浏览器打开 `http://127.0.0.1:8108`
## 数据来源
- `pacs_dicom_files`DICOM 检查索引。
- `pacs_dicom_study_summaries`DICOM 标注/完成状态汇总。
- `upp_exam_assets`UPP 列表与 STL 资产主索引,为 STL 资产提供患者、任务、算法模型、状态等补充信息。
- `upp_stl_files`UPP STL 文件、分割名称和分类聚合。
CT 号关联使用严格一致匹配,`DCT...``CT...` 会被视为两个不同 CT 号。

View File

@@ -0,0 +1,920 @@
from __future__ import annotations
import json
import hashlib
import os
import re
import secrets
import subprocess
import unicodedata
from itertools import product
from pathlib import Path
from typing import Any
from fastapi import Depends, FastAPI, Header, HTTPException, Query
from pydantic import BaseModel
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
try:
from pypinyin import Style, pinyin
except ImportError: # pragma: no cover - Docker image installs this dependency.
Style = None
pinyin = None
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
PGHOST = os.getenv("PGHOST", "192.168.3.3")
PGPORT = os.getenv("PGPORT", "5432")
PGUSER = os.getenv("PGUSER", "his_user")
PGDATABASE = os.getenv("PGDATABASE", "pacs_db")
PACS_TABLE = os.getenv("PACS_TABLE", "pacs_dicom_files")
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]")
LATIN_RE = re.compile(r"[a-z0-9]+")
def ident(name: str) -> str:
if not IDENT_RE.fullmatch(name):
raise RuntimeError(f"invalid SQL identifier: {name}")
return name
PACS_TABLE_SQL = ident(PACS_TABLE)
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")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
TOKENS: dict[str, dict[str, str]] = {}
class LoginPayload(BaseModel):
username: str
password: str
class UserPayload(BaseModel):
username: str
password: str = ""
role: str = "阅片员"
status: str = "启用"
class PasswordPayload(BaseModel):
password: str
def pg_env() -> dict[str, str]:
env = os.environ.copy()
env.update({"PGHOST": PGHOST, "PGPORT": PGPORT, "PGUSER": PGUSER, "PGDATABASE": PGDATABASE})
return env
def run_psql(sql: str, timeout: int = 20) -> subprocess.CompletedProcess[str]:
return subprocess.run(
["psql", "-X", "-q", "-t", "-A", "-v", "ON_ERROR_STOP=1", "-c", sql],
text=True,
capture_output=True,
timeout=timeout,
env=pg_env(),
)
def pg_scalar(sql: str, timeout: int = 20) -> str:
result = run_psql(sql, timeout=timeout)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or result.stdout.strip())
return result.stdout.strip()
def pg_json_rows(select_sql: str, timeout: int = 24) -> list[dict[str, Any]]:
payload = pg_scalar(f"SELECT COALESCE(json_agg(row_to_json(q)), '[]'::json)::text FROM ({select_sql}) q", timeout=timeout)
return json.loads(payload or "[]")
def sql_literal(value: Any) -> str:
return "'" + str(value).replace("'", "''") + "'"
def password_hash(password: str) -> str:
return hashlib.sha256(password.encode("utf-8")).hexdigest()
def normalize_ct(value: str) -> str:
return re.sub(r"\s+", "", str(value or "").upper())
def normalize_latin_name(value: Any) -> str:
text = unicodedata.normalize("NFKD", str(value or "").replace("ü", "u").replace("Ü", "U"))
text = "".join(char for char in text if not unicodedata.combining(char)).lower()
return "".join(LATIN_RE.findall(text))
def normalize_cjk_name(value: Any) -> str:
return "".join(CJK_RE.findall(str(value or "")))
def pinyin_name_candidates(value: Any) -> set[str]:
if pinyin is None or Style is None:
return set()
chinese = normalize_cjk_name(value)
if not chinese:
return set()
syllables = pinyin(chinese, style=Style.NORMAL, heteronym=True, errors="ignore")
choices: list[list[str]] = []
for item in syllables[:6]:
normalized = sorted({normalize_latin_name(part) for part in item if normalize_latin_name(part)})
if normalized:
choices.append(normalized[:4])
if not choices:
return set()
return {"".join(parts) for parts in product(*choices)}
def edit_distance_with_cap(left: str, right: str, cap: int = 1) -> int:
if abs(len(left) - len(right)) > cap:
return cap + 1
previous = list(range(len(right) + 1))
for i, left_char in enumerate(left, 1):
current = [i]
row_min = current[0]
for j, right_char in enumerate(right, 1):
cost = 0 if left_char == right_char else 1
value = min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost)
current.append(value)
row_min = min(row_min, value)
if row_min > cap:
return cap + 1
previous = current
return previous[-1]
def near_pinyin_match(left: str, candidates: set[str]) -> bool:
if len(left) < 6:
return False
return any(len(candidate) >= 6 and edit_distance_with_cap(left, candidate, cap=1) <= 1 for candidate in candidates)
def match_patient_names(pacs_name: Any, upp_name: Any) -> tuple[str, str]:
pacs_text = str(pacs_name or "")
upp_text = str(upp_name or "")
if not pacs_text or not upp_text:
return "", ""
pacs_latin = normalize_latin_name(pacs_text)
upp_latin = normalize_latin_name(upp_text)
pacs_cjk = normalize_cjk_name(pacs_text)
upp_cjk = normalize_cjk_name(upp_text)
if pacs_cjk and upp_cjk and pacs_cjk == upp_cjk:
return "same", "中文姓名一致"
if pacs_latin and upp_latin and pacs_latin == upp_latin:
return "same", "拼音/拉丁姓名一致"
if upp_latin and upp_latin in pinyin_name_candidates(pacs_text):
return "same", "DICOM中文姓名与UPP拼音匹配"
if upp_latin and near_pinyin_match(upp_latin, pinyin_name_candidates(pacs_text)):
return "same", "DICOM中文姓名与UPP拼音近似匹配"
if pacs_latin and pacs_latin in pinyin_name_candidates(upp_text):
return "same", "UPP中文姓名与DICOM拼音匹配"
if pacs_latin and near_pinyin_match(pacs_latin, pinyin_name_candidates(upp_text)):
return "same", "UPP中文姓名与DICOM拼音近似匹配"
return "different", f"DICOM患者{pacs_text}UPP患者{upp_text}"
def enrich_patient_match(row: dict[str, Any]) -> dict[str, Any]:
pacs_name = row.get("pacs_patient_name") or ""
upp_names = [row.get("upp_patient_name"), *(row.get("upp_patient_names") or [])]
upp_names = list(dict.fromkeys([str(name) for name in upp_names if name]))
if not pacs_name or not upp_names:
row["patient_name_match"] = ""
row["patient_name_match_note"] = ""
return row
mismatch_notes = []
for upp_name in upp_names:
status, note = match_patient_names(pacs_name, upp_name)
if status == "same":
row["patient_name_match"] = "same"
row["patient_name_match_note"] = note
row["upp_patient_name"] = row.get("upp_patient_name") or upp_name
return row
if note:
mismatch_notes.append(note)
row["patient_name_match"] = "different"
row["patient_name_match_note"] = "".join(mismatch_notes[:3])
return row
def db_available() -> tuple[bool, str]:
try:
pg_scalar("SELECT 1", timeout=4)
return True, "connected"
except Exception as exc: # noqa: BLE001
return False, str(exc)
def ensure_user_table() -> None:
run_psql(
f"""
CREATE TABLE IF NOT EXISTS public.{USER_TABLE_SQL} (
username text PRIMARY KEY,
password_hash text NOT NULL,
role text NOT NULL DEFAULT '阅片员',
status text NOT NULL DEFAULT '启用',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status)
VALUES ({sql_literal(WEB_ADMIN_USER)}, {sql_literal(password_hash(WEB_ADMIN_PASSWORD))}, '管理员', '启用')
ON CONFLICT (username) DO NOTHING;
""",
timeout=10,
)
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(
f"""
SELECT username, password_hash, role, status, created_at, updated_at
FROM public.{USER_TABLE_SQL}
WHERE username = {sql_literal(username)}
LIMIT 1
""",
timeout=10,
)
return rows[0] if rows else None
def current_user(authorization: str | None = Header(default=None)) -> dict[str, str]:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="unauthorized")
token = authorization.removeprefix("Bearer ").strip()
user = TOKENS.get(token)
if not user:
raise HTTPException(status_code=401, detail="unauthorized")
return user
def admin_user(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
if user.get("role") != "管理员":
raise HTTPException(status_code=403, detail="forbidden")
return user
@app.on_event("startup")
def startup() -> None:
try:
ensure_user_table()
ensure_registration_table()
except Exception:
pass
def relation_cte() -> str:
return f"""
WITH
p AS (
SELECT
p.*,
upper(p.ct_number) AS ct_key
FROM public.{PACS_TABLE_SQL} p
),
ps AS (
SELECT *
FROM public.{PACS_SUMMARY_TABLE_SQL}
),
a_label_raw AS (
SELECT
a.ct_number,
CASE part.value
WHEN 'head_neck' THEN '头颈部'
WHEN 'chest' THEN '胸部-' || CASE COALESCE(NULLIF(a.chest_window, ''), 'unknown')
WHEN 'lung' THEN '肺窗'
WHEN 'mediastinal' THEN '纵隔窗'
ELSE '无法判别'
END
WHEN 'upper_abdomen' THEN '上腹部-' || CASE COALESCE(NULLIF(a.upper_abdomen_phase, ''), 'unknown')
WHEN 'plain' THEN '平扫'
WHEN 'arterial' THEN '动脉期'
WHEN 'portal_venous' THEN '门脉期'
WHEN 'delayed' THEN '延迟期'
ELSE '无法判别'
END
WHEN 'abdomen_pelvis' THEN '腹盆部'
WHEN 'lower_abdomen' THEN '腹盆部'
WHEN 'pelvis' THEN '腹盆部'
ELSE NULL
END AS label,
CASE part.value
WHEN 'head_neck' THEN 1
WHEN 'chest' THEN 2
WHEN 'upper_abdomen' THEN 3
WHEN 'abdomen_pelvis' THEN 4
WHEN 'lower_abdomen' THEN 4
WHEN 'pelvis' THEN 4
ELSE 99
END AS sort_key
FROM public.{PACS_ANNOTATION_TABLE_SQL} a
CROSS JOIN LATERAL jsonb_array_elements_text(a.body_parts) AS part(value)
WHERE COALESCE(a.skipped, false) IS NOT TRUE
),
a_label_distinct AS (
SELECT ct_number, label, min(sort_key) AS sort_key
FROM a_label_raw
WHERE label IS NOT NULL
GROUP BY ct_number, label
),
a_labels AS (
SELECT
ct_number,
to_jsonb(array_agg(label ORDER BY sort_key, label)) AS dicom_annotation_labels
FROM a_label_distinct
GROUP BY ct_number
),
u_raw AS (
SELECT
u.*,
upper(u.ct_number) AS ct_key,
COALESCE(NULLIF(u.algorithm_model, ''), '未指定模型') AS algorithm_model_key
FROM public.{UPP_ASSET_TABLE_SQL} u
),
u_model_ranked AS (
SELECT
u_raw.*,
row_number() OVER (
PARTITION BY ct_key, algorithm_model_key
ORDER BY
COALESCE(stl_present, false) DESC,
COALESCE(stl_file_count, 0) DESC,
updated_at DESC NULLS LAST
) AS model_rank
FROM u_raw
),
u_model AS (
SELECT *
FROM u_model_ranked
WHERE model_rank = 1
),
u_duplicate AS (
SELECT
ct_key,
COALESCE(sum(model_count - 1), 0)::int AS duplicate_model_asset_count
FROM (
SELECT ct_key, algorithm_model_key, count(*)::int AS model_count
FROM u_raw
GROUP BY ct_key, algorithm_model_key
HAVING count(*) > 1
) duplicate_models
GROUP BY ct_key
),
u AS (
SELECT
u_model.ct_key,
(array_agg(u_model.ct_number ORDER BY u_model.updated_at DESC NULLS LAST) FILTER (WHERE u_model.ct_number IS NOT NULL))[1] AS ct_number,
bool_or(COALESCE(u_model.list_present, false)) AS list_present,
bool_or(COALESCE(u_model.stl_present, false)) AS stl_present,
(array_agg(u_model.patient_name ORDER BY u_model.updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(u_model.patient_name, '') IS NOT NULL))[1] AS patient_name,
to_jsonb(array_remove(array_agg(DISTINCT NULLIF(u_model.patient_name, '')), NULL)) AS patient_names,
(array_agg(u_model.patient_sex ORDER BY u_model.updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(u_model.patient_sex, '') IS NOT NULL))[1] AS patient_sex,
(array_agg(u_model.patient_age ORDER BY u_model.updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(u_model.patient_age, '') IS NOT NULL))[1] AS patient_age,
(array_agg(u_model.patient_id_masked ORDER BY u_model.updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(u_model.patient_id_masked, '') IS NOT NULL))[1] AS patient_id_masked,
max(u_model.exam_date) AS exam_date,
max(u_model.task_created_at) AS task_created_at,
(array_agg(u_model.exam_description ORDER BY u_model.updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(u_model.exam_description, '') IS NOT NULL))[1] AS exam_description,
(array_agg(u_model.exam_device ORDER BY u_model.updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(u_model.exam_device, '') IS NOT NULL))[1] AS exam_device,
array_to_string(array_remove(array_agg(DISTINCT NULLIF(u_model.algorithm_model, '')), NULL), '') AS algorithm_model,
to_jsonb(array_remove(array_agg(DISTINCT NULLIF(u_model.algorithm_model, '')), NULL)) AS algorithm_models,
array_to_string(array_remove(array_agg(DISTINCT NULLIF(u_model.upp_status, '')), NULL), '') AS upp_status,
COALESCE(sum(COALESCE(u_model.list_record_count, 0)), 0)::int AS list_record_count,
(array_agg(u_model.processed_stl_dir ORDER BY u_model.updated_at DESC NULLS LAST) FILTER (WHERE NULLIF(u_model.processed_stl_dir, '') IS NOT NULL))[1] AS processed_stl_dir,
COALESCE(sum(COALESCE(u_model.stl_file_count, 0)), 0)::int AS stl_file_count,
COALESCE(sum(COALESCE(u_model.stl_total_bytes, 0)), 0)::bigint AS stl_total_bytes,
max(u_model.updated_at) AS updated_at,
count(*)::int AS upp_asset_count,
COALESCE(max(u_duplicate.duplicate_model_asset_count), 0)::int AS duplicate_model_asset_count,
jsonb_agg(
jsonb_build_object(
'ct_number', u_model.ct_number,
'algorithm_model', u_model.algorithm_model,
'upp_status', u_model.upp_status,
'file_count', u_model.stl_file_count,
'total_bytes', u_model.stl_total_bytes,
'processed_stl_dir', u_model.processed_stl_dir,
'updated_at', u_model.updated_at
)
ORDER BY u_model.updated_at DESC NULLS LAST
) AS upp_assets
FROM u_model
LEFT JOIN u_duplicate ON u_duplicate.ct_key = u_model.ct_key
GROUP BY u_model.ct_key
),
s_raw AS (
SELECT
s.*,
upper(s.ct_number) AS ct_key
FROM public.{UPP_STL_TABLE_SQL} s
),
s_segment_names AS (
SELECT
ct_key,
COALESCE(jsonb_agg(DISTINCT name.value) FILTER (WHERE name.value IS NOT NULL), '[]'::jsonb) AS segment_names
FROM s_raw
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_names, '[]'::jsonb)) AS name(value)
GROUP BY ct_key
),
s_segment_families AS (
SELECT
ct_key,
COALESCE(jsonb_agg(DISTINCT family.value) FILTER (WHERE family.value IS NOT NULL), '[]'::jsonb) AS segment_families
FROM s_raw
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_families, '[]'::jsonb)) AS family(value)
GROUP BY ct_key
),
s_segment_categories AS (
SELECT
ct_key,
COALESCE(jsonb_agg(DISTINCT category.value) FILTER (WHERE category.value IS NOT NULL), '[]'::jsonb) AS segment_categories
FROM s_raw
CROSS JOIN LATERAL jsonb_array_elements_text(COALESCE(s_raw.segment_categories, '[]'::jsonb)) AS category(value)
GROUP BY ct_key
),
s_segments AS (
SELECT
s_raw.ct_key,
COALESCE(s_segment_names.segment_names, '[]'::jsonb) AS segment_names,
COALESCE(s_segment_families.segment_families, '[]'::jsonb) AS segment_families,
COALESCE(s_segment_categories.segment_categories, '[]'::jsonb) AS segment_categories
FROM (SELECT DISTINCT ct_key FROM s_raw) s_raw
LEFT JOIN s_segment_names ON s_segment_names.ct_key = s_raw.ct_key
LEFT JOIN s_segment_families ON s_segment_families.ct_key = s_raw.ct_key
LEFT JOIN s_segment_categories ON s_segment_categories.ct_key = s_raw.ct_key
),
s AS (
SELECT
s_raw.ct_key,
(array_agg(s_raw.ct_number ORDER BY s_raw.updated_at DESC NULLS LAST) FILTER (WHERE s_raw.ct_number IS NOT NULL))[1] AS ct_number,
COALESCE(sum(COALESCE(s_raw.file_count, 0)), 0)::int AS file_count,
COALESCE(sum(COALESCE(s_raw.total_bytes, 0)), 0)::bigint AS total_bytes,
COALESCE((array_agg(s_segments.segment_names) FILTER (WHERE s_segments.segment_names IS NOT NULL))[1], '[]'::jsonb) AS segment_names,
COALESCE((array_agg(s_segments.segment_families) FILTER (WHERE s_segments.segment_families IS NOT NULL))[1], '[]'::jsonb) AS segment_families,
COALESCE((array_agg(s_segments.segment_categories) FILTER (WHERE s_segments.segment_categories IS NOT NULL))[1], '[]'::jsonb) AS segment_categories,
max(s_raw.updated_at) AS updated_at,
count(*)::int AS stl_asset_count,
jsonb_agg(
jsonb_build_object(
'ct_number', s_raw.ct_number,
'file_count', s_raw.file_count,
'total_bytes', s_raw.total_bytes,
'updated_at', s_raw.updated_at,
'segment_categories', s_raw.segment_categories,
'segment_families', s_raw.segment_families
)
ORDER BY s_raw.updated_at DESC NULLS LAST
) AS stl_assets
FROM s_raw
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(*) FILTER (WHERE COALESCE(registration_status, 'unregistered') = 'registered')::int AS registration_count,
count(DISTINCT series_instance_uid) FILTER (
WHERE COALESCE(registration_status, 'unregistered') = 'registered'
AND series_instance_uid <> ''
)::int AS registered_series,
count(*) FILTER (
WHERE COALESCE(registration_status, 'unregistered') = 'registered'
AND COALESCE(locked, false)
)::int AS locked_count,
max(updated_at) FILTER (WHERE COALESCE(registration_status, 'unregistered') = 'registered') AS registration_updated_at
FROM public.{REGISTRATION_TABLE_SQL}
GROUP BY upper(ct_number)
),
keys AS (
SELECT ct_key FROM p
UNION
SELECT ct_key FROM u
UNION
SELECT ct_key FROM s
),
relation AS (
SELECT
k.ct_key,
p.ct_number AS pacs_ct_number,
COALESCE(s.ct_number, u.ct_number) AS stl_ct_number,
u.ct_number AS upp_ct_number,
s.ct_number AS stl_file_ct_number,
p.ct_number IS NOT NULL AS pacs_present,
u.ct_number IS NOT NULL OR s.ct_number IS NOT NULL AS stl_asset_present,
u.ct_number IS NOT NULL AS upp_present,
COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL AS stl_present,
CASE
WHEN p.ct_number IS NOT NULL AND (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'complete'
WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL THEN 'no_stl'
WHEN p.ct_number IS NOT NULL THEN 'pacs_only'
WHEN (COALESCE(u.stl_present, false) OR s.ct_number IS NOT NULL) THEN 'stl_only'
WHEN u.ct_number IS NOT NULL THEN 'list_only'
ELSE 'unknown'
END AS relation_status,
CASE
WHEN p.ct_number IS NOT NULL AND u.ct_number IS NOT NULL AND p.ct_number = u.ct_number THEN 'exact'
ELSE ''
END AS match_type,
p.batch_name,
p.source_patient_name,
p.patient_name_dicom,
COALESCE(NULLIF(p.source_patient_name, ''), NULLIF(p.patient_name_dicom, '')) AS pacs_patient_name,
p.patient_id,
p.patient_sex AS pacs_patient_sex,
p.study_date,
p.study_time,
p.study_description,
p.modality,
p.series_count AS pacs_series_count,
p.dicom_file_count,
p.processed_path,
p.updated_at AS pacs_updated_at,
ps.annotated_series,
ps.undetermined_series,
ps.completed,
ps.body_parts,
COALESCE(a_labels.dicom_annotation_labels, '[]'::jsonb) AS dicom_annotation_labels,
to_jsonb(array_remove(ARRAY[
CASE WHEN ps.body_parts ? 'head_neck' THEN '头颈部' END,
CASE WHEN ps.body_parts ? 'chest' THEN '胸部' END,
CASE WHEN ps.body_parts ? 'upper_abdomen' THEN '上腹部' END,
CASE WHEN ps.body_parts ? 'abdomen_pelvis' OR ps.body_parts ? 'lower_abdomen' OR ps.body_parts ? 'pelvis' THEN '腹盆部' END
], NULL)) AS dicom_body_parts,
u.patient_name AS upp_patient_name,
COALESCE(u.patient_names, '[]'::jsonb) AS upp_patient_names,
u.patient_sex AS upp_patient_sex,
u.patient_age,
u.patient_id_masked,
u.exam_date,
u.task_created_at,
u.exam_description,
u.exam_device,
u.algorithm_model,
COALESCE(u.algorithm_models, '[]'::jsonb) AS algorithm_models,
u.upp_status,
u.list_present,
u.list_record_count,
u.processed_stl_dir,
u.stl_file_count,
u.stl_total_bytes,
u.updated_at AS upp_updated_at,
COALESCE(u.upp_asset_count, 0) AS upp_asset_count,
COALESCE(u.duplicate_model_asset_count, 0) AS duplicate_model_asset_count,
COALESCE(u.upp_assets, '[]'::jsonb) AS upp_assets,
s.file_count AS stl_file_count_agg,
s.total_bytes AS stl_total_bytes_agg,
s.segment_names,
s.segment_families,
s.segment_categories,
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 ''
WHEN regexp_replace(COALESCE(NULLIF(p.source_patient_name, ''), p.patient_name_dicom), '\\s+', '', 'g')
= regexp_replace(u.patient_name, '\\s+', '', 'g') THEN 'same'
ELSE 'different'
END AS patient_name_match
FROM keys k
LEFT JOIN p ON p.ct_key = k.ct_key
LEFT JOIN ps ON ps.ct_number = p.ct_number
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
)
"""
def relation_where(q: str, status: str, algorithm_model: str, dicom_part: str) -> str:
clauses: list[str] = []
query = q.strip()
if query:
like = "%" + query.replace("%", "").replace("_", "") + "%"
literal = sql_literal(like)
clauses.append(
" OR ".join(
[
f"ct_key ILIKE {literal}",
f"pacs_ct_number ILIKE {literal}",
f"stl_ct_number ILIKE {literal}",
f"pacs_patient_name ILIKE {literal}",
f"upp_patient_name ILIKE {literal}",
f"patient_id ILIKE {literal}",
f"algorithm_model ILIKE {literal}",
f"upp_status ILIKE {literal}",
f"exam_description ILIKE {literal}",
f"study_description ILIKE {literal}",
f"COALESCE(dicom_body_parts::text, '') ILIKE {literal}",
]
).join(("(", ")"))
)
if status == "complete":
clauses.append("relation_status = 'complete'")
elif status == "no_stl":
clauses.append("relation_status = 'no_stl'")
elif status == "pacs_only":
clauses.append("relation_status = 'pacs_only'")
elif status == "stl_only":
clauses.append("relation_status = 'stl_only'")
elif status == "list_only":
clauses.append("relation_status = 'list_only'")
elif status == "pending_dicom":
clauses.append("pacs_present AND COALESCE(completed, false) IS NOT TRUE")
elif status == "undetermined":
clauses.append("COALESCE(undetermined_series, 0) > 0")
elif status != "all":
raise HTTPException(status_code=400, detail="invalid status filter")
if algorithm_model:
clauses.append(f"algorithm_model = {sql_literal(algorithm_model)}")
if dicom_part:
valid_parts = {"head_neck", "chest", "upper_abdomen", "abdomen_pelvis"}
if dicom_part not in valid_parts:
raise HTTPException(status_code=400, detail="invalid DICOM part filter")
if dicom_part == "abdomen_pelvis":
clauses.append("(body_parts ? 'abdomen_pelvis' OR body_parts ? 'lower_abdomen' OR body_parts ? 'pelvis')")
else:
clauses.append(f"body_parts ? {sql_literal(dicom_part)}")
return "WHERE " + " AND ".join(clauses) if clauses else ""
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/health")
def health() -> str:
return "ok"
@app.post("/api/auth/login")
def login(payload: LoginPayload) -> dict[str, str]:
username = payload.username.strip()
try:
user = fetch_user(username)
except Exception:
user = None
if not user and username == WEB_ADMIN_USER:
user = {"username": WEB_ADMIN_USER, "password_hash": password_hash(WEB_ADMIN_PASSWORD), "role": "管理员", "status": "启用"}
if not user or user.get("status") != "启用" or user.get("password_hash") != password_hash(payload.password):
raise HTTPException(status_code=401, detail="用户名或密码错误")
token = secrets.token_urlsafe(32)
TOKENS[token] = {"username": user["username"], "role": user.get("role", "阅片员")}
return {"token": token, "username": user["username"], "role": user.get("role", "阅片员")}
@app.get("/api/auth/me")
def me(user: dict[str, str] = Depends(current_user)) -> dict[str, str]:
return user
@app.get("/api/settings")
def settings(user: dict[str, str] = Depends(admin_user)) -> dict[str, Any]:
rows = pg_json_rows(
f"""
SELECT username, role, status, created_at, updated_at
FROM public.{USER_TABLE_SQL}
ORDER BY username
""",
timeout=10,
)
return {
"user": user,
"viewer_url": PACS_VIEWER_URL,
"registration_url": REGISTRATION_URL,
"users": rows,
"database": {"host": PGHOST, "port": PGPORT, "database": PGDATABASE},
"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,
},
}
@app.post("/api/settings/users")
def save_user(payload: UserPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]:
username = payload.username.strip()
if not username:
raise HTTPException(status_code=400, detail="账号不能为空")
role = payload.role if payload.role in {"管理员", "阅片员", "访客"} else "阅片员"
status = payload.status if payload.status in {"启用", "停用"} else "启用"
existing = fetch_user(username)
if existing and not payload.password:
run_psql(
f"""
UPDATE public.{USER_TABLE_SQL}
SET role = {sql_literal(role)}, status = {sql_literal(status)}, updated_at = now()
WHERE username = {sql_literal(username)}
""",
timeout=10,
)
else:
if not payload.password:
raise HTTPException(status_code=400, detail="新账号需要初始密码")
run_psql(
f"""
INSERT INTO public.{USER_TABLE_SQL} (username, password_hash, role, status, updated_at)
VALUES ({sql_literal(username)}, {sql_literal(password_hash(payload.password))}, {sql_literal(role)}, {sql_literal(status)}, now())
ON CONFLICT (username) DO UPDATE
SET password_hash = EXCLUDED.password_hash,
role = EXCLUDED.role,
status = EXCLUDED.status,
updated_at = now()
""",
timeout=10,
)
return {"status": "ok", "updated_by": user["username"]}
@app.put("/api/settings/users/{username}/password")
def change_password(username: str, payload: PasswordPayload, user: dict[str, str] = Depends(admin_user)) -> dict[str, str]:
if not payload.password:
raise HTTPException(status_code=400, detail="密码不能为空")
run_psql(
f"""
UPDATE public.{USER_TABLE_SQL}
SET password_hash = {sql_literal(password_hash(payload.password))}, updated_at = now()
WHERE username = {sql_literal(username)}
""",
timeout=10,
)
return {"status": "ok", "updated_by": user["username"]}
@app.get("/api/status")
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()}
SELECT
count(*)::int AS total_ct,
count(*) FILTER (WHERE pacs_present)::int AS pacs_count,
count(*) FILTER (WHERE stl_asset_present)::int AS stl_asset_count,
count(*) FILTER (WHERE list_present)::int AS list_count,
count(*) FILTER (WHERE stl_present)::int AS stl_count,
count(*) FILTER (WHERE relation_status = 'complete')::int AS complete_count,
count(*) FILTER (WHERE relation_status = 'no_stl')::int AS no_stl_count,
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
""",
timeout=24,
)
counts = rows[0] if rows else {}
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,
}
@app.get("/api/relations")
def relations(
q: str = "",
status: str = "all",
algorithm_model: str = "",
dicom_part: str = "",
limit: int = Query(default=300, ge=1, le=1000),
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"""
{relation_cte()}
SELECT
ct_key, pacs_ct_number, stl_ct_number,
pacs_present, stl_asset_present, list_present, stl_present, relation_status, match_type,
pacs_patient_name, upp_patient_name, upp_patient_names, patient_name_match, patient_id, patient_id_masked,
study_date, study_time, exam_date, task_created_at,
study_description, exam_description, algorithm_model, upp_status,
pacs_series_count, dicom_file_count, annotated_series, undetermined_series, completed,
list_present, list_record_count, stl_file_count, stl_file_count_agg,
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}
ORDER BY
pacs_present DESC,
relation_status,
COALESCE(study_date, '') DESC,
COALESCE(study_time, '') DESC,
ct_key
LIMIT {int(limit)}
OFFSET {int(offset)}
""",
timeout=24,
)
return [enrich_patient_match(row) for row in rows]
@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")
rows = pg_json_rows(
f"""
{relation_cte()}
SELECT *
FROM relation
WHERE ct_key = {sql_literal(ct_key)}
LIMIT 1
""",
timeout=24,
)
if not rows:
raise HTTPException(status_code=404, detail="CT number not found")
return enrich_patient_match(rows[0])

View File

@@ -0,0 +1,12 @@
name: pacs-database-visualizer
services:
pacs-database-visualizer:
build:
context: .
container_name: pacs-database-visualizer
restart: unless-stopped
env_file:
- .env
ports:
- "8108:8108"

View File

@@ -0,0 +1,3 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
pypinyin==0.54.0

View File

@@ -0,0 +1,688 @@
const app = {
token: localStorage.getItem("pacs_relation_token") || "",
user: null,
rows: [],
active: null,
filter: "all",
search: "",
algorithmModel: "",
dicomPart: "",
viewerUrl: "http://127.0.0.1:8107",
registrationUrl: "http://127.0.0.1:8109",
searchTimer: null,
offset: 0,
limit: 20,
hasMore: true,
loading: false,
};
const $ = (id) => document.getElementById(id);
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
async function request(path, options = {}) {
const headers = { ...(options.headers || {}) };
if (app.token) headers.Authorization = `Bearer ${app.token}`;
if (options.body) headers["Content-Type"] = "application/json";
const res = await fetch(path, { ...options, headers });
if (res.status === 401) {
showLogin(true);
throw new Error("unauthorized");
}
if (!res.ok) {
let detail = res.statusText;
try {
const data = await res.json();
detail = data.detail || detail;
} catch (_) {
detail = await res.text();
}
throw new Error(detail);
}
return res;
}
async function json(path, options = {}) {
const res = await request(path, options);
return res.json();
}
function showLogin(show) {
$("loginOverlay").classList.toggle("hidden", !show);
}
function setLoading(show) {
app.loading = show;
$("topLoading").classList.toggle("hidden", !show);
$("listLoading").classList.toggle("hidden", !show || !app.rows.length);
}
function showViewer() {
$("viewerPage").classList.remove("hidden");
$("settingsPage").classList.add("hidden");
}
async function showSettings() {
if (!isAdmin()) return;
$("viewerPage").classList.add("hidden");
$("settingsPage").classList.remove("hidden");
await renderSettingsPage();
}
function isAdmin() {
return app.user?.role === "管理员";
}
function applyAuthUi() {
$("userBadge").textContent = app.user ? `${app.user.username} · ${app.user.role}` : "未登录";
$("settingsBtn").classList.toggle("hidden", !isAdmin());
}
function fmtDate(value) {
const text = String(value || "");
if (!text) return "";
if (/^\d{8}$/.test(text)) return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
return text.replace("T", " ").slice(0, 19);
}
function fmtTime(value) {
const digits = String(value || "").replace(/\D/g, "").slice(0, 6);
if (digits.length < 6) return value || "";
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}:${digits.slice(4, 6)}`;
}
function fmtBytes(value) {
const size = Number(value || 0);
if (!size) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
const index = Math.min(units.length - 1, Math.floor(Math.log(size) / Math.log(1024)));
return `${(size / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
}
function asList(value) {
return Array.isArray(value) ? value : [];
}
function uniq(list) {
return Array.from(new Set(list.filter(Boolean)));
}
function statusLabel(value) {
return {
complete: "DICOM + STL",
no_stl: "DICOM + UPP",
pacs_only: "仅 DICOM",
stl_only: "仅 STL",
list_only: "仅 UPP",
unknown: "未知",
}[value] || value || "未知";
}
function partLabel(value) {
return {
head_neck: "头颈部",
chest: "胸部",
upper_abdomen: "上腹部",
abdomen_pelvis: "腹盆部",
}[value] || value;
}
function dicomParts(row) {
const labels = asList(row.dicom_body_parts);
if (labels.length) return labels;
return asList(row.body_parts).map(partLabel);
}
function annotationLabels(row) {
const labels = asList(row.dicom_annotation_labels);
return labels.length ? labels : dicomParts(row);
}
function patientNameMismatch(row) {
return row.pacs_present && row.stl_asset_present && row.patient_name_match === "different";
}
function stlFileCount(row) {
return Number(row.stl_file_count || row.stl_file_count_agg || 0);
}
function stlAssetCount(row) {
return Number(row.upp_asset_count || row.stl_asset_count || 0);
}
function assetSummary(row) {
const assets = asList(row.upp_assets).length ? asList(row.upp_assets) : asList(row.stl_assets);
return assets
.slice(0, 6)
.map((asset) => {
const model = asset.algorithm_model || "STL";
const count = Number(asset.file_count || 0);
return `${model}${count ? ` ${count}` : ""}`;
})
.join("");
}
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} 已连接`;
pill.className = "status-pill online";
} else {
pill.textContent = "数据库异常";
pill.className = "status-pill offline";
}
}
function renderMetrics(counts = {}) {
const items = [
["完整关联", counts.complete_count],
["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>`)
.join("");
}
function cardTitle(row) {
return [
`CT号${row.ct_key}`,
`DICOM${row.pacs_ct_number || "无"}`,
`STL${row.stl_ct_number || "无"}`,
`状态:${statusLabel(row.relation_status)}`,
`DICOM患者${row.pacs_patient_name || "无"}`,
`UPP患者${row.upp_patient_name || "无"}`,
`重建模型:${row.algorithm_model || "无"}`,
`部位标注:${annotationLabels(row).join("、") || "无"}`,
`配准:${Number(row.registered_series || 0)} 序列,${Number(row.locked_count || 0)} 锁定`,
].join("\n");
}
function renderPartTags(parts) {
if (!parts.length) return `<span class="subtle">无部位标注</span>`;
return parts.map((part) => `<em title="${escapeHtml(part)}">${escapeHtml(part)}</em>`).join("");
}
function renderList() {
const list = $("relationList");
$("resultCount").textContent = String(app.rows.length);
if (!app.rows.length) {
list.innerHTML = `<p class="empty">${app.loading ? "正在加载..." : "没有匹配记录"}</p>`;
if (!app.loading) clearDetail();
return;
}
list.innerHTML = "";
for (const row of app.rows) {
const card = document.createElement("button");
card.className = "relation-card";
card.classList.toggle("active", app.active?.ct_key === row.ct_key);
card.title = cardTitle(row);
const stlCount = stlFileCount(row);
const assetCount = stlAssetCount(row);
const labels = dicomParts(row);
card.innerHTML = `
<div class="card-topline">
<div class="card-main">
<strong>${escapeHtml(row.ct_key)}</strong>
<span>${escapeHtml(row.pacs_patient_name || row.upp_patient_name || "无姓名")} · ${escapeHtml(row.patient_id || row.patient_id_masked || "无ID")}</span>
<small>DICOM ${Number(row.pacs_series_count || 0)} 序列${stlCount ? ` · STL ${stlCount}` : ""}${assetCount > 1 ? ` · ${assetCount}` : ""}</small>
</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>`
: ""
}
${
patientNameMismatch(row)
? `<small class="name-check-warning" title="${escapeHtml(row.patient_name_match_note || "DICOM 与 UPP 患者姓名不一致")}">姓名需核对</small>`
: ""
}
</div>
</div>
<div class="card-tag-rail">
${row.algorithm_model ? `<em class="model-tag" title="${escapeHtml(row.algorithm_model)}">${escapeHtml(row.algorithm_model)}</em>` : ""}
${renderPartTags(labels)}
</div>
`;
card.onclick = () => selectRelation(row.ct_key);
list.appendChild(card);
}
}
function renderDl(id, entries) {
const html = entries
.map(([key, value]) => {
const text = value || value === 0 ? String(value) : "-";
return `<dt>${escapeHtml(key)}</dt><dd title="${escapeHtml(text)}">${escapeHtml(text)}</dd>`;
})
.join("");
$(id).innerHTML = html;
}
function markNode(id, state, strong, meta) {
const node = $(id);
node.classList.remove("ok", "warn", "missing");
node.classList.add(state);
node.querySelector("strong").textContent = strong;
node.querySelector("em").textContent = meta;
}
function viewerBaseUrl() {
try {
const url = new URL(app.viewerUrl || `${location.protocol}//${location.hostname}:8107`, 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}:8107`;
}
}
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() {
app.active = null;
$("activeCt").textContent = "未选择 CT";
$("activeSubtitle").textContent = "从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系";
$("relationBadge").className = "relation-badge idle";
$("relationBadge").textContent = "等待选择";
markNode("nodePacs", "missing", "未选择", "检查与序列");
$("nodeCtValue").textContent = "-";
markNode("nodeStl", "missing", "未选择", "含UPP列表信息");
renderDl("pacsDetails", []);
renderDl("stlDetails", []);
updateOpenDicomButton();
}
function renderDetail(row) {
app.active = row;
$("activeCt").textContent = row.ct_key || "未选择 CT";
$("activeSubtitle").textContent = `${row.pacs_ct_number || "无 DICOM"}${row.stl_ct_number || "无 STL"}`;
const badge = $("relationBadge");
badge.className = `relation-badge ${row.relation_status || "idle"}`;
badge.textContent = statusLabel(row.relation_status);
const labels = annotationLabels(row);
$("nodeCtValue").textContent = row.ct_key || "-";
markNode(
"nodePacs",
row.pacs_present ? "ok" : "missing",
row.pacs_ct_number || "缺失",
`${Number(row.pacs_series_count || 0)} 序列 · ${labels.join("、") || "未标注"}`,
);
markNode(
"nodeStl",
row.stl_asset_present ? (row.stl_present ? "ok" : "warn") : "missing",
row.stl_ct_number || "缺失",
`${row.algorithm_model || "无重建模型"} · ${stlFileCount(row)} STL${stlAssetCount(row) > 1 ? ` · ${stlAssetCount(row)} 组资产` : ""}`,
);
renderDl("pacsDetails", [
["CT号", row.pacs_ct_number],
["患者", row.pacs_patient_name],
["患者ID", row.patient_id],
["检查时间", `${fmtDate(row.study_date)} ${fmtTime(row.study_time)}`.trim()],
["描述", row.study_description],
["序列/文件", `${Number(row.pacs_series_count || 0)} / ${Number(row.dicom_file_count || 0)}`],
["标注", `${Number(row.annotated_series || 0)} 已标注`],
["完成", row.completed ? "已完成" : "待处理"],
["部位标注", labels.join("、")],
["配准状态", row.registration_count ? `${Number(row.registered_series || 0)} 个序列已配准 / ${Number(row.locked_count || 0)} 条锁定` : "未配准"],
]);
renderDl("stlDetails", [
["CT号", row.stl_ct_number],
["患者", row.upp_patient_name],
["STL状态", row.stl_present ? "存在" : "缺失"],
["重建模型", row.algorithm_model],
["STL资产", `${stlAssetCount(row)}`],
["同模型重复", Number(row.duplicate_model_asset_count || 0) ? `${Number(row.duplicate_model_asset_count)} 组已折叠` : ""],
["资产明细", assetSummary(row)],
["UPP记录", row.upp_asset_count ? `${Number(row.upp_asset_count)}` : ""],
["UPP状态", row.upp_status],
["姓名核对", row.patient_name_match === "different" ? row.patient_name_match_note : row.patient_name_match === "same" ? row.patient_name_match_note || "匹配" : ""],
["年龄/性别", [row.patient_age, row.upp_patient_sex].filter(Boolean).join(" / ")],
["检查时间", fmtDate(row.exam_date)],
["任务时间", fmtDate(row.task_created_at)],
["文件数", stlFileCount(row)],
["总大小", fmtBytes(row.stl_total_bytes || row.stl_total_bytes_agg)],
["目录", 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();
renderList();
}
async function selectRelation(ctKey) {
const row = await json(`/api/relations/${encodeURIComponent(ctKey)}`);
renderDetail(row);
}
async function loadStatus() {
const data = await json("/api/status");
setDbStatus(data);
renderMetrics(data.counts || {});
}
async function loadRelations(reset = true) {
if (app.loading) return;
setLoading(true);
if (reset) {
app.offset = 0;
app.hasMore = true;
app.rows = [];
app.active = null;
renderList();
}
if (!app.hasMore) {
setLoading(false);
return;
}
const params = new URLSearchParams();
params.set("status", app.filter);
params.set("limit", String(app.limit));
params.set("offset", String(app.offset));
if (app.search) params.set("q", app.search);
if (app.algorithmModel) params.set("algorithm_model", app.algorithmModel);
if (app.dicomPart) params.set("dicom_part", app.dicomPart);
try {
const rows = await json(`/api/relations?${params.toString()}`);
app.rows = reset ? rows : [...app.rows, ...rows.filter((row) => !app.rows.some((item) => item.ct_key === row.ct_key))];
app.offset += rows.length;
app.hasMore = rows.length === app.limit;
renderList();
if (reset && app.rows.length) await selectRelation(app.rows[0].ct_key);
if (reset && !app.rows.length) clearDetail();
} finally {
setLoading(false);
}
}
async function refreshAll() {
try {
await loadStatus();
await loadRelations(true);
} catch (err) {
$("dbStatus").textContent = err.message;
$("dbStatus").className = "status-pill offline";
setLoading(false);
}
}
async function login(event) {
event.preventDefault();
$("loginError").textContent = "";
try {
const data = await json("/api/auth/login", {
method: "POST",
body: JSON.stringify({ username: $("username").value.trim(), password: $("password").value }),
});
app.token = data.token;
app.user = { username: data.username, role: data.role };
localStorage.setItem("pacs_relation_token", app.token);
applyAuthUi();
showLogin(false);
showViewer();
await refreshAll();
} catch (_) {
$("loginError").textContent = "登录失败";
}
}
function logout() {
app.token = "";
app.user = null;
app.rows = [];
app.active = null;
localStorage.removeItem("pacs_relation_token");
applyAuthUi();
showViewer();
clearDetail();
showLogin(true);
}
async function loadCurrentUser() {
if (!app.token) {
showLogin(true);
return false;
}
try {
app.user = await json("/api/auth/me");
applyAuthUi();
showLogin(false);
return true;
} catch (_) {
app.token = "";
localStorage.removeItem("pacs_relation_token");
applyAuthUi();
showLogin(true);
return false;
}
}
function settingsTable(users) {
if (!users?.length) return `<p class="empty">暂无账号</p>`;
return `
<table class="settings-table">
<thead><tr><th>账号</th><th>角色</th><th>状态</th><th>更新时间</th><th>重置密码</th></tr></thead>
<tbody>
${users
.map(
(user) => `
<tr>
<td>${escapeHtml(user.username)}</td>
<td>${escapeHtml(user.role)}</td>
<td>${escapeHtml(user.status)}</td>
<td>${escapeHtml(fmtDate(user.updated_at))}</td>
<td>
<div class="password-reset">
<input data-password-user="${escapeHtml(user.username)}" placeholder="新密码" type="password" />
<button data-reset-user="${escapeHtml(user.username)}" type="button">更新</button>
</div>
</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
async function renderSettingsPage() {
const data = await json("/api/settings");
$("settingsContent").innerHTML = `
<section class="settings-section">
<div class="settings-title">
<h3>账号创建</h3>
<span>当前登录:${escapeHtml(app.user?.username || "-")}</span>
</div>
<form id="createUserForm" class="settings-form">
<input name="username" placeholder="新账号" />
<input name="password" type="password" placeholder="初始密码" />
<select name="role">
<option value="阅片员">阅片员</option>
<option value="管理员">管理员</option>
<option value="访客">访客</option>
</select>
<select name="status">
<option value="启用">启用</option>
<option value="停用">停用</option>
</select>
<button class="primary-btn" type="submit">保存账号</button>
</form>
${settingsTable(data.users || [])}
</section>
<section class="settings-section split">
<div>
<div class="settings-title"><h3>系统管理</h3><span>跳转与联动</span></div>
<dl class="settings-dl">
<dt>阅片系统</dt><dd>${escapeHtml(data.viewer_url || "-")}</dd>
<dt>数据库</dt><dd>${escapeHtml(data.database?.host || "-")}:${escapeHtml(data.database?.port || "-")} / ${escapeHtml(data.database?.database || "-")}</dd>
<dt>DICOM表</dt><dd>${escapeHtml(data.tables?.dicom || "-")}</dd>
<dt>摘要表</dt><dd>${escapeHtml(data.tables?.dicom_summary || "-")}</dd>
</dl>
</div>
<div>
<div class="settings-title"><h3>权限说明</h3><span>页面级控制</span></div>
<div class="role-grid">
<div class="role-card"><strong>管理员</strong><span>查看关联、系统设置、账号密码管理</span></div>
<div class="role-card"><strong>阅片员</strong><span>查看关联与跳转阅片系统</span></div>
<div class="role-card"><strong>访客</strong><span>只读查看关联信息</span></div>
</div>
</div>
</section>
`;
$("createUserForm").addEventListener("submit", saveUser);
document.querySelectorAll("[data-reset-user]").forEach((button) => {
button.addEventListener("click", () => resetPassword(button.dataset.resetUser));
});
}
async function saveUser(event) {
event.preventDefault();
const form = new FormData(event.currentTarget);
await json("/api/settings/users", {
method: "POST",
body: JSON.stringify({
username: String(form.get("username") || "").trim(),
password: String(form.get("password") || ""),
role: String(form.get("role") || "阅片员"),
status: String(form.get("status") || "启用"),
}),
});
await renderSettingsPage();
}
async function resetPassword(username) {
const input = Array.from(document.querySelectorAll("[data-password-user]")).find((item) => item.dataset.passwordUser === username);
const password = input?.value || "";
if (!password) return;
await json(`/api/settings/users/${encodeURIComponent(username)}/password`, {
method: "PUT",
body: JSON.stringify({ password }),
});
await renderSettingsPage();
}
function openDicomViewer() {
const url = dicomViewerUrl();
if (url) window.open(url, "_blank", "noopener");
}
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);
$("settingsBtn").addEventListener("click", showSettings);
$("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);
});
$("searchInput").addEventListener("input", () => {
clearTimeout(app.searchTimer);
app.searchTimer = setTimeout(() => {
app.search = $("searchInput").value.trim();
loadRelations();
}, 250);
});
$("algorithmFilter").addEventListener("change", () => {
app.algorithmModel = $("algorithmFilter").value;
loadRelations();
});
$("dicomPartFilter").addEventListener("change", () => {
app.dicomPart = $("dicomPartFilter").value;
loadRelations();
});
document.querySelectorAll("[data-filter]").forEach((button) => {
button.addEventListener("click", () => {
app.filter = button.dataset.filter;
document.querySelectorAll("[data-filter]").forEach((item) => item.classList.toggle("active", item === button));
loadRelations();
});
});
}
async function boot() {
clearDetail();
applyAuthUi();
wire();
const ok = await loadCurrentUser();
if (ok) await refreshAll();
}
boot();

View File

@@ -0,0 +1,146 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DICOM / UPP 数据库关联可视化</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-mark"></span>
<div>
<h1>DICOM / UPP 数据库关联可视化</h1>
<p>以 CT 号精确关联 DICOM 阅片分类与 UPP STL 重建资产</p>
</div>
</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>
<button id="settingsBtn" class="dark-btn" type="button">设置</button>
<button id="logoutBtn" class="dark-btn" type="button">退出</button>
</div>
</header>
<div id="topLoading" class="top-loading hidden"><span></span></div>
<main id="viewerPage" class="layout">
<aside class="sidebar">
<section class="panel search-panel">
<div class="panel-head">
<h2>关联列表</h2>
<span id="resultCount">0</span>
</div>
<input id="searchInput" class="search-input" placeholder="搜索 CT号 / 姓名 / ID / 描述 / 模型" />
<div class="model-filters">
<select id="algorithmFilter">
<option value="">全部重建模型</option>
<option value="肝胆模型">肝胆模型</option>
<option value="泌尿模型">泌尿模型</option>
<option value="胸外模型">胸外模型</option>
</select>
<select id="dicomPartFilter">
<option value="">全部部位标注</option>
<option value="head_neck">头颈部</option>
<option value="chest">胸部</option>
<option value="upper_abdomen">上腹部</option>
<option value="abdomen_pelvis">腹盆部</option>
</select>
</div>
<div class="filter-grid">
<button class="active" data-filter="all">全部</button>
<button data-filter="complete">完整关联</button>
<button data-filter="pacs_only">仅 DICOM</button>
<button data-filter="stl_only">仅 STL</button>
</div>
</section>
<section class="panel list-panel">
<div id="relationList" class="relation-list"></div>
<div id="listLoading" class="list-loading hidden">加载中...</div>
</section>
</aside>
<section class="content">
<section class="metrics" id="metrics"></section>
<section class="panel hero-panel">
<div class="hero-head">
<div>
<h2 id="activeCt">未选择 CT</h2>
<p id="activeSubtitle">从左侧选择一个 CT 号查看 DICOM 与 UPP STL 之间的关系</p>
</div>
<span id="relationBadge" class="relation-badge idle">等待选择</span>
</div>
<div class="link-map">
<div id="nodePacs" class="link-node">
<span>DICOM 阅片分类系统</span>
<strong>未选择</strong>
<em id="nodePacsMeta">检查与序列</em>
</div>
<div class="link-line"></div>
<div id="nodeCt" class="link-node ct-node">
<span>CT 号</span>
<strong id="nodeCtValue">-</strong>
<em>严格一致匹配</em>
</div>
<div class="link-line"></div>
<div id="nodeStl" class="link-node">
<span>UPP 重建资产</span>
<strong>未选择</strong>
<em id="nodeStlMeta">含UPP列表信息</em>
</div>
</div>
</section>
<section class="detail-grid">
<article class="panel detail-card">
<div class="detail-title">
<h3>DICOM 阅片分类系统</h3>
<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>
<article class="panel detail-card">
<h3>UPP STL</h3>
<dl id="stlDetails"></dl>
</article>
</section>
</section>
</main>
<main id="settingsPage" class="settings-page hidden">
<section class="panel settings-shell">
<div class="settings-head">
<div>
<h2>设置</h2>
<p>系统管理、账号密码与 DICOM 阅片系统跳转配置</p>
</div>
<button id="backToViewer" class="dark-btn" type="button">返回</button>
</div>
<div id="settingsContent" class="settings-content"></div>
</section>
</main>
<div id="loginOverlay" class="login-overlay">
<form id="loginForm" class="login-panel">
<span class="brand-mark"></span>
<h2>DICOM / UPP 数据库关联可视化</h2>
<p>请登录后查看数据库关联信息</p>
<input id="username" class="search-input" autocomplete="username" placeholder="账号" />
<input id="password" class="search-input" type="password" autocomplete="current-password" placeholder="密码" />
<button class="primary-btn" type="submit">登录</button>
<span id="loginError" class="error-line"></span>
</form>
</div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,954 @@
:root {
color-scheme: dark;
--bg: #070a0f;
--panel: #101722;
--panel-2: #0b111b;
--line: #1f2c3c;
--line-strong: #345070;
--text: #e8f0fb;
--muted: #8ea2bd;
--blue: #3276f6;
--cyan: #19d4c2;
--green: #16b981;
--amber: #f0b54e;
--red: #fb7185;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
overflow: hidden;
background:
linear-gradient(90deg, rgba(52, 118, 246, 0.07) 1px, transparent 1px),
linear-gradient(180deg, rgba(25, 212, 194, 0.055) 1px, transparent 1px),
radial-gradient(circle at 12% 0%, rgba(25, 212, 194, 0.13), transparent 28%),
var(--bg);
background-size: 72px 72px, 72px 72px, auto;
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
}
button,
input,
select {
font: inherit;
}
button {
cursor: pointer;
}
.topbar {
height: 66px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: rgba(7, 10, 15, 0.92);
}
.brand {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand-mark {
width: 30px;
height: 30px;
flex: 0 0 auto;
border-radius: 8px;
background: linear-gradient(135deg, var(--cyan), var(--blue));
box-shadow: 0 0 24px rgba(25, 212, 194, 0.24);
}
.brand h1 {
margin: 0;
font-size: 18px;
line-height: 1.2;
}
.brand p {
margin: 3px 0 0;
color: var(--muted);
font-size: 12px;
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.top-loading {
position: fixed;
top: 66px;
left: 0;
right: 0;
z-index: 15;
height: 3px;
overflow: hidden;
background: rgba(31, 44, 60, 0.72);
}
.top-loading span {
position: absolute;
top: 0;
bottom: 0;
width: 36%;
border-radius: 999px;
background: linear-gradient(90deg, transparent, var(--cyan), var(--blue), transparent);
animation: loading-sweep 1.1s ease-in-out infinite;
}
@keyframes loading-sweep {
from {
transform: translateX(-110%);
}
to {
transform: translateX(310%);
}
}
.hidden {
display: none !important;
}
.user-badge {
max-width: 170px;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-pill,
.relation-badge {
min-width: 82px;
display: inline-flex;
align-items: center;
justify-content: center;
height: 30px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.status-pill.online,
.relation-badge.complete {
border-color: rgba(22, 185, 129, 0.46);
color: #a5f5d4;
background: rgba(22, 185, 129, 0.08);
}
.status-pill.offline,
.relation-badge.pacs_only,
.relation-badge.stl_only,
.relation-badge.list_only {
border-color: rgba(251, 113, 133, 0.46);
color: #fecdd3;
background: rgba(251, 113, 133, 0.08);
}
.relation-badge.no_stl {
border-color: rgba(240, 181, 78, 0.5);
color: #ffe0a3;
background: rgba(240, 181, 78, 0.08);
}
.dark-btn {
height: 34px;
padding: 0 14px;
border: 1px solid rgba(142, 162, 189, 0.38);
border-radius: 8px;
background: linear-gradient(180deg, rgba(23, 34, 51, 0.96), rgba(11, 17, 27, 0.96));
color: var(--text);
box-shadow:
inset 0 1px 0 rgba(232, 240, 251, 0.06),
0 0 0 1px rgba(7, 10, 15, 0.3);
}
.top-actions .dark-btn {
border-color: rgba(82, 124, 184, 0.72);
color: #eaf2ff;
background: linear-gradient(180deg, rgba(28, 42, 62, 0.98), rgba(13, 21, 33, 0.98));
}
.top-actions #viewerHomeBtn {
border-color: rgba(25, 212, 194, 0.66);
color: #c8fff7;
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;
padding: 0 14px;
border: 1px solid rgba(52, 118, 246, 0.72);
border-radius: 8px;
background: var(--blue);
color: white;
font-weight: 700;
}
.link-btn {
height: 30px;
border-color: rgba(25, 212, 194, 0.42);
background: rgba(25, 212, 194, 0.12);
color: #baf8ee;
font-size: 12px;
}
.link-btn:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.dark-btn:hover,
.filter-grid button:hover {
border-color: rgba(52, 118, 246, 0.82);
background: #172233;
}
.top-actions #viewerHomeBtn:hover {
border-color: var(--cyan);
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;
grid-template-columns: 360px minmax(0, 1fr);
gap: 14px;
padding: 14px;
}
.sidebar,
.content {
min-height: 0;
display: grid;
gap: 12px;
}
.sidebar {
grid-template-rows: auto minmax(0, 1fr);
}
.content {
grid-template-rows: auto auto minmax(0, 1fr);
}
.panel {
min-width: 0;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(16, 23, 34, 0.9);
box-shadow: var(--shadow);
}
.search-panel,
.list-panel,
.hero-panel,
.segment-panel {
padding: 14px;
}
.panel-head,
.hero-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-head h2,
.hero-head h2 {
margin: 0;
font-size: 16px;
}
.panel-head span,
.hero-head p {
color: var(--muted);
font-size: 12px;
}
.hero-head p {
margin: 4px 0 0;
}
.search-input {
width: 100%;
height: 40px;
margin-top: 12px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 8px;
outline: 0;
background: #080d15;
color: var(--text);
}
.search-input:focus {
border-color: rgba(52, 118, 246, 0.76);
}
.model-filters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
.model-filters select {
min-width: 0;
height: 34px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 8px;
outline: 0;
background: #080d15;
color: var(--text);
}
.model-filters select:focus {
border-color: rgba(52, 118, 246, 0.76);
}
.filter-grid {
display: flex;
gap: 7px;
overflow-x: auto;
padding-bottom: 4px;
margin-top: 10px;
scrollbar-width: thin;
}
.filter-grid button {
min-width: 112px;
flex: 0 0 auto;
height: 30px;
border: 1px solid var(--line);
border-radius: 7px;
background: #0b111b;
color: var(--muted);
font-size: 12px;
}
.filter-grid button.active {
border-color: var(--blue);
color: white;
background: rgba(52, 118, 246, 0.26);
}
.relation-list {
height: 100%;
overflow: auto;
padding-right: 3px;
padding-bottom: 34px;
}
.list-panel {
position: relative;
}
.list-loading {
position: absolute;
right: 14px;
bottom: 10px;
left: 14px;
height: 26px;
display: grid;
place-items: center;
border: 1px solid rgba(25, 212, 194, 0.32);
border-radius: 999px;
color: #baf8ee;
background: rgba(7, 10, 15, 0.88);
font-size: 12px;
}
.relation-card {
width: 100%;
display: grid;
gap: 7px;
margin-bottom: 10px;
padding: 12px;
border: 1px solid transparent;
border-radius: 8px;
background: #0b111b;
color: var(--text);
text-align: left;
}
.relation-card.active {
border-color: rgba(25, 212, 194, 0.82);
background: #0e1823;
}
.relation-card strong,
.relation-card span,
.relation-card small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.relation-card strong {
font-size: 15px;
}
.relation-card span,
.relation-card small {
color: var(--muted);
font-size: 12px;
}
.card-topline {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: start;
}
.card-main {
min-width: 0;
display: grid;
gap: 7px;
}
.card-status {
display: grid;
justify-items: end;
gap: 5px;
}
.name-check-warning {
max-width: 84px;
padding: 2px 7px;
overflow: hidden;
border: 1px solid rgba(240, 181, 78, 0.58);
border-radius: 999px;
color: #ffe0a3;
background: rgba(240, 181, 78, 0.1);
font-size: 11px;
font-weight: 700;
line-height: 1.45;
text-overflow: ellipsis;
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;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
font-size: 11px;
}
.mini-badge.complete {
border-color: rgba(22, 185, 129, 0.5);
color: #a5f5d4;
}
.mini-badge.no_stl {
border-color: rgba(240, 181, 78, 0.5);
color: #ffe0a3;
}
.mini-badge.pacs_only,
.mini-badge.stl_only,
.mini-badge.list_only {
border-color: rgba(251, 113, 133, 0.48);
color: #fecdd3;
}
.inline-tags {
display: flex;
min-width: 0;
gap: 6px;
overflow: hidden;
}
.inline-tags em,
.card-tag-rail em {
max-width: 96px;
padding: 2px 7px;
overflow: hidden;
border: 1px solid rgba(25, 212, 194, 0.42);
border-radius: 999px;
color: #baf8ee;
background: rgba(25, 212, 194, 0.08);
font-size: 11px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-tag-rail {
display: flex;
gap: 6px;
min-width: 0;
overflow-x: auto;
padding: 2px 0 3px;
scrollbar-width: thin;
}
.card-tag-rail em {
max-width: 112px;
flex: 0 0 auto;
}
.card-tag-rail .model-tag {
border-color: rgba(52, 118, 246, 0.46);
color: #cfe0ff;
background: rgba(52, 118, 246, 0.1);
}
.subtle {
color: var(--muted);
font-size: 12px;
}
.metrics {
display: flex;
gap: 10px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
}
.metric {
min-width: 140px;
flex: 1 0 140px;
min-height: 76px;
padding: 13px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(11, 17, 27, 0.92);
}
.metric span {
color: var(--muted);
font-size: 12px;
}
.metric strong {
display: block;
margin-top: 8px;
font-size: 24px;
}
.link-map {
display: grid;
grid-template-columns: minmax(180px, 1fr) 56px minmax(160px, 0.72fr) 56px minmax(180px, 1fr);
align-items: center;
gap: 8px;
margin-top: 16px;
}
.link-node {
min-height: 104px;
display: grid;
align-content: center;
gap: 7px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: #0b111b;
}
.link-node.ok {
border-color: rgba(25, 212, 194, 0.55);
background: rgba(25, 212, 194, 0.07);
}
.link-node.warn {
border-color: rgba(240, 181, 78, 0.48);
background: rgba(240, 181, 78, 0.06);
}
.link-node.missing {
border-color: rgba(251, 113, 133, 0.45);
background: rgba(251, 113, 133, 0.06);
}
.link-node span,
.link-node em {
color: var(--muted);
font-size: 12px;
font-style: normal;
}
.link-node strong {
overflow: hidden;
font-size: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.ct-node {
border-color: rgba(52, 118, 246, 0.68);
background: rgba(52, 118, 246, 0.09);
}
.link-line {
height: 2px;
border-radius: 999px;
background: linear-gradient(90deg, var(--line), rgba(25, 212, 194, 0.72), var(--line));
}
.detail-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.detail-card {
padding: 14px;
}
.detail-card h3,
.detail-title h3 {
margin: 0 0 12px;
font-size: 15px;
}
.detail-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.detail-actions {
display: flex;
gap: 8px;
align-items: center;
}
.detail-title h3 {
margin: 0;
}
dl {
display: grid;
grid-template-columns: 96px minmax(0, 1fr);
gap: 8px 10px;
margin: 0;
}
dt {
color: var(--muted);
font-size: 12px;
}
dd {
min-width: 0;
margin: 0;
overflow: hidden;
color: var(--text);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.segment-panel {
min-height: 0;
overflow: hidden;
}
.segment-groups {
max-height: 100%;
overflow: auto;
margin-top: 12px;
}
.segment-group {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);
gap: 10px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.segment-group:first-child {
border-top: 0;
}
.segment-group strong {
color: #cfe0f5;
font-size: 13px;
}
.segment-tags {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.segment-tags em {
max-width: 190px;
padding: 4px 8px;
overflow: hidden;
border: 1px solid rgba(25, 212, 194, 0.32);
border-radius: 999px;
color: #baf8ee;
background: rgba(25, 212, 194, 0.07);
font-size: 12px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
margin: 18px 0 0;
color: var(--muted);
font-size: 13px;
text-align: center;
}
.error-line {
color: #fecdd3;
font-size: 12px;
}
.login-overlay {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
padding: 22px;
background:
radial-gradient(circle at 30% 20%, rgba(25, 212, 194, 0.2), transparent 30%),
rgba(7, 10, 15, 0.86);
backdrop-filter: blur(8px);
}
.login-panel {
width: min(420px, 100%);
display: grid;
gap: 12px;
padding: 26px;
border: 1px solid var(--line-strong);
border-radius: 10px;
background: rgba(11, 17, 27, 0.96);
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.45);
}
.login-panel h2 {
margin: 4px 0 0;
font-size: 20px;
}
.login-panel p {
margin: 0 0 4px;
color: var(--muted);
font-size: 13px;
}
.settings-page {
height: calc(100vh - 66px);
padding: 14px;
overflow: auto;
}
.settings-shell {
max-width: 1180px;
margin: 0 auto;
padding: 18px;
}
.settings-head,
.settings-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.settings-head h2,
.settings-title h3 {
margin: 0;
}
.settings-head p,
.settings-title span {
margin: 4px 0 0;
color: var(--muted);
font-size: 12px;
}
.settings-content {
display: grid;
gap: 14px;
margin-top: 18px;
}
.settings-section {
padding: 14px;
border: 1px solid var(--line);
border-radius: 8px;
background: rgba(7, 10, 15, 0.55);
}
.settings-section.split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
}
.settings-form {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) 130px 110px 110px;
gap: 8px;
margin: 14px 0;
}
.settings-form input,
.settings-form select,
.password-reset input {
min-width: 0;
height: 34px;
padding: 0 10px;
border: 1px solid var(--line);
border-radius: 8px;
outline: 0;
background: #080d15;
color: var(--text);
}
.settings-table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 8px;
background: #0b111b;
}
.settings-table th,
.settings-table td {
padding: 10px;
border-bottom: 1px solid var(--line);
color: var(--text);
font-size: 12px;
text-align: left;
}
.settings-table th {
color: #b8c7dc;
background: rgba(52, 118, 246, 0.08);
}
.password-reset {
display: grid;
grid-template-columns: minmax(0, 1fr) 64px;
gap: 6px;
}
.password-reset button {
border: 1px solid rgba(25, 212, 194, 0.42);
border-radius: 8px;
background: rgba(25, 212, 194, 0.1);
color: #baf8ee;
}
.settings-dl {
margin-top: 12px;
}
.role-grid {
display: grid;
gap: 8px;
margin-top: 12px;
}
.role-card {
display: grid;
gap: 5px;
padding: 10px;
border: 1px solid var(--line);
border-radius: 8px;
background: #0b111b;
}
.role-card span {
color: var(--muted);
font-size: 12px;
}
@media (max-width: 1180px) {
body {
overflow: auto;
}
.layout {
height: auto;
grid-template-columns: 1fr;
}
.metrics,
.detail-grid,
.settings-section.split,
.link-map {
grid-template-columns: 1fr;
}
.settings-form {
grid-template-columns: 1fr;
}
.link-line {
width: 2px;
height: 22px;
margin: 0 auto;
}
}