Compare commits
56 Commits
7eebab455d
...
stage-2026
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cad4257a4b | ||
|
|
30f73937aa | ||
|
|
40e18ed45d | ||
|
|
c1fbdd8bac | ||
|
|
bdd0db2a4c | ||
|
|
86e25d0a51 | ||
|
|
692c50e22c | ||
|
|
1d6f04061a | ||
|
|
aecfa957ef | ||
|
|
4bb482c4b0 | ||
|
|
adb2f41213 | ||
|
|
989303ec35 | ||
|
|
45e719aab0 | ||
|
|
69a5926e3c | ||
|
|
54bae18a21 | ||
|
|
dbf52d147a | ||
|
|
559266d5e6 | ||
|
|
f9063b674c | ||
|
|
c4f0fd54d6 | ||
|
|
7a38272e8b | ||
|
|
4ffdd28fbe | ||
|
|
1679ddb03a | ||
|
|
cb18aabb4d | ||
|
|
07df6ce446 | ||
|
|
2c6274ac0d | ||
|
|
026f6ce83c | ||
|
|
160790d38d | ||
|
|
5aabb300f2 | ||
|
|
92ca5e5b38 | ||
|
|
4fe6466e20 | ||
|
|
b6149a31eb | ||
|
|
48b0e23d64 | ||
|
|
f34e40ec0a | ||
|
|
e788f08037 | ||
|
|
bfa2436327 | ||
|
|
f5b40c5f5b | ||
|
|
2c2838adf9 | ||
|
|
8212d3b819 | ||
|
|
2a858be166 | ||
|
|
223e853bad | ||
|
|
555a04f337 | ||
|
|
fa38abf213 | ||
|
|
6653c147c8 | ||
|
|
fc681fa08b | ||
|
|
3a4bf5a486 | ||
|
|
0cf7725dfc | ||
|
|
cfd2b379b5 | ||
|
|
ddfee82b0b | ||
|
|
806807f4bc | ||
|
|
3c954cd274 | ||
|
|
0f06b04bf7 | ||
|
|
bbf8466d56 | ||
|
|
45017fbdfd | ||
|
|
ac75c37e3f | ||
|
|
9c478ed392 | ||
|
|
f3e3cfff1d |
11
.gitignore
vendored
11
.gitignore
vendored
@@ -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配准/缓存数据
|
||||
|
||||
4
DICOM_and_UPP配准/.dockerignore
Normal file
4
DICOM_and_UPP配准/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
23
DICOM_and_UPP配准/.env.example
Normal file
23
DICOM_and_UPP配准/.env.example
Normal 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
|
||||
17
DICOM_and_UPP配准/Dockerfile
Normal file
17
DICOM_and_UPP配准/Dockerfile
Normal 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"]
|
||||
27
DICOM_and_UPP配准/README.md
Normal file
27
DICOM_and_UPP配准/README.md
Normal 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
1450
DICOM_and_UPP配准/app.py
Normal file
File diff suppressed because it is too large
Load Diff
35
DICOM_and_UPP配准/docker-compose.yml
Normal file
35
DICOM_and_UPP配准/docker-compose.yml
Normal 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
|
||||
5
DICOM_and_UPP配准/requirements.txt
Normal file
5
DICOM_and_UPP配准/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
pydicom==2.4.5
|
||||
numpy>=2.0
|
||||
pillow>=11.0
|
||||
3316
DICOM_and_UPP配准/static/app.js
Normal file
3316
DICOM_and_UPP配准/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
423
DICOM_and_UPP配准/static/index.html
Normal file
423
DICOM_and_UPP配准/static/index.html
Normal file
@@ -0,0 +1,423 @@
|
||||
<!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">
|
||||
<button id="selectAllStlBtn" type="button">全选</button>
|
||||
<button id="invertStlBtn" type="button">反选</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>
|
||||
<em id="saveState">未保存</em>
|
||||
</div>
|
||||
<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 class="tool-section-title">
|
||||
<span>位姿自动调整</span>
|
||||
<em id="autoState">未运行</em>
|
||||
</div>
|
||||
<div class="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="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="registration-operation-hint">
|
||||
下方为配准操作区:保存当前位姿、保存并迭代自动微调,或退回上次自动调整前位姿。
|
||||
</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>
|
||||
2322
DICOM_and_UPP配准/static/styles.css
Normal file
2322
DICOM_and_UPP配准/static/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
1963
DICOM_and_UPP配准/static/vendor/OrbitControls.js
vendored
Normal file
1963
DICOM_and_UPP配准/static/vendor/OrbitControls.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
421
DICOM_and_UPP配准/static/vendor/STLLoader.js
vendored
Normal file
421
DICOM_and_UPP配准/static/vendor/STLLoader.js
vendored
Normal 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 };
|
||||
6
DICOM_and_UPP配准/static/vendor/three.core.min.js
vendored
Normal file
6
DICOM_and_UPP配准/static/vendor/three.core.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
6
DICOM_and_UPP配准/static/vendor/three.module.min.js
vendored
Normal file
6
DICOM_and_UPP配准/static/vendor/three.module.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
19
PACS_DICOM处理/README.md
Normal file
19
PACS_DICOM处理/README.md
Normal 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` 中排除。
|
||||
567
PACS_DICOM处理/数据处理工作区/preprocess_pacs_dicom_batch.py
Normal file
567
PACS_DICOM处理/数据处理工作区/preprocess_pacs_dicom_batch.py
Normal 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())
|
||||
7
PACS_DICOM处理/数据处理网页端/.dockerignore
Normal file
7
PACS_DICOM处理/数据处理网页端/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
data/
|
||||
logs/
|
||||
*.log
|
||||
17
PACS_DICOM处理/数据处理网页端/.env.example
Normal file
17
PACS_DICOM处理/数据处理网页端/.env.example
Normal 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
|
||||
20
PACS_DICOM处理/数据处理网页端/Dockerfile
Normal file
20
PACS_DICOM处理/数据处理网页端/Dockerfile
Normal 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"]
|
||||
53
PACS_DICOM处理/数据处理网页端/README.md
Normal file
53
PACS_DICOM处理/数据处理网页端/README.md
Normal 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` 中。
|
||||
1907
PACS_DICOM处理/数据处理网页端/app.py
Normal file
1907
PACS_DICOM处理/数据处理网页端/app.py
Normal file
File diff suppressed because it is too large
Load Diff
33
PACS_DICOM处理/数据处理网页端/docker-compose.yml
Normal file
33
PACS_DICOM处理/数据处理网页端/docker-compose.yml
Normal 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
|
||||
5
PACS_DICOM处理/数据处理网页端/requirements.txt
Normal file
5
PACS_DICOM处理/数据处理网页端/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
pydicom==2.4.5
|
||||
numpy>=2.0
|
||||
pillow>=11.0
|
||||
1590
PACS_DICOM处理/数据处理网页端/static/app.js
Normal file
1590
PACS_DICOM处理/数据处理网页端/static/app.js
Normal file
File diff suppressed because it is too large
Load Diff
220
PACS_DICOM处理/数据处理网页端/static/index.html
Normal file
220
PACS_DICOM处理/数据处理网页端/static/index.html
Normal 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>
|
||||
1476
PACS_DICOM处理/数据处理网页端/static/styles.css
Normal file
1476
PACS_DICOM处理/数据处理网页端/static/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
- 同一 CT 号有多个 STL 目录时,优先选择 STL 文件数更多的目录。
|
||||
- 文件数相同,则选择病例名或 `*-STL` 目录中序号更大的目录。
|
||||
- 规范化输出到 `UPP_STL处理/已处理STL数据/<CT号>/`。
|
||||
- 每个批次处理完成后,先核对待处理批次中的唯一 CT 号都已在 `已处理STL数据` 中生成对应 STL,再删除 `待处理STL数据/<批次目录>`,避免后续重复扫描和重复入库。
|
||||
- 数据库中 `upp_exam_assets.ct_number` 是主键,可用 list 的检查号查询对应 STL 路径;未来 CT 数据也可以继续挂在同一行。
|
||||
|
||||
运行:
|
||||
|
||||
11
UPP_数据库构建/UPP_STL处理记录.md
Normal file
11
UPP_数据库构建/UPP_STL处理记录.md
Normal 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数据/<批次目录>`,避免重复处理。
|
||||
@@ -111,6 +111,16 @@
|
||||
"contains": "肝胆外科",
|
||||
"业务分类1": "PACS",
|
||||
"业务分类2": "肝胆外科"
|
||||
},
|
||||
{
|
||||
"contains": "泌尿外科",
|
||||
"业务分类1": "PACS",
|
||||
"业务分类2": "泌尿外科"
|
||||
},
|
||||
{
|
||||
"contains": "胸外科",
|
||||
"业务分类1": "PACS",
|
||||
"业务分类2": "胸外科"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
16
数据库Web可视化/.env.example
Normal file
16
数据库Web可视化/.env.example
Normal 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
|
||||
20
数据库Web可视化/Dockerfile
Normal file
20
数据库Web可视化/Dockerfile
Normal 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"]
|
||||
23
数据库Web可视化/README.md
Normal file
23
数据库Web可视化/README.md
Normal 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 号。
|
||||
920
数据库Web可视化/app.py
Normal file
920
数据库Web可视化/app.py
Normal 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])
|
||||
12
数据库Web可视化/docker-compose.yml
Normal file
12
数据库Web可视化/docker-compose.yml
Normal 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"
|
||||
3
数据库Web可视化/requirements.txt
Normal file
3
数据库Web可视化/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
fastapi==0.116.1
|
||||
uvicorn[standard]==0.35.0
|
||||
pypinyin==0.54.0
|
||||
688
数据库Web可视化/static/app.js
Normal file
688
数据库Web可视化/static/app.js
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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();
|
||||
146
数据库Web可视化/static/index.html
Normal file
146
数据库Web可视化/static/index.html
Normal 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>
|
||||
954
数据库Web可视化/static/styles.css
Normal file
954
数据库Web可视化/static/styles.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user