feat(review): 核对包改成医生读得懂;资料库超声一路只留给 agent 的参数
- ceus:灌注指标改为对病灶去暗区(roi_perf)和紫圈(roi_pan)两组算,roi_all 指标删除; D1–D4 仍要 roi_all、roi_perf 两条都过才定位(负责人 2026-10-17:医生要正常区与病灶对比看) - build/library:tic.json 只出两组 11 个指标与 7 个槽位的视频时间;医生截图挪进 ultrasound/keyframes/key.png,UltrasoundRoute 去掉 key_image、keyframes 多一个 key - review:TIC参数.csv 取代 TIC结果.csv;问题清单按原因对照表写成大白话,对照表外的原因报错; 程序原文进 snapshot/问题清单_原文.csv;说明.pdf 整份重写为合作者口吻 - 文档:结构.md、医生核对包、装配两个库、CEUS定量 任务书 04。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -160,15 +160,28 @@ library/<构建号>/
|
|||||||
manifest.json 构建清单:每个文件的 SHA-256、构建时的 git 提交、配置哈希、所用 CEUS 运行号(没装为 null)
|
manifest.json 构建清单:每个文件的 SHA-256、构建时的 git 提交、配置哈希、所用 CEUS 运行号(没装为 null)
|
||||||
cases/<化名>/
|
cases/<化名>/
|
||||||
clinical/clinical.json 临床那一路
|
clinical/clinical.json 临床那一路
|
||||||
ultrasound/ 超声那一路:report.txt、key.png;装了 CEUS 结果时另有
|
ultrasound/ 超声那一路:report.txt、keyframes/key.png;装了 CEUS 结果时
|
||||||
keyframes/<槽位>.png 和 tic.json
|
keyframes/ 里另有定位到的 <槽位>.png,另有 tic.json
|
||||||
mr/report.txt MR 那一路
|
mr/report.txt MR 那一路
|
||||||
```
|
```
|
||||||
|
|
||||||
`build.yaml` 的 `ceus_run` 指向某次 CEUS 定量的运行目录时,build 才往超声一路装 CEUS 结果,为 null 时不装。装的时候:
|
超声一路的图都放在 `keyframes/` 一个文件夹里。医生截的关键图 `key.png` 来自交付包、不来自 CEUS,所以每例都有,没装 CEUS 时 `keyframes/` 里只有它一张;去掉文本块、EXIF 等附加数据,只按像素重新编码,不能逐像素保真重编码的整例跳过(C14)。
|
||||||
|
|
||||||
- `keyframes/<槽位>.png`:下文关键帧表里定位到的那几张(槽位即 B0、D1 等),和 `key.png` 一样去掉附加数据重新编码;每例最多 8 张图(7 张关键帧加 `key.png`)。
|
`build.yaml` 的 `ceus_run` 指向某次 CEUS 定量的运行目录时,build 才往超声一路装 CEUS 结果,为 null 时不装(没有 `tic.json`)。装的时候:
|
||||||
- `tic.json`:从这一例的 `summary.json` 里逐字段挑出,此外什么都不写:四条曲线(`roi_all` 病灶整体、`roi_perf` 病灶去暗区、`roi_pan` 紫圈即医生圈的一块正常胰腺、`background_circle` 背景圆)各自的拟合是否成功(`success`)和拟合优度 R²(`r2`);病灶两条曲线的灌注指标(`perfusion_metrics`,拟合没过质量门为 null);紫圈曲线是否可用(`pan_curve_usable`);7 个槽位各自是否定位到(`located`)、定位到的视频时间(`video_time_s`,秒)、没定位的原因(`reason`)。视频号、文件路径、配置、帧号、拟合参数都不写。挑哪些字段只在 `src/cadx/build/_ceus.py` 里列。
|
|
||||||
|
- `keyframes/<槽位>.png`:下文关键帧表里定位到的那几张(槽位即 B0、D1 等),和 `key.png` 一样去掉附加数据重新编码、过 C14;每例最多 8 张图(7 张关键帧加 `key.png`)。
|
||||||
|
- `tic.json`:只放「给专家的参数」,此外什么都不写:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"perfusion_metrics": {"roi_pan": {11 个指标} 或 null, "roi_perf": {11 个指标} 或 null},
|
||||||
|
"keyframe_times_s": {"B0": 秒或 null, …, "F2": 秒或 null}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`perfusion_metrics` 是两组灌注指标:`roi_pan` 是紫圈(医生在正常胰腺实质上圈的一块),`roi_perf` 是病灶去掉暗区(不进造影剂的囊液部分)后的区域,两组放在一起,是为了让专家把病灶和正常胰腺对着看(负责人 2026-10-17)。每组是下文第 9 步的 11 个指标,键名沿用 `summary.json` 的 `values`(`PE_au`、`TTP_s` …);这一组的拟合没过质量门整组为 null,观测不够算不出的单项为 null,不外推。`keyframe_times_s` 是 7 个槽位定位到的视频时间(秒),没定位到为 null。
|
||||||
|
|
||||||
|
不放:拟合成败和 R²、病灶整体(`roi_all`)和背景圆的曲线、紫圈是否可用、关键帧没定位的原因、指标的信号域等口径标签和辅助时刻(TI、TO、mTT,只是算其他指标的中间量),以及视频号、文件路径、配置、帧号、拟合参数。资料库的定义是「专家能看到什么」,可见范围只在 build 一处决定:多放进去、等会诊那边再挑,就成了两处决定,改了一处忘了另一处就会悄悄漏给专家(任务书 04,负责人 2026-10-17 认可)。挑哪些字段只在 `src/cadx/build/_ceus.py` 里列。
|
||||||
- 核对这次 CEUS 运行是在这份交付包上跑的:运行快照里 `ceus.yaml` 的 `paths.delivery_dir` 与 `build.yaml` 的 `delivery_dir` 规范化后不是同一路径;或逐例比,交付包里这段视频文件的 SHA-256 与 `summary.json` 的 `video_sha256` 不同,或交付包的推注时间与 `summary.json` 的 `injection_offset_s` 不同,整次停下报错。这两个字段不进 `tic.json`。
|
- 核对这次 CEUS 运行是在这份交付包上跑的:运行快照里 `ceus.yaml` 的 `paths.delivery_dir` 与 `build.yaml` 的 `delivery_dir` 规范化后不是同一路径;或逐例比,交付包里这段视频文件的 SHA-256 与 `summary.json` 的 `video_sha256` 不同,或交付包的推注时间与 `summary.json` 的 `injection_offset_s` 不同,整次停下报错。这两个字段不进 `tic.json`。
|
||||||
- CEUS 在这一例上失败(在 `failed.csv` 里)或运行目录里没有这一例的结果,这一例整例跳过,记 C15。失败的病例要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。
|
- CEUS 在这一例上失败(在 `failed.csv` 里)或运行目录里没有这一例的结果,这一例整例跳过,记 C15。失败的病例要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。
|
||||||
|
|
||||||
@@ -179,7 +192,7 @@ library/<构建号>/
|
|||||||
| 性别、年龄 | 看得见 | 看不见 | 看不见 |
|
| 性别、年龄 | 看得见 | 看不见 | 看不见 |
|
||||||
| 5 个症状、5 项既往史、5 项化验(原值与是否异常) | 看得见 | 看不见 | 看不见 |
|
| 5 个症状、5 项既往史、5 项化验(原值与是否异常) | 看得见 | 看不见 | 看不见 |
|
||||||
| 超声报告「所见」段、医生截的关键图 | 看不见 | 看得见 | 看不见 |
|
| 超声报告「所见」段、医生截的关键图 | 看不见 | 看得见 | 看不见 |
|
||||||
| CEUS 关键帧、TIC 灌注指标 | 看不见 | 看得见(给哪些指标、几张图由会诊任务书定) | 看不见 |
|
| CEUS 关键帧图与视频时间、紫圈和病灶去暗区两组灌注指标(上文 `tic.json`) | 看不见 | 看得见 | 看不见 |
|
||||||
| MR 报告「所见」段 | 看不见 | 看不见 | 看得见 |
|
| MR 报告「所见」段 | 看不见 | 看不见 | 看得见 |
|
||||||
| 病理诊断、报告「印象 / 结论」段、医生标注工作簿、真实病例号、Excel 行序、姓名 | 看不见 | 看不见 | 看不见 |
|
| 病理诊断、报告「印象 / 结论」段、医生标注工作簿、真实病例号、Excel 行序、姓名 | 看不见 | 看不见 | 看不见 |
|
||||||
|
|
||||||
@@ -198,7 +211,23 @@ library/<构建号>/
|
|||||||
|
|
||||||
### 医生核对包(`runs/review_pack/<运行号>/`)
|
### 医生核对包(`runs/review_pack/<运行号>/`)
|
||||||
|
|
||||||
把一次构建的两个库和它装入的那次 CEUS 运行,整理成医生不用写代码就能看的一个文件夹(表格 CSV、文字 PDF、图和视频逐字节拷贝),由负责人打包交给临床方核对我们的处理结果。病例目录用医生的病例号,问题清单只写程序观察到的现象;包里有真实病例号、报告原文和诊断,只在临床方与我们之间传递。它不是流水线的一段:不产出任何后续段要读的东西,医生每交一版数据、跑完 CEUS 定量和装配之后出一次。包内布局和每个文件的格式见 `../任务书/03-医生核对包.md` 和包里的 `说明.pdf`,怎么跑见 `../guides/医生核对包.md`。
|
把一次构建的两个库和它装入的那次 CEUS 运行,整理成医生不用写代码就能看的一个文件夹(表格 CSV、文字 PDF、图和视频逐字节拷贝),由负责人打包交给临床方核对我们的处理结果。包里有真实病例号、报告原文和诊断,只在临床方与我们之间传递。它不是流水线的一段:不产出任何后续段要读的东西,医生每交一版数据、跑完 CEUS 定量和装配之后出一次。
|
||||||
|
|
||||||
|
```
|
||||||
|
runs/review_pack/<运行号>/
|
||||||
|
snapshot/ 快照,含程序原文的 问题清单_原文.csv(不给医生)
|
||||||
|
医生核对包_<构建号>/
|
||||||
|
说明.pdf 问题清单.csv 病例总表.csv
|
||||||
|
标准答案/ 答案库 4 张表原样拷贝
|
||||||
|
病例/<病例号>/ 临床信息.csv 超声报告.pdf MR报告.pdf
|
||||||
|
关键帧/医生截图.png、<槽位>_第<秒>秒.png
|
||||||
|
TIC曲线.png TIC参数.csv 回放视频.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
- 医生看到的超声参数就是 AI 专家看到的那份:`关键帧/医生截图.png` 拷自资料库 `ultrasound/keyframes/key.png`,`TIC参数.csv` 的数值取自资料库 `tic.json`(紫圈、病灶去暗区两组 × 11 个指标)。只有某组整组为空时的「没算出的原因」取自 CEUS 运行的 `summary.json`,这一行只给医生看。
|
||||||
|
- 问题清单是给医生的大白话:程序记录的原因经 review 里的一张对照表(检查编号、CEUS 各步的失败原因、拟合原因码、关键帧未取到的原因、紫圈不可用)换成一句医生能懂的话,同一个原因引起的几条合成一行;对照表里没有的原因直接报错停下。程序原文留在快照里给我们自己追溯。来源:任务书 04,负责人 2026-10-17「合作方是医生,感觉他们也不一定看得懂」。
|
||||||
|
|
||||||
|
每个文件的格式见 `../任务书/03-医生核对包.md`(04 改过的以 `../任务书/04-核对包改成医生读得懂.md` 为准)和包里的 `说明.pdf`,怎么跑见 `../guides/医生核对包.md`。
|
||||||
|
|
||||||
### CEUS 定量的产物(`runs/ceus/` 与 `work/`)
|
### CEUS 定量的产物(`runs/ceus/` 与 `work/`)
|
||||||
|
|
||||||
@@ -216,7 +245,7 @@ CEUS 定量按下面的阶段进行(旧项目的阶段划分,迁移时逐模
|
|||||||
| 6 TIC 采样 | 逐帧取病灶(去不去暗区两种)平均亮度,得到 TIC(时间-强度曲线) | 否 |
|
| 6 TIC 采样 | 逐帧取病灶(去不去暗区两种)平均亮度,得到 TIC(时间-强度曲线) | 否 |
|
||||||
| 7 拟合 | 用 Lognormal(对数正态)曲线拟合 TIC,过质量门 | 否 |
|
| 7 拟合 | 用 Lognormal(对数正态)曲线拟合 TIC,过质量门 | 否 |
|
||||||
| 8 关键帧 | 按下面的定义挑 7 张 | 否 |
|
| 8 关键帧 | 按下面的定义挑 7 张 | 否 |
|
||||||
| 9 灌注指标 | 从拟合曲线算 11 个指标(峰值强度、达峰时间、洗入速率等,口径仿照商用造影定量软件 VueBOX) | 否 |
|
| 9 灌注指标 | 对病灶去暗区(`roi_perf`)和紫圈(`roi_pan`)两条曲线各算一组 11 个指标(峰值强度、达峰时间、洗入速率等,口径仿照商用造影定量软件 VueBOX),只从过了质量门的拟合曲线算,没过的那组为空。两组是为了让病灶和正常胰腺对着看;病灶整体(`roi_all`)含暗区,不算(负责人 2026-10-17,医生要正常区与病灶对比看) | 否 |
|
||||||
|
|
||||||
只有阶段 3 跨运行复用,其余每次重算:阶段 3 要显卡,最贵;其余阶段在 CPU 上算。改了后段参数不必重跑显卡。
|
只有阶段 3 跨运行复用,其余每次重算:阶段 3 要显卡,最贵;其余阶段在 CPU 上算。改了后段参数不必重跑显卡。
|
||||||
|
|
||||||
@@ -309,19 +338,19 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
|
|||||||
|
|
||||||
| 模块 | 预期行数 | 实际行数 | 旧项目对应部分的行数 |
|
| 模块 | 预期行数 | 实际行数 | 旧项目对应部分的行数 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | 7103 | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 |
|
| ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | 7106 | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 |
|
||||||
| segment | 150–300 | 300 | `medsam2_local_track.py` 240 |
|
| segment | 150–300 | 300 | `medsam2_local_track.py` 240 |
|
||||||
| delivery | 400–700 | 739 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 |
|
| delivery | 400–700 | 739 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 |
|
||||||
| library | 150–300 | 377 | `input_packages.py` 104 |
|
| library | 150–300 | 377 | `input_packages.py` 104 |
|
||||||
| answers | 100–200 | 227 | — |
|
| answers | 100–200 | 227 | — |
|
||||||
| verdicts | 100–200 | — | — |
|
| verdicts | 100–200 | — | — |
|
||||||
| build | 300–500 | 537(含装入 CEUS 结果的 `_ceus.py`;读 CEUS 运行目录已挪进 runs) | `input_readers.py` 329 的装配部分 |
|
| build | 300–500 | 520(含装入 CEUS 结果的 `_ceus.py`;读 CEUS 运行目录已挪进 runs) | `input_readers.py` 329 的装配部分 |
|
||||||
| llm | 200–450 | — | ClinDB `llm/` 415 |
|
| llm | 200–450 | — | ClinDB `llm/` 415 |
|
||||||
| ledger | 200–300 | — | ClinDB `ledger/` 274 |
|
| ledger | 200–300 | — | ClinDB `ledger/` 274 |
|
||||||
| runs | 100–250 | 201(快照 `_snapshot.py` 98、读 CEUS 运行的 `_ceus_run.py` 82、`__init__.py` 21) | — |
|
| runs | 100–250 | 201(快照 `_snapshot.py` 98、读 CEUS 运行的 `_ceus_run.py` 82、`__init__.py` 21) | — |
|
||||||
| consult | 500–1000(会诊口径定后重估) | — | — |
|
| consult | 500–1000(会诊口径定后重估) | — | — |
|
||||||
| evaluate | 300–600(评价口径定后重估) | — | 已删的 `core/eval` 622 |
|
| evaluate | 300–600(评价口径定后重估) | — | 已删的 `core/eval` 622 |
|
||||||
| review | 300–500 | 610(`_pack.py` 407、`_pdf.py` 129、`_config.py` 64、`__init__.py` 10;不含说明模板 `readme_template.md` 202 行) | — |
|
| review | 300–500 | 1152(`_pack.py` 447、`_problems.py` 502、`_pdf.py` 129、`_config.py` 64、`__init__.py` 10;不含说明模板 `readme_template.md` 91 行)。超出一倍:任务书 04 要求的原因对照表(`_problems.py`)大半是逐条的原因与医生写法,待负责人确认是否接受 | — |
|
||||||
| scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88、`ceus_quant.py` 403(超出,含目视核对表的读取与核对、开跑即写快照)、`ceus_align_review.py` 243、`review_pack.py` 37 | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 |
|
| scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88、`ceus_quant.py` 403(超出,含目视核对表的读取与核对、开跑即写快照)、`ceus_align_review.py` 243、`review_pack.py` 37 | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -365,7 +394,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
|
|||||||
- `read_build_info(build_dir)`:读构建清单的概况,交出 `BuildInfo`:构建号、编码列是否解码、装入的 CEUS 运行号(没装为 None)。
|
- `read_build_info(build_dir)`:读构建清单的概况,交出 `BuildInfo`:构建号、编码列是否解码、装入的 CEUS 运行号(没装为 None)。
|
||||||
- `read_clinical(build_dir, alias)`、`read_ultrasound(build_dir, alias)`、`read_mr(build_dir, alias)`:按路读出一例,读时核对 SHA-256,拒绝 `.partial` 目录。
|
- `read_clinical(build_dir, alias)`、`read_ultrasound(build_dir, alias)`、`read_mr(build_dir, alias)`:按路读出一例,读时核对 SHA-256,拒绝 `.partial` 目录。
|
||||||
- `ClinicalRoute`:临床一路:是否已解码、22 列字段。
|
- `ClinicalRoute`:临床一路:是否已解码、22 列字段。
|
||||||
- `UltrasoundRoute`:超声一路:报告「所见」原文、关键图路径、tic.json 的内容(没装 CEUS 时为 None)、槽位 → 关键帧路径(没装时为空)。
|
- `UltrasoundRoute`:超声一路:报告「所见」原文、tic.json 的内容(没装 CEUS 时为 None)、`keyframes`(`keyframes/` 下的图,文件名主干 → 路径:总有医生截的关键图 `key`,即 `KEY_SLOT`;装了 CEUS 时另有定位到的槽位)。
|
||||||
- `MrRoute`:MR 一路:报告「所见」原文。
|
- `MrRoute`:MR 一路:报告「所见」原文。
|
||||||
- `scan_for_leaks(build_dir, forbidden_strings, forbidden_keys)`:在构建目录的全部 `.txt`、`.json` 里找禁止的字符串(NFKC 归一、不分大小写、按子串)和禁止的 JSON 键名,返回命中列表。
|
- `scan_for_leaks(build_dir, forbidden_strings, forbidden_keys)`:在构建目录的全部 `.txt`、`.json` 里找禁止的字符串(NFKC 归一、不分大小写、按子串)和禁止的 JSON 键名,返回命中列表。
|
||||||
- `LeakHit`:一处命中:文件(相对构建目录)、类别(「字符串」或「键名」)、命中的词。
|
- `LeakHit`:一处命中:文件(相对构建目录)、类别(「字符串」或「键名」)、命中的词。
|
||||||
@@ -399,7 +428,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
|
|||||||
- `register_halves(frames, left_view, right_view, cfg.registration, cfg.prompt_source, cfg.pan_source)`:第 1 步的两半配准,交出 `HalfRegistration`:左半局部坐标 → 右半局部坐标的平移,`correlation`(两半扇形区边缘图的归一化互相关,采用它)、`template`(左半扇形内多块模板逐帧匹配后取中位数,只用来核对)、各次匹配明细、两者各分量之差的最大值;`contrast_to_target(side)` 换成造影那一半 → 另一半的平移。
|
- `register_halves(frames, left_view, right_view, cfg.registration, cfg.prompt_source, cfg.pan_source)`:第 1 步的两半配准,交出 `HalfRegistration`:左半局部坐标 → 右半局部坐标的平移,`correlation`(两半扇形区边缘图的归一化互相关,采用它)、`template`(左半扇形内多块模板逐帧匹配后取中位数,只用来核对)、各次匹配明细、两者各分量之差的最大值;`contrast_to_target(side)` 换成造影那一半 → 另一半的平移。
|
||||||
- `SegmentationInput`:视频的 fps、帧数、宽高、逐帧时间戳,分屏线,`registration`(`HalfRegistration`),`offset`(造影那一半局部坐标 + `offset` = `target_view` 局部坐标),`contrast`(`ContrastSide`:哪一半是造影画面、造影视图和分割那一半视图 `target_view`、提示帧),`target_frames()`(重新解码,逐帧交出分割那一半的 BGR 图像;不存整段),`prompt_masks`(提示帧帧号 → 绿框按 `offset` 挪到 `target_view` 局部坐标的掩膜),造影那一半的第一帧。
|
- `SegmentationInput`:视频的 fps、帧数、宽高、逐帧时间戳,分屏线,`registration`(`HalfRegistration`),`offset`(造影那一半局部坐标 + `offset` = `target_view` 局部坐标),`contrast`(`ContrastSide`:哪一半是造影画面、造影视图和分割那一半视图 `target_view`、提示帧),`target_frames()`(重新解码,逐帧交出分割那一半的 BGR 图像;不存整段),`prompt_masks`(提示帧帧号 → 绿框按 `offset` 挪到 `target_view` 局部坐标的掩膜),造影那一半的第一帧。
|
||||||
- `quantify_case(prepared, target_masks, injection_offset_s, cfg)`:第 4–9 步。`target_masks` 是分割在 `target_view` 局部坐标上的 `(帧数, 高, 宽)` 布尔数组,`injection_offset_s` 是推注时间 X;分割一帧有效轮廓都没有抛 `NoValidContourError`(第 4 步)。拟合没过质量门不抛错。
|
- `quantify_case(prepared, target_masks, injection_offset_s, cfg)`:第 4–9 步。`target_masks` 是分割在 `target_view` 局部坐标上的 `(帧数, 高, 宽)` 布尔数组,`injection_offset_s` 是推注时间 X;分割一帧有效轮廓都没有抛 `NoValidContourError`(第 4 步)。拟合没过质量门不抛错。
|
||||||
- `QuantResult`:`trajectory`(`Trajectory`)、`tic`(`TicResult`)、`curves`(`TicCurves`)、推注时间、病灶两条曲线拟合是否都过质量门、`anchors`(7 个槽位的 `AnchorFrameLocation`)、`perfusion`(`roi_all`、`roi_perf` 各自的 `LognormalPerfusionMetrics`,拟合没过为 None)、紫圈检出率。
|
- `QuantResult`:`trajectory`(`Trajectory`)、`tic`(`TicResult`)、`curves`(`TicCurves`)、推注时间、病灶两条曲线拟合是否都过质量门、`anchors`(7 个槽位的 `AnchorFrameLocation`)、`perfusion`(`METRIC_CURVES` 即 `roi_perf`、`roi_pan` 各自的 `LognormalPerfusionMetrics`,拟合没过为 None)、紫圈检出率。
|
||||||
- `AnchorFrameLocation`:一个关键帧槽位的帧号、视频时间、推注后时刻、状态(`ok` 或未定位的类别)、未定位的原因、锚点时刻。
|
- `AnchorFrameLocation`:一个关键帧槽位的帧号、视频时间、推注后时刻、状态(`ok` 或未定位的类别)、未定位的原因、锚点时刻。
|
||||||
- 写产物用:`pack_trajectory(trajectory)`(trajectory.npz 字节)、`pack_tic_curves(curves)`(tic_curves.npz 字节)、`render_tic_png(tic, markers=tic_plot_markers(anchors))`(tic.png,标出 D1–D4)、`render_case_overlay(prepared, result)`(overlay.mp4 字节)、`keyframe_images(prepared, anchors)`(定位到的关键帧整幅原图,槽位 → 图像)、`quant_summary(result)`(拟合、灌注指标、紫圈检出率、7 个关键帧定位,可直接写成 JSON);读回用 `unpack_trajectory`、`unpack_tic_curves`。
|
- 写产物用:`pack_trajectory(trajectory)`(trajectory.npz 字节)、`pack_tic_curves(curves)`(tic_curves.npz 字节)、`render_tic_png(tic, markers=tic_plot_markers(anchors))`(tic.png,标出 D1–D4)、`render_case_overlay(prepared, result)`(overlay.mp4 字节)、`keyframe_images(prepared, anchors)`(定位到的关键帧整幅原图,槽位 → 图像)、`quant_summary(result)`(拟合、灌注指标、紫圈检出率、7 个关键帧定位,可直接写成 JSON);读回用 `unpack_trajectory`、`unpack_tic_curves`。
|
||||||
- `fan_mask(std, cfg.registration)`:由一半画面的逐像素时间标准差求扇形区掩膜;`color_marks(frames, prompt_cfg, pan_cfg, dilate)`:任一帧里落在绿框或紫圈颜色范围内的像素。两者配准用,目视核对出图也用。
|
- `fan_mask(std, cfg.registration)`:由一半画面的逐像素时间标准差求扇形区掩膜;`color_marks(frames, prompt_cfg, pan_cfg, dilate)`:任一帧里落在绿框或紫圈颜色范围内的像素。两者配准用,目视核对出图也用。
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
- **配准**:估出左半画面局部坐标到右半画面局部坐标的平移 (dx, dy)(方向固定是左到右,与哪一半是造影画面无关),让两半里同一个解剖位置对上。程序在造影画面上找绿框,挪到灰阶那一半交给分割模型,分割完再把轮廓挪回造影那一半采亮度,挪的就是这个平移。在灰阶那一半分割,是因为造影画面里病灶边界随造影剂进出时明时暗,灰阶画面上边界稳定(沿用旧代码,任务书 02「先认识几个词」)。配准错了不会报错,只会让亮度采在病灶旁边,所以全量运行之前要做一轮**目视核对**(第二节)。
|
- **配准**:估出左半画面局部坐标到右半画面局部坐标的平移 (dx, dy)(方向固定是左到右,与哪一半是造影画面无关),让两半里同一个解剖位置对上。程序在造影画面上找绿框,挪到灰阶那一半交给分割模型,分割完再把轮廓挪回造影那一半采亮度,挪的就是这个平移。在灰阶那一半分割,是因为造影画面里病灶边界随造影剂进出时明时暗,灰阶画面上边界稳定(沿用旧代码,任务书 02「先认识几个词」)。配准错了不会报错,只会让亮度采在病灶旁边,所以全量运行之前要做一轮**目视核对**(第二节)。
|
||||||
- **提示帧**:绿框位置或形状明显变化的那几帧,第 2 步挑出,交给分割模型告诉它「病灶在这里」。
|
- **提示帧**:绿框位置或形状明显变化的那几帧,第 2 步挑出,交给分割模型告诉它「病灶在这里」。
|
||||||
- **MedSAM2**:医学图像分割模型,给它几帧提示帧上的病灶框,它把病灶轮廓传播到整段视频的每一帧。要用显卡,是最慢的一步(第 3 步)。怎么装见 `开发环境.md`。
|
- **MedSAM2**:医学图像分割模型,给它几帧提示帧上的病灶框,它把病灶轮廓传播到整段视频的每一帧。要用显卡,是最慢的一步(第 3 步)。怎么装见 `开发环境.md`。
|
||||||
- **TIC**(时间-强度曲线):ROI 平均亮度随时间的变化,造影剂进来变亮、洗出变暗。程序用 Lognormal(对数正态)形状的光滑曲线去拟合它,拟合要过一道**质量门**(拟合优度等门槛,见 `ceus.yaml` 的 `tic_analysis.fitting`)才算成功。病灶有两条曲线(整体、去掉暗区),两条都过了质量门才算**病灶拟合成功**,D1–D4 和灌注指标都要它。
|
- **TIC**(时间-强度曲线):ROI 平均亮度随时间的变化,造影剂进来变亮、洗出变暗。程序用 Lognormal(对数正态)形状的光滑曲线去拟合它,拟合要过一道**质量门**(拟合优度等门槛,见 `ceus.yaml` 的 `tic_analysis.fitting`)才算成功。病灶有两条曲线(整体、去掉暗区),两条都过了质量门才算**病灶拟合成功**,B0、D1–D4 要它。
|
||||||
- **灌注指标**:从拟合曲线算出的 11 个数(峰值强度、达峰时间、洗入速率等),口径仿照商用造影定量软件 VueBOX(`结构.md` 第四部分)。
|
- **灌注指标**:从拟合曲线算出的 11 个数(峰值强度、达峰时间、洗入速率等),口径仿照商用造影定量软件 VueBOX(`结构.md` 第四部分)。对病灶去暗区(`roi_perf`)和紫圈(`roi_pan`)两条曲线各算一组,哪条的拟合过了质量门就算哪条,没过的那组为 null。
|
||||||
- **关键帧与槽位**:按 TIC 挑出的 7 个时刻的画面,每个时刻叫一个槽位:B0(刚开始变亮之前)、D1(开始变亮)、D2(变亮最快)、D3(最亮)、D4(变暗最快)从拟合曲线上取;F1、F2 是推注后第 20、60 秒。详细定义见 `../explanation/结构.md` 第四部分的关键帧表。
|
- **关键帧与槽位**:按 TIC 挑出的 7 个时刻的画面,每个时刻叫一个槽位:B0(刚开始变亮之前)、D1(开始变亮)、D2(变亮最快)、D3(最亮)、D4(变暗最快)从拟合曲线上取;F1、F2 是推注后第 20、60 秒。详细定义见 `../explanation/结构.md` 第四部分的关键帧表。
|
||||||
- **推注时间 X**:超声表的「推注开始时间s」,含义是视频第 0 秒时已经推注了多少秒(不是「第几秒开始推注」),交付时已检查是非负整数。F1 取视频第 20 − X 秒,F2 取第 60 − X 秒。
|
- **推注时间 X**:超声表的「推注开始时间s」,含义是视频第 0 秒时已经推注了多少秒(不是「第几秒开始推注」),交付时已检查是非负整数。F1 取视频第 20 − X 秒,F2 取第 60 − X 秒。
|
||||||
- **第 N 步**:一段视频依次经过的 9 步:1 解码、分屏与两半配准,2 找绿框、选提示帧,3 MedSAM2 分割,4 整理轮廓,5 找暗区,6 TIC 采样,7 拟合,8 关键帧,9 灌注指标(`结构.md` 第四部分的表)。
|
- **第 N 步**:一段视频依次经过的 9 步:1 解码、分屏与两半配准,2 找绿框、选提示帧,3 MedSAM2 分割,4 整理轮廓,5 找暗区,6 TIC 采样,7 拟合,8 关键帧,9 灌注指标(`结构.md` 第四部分的表)。
|
||||||
@@ -162,7 +162,7 @@ snapshot/
|
|||||||
|
|
||||||
几点说明:
|
几点说明:
|
||||||
|
|
||||||
- 病灶拟合没过质量门**不算失败**:这一例照常有结果目录,D1–D4 记为未定位,灌注指标写 null,原因写在 `summary.json`;F1、F2 照常按推注时间取。个别槽位未定位也不算失败。
|
- 病灶拟合没过质量门**不算失败**:这一例照常有结果目录,B0、D1–D4 记为未定位,拟合没过的那条曲线(`roi_perf`、`roi_pan`)的灌注指标写 null,原因写在 `summary.json`;F1、F2 照常按推注时间取。个别槽位未定位也不算失败。
|
||||||
- 不进 `failed.csv`、而是整次停下的:配准核对表对不上(第二节末)、MedSAM2 报错(如显存不够、权重找不到)、重新解码时读不到关键帧、配置写错。报错信息在终端和日志里,留下 `.partial`。
|
- 不进 `failed.csv`、而是整次停下的:配准核对表对不上(第二节末)、MedSAM2 报错(如显存不够、权重找不到)、重新解码时读不到关键帧、配置写错。报错信息在终端和日志里,留下 `.partial`。
|
||||||
- 失败的病例构建时按 C15 整例跳过(`装配两个库.md` 第四节);要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。
|
- 失败的病例构建时按 C15 整例跳过(`装配两个库.md` 第四节);要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 医生核对包
|
# 医生核对包
|
||||||
|
|
||||||
医生核对包是一个文件夹,交给临床方的医生,请他们核对我们对交付资料的处理结果。医生不用写代码就能打开:表格是 CSV,文字是 PDF,图和视频原样拷贝。本文写给出包的人:什么时候跑、怎么跑、产物在哪、怎么交出去、报错了怎么办。为什么做、包里每个文件的格式,见 `../任务书/03-医生核对包.md`;它在代码里的位置见 `../explanation/结构.md` 第三、四部分。
|
医生核对包是一个文件夹,交给临床方的医生,请他们核对我们对交付资料的处理结果。医生不用写代码就能打开:表格是 CSV,文字是 PDF,图和视频原样拷贝。本文写给出包的人:什么时候跑、怎么跑、产物在哪、怎么交出去、报错了怎么办。为什么做、包里每个文件的格式,见 `../任务书/03-医生核对包.md` 和改成医生读得懂的 `../任务书/04-核对包改成医生读得懂.md`(两者不一致处以 04 为准);它在代码里的位置见 `../explanation/结构.md` 第三、四部分。
|
||||||
|
|
||||||
## 先认识几个词
|
## 先认识几个词
|
||||||
|
|
||||||
@@ -15,11 +15,13 @@
|
|||||||
- **CEUS 定量运行**:跑一次 `scripts/ceus_quant.py`(`CEUS定量.md`),产物在 `runs/ceus/<CEUS运行号>/`。
|
- **CEUS 定量运行**:跑一次 `scripts/ceus_quant.py`(`CEUS定量.md`),产物在 `runs/ceus/<CEUS运行号>/`。
|
||||||
- **`failed.csv`**:这次运行里记各病例在第几步失败、失败原因的表。
|
- **`failed.csv`**:这次运行里记各病例在第几步失败、失败原因的表。
|
||||||
- **视频号**:每例 CEUS 视频的编号,`U` 加三位病例号(如 `U001`),CEUS 运行的结果目录按它命名。
|
- **视频号**:每例 CEUS 视频的编号,`U` 加三位病例号(如 `U001`),CEUS 运行的结果目录按它命名。
|
||||||
- **TIC、拟合**:TIC(时间-强度曲线)是病灶区域平均亮度随时间的变化。拟合是用一条光滑的模型曲线(Lognormal,对数正态形状)去贴 TIC。拟合要过一道质量门才算成功;没成功时,关键帧 D1–D4 和灌注指标都取不到。
|
- **TIC、拟合**:TIC(时间-强度曲线)是一块区域平均亮度随时间的变化。拟合是用一条光滑的模型曲线(Lognormal,对数正态形状)去贴 TIC。拟合要过一道质量门才算成功;病灶曲线没成功时关键帧 B0、D1–D4 取不到,某条曲线没成功时它那一组灌注指标为空。
|
||||||
|
- **TIC 参数**:从过了质量门的拟合曲线算出的 11 个灌注指标(PE、TTP 等),紫圈(胰腺实质)和病灶去暗区各一组,就是资料库 `tic.json` 里给 AI 专家的那份。
|
||||||
- **紫圈**:临床方在视频上画的紫色圈,圈一块正常胰腺作对照,程序也为它画一条 TIC。
|
- **紫圈**:临床方在视频上画的紫色圈,圈一块正常胰腺作对照,程序也为它画一条 TIC。
|
||||||
- **关键帧、槽位**:从视频里挑出的 7 个时刻的画面,每个时刻叫一个槽位。B0 是刚开始变亮之前,D1–D4 是从拟合曲线上取的开始变亮、变亮最快、最亮、变暗最快,F1、F2 是推注造影剂后第 20、60 秒。细节见包里的 `说明.pdf`。
|
- **关键帧、槽位**:从视频里挑出的 7 个时刻的画面,每个时刻叫一个槽位。B0 是刚开始变亮之前,D1–D4 是从拟合曲线上取的开始变亮、变亮最快、最亮、变暗最快,F1、F2 是推注造影剂后第 20、60 秒。细节见包里的 `说明.pdf`。
|
||||||
- **回放视频**:原视频逐帧叠加程序画的病灶轮廓等线条,医生用它看程序圈得对不对。
|
- **回放视频**:原视频逐帧叠加程序画的病灶轮廓等线条,医生用它看程序圈得对不对。
|
||||||
- **问题清单**:包里的 `问题清单.csv`,列出程序在每个病例上观察到的现象:某例没进资料库、CEUS 在第几步失败、拟合没完成、关键帧没取到、紫圈曲线不可用。每条都抄自某个已有文件里的值,只写现象,不写原因推测和建议。这是负责人 2026-10-17 定的(任务书 03「不做什么」):包只请医生核对事实,推测会把医生的注意力引向我们的猜测。
|
- **问题清单**:包里的 `问题清单.csv`,列出程序在每个病例上观察到的现象:某例没进资料库、造影视频处理失败、曲线没能拟合、关键帧没取到、紫圈不可用。每条都抄自某个已有文件里的值,只写现象,不写原因推测和建议(负责人 2026-10-17,任务书 03「不做什么」):包只请医生核对事实,推测会把医生的注意力引向我们的猜测。
|
||||||
|
- **写成医生能懂的话**:程序记录的原因(检查编号、原因码、英文字段名)经 `src/cadx/review/_problems.py` 里的对照表换成一句大白话,同一个原因引起的几条合成一行(任务书 04)。对照表里没有的原因直接报错停下,不把程序原文给医生;程序原文另存在快照的 `问题清单_原文.csv` 里。
|
||||||
- **三种编号**:格式都是 `YYYYMMDD-HHMMSS-<git 短哈希>`,工作区有未提交改动时末尾加 `-dirty`(`装配两个库.md` 第三节「构建号」)。三者各指一样东西,本文用不同的名字区分:
|
- **三种编号**:格式都是 `YYYYMMDD-HHMMSS-<git 短哈希>`,工作区有未提交改动时末尾加 `-dirty`(`装配两个库.md` 第三节「构建号」)。三者各指一样东西,本文用不同的名字区分:
|
||||||
- **构建号**:一次构建,即 `library/<构建号>/`、`answers/<构建号>/`;
|
- **构建号**:一次构建,即 `library/<构建号>/`、`answers/<构建号>/`;
|
||||||
- **CEUS 运行号**:一次 CEUS 定量运行,即 `runs/ceus/<CEUS运行号>/`;
|
- **CEUS 运行号**:一次 CEUS 定量运行,即 `runs/ceus/<CEUS运行号>/`;
|
||||||
@@ -27,15 +29,16 @@
|
|||||||
|
|
||||||
## 一、什么时候跑
|
## 一、什么时候跑
|
||||||
|
|
||||||
医生每交一版新数据,按顺序做完:
|
医生每交一版新数据,或者处理规则改了,按顺序做完:
|
||||||
|
|
||||||
1. CEUS 定量 `scripts/ceus_quant.py`,得到新的 `runs/ceus/<CEUS运行号>/`;
|
1. **干净工作区**:先确认 `git status` 没有任何改动(包括未跟踪文件),有就先提交或请改动的人处理。工作区不干净时三个运行号末尾都会带 `-dirty`,记下的提交号复现不出这次结果;
|
||||||
2. 把 `config/build.yaml` 的 `ceus_run` 指向这个目录,跑 `scripts/build.py` 构建两个库,得到新的构建号;
|
2. **ceus_quant**:CEUS 定量 `scripts/ceus_quant.py`,得到新的 `runs/ceus/<CEUS运行号>/`;
|
||||||
3. 再跑本入口出核对包。
|
3. **build**:把 `config/build.yaml` 的 `ceus_run` 指向这个目录,跑 `scripts/build.py` 构建两个库,得到新的构建号;
|
||||||
|
4. **review_pack**:再跑本入口出核对包。
|
||||||
|
|
||||||
核对包只读已经构建好的两个库和那次 CEUS 运行,不碰交付包,也不改两个库。所以交付数据或处理规则变了,要先重新构建,再出包。一次构建可以出多次包,每次一个新的出包号。
|
核对包只读已经构建好的两个库和那次 CEUS 运行,不碰交付包,也不改两个库。所以交付数据或处理规则变了,要先重新构建,再出包。一次构建可以出多次包,每次一个新的出包号。
|
||||||
|
|
||||||
没装 CEUS 结果的构建(`build.yaml` 的 `ceus_run` 为 null)也能出包。包里没有关键帧、TIC 曲线、TIC 结果和回放视频,病例总表的 CEUS 各列留空,`说明.pdf` 里写明这次没有 CEUS 结果。
|
没装 CEUS 结果的构建(`build.yaml` 的 `ceus_run` 为 null)也能出包。病例的 `关键帧/` 里只有医生截图,没有程序取的关键帧、TIC 曲线图、TIC 参数和回放视频,病例总表的 CEUS 各列留空,`说明.pdf` 里写明这一版没有造影视频的处理结果。
|
||||||
|
|
||||||
## 二、怎么跑
|
## 二、怎么跑
|
||||||
|
|
||||||
@@ -87,8 +90,27 @@ runs/review_pack/<出包号>/ (output_root 下)
|
|||||||
- `snapshot.json`:git 状态、Python 版本、`uv.lock` 哈希、开始时间、配置哈希;
|
- `snapshot.json`:git 状态、Python 版本、`uv.lock` 哈希、开始时间、配置哈希;
|
||||||
- `review_pack.yaml`:配置原文副本;
|
- `review_pack.yaml`:配置原文副本;
|
||||||
- `review.json`:构建号、CEUS 运行号、pandoc 与 soffice 的版本行;
|
- `review.json`:构建号、CEUS 运行号、pandoc 与 soffice 的版本行;
|
||||||
|
- `问题清单_原文.csv`:问题清单的程序原文(检查编号、原因码、程序记的原因),列同包里的问题清单,给我们自己追溯;
|
||||||
- `finished.json`:结束时间、病例数、问题清单行数。
|
- `finished.json`:结束时间、病例数、问题清单行数。
|
||||||
- `医生核对包_<构建号>/`:目录布局、每张 CSV 的列和每个文件的来源,见任务书 03「核对包的内容」。也可以直接打开包里的 `说明.pdf`:它是写给医生的完整说明,包括目录结构、每一列的意思、处理流程、关键帧和 TIC 曲线怎么看、编码含义。这里不重抄,免得两处说法不一致。`说明.pdf` 的源文件是 `src/cadx/review/readme_template.md`。
|
- `医生核对包_<构建号>/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
说明.pdf 给医生的说明,源文件是 src/cadx/review/readme_template.md
|
||||||
|
问题清单.csv 病例号、视频号、环节、问题(一句话)
|
||||||
|
病例总表.csv 全部病例一例一行
|
||||||
|
标准答案/ 化名对照.csv、诊断.csv、跳过病例.csv、报告复核句.csv(答案库原样拷贝)
|
||||||
|
病例/<病例号>/ 只有进了资料库的病例才有
|
||||||
|
临床信息.csv 临床一路的字段
|
||||||
|
超声报告.pdf、MR报告.pdf 两路报告的描述段原文
|
||||||
|
关键帧/
|
||||||
|
医生截图.png 资料库 ultrasound/keyframes/key.png
|
||||||
|
<槽位>_第<秒>秒.png 程序定位到的关键帧,秒数取 tic.json 的 keyframe_times_s
|
||||||
|
TIC曲线.png CEUS 运行的 tic.png
|
||||||
|
TIC参数.csv 11 个灌注指标 × 紫圈、病灶去暗区两组,数值取自资料库 tic.json
|
||||||
|
回放视频.mp4 CEUS 运行的 overlay.mp4
|
||||||
|
```
|
||||||
|
|
||||||
|
CSV 都是带 BOM 的 UTF-8、中文列名、缺失为空。`TIC参数.csv` 的时间类指标保留 1 位小数、其余 2 位;某一组整组为空时最后加一行「没算出的原因」,原因从 CEUS 运行的 `summary.json` 读,经对照表写成医生的话(资料库里已经没有原因,AI 专家拿到的只是 null)。图片和视频逐字节拷贝。每列的意思和怎么看写在 `说明.pdf` 里,这里不重抄,免得两处说法不一致。
|
||||||
|
|
||||||
运行目录先写成 `<出包号>.partial/`,全部写完再改名成 `<出包号>/`。看到 `.partial`,说明那次出包中途出错。它不会被自动删除,留着查原因,查完由人决定删不删。已有的运行目录不删、不覆盖(`AGENTS.md`「两档标准」)。
|
运行目录先写成 `<出包号>.partial/`,全部写完再改名成 `<出包号>/`。看到 `.partial`,说明那次出包中途出错。它不会被自动删除,留着查原因,查完由人决定删不删。已有的运行目录不删、不覆盖(`AGENTS.md`「两档标准」)。
|
||||||
|
|
||||||
@@ -120,4 +142,7 @@ runs/review_pack/<出包号>/ (output_root 下)
|
|||||||
| `… 的 ceus_run 是 …,与资料库记的 CEUS 运行号 … 不符` | 答案库快照里 `build.yaml` 的 `ceus_run`(路径)与资料库 `manifest.json` 的 `ceus_source`(CEUS 运行号)对不上 | 两个库不是同一次构建写出的,检查配置是否指错 |
|
| `… 的 ceus_run 是 …,与资料库记的 CEUS 运行号 … 不符` | 答案库快照里 `build.yaml` 的 `ceus_run`(路径)与资料库 `manifest.json` 的 `ceus_source`(CEUS 运行号)对不上 | 两个库不是同一次构建写出的,检查配置是否指错 |
|
||||||
| `CEUS 运行目录不存在` / `是没跑完的 .partial` / `缺快照` | 构建所用的 CEUS 运行目录被移走或删了 | CEUS 运行目录只追加不删;找回原目录 |
|
| `CEUS 运行目录不存在` / `是没跑完的 .partial` / `缺快照` | 构建所用的 CEUS 运行目录被移走或删了 | CEUS 运行目录只追加不删;找回原目录 |
|
||||||
| `CEUS 运行 failed.csv 里的视频号 … 对不上这次构建的任何病例` | 那次 CEUS 运行里失败的病例不在这次构建里,既没进库,也不在跳过清单上 | 检查 `build.yaml` 的 `ceus_run` 与 `delivery_dir`(交付包目录)是否配套,即那次 CEUS 运行是否在这份交付包上跑的 |
|
| `CEUS 运行 failed.csv 里的视频号 … 对不上这次构建的任何病例` | 那次 CEUS 运行里失败的病例不在这次构建里,既没进库,也不在跳过清单上 | 检查 `build.yaml` 的 `ceus_run` 与 `delivery_dir`(交付包目录)是否配套,即那次 CEUS 运行是否在这份交付包上跑的 |
|
||||||
| `tic.json 里有 TIC结果.csv 没有中文名的字段` | build 往 `tic.json`(资料库里每例的 CEUS 结果摘要)里加了新字段,核对包还没给它起中文名 | 在 `src/cadx/review/_pack.py` 的 `TIC_ITEMS` 里补上中文名,同时补说明模板 |
|
| `tic.json 的字段是 …,核对包只认得 …` / `灌注指标分组是 …` / `灌注指标是 …` | build 改了 `tic.json`(资料库里每例给 AI 专家的 CEUS 参数)的字段,核对包还没跟上 | 改 `src/cadx/review/_pack.py` 的 `TIC_GROUPS`、`TIC_METRICS`,同时补说明模板 |
|
||||||
|
| `… 不在对照表里:…` | 程序记录了一种新的原因(新的检查编号、新的失败原因、新的原因码、新的列),对照表里还没有给医生的写法 | 在 `src/cadx/review/_problems.py` 的对照表里补一句医生能懂的话,并在 `tests/test_review_reasons.py` 补一条手写预期 |
|
||||||
|
| `给医生的话里出现了程序用语「…」` | 某句给医生的话里带出了检查编号、字段名、下划线、括号或运行号(多半是交付资料里的取值本身带下划线等字符) | 看是哪一句:对照表写法的问题就改写法;是交付取值带出来的,问负责人怎么写 |
|
||||||
|
| `病灶曲线拟合失败 … 与因此未取到的关键帧 … 对不上` | CEUS 运行的 `summary.json` 里拟合结果和关键帧状态互相矛盾 | CEUS 运行本身有问题,查那次运行,不要改核对包绕过 |
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ manifest.json
|
|||||||
cases/<化名>/
|
cases/<化名>/
|
||||||
clinical/clinical.json
|
clinical/clinical.json
|
||||||
ultrasound/report.txt
|
ultrasound/report.txt
|
||||||
ultrasound/key.png
|
ultrasound/keyframes/key.png
|
||||||
ultrasound/keyframes/<槽位>.png (ceus_run 不为 null 时;只有定位到的槽位)
|
ultrasound/keyframes/<槽位>.png (ceus_run 不为 null 时;只有定位到的槽位)
|
||||||
ultrasound/tic.json (ceus_run 不为 null 时)
|
ultrasound/tic.json (ceus_run 不为 null 时)
|
||||||
mr/report.txt
|
mr/report.txt
|
||||||
@@ -105,9 +105,9 @@ cases/<化名>/
|
|||||||
| `manifest.json` | 构建号、git 提交号、工作区是否干净、配置文件(解码时含映射表)的 SHA-256、`codes_decoded`、`uncompared`(没比对的项)、`ceus_source`(所用 CEUS 运行号,`ceus_run` 为 null 时是 null)、化名列表(按化名排序)、每个文件的相对路径和 SHA-256。不含真实病例号、交付文件名和诊断 |
|
| `manifest.json` | 构建号、git 提交号、工作区是否干净、配置文件(解码时含映射表)的 SHA-256、`codes_decoded`、`uncompared`(没比对的项)、`ceus_source`(所用 CEUS 运行号,`ceus_run` 为 null 时是 null)、化名列表(按化名排序)、每个文件的相对路径和 SHA-256。不含真实病例号、交付文件名和诊断 |
|
||||||
| `clinical.json` | `{"codes_decoded": ..., "fields": {列名: 值}}`。字段是临床表去掉编号和诊断后的 22 列:性别、年龄、5 个症状、5 项既往史、5 项化验的原值和档位。没测的化验写 null |
|
| `clinical.json` | `{"codes_decoded": ..., "fields": {列名: 值}}`。字段是临床表去掉编号和诊断后的 22 列:性别、年龄、5 个症状、5 项既往史、5 项化验的原值和档位。没测的化验写 null |
|
||||||
| `ultrasound/report.txt` | 超声报告的「所见」原文,UTF-8,不加标题 |
|
| `ultrasound/report.txt` | 超声报告的「所见」原文,UTF-8,不加标题 |
|
||||||
| `ultrasound/key.png` | 医生截的超声关键图,只保留像素和颜色模式重新编码,不带文本块、EXIF、ICC 等附加数据(附加数据里可能有文件名、时间等线索)。超声帧上烧录的信息栏不遮蔽(`AGENTS.md` 硬性约束 3) |
|
| `ultrasound/keyframes/key.png` | 医生截的超声关键图(来自交付包,每例都有,和关键帧放在同一个文件夹),只保留像素和颜色模式重新编码,不带文本块、EXIF、ICC 等附加数据(附加数据里可能有文件名、时间等线索)。超声帧上烧录的信息栏不遮蔽(`AGENTS.md` 硬性约束 3) |
|
||||||
| `ultrasound/keyframes/<槽位>.png` | CEUS 运行里定位到的关键帧整幅画面,和 `key.png` 一样去掉附加数据重新编码 |
|
| `ultrasound/keyframes/<槽位>.png` | CEUS 运行里定位到的关键帧整幅画面,和 `key.png` 一样去掉附加数据重新编码 |
|
||||||
| `ultrasound/tic.json` | 四条 TIC 曲线的拟合是否成功和 R²、病灶两条曲线的灌注指标、紫圈曲线是否可用、7 个槽位是否定位到及视频时间或未定位原因。字段逐个从 CEUS 的 `summary.json` 挑出,不含视频号、路径、配置、帧号(`结构.md` 第四部分) |
|
| `ultrasound/tic.json` | 只有两项:紫圈(`roi_pan`)和病灶去暗区(`roi_perf`)两组各 11 个灌注指标(拟合没过整组为 null,单项算不出为 null),7 个槽位定位到的视频时间(秒,没定位到为 null)。字段逐个从 CEUS 的 `summary.json` 挑出,拟合成败、R²、没定位的原因、视频号、路径、配置、帧号都不进(`结构.md` 第四部分) |
|
||||||
| `mr/report.txt` | MR 报告的「所见」原文 |
|
| `mr/report.txt` | MR 报告的「所见」原文 |
|
||||||
|
|
||||||
性别、年龄只在临床一路:两份报告表里医院也复制了这两列,但影像两路先看到会被人口学先验带偏(`结构.md` 第四部分)。
|
性别、年龄只在临床一路:两份报告表里医院也复制了这两列,但影像两路先看到会被人口学先验带偏(`结构.md` 第四部分)。
|
||||||
|
|||||||
+30
-47
@@ -1,8 +1,11 @@
|
|||||||
"""从一次 CEUS 定量运行按白名单挑出超声一路的 tic.json 和关键帧(C15)。
|
"""从一次 CEUS 定量运行按白名单挑出超声一路的 tic.json 和关键帧(C15)。
|
||||||
|
|
||||||
运行目录的布局由 runs 读(`open_ceus_run`);tic.json 的字段只在这里列,视频号、路径、配置、帧号和
|
运行目录的布局由 runs 读(`open_ceus_run`);tic.json 的字段只在这里列:紫圈和病灶去暗区两组的
|
||||||
拟合参数都不进去。快照里的交付包路径、summary.json 里记的视频 SHA-256 和推注时间要与这份交付包
|
11 个灌注指标、7 个关键帧槽位的视频时间。拟合成败与 R²、病灶整体和背景圆、紫圈是否可用、
|
||||||
一致,否则运行不是在这份交付包上跑的,整次停下。
|
关键帧未定位的原因、视频号、路径、配置、帧号和拟合参数都不进去。
|
||||||
|
|
||||||
|
快照里的交付包路径、summary.json 里记的视频 SHA-256 和推注时间要与这份交付包一致,否则运行
|
||||||
|
不是在这份交付包上跑的,整次停下。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -17,12 +20,10 @@ from cadx.runs import CeusRun, open_ceus_run
|
|||||||
C15 = "C15"
|
C15 = "C15"
|
||||||
C15_DESCRIPTION = "CEUS 定量结果齐全"
|
C15_DESCRIPTION = "CEUS 定量结果齐全"
|
||||||
|
|
||||||
# 四条曲线:病灶整体、病灶去暗区、紫圈(医生圈的正常胰腺对照)、背景圆。
|
# 给专家的两组灌注指标:紫圈(医生圈的正常胰腺实质)与病灶去暗区,让病灶和正常胰腺对着看。
|
||||||
_CURVES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
|
_METRIC_CURVES = ("roi_pan", "roi_perf")
|
||||||
_LESION_CURVES = ("roi_all", "roi_perf")
|
|
||||||
_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||||
_PERFUSION_LABELS = ("signal_domain", "time_origin", "area_curve")
|
# 11 个灌注指标(summary.json 的 perfusion_metrics.<曲线>.values 下的键)。
|
||||||
# 11 个灌注指标(同旧 replay summary 的 perfusion_metrics.values)。
|
|
||||||
_PERFUSION_VALUES = (
|
_PERFUSION_VALUES = (
|
||||||
"PE_au",
|
"PE_au",
|
||||||
"WiAUC_au_s",
|
"WiAUC_au_s",
|
||||||
@@ -36,9 +37,6 @@ _PERFUSION_VALUES = (
|
|||||||
"FT_s",
|
"FT_s",
|
||||||
"WoR_au_per_s",
|
"WoR_au_per_s",
|
||||||
)
|
)
|
||||||
_SUPPORTING_TIMES = ("TI", "mTT", "TO")
|
|
||||||
# 未定位原因由 ceus 按固定模板加数值生成,不含路径;出现这些字符说明模板变了,停下检查。
|
|
||||||
_PATH_MARKS = ("/", "\\", ".mp4", ".png")
|
|
||||||
|
|
||||||
|
|
||||||
def open_matching_run(run_dir: Path, delivery_dir: Path, repo_root: Path) -> CeusRun:
|
def open_matching_run(run_dir: Path, delivery_dir: Path, repo_root: Path) -> CeusRun:
|
||||||
@@ -69,45 +67,30 @@ def _typed[T](value: object, kind: type[T]) -> T:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _perfusion(section: dict[str, Any] | None) -> dict[str, Any] | None:
|
def _perfusion(section: dict[str, Any] | None) -> dict[str, float | int | None] | None:
|
||||||
|
"""一组的 11 个指标;拟合没过质量门(summary 里整组为 null)时整组为 None。"""
|
||||||
if section is None:
|
if section is None:
|
||||||
return None
|
return None
|
||||||
|
return {key: _number(section["values"][key]) for key in _PERFUSION_VALUES}
|
||||||
|
|
||||||
|
|
||||||
|
def _keyframe_time(anchor: dict[str, Any]) -> float | int | None:
|
||||||
|
"""一个槽位定位到的视频时间(秒),没定位到为 None;状态与时间互相矛盾时报错。"""
|
||||||
|
time_s = _number(anchor["frame_time_video_s"])
|
||||||
|
if (anchor["status"] == "ok") != (time_s is not None):
|
||||||
|
raise ValueError(f"summary.json 的关键帧状态与时间互相矛盾:{anchor}")
|
||||||
|
return time_s
|
||||||
|
|
||||||
|
|
||||||
|
def tic_view(summary: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""从一例的 summary.json 按白名单构造 tic.json:两组灌注指标、7 个槽位的视频时间,别的不写。"""
|
||||||
return {
|
return {
|
||||||
**{key: _typed(section[key], str) for key in _PERFUSION_LABELS},
|
|
||||||
"values": {key: _number(section["values"][key]) for key in _PERFUSION_VALUES},
|
|
||||||
"supporting_times_s": {
|
|
||||||
key: _number(section["supporting_times_s"][key]) for key in _SUPPORTING_TIMES
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _keyframe(anchor: dict[str, Any], video_id: str) -> dict[str, Any]:
|
|
||||||
located = anchor["status"] == "ok"
|
|
||||||
time_s, reason = _number(anchor["frame_time_video_s"]), anchor["reason"]
|
|
||||||
if located != (time_s is not None) or located != (reason is None):
|
|
||||||
raise ValueError(f"summary.json 的关键帧状态、时间、原因互相矛盾:{anchor}")
|
|
||||||
if reason is not None and (
|
|
||||||
not isinstance(reason, str) or video_id in reason or any(m in reason for m in _PATH_MARKS)
|
|
||||||
):
|
|
||||||
raise ValueError(f"关键帧未定位原因含视频号或路径样的字符,不能给专家:{reason!r}")
|
|
||||||
return {"located": located, "video_time_s": time_s, "reason": reason}
|
|
||||||
|
|
||||||
|
|
||||||
def tic_view(summary: dict[str, Any], video_id: str) -> dict[str, Any]:
|
|
||||||
"""从一例的 summary.json 按白名单逐字段构造 tic.json 的内容。"""
|
|
||||||
return {
|
|
||||||
"fits": {
|
|
||||||
name: {
|
|
||||||
"success": _typed(summary["fits"][name]["success"], bool),
|
|
||||||
"r2": _number(summary["fits"][name]["quality"]["r2"]),
|
|
||||||
}
|
|
||||||
for name in _CURVES
|
|
||||||
},
|
|
||||||
"perfusion_metrics": {
|
"perfusion_metrics": {
|
||||||
name: _perfusion(summary["perfusion_metrics"][name]) for name in _LESION_CURVES
|
name: _perfusion(summary["perfusion_metrics"][name]) for name in _METRIC_CURVES
|
||||||
|
},
|
||||||
|
"keyframe_times_s": {
|
||||||
|
slot: _keyframe_time(summary["anchor_frames"][slot]) for slot in _SLOTS
|
||||||
},
|
},
|
||||||
"pan_curve_usable": _typed(summary["reference_valid"], bool),
|
|
||||||
"keyframes": {slot: _keyframe(summary["anchor_frames"][slot], video_id) for slot in _SLOTS},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -141,8 +124,8 @@ def case_ceus(run: CeusRun, case: CaseRecord) -> LibraryCeus | SkipRecord:
|
|||||||
f"CEUS 运行不是在这份交付包上跑的:交付包的推注时间 {case.bolus_start_s} 秒与 "
|
f"CEUS 运行不是在这份交付包上跑的:交付包的推注时间 {case.bolus_start_s} 秒与 "
|
||||||
f"{summary_path} 的 injection_offset_s 不同"
|
f"{summary_path} 的 injection_offset_s 不同"
|
||||||
)
|
)
|
||||||
tic = tic_view(summary, case.video_id)
|
tic = tic_view(summary)
|
||||||
located = [slot for slot in _SLOTS if tic["keyframes"][slot]["located"]]
|
located = [slot for slot in _SLOTS if tic["keyframe_times_s"][slot] is not None]
|
||||||
files = run.keyframe_files(case.video_id)
|
files = run.keyframe_files(case.video_id)
|
||||||
# 文件必须恰好是定位到的各槽位的 `<槽位>.png`:多一个(含同名不同扩展名)、少一个都报错。
|
# 文件必须恰好是定位到的各槽位的 `<槽位>.png`:多一个(含同名不同扩展名)、少一个都报错。
|
||||||
if sorted(p.name for p in files) != sorted(f"{slot}.png" for slot in located):
|
if sorted(p.name for p in files) != sorted(f"{slot}.png" for slot in located):
|
||||||
|
|||||||
@@ -55,8 +55,11 @@ from cadx.ceus._tic_plot import TicPlotMarker
|
|||||||
from cadx.ceus._trajectory import Trajectory
|
from cadx.ceus._trajectory import Trajectory
|
||||||
from cadx.ceus._video import VideoDecodeError, VideoFile
|
from cadx.ceus._video import VideoDecodeError, VideoFile
|
||||||
|
|
||||||
# 灌注指标只对病灶两条曲线算(同旧 replay 的 `_LESION_CURVES`)。
|
# 病灶两条曲线:两条的拟合都过了质量门,才定位 B0、D1–D4(同旧 replay 的 `_LESION_CURVES`)。
|
||||||
LESION_CURVES = ("roi_all", "roi_perf")
|
LESION_CURVES = ("roi_all", "roi_perf")
|
||||||
|
# 算灌注指标的两条曲线:病灶去暗区与紫圈(医生圈的正常胰腺实质),让病灶和正常胰腺对着看。
|
||||||
|
# 病灶整体(roi_all)含不进造影剂的暗区,不算指标。来源:负责人 2026-10-17 决定。
|
||||||
|
METRIC_CURVES = ("roi_perf", "roi_pan")
|
||||||
|
|
||||||
# tic.png 上 D1–D4 竖线的颜色(BGR,同旧 replay)。
|
# tic.png 上 D1–D4 竖线的颜色(BGR,同旧 replay)。
|
||||||
_ANCHOR_COLORS = {
|
_ANCHOR_COLORS = {
|
||||||
@@ -119,7 +122,7 @@ class QuantResult:
|
|||||||
- `injection_offset_s`:推注时间 X(秒,视频第 0 秒时已推注的时长);
|
- `injection_offset_s`:推注时间 X(秒,视频第 0 秒时已推注的时长);
|
||||||
- `lesion_fit_ready`:病灶两条曲线的拟合是否都过了质量门;
|
- `lesion_fit_ready`:病灶两条曲线的拟合是否都过了质量门;
|
||||||
- `anchors`:7 个关键帧槽位的定位(按 `ANCHOR_SLOTS` 顺序);
|
- `anchors`:7 个关键帧槽位的定位(按 `ANCHOR_SLOTS` 顺序);
|
||||||
- `perfusion`:`roi_all`、`roi_perf` 各自的灌注指标,拟合没过质量门为 None;
|
- `perfusion`:`METRIC_CURVES`(`roi_perf`、`roi_pan`)各自的灌注指标,拟合没过质量门为 None;
|
||||||
- `pan_detected_fraction`:紫圈检出率(非重复帧里认出紫圈的帧占比)。
|
- `pan_detected_fraction`:紫圈检出率(非重复帧里认出紫圈的帧占比)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -311,7 +314,7 @@ def quantify_case(
|
|||||||
|
|
||||||
# 第 9 步:灌注指标。
|
# 第 9 步:灌注指标。
|
||||||
perfusion = {
|
perfusion = {
|
||||||
name: compute_lognormal_perfusion_metrics(tic.fits[name]) for name in LESION_CURVES
|
name: compute_lognormal_perfusion_metrics(tic.fits[name]) for name in METRIC_CURVES
|
||||||
}
|
}
|
||||||
|
|
||||||
trajectory = Trajectory(
|
trajectory = Trajectory(
|
||||||
@@ -516,7 +519,7 @@ def quant_summary(result: QuantResult) -> dict[str, Any]:
|
|||||||
"lesion_fit_ready": result.lesion_fit_ready,
|
"lesion_fit_ready": result.lesion_fit_ready,
|
||||||
"fits": {name: _fit_summary(tic.fits[name]) for name in CURVE_NAMES},
|
"fits": {name: _fit_summary(tic.fits[name]) for name in CURVE_NAMES},
|
||||||
"perfusion_metrics": {
|
"perfusion_metrics": {
|
||||||
name: _perfusion_summary(result.perfusion[name]) for name in LESION_CURVES
|
name: _perfusion_summary(result.perfusion[name]) for name in METRIC_CURVES
|
||||||
},
|
},
|
||||||
"anchor_frames": {slot: _anchor_summary(item) for slot, item in result.anchors.items()},
|
"anchor_frames": {slot: _anchor_summary(item) for slot, item in result.anchors.items()},
|
||||||
"available_dynamic_slots": [
|
"available_dynamic_slots": [
|
||||||
|
|||||||
@@ -15,9 +15,10 @@ from cadx.library._read import (
|
|||||||
read_ultrasound,
|
read_ultrasound,
|
||||||
)
|
)
|
||||||
from cadx.library._scan import LeakHit, scan_for_leaks
|
from cadx.library._scan import LeakHit, scan_for_leaks
|
||||||
from cadx.library._write import LibraryCase, LibraryCeus, check_key_image, write_library
|
from cadx.library._write import KEY_SLOT, LibraryCase, LibraryCeus, check_key_image, write_library
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"KEY_SLOT",
|
||||||
"BuildInfo",
|
"BuildInfo",
|
||||||
"ClinicalRoute",
|
"ClinicalRoute",
|
||||||
"LeakHit",
|
"LeakHit",
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from typing import Any, cast
|
|||||||
|
|
||||||
from cadx.library._write import (
|
from cadx.library._write import (
|
||||||
CLINICAL_JSON,
|
CLINICAL_JSON,
|
||||||
KEY_IMAGE,
|
|
||||||
KEYFRAMES_DIR,
|
KEYFRAMES_DIR,
|
||||||
MANIFEST,
|
MANIFEST,
|
||||||
MR_REPORT,
|
MR_REPORT,
|
||||||
@@ -28,11 +27,11 @@ class ClinicalRoute:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class UltrasoundRoute:
|
class UltrasoundRoute:
|
||||||
"""超声一路:报告「所见」原文、医生截的关键图(给路径);装了 CEUS 结果时还有 tic.json 的内容和
|
"""超声一路:报告「所见」原文;`keyframes` 是 keyframes/ 下的图(主干 → 路径),总有医生截的
|
||||||
定位到的关键帧(槽位 → 路径),没装时 `tic` 为 None、`keyframes` 为空。"""
|
关键图 `key`(`KEY_SLOT`),装了 CEUS 结果时还有定位到的各槽位;`tic` 是 tic.json 的内容,
|
||||||
|
没装 CEUS 时为 None。"""
|
||||||
|
|
||||||
report: str
|
report: str
|
||||||
key_image: Path
|
|
||||||
tic: dict[str, Any] | None
|
tic: dict[str, Any] | None
|
||||||
keyframes: dict[str, Path]
|
keyframes: dict[str, Path]
|
||||||
|
|
||||||
@@ -99,18 +98,17 @@ def read_clinical(build_dir: Path, alias: str) -> ClinicalRoute:
|
|||||||
def read_ultrasound(build_dir: Path, alias: str) -> UltrasoundRoute:
|
def read_ultrasound(build_dir: Path, alias: str) -> UltrasoundRoute:
|
||||||
"""读一例的超声一路。"""
|
"""读一例的超声一路。"""
|
||||||
report = _text(_verified(build_dir, alias, ULTRASOUND_REPORT))
|
report = _text(_verified(build_dir, alias, ULTRASOUND_REPORT))
|
||||||
key_image = _verified(build_dir, alias, KEY_IMAGE)
|
|
||||||
manifest = _manifest(build_dir)
|
manifest = _manifest(build_dir)
|
||||||
if manifest["ceus_source"] is None:
|
|
||||||
return UltrasoundRoute(report=report, key_image=key_image, tic=None, keyframes={})
|
|
||||||
prefix = case_file(alias, f"{KEYFRAMES_DIR}/")
|
prefix = case_file(alias, f"{KEYFRAMES_DIR}/")
|
||||||
keyframes = {
|
keyframes = {
|
||||||
Path(key).stem: _verified(build_dir, alias, key.removeprefix(f"cases/{alias}/"))
|
Path(key).stem: _verified(build_dir, alias, key.removeprefix(f"cases/{alias}/"))
|
||||||
for key in manifest["files"]
|
for key in manifest["files"]
|
||||||
if key.startswith(prefix)
|
if key.startswith(prefix)
|
||||||
}
|
}
|
||||||
|
if manifest["ceus_source"] is None:
|
||||||
|
return UltrasoundRoute(report=report, tic=None, keyframes=keyframes)
|
||||||
tic = json.loads(_text(_verified(build_dir, alias, TIC_JSON)))
|
tic = json.loads(_text(_verified(build_dir, alias, TIC_JSON)))
|
||||||
return UltrasoundRoute(report=report, key_image=key_image, tic=tic, keyframes=keyframes)
|
return UltrasoundRoute(report=report, tic=tic, keyframes=keyframes)
|
||||||
|
|
||||||
|
|
||||||
def read_mr(build_dir: Path, alias: str) -> MrRoute:
|
def read_mr(build_dir: Path, alias: str) -> MrRoute:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
manifest.json
|
manifest.json
|
||||||
cases/<化名>/clinical/clinical.json
|
cases/<化名>/clinical/clinical.json
|
||||||
cases/<化名>/ultrasound/report.txt
|
cases/<化名>/ultrasound/report.txt
|
||||||
cases/<化名>/ultrasound/key.png
|
cases/<化名>/ultrasound/keyframes/key.png 医生截的关键图(来自交付包,总有)
|
||||||
cases/<化名>/ultrasound/keyframes/<槽位>.png (装了 CEUS 结果时;只有定位到的槽位)
|
cases/<化名>/ultrasound/keyframes/<槽位>.png (装了 CEUS 结果时;只有定位到的槽位)
|
||||||
cases/<化名>/ultrasound/tic.json (装了 CEUS 结果时)
|
cases/<化名>/ultrasound/tic.json (装了 CEUS 结果时)
|
||||||
cases/<化名>/mr/report.txt
|
cases/<化名>/mr/report.txt
|
||||||
@@ -23,8 +23,10 @@ from PIL import Image
|
|||||||
MANIFEST = "manifest.json"
|
MANIFEST = "manifest.json"
|
||||||
CLINICAL_JSON = "clinical/clinical.json"
|
CLINICAL_JSON = "clinical/clinical.json"
|
||||||
ULTRASOUND_REPORT = "ultrasound/report.txt"
|
ULTRASOUND_REPORT = "ultrasound/report.txt"
|
||||||
KEY_IMAGE = "ultrasound/key.png"
|
|
||||||
KEYFRAMES_DIR = "ultrasound/keyframes"
|
KEYFRAMES_DIR = "ultrasound/keyframes"
|
||||||
|
# 医生截的关键图与 CEUS 定位的关键帧放在同一个文件夹,文件名主干 `key` 不与槽位名重。
|
||||||
|
KEY_SLOT = "key"
|
||||||
|
KEY_IMAGE = f"{KEYFRAMES_DIR}/{KEY_SLOT}.png"
|
||||||
TIC_JSON = "ultrasound/tic.json"
|
TIC_JSON = "ultrasound/tic.json"
|
||||||
MR_REPORT = "mr/report.txt"
|
MR_REPORT = "mr/report.txt"
|
||||||
|
|
||||||
@@ -120,7 +122,7 @@ def write_library(
|
|||||||
if (case.ceus is None) != (ceus_source is None):
|
if (case.ceus is None) != (ceus_source is None):
|
||||||
raise ValueError(f"化名 {case.alias} 的 CEUS 结果与 ceus_source={ceus_source!r} 不配套")
|
raise ValueError(f"化名 {case.alias} 的 CEUS 结果与 ceus_source={ceus_source!r} 不配套")
|
||||||
case_dir = build_dir / "cases" / case.alias
|
case_dir = build_dir / "cases" / case.alias
|
||||||
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, MR_REPORT):
|
for relative in (CLINICAL_JSON, KEY_IMAGE, MR_REPORT):
|
||||||
(case_dir / relative).parent.mkdir(parents=True, exist_ok=True)
|
(case_dir / relative).parent.mkdir(parents=True, exist_ok=True)
|
||||||
_write_json(
|
_write_json(
|
||||||
case_dir / CLINICAL_JSON,
|
case_dir / CLINICAL_JSON,
|
||||||
@@ -131,7 +133,6 @@ def write_library(
|
|||||||
(case_dir / MR_REPORT).write_text(case.mr_report, encoding="utf-8")
|
(case_dir / MR_REPORT).write_text(case.mr_report, encoding="utf-8")
|
||||||
written = [CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT]
|
written = [CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT]
|
||||||
if case.ceus is not None:
|
if case.ceus is not None:
|
||||||
(case_dir / KEYFRAMES_DIR).mkdir()
|
|
||||||
for slot, source in case.ceus.keyframe_sources.items():
|
for slot, source in case.ceus.keyframe_sources.items():
|
||||||
_write_clean_png(source, case_dir / KEYFRAMES_DIR / f"{slot}.png")
|
_write_clean_png(source, case_dir / KEYFRAMES_DIR / f"{slot}.png")
|
||||||
written.append(f"{KEYFRAMES_DIR}/{slot}.png")
|
written.append(f"{KEYFRAMES_DIR}/{slot}.png")
|
||||||
|
|||||||
+173
-133
@@ -1,17 +1,20 @@
|
|||||||
"""出一次医生核对包:目录布局、每个文件的格式、问题清单的来源与写法。
|
"""出一次医生核对包:目录布局、每个文件的格式、问题清单的来源。
|
||||||
|
|
||||||
```
|
```
|
||||||
<output_root>/<运行号>/
|
<output_root>/<运行号>/
|
||||||
snapshot/ 快照(不给医生)
|
snapshot/ 快照(不给医生),含程序原文的 问题清单_原文.csv
|
||||||
医生核对包_<构建号>/
|
医生核对包_<构建号>/
|
||||||
说明.pdf 问题清单.csv 病例总表.csv
|
说明.pdf 问题清单.csv 病例总表.csv
|
||||||
标准答案/化名对照.csv 诊断.csv 跳过病例.csv 报告复核句.csv
|
标准答案/化名对照.csv 诊断.csv 跳过病例.csv 报告复核句.csv
|
||||||
病例/<病例号>/临床信息.csv 超声报告.pdf MR报告.pdf 关键图.png
|
病例/<病例号>/临床信息.csv 超声报告.pdf MR报告.pdf
|
||||||
关键帧/<槽位>_第<秒>秒.png TIC曲线.png TIC结果.csv 回放视频.mp4
|
关键帧/医生截图.png <槽位>_第<秒>秒.png
|
||||||
|
TIC曲线.png TIC参数.csv 回放视频.mp4
|
||||||
```
|
```
|
||||||
|
|
||||||
只读已经构建好的资料库、答案库和那次 CEUS 运行,不写它们;图片和视频按字节拷贝,不打开。
|
只读已经构建好的资料库、答案库和那次 CEUS 运行,不写它们;图片和视频按字节拷贝,不打开。
|
||||||
问题清单每一行都抄自某个已有文件里的某个值,不另做判断。
|
TIC参数.csv 的数值取自资料库的 tic.json(医生核对的就是 AI 专家看到的那份),只有整组没算出的
|
||||||
|
原因取自 CEUS 运行的 summary.json。问题清单每一行都抄自某个已有文件里的某个值,经 `_problems`
|
||||||
|
的对照表换成医生能懂的话;程序原文另存在快照里。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
@@ -29,10 +32,19 @@ from typing import Any
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from cadx.answers import read_aliases, read_skipped, verified_file
|
from cadx.answers import SkippedRow, read_aliases, read_skipped, verified_file
|
||||||
from cadx.library import read_build_info, read_clinical, read_mr, read_ultrasound
|
from cadx.library import KEY_SLOT, read_build_info, read_clinical, read_mr, read_ultrasound
|
||||||
from cadx.review._config import load_review_config
|
from cadx.review._config import load_review_config
|
||||||
from cadx.review._pdf import markdown_to_pdf, report_markdown, tool_versions
|
from cadx.review._pdf import markdown_to_pdf, report_markdown, tool_versions
|
||||||
|
from cadx.review._problems import (
|
||||||
|
STAGE_VIDEO,
|
||||||
|
case_label,
|
||||||
|
ceus_failure,
|
||||||
|
check_plain,
|
||||||
|
curve_problems,
|
||||||
|
group_reason,
|
||||||
|
skip_problem,
|
||||||
|
)
|
||||||
from cadx.runs import (
|
from cadx.runs import (
|
||||||
CeusRun,
|
CeusRun,
|
||||||
finish_snapshot,
|
finish_snapshot,
|
||||||
@@ -46,7 +58,10 @@ SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
|||||||
_CASE_COLUMNS = ("病例号", "视频号", "化名", "是否进入资料库", "没进的原因")
|
_CASE_COLUMNS = ("病例号", "视频号", "化名", "是否进入资料库", "没进的原因")
|
||||||
SUMMARY_HEADER = (*_CASE_COLUMNS, "CEUS 定量是否成功", "CEUS 失败在第几步", *SLOTS)
|
SUMMARY_HEADER = (*_CASE_COLUMNS, "CEUS 定量是否成功", "CEUS 失败在第几步", *SLOTS)
|
||||||
PROBLEM_HEADER = ("病例号", "视频号", "环节", "问题")
|
PROBLEM_HEADER = ("病例号", "视频号", "环节", "问题")
|
||||||
TIC_HEADER = ("项目", "值")
|
TIC_PARAMS_HEADER = ("指标", "单位", "胰腺实质(紫圈)", "病灶(去暗区)")
|
||||||
|
NO_GROUP_ROW = "没算出的原因"
|
||||||
|
KEY_IMAGE_NAME = "医生截图.png"
|
||||||
|
RAW_PROBLEMS = "问题清单_原文.csv"
|
||||||
# 答案库的表 → 包里的文件名;内容逐字节照抄(答案库写的就是带 BOM 的 UTF-8、中文列名、缺失为空)。
|
# 答案库的表 → 包里的文件名;内容逐字节照抄(答案库写的就是带 BOM 的 UTF-8、中文列名、缺失为空)。
|
||||||
ANSWER_TABLES = {
|
ANSWER_TABLES = {
|
||||||
"aliases.csv": "化名对照.csv",
|
"aliases.csv": "化名对照.csv",
|
||||||
@@ -54,49 +69,32 @@ ANSWER_TABLES = {
|
|||||||
"skipped.csv": "跳过病例.csv",
|
"skipped.csv": "跳过病例.csv",
|
||||||
"review.csv": "报告复核句.csv",
|
"review.csv": "报告复核句.csv",
|
||||||
}
|
}
|
||||||
STAGE_ASSEMBLY = "装配资料库"
|
# TIC参数.csv 的两组(tic.json 里 perfusion_metrics 的键,按列的顺序)和 11 行:
|
||||||
STAGE_FIT = "TIC 拟合"
|
# tic.json 里的键、中文名加缩写、单位。时间类(单位秒)保留 1 位小数,其余 2 位。
|
||||||
STAGE_KEYFRAME = "关键帧"
|
TIC_GROUPS = ("roi_pan", "roi_perf")
|
||||||
STAGE_PAN = "紫圈"
|
TIC_METRICS = (
|
||||||
|
("PE_au", "峰值增强 PE", "灰度"),
|
||||||
|
("TTP_s", "达峰时间 TTP", "秒"),
|
||||||
|
("WiR_au_per_s", "最大洗入斜率 WiR", "灰度/秒"),
|
||||||
|
("RT_s", "上升时间 RT", "秒"),
|
||||||
|
("WiAUC_au_s", "洗入曲线下面积 WiAUC", "灰度·秒"),
|
||||||
|
("WiPI_au", "洗入灌注指数 WiPI", "灰度"),
|
||||||
|
("WoR_au_per_s", "最大洗出斜率 WoR", "灰度/秒"),
|
||||||
|
("FT_s", "下降时间 FT", "秒"),
|
||||||
|
("WoAUC_au_s", "洗出曲线下面积 WoAUC", "灰度·秒"),
|
||||||
|
("WiWoAUC_au_s", "洗入洗出曲线下面积 WiWoAUC", "灰度·秒"),
|
||||||
|
("mTTl_s", "平均渡越时间 mTTl", "秒"),
|
||||||
|
)
|
||||||
|
_TIC_KEYS = ("perfusion_metrics", "keyframe_times_s")
|
||||||
|
# 快照里程序原文问题清单(给我们自己追溯,写法同任务书 03)的「环节」。
|
||||||
|
_RAW_ASSEMBLY = "装配资料库"
|
||||||
|
_RAW_FIT = "TIC 拟合"
|
||||||
|
_RAW_KEYFRAME = "关键帧"
|
||||||
|
_RAW_PAN = "紫圈"
|
||||||
_LESION_CURVES = {"roi_all": "病灶整体曲线", "roi_perf": "病灶去暗区曲线"}
|
_LESION_CURVES = {"roi_all": "病灶整体曲线", "roi_perf": "病灶去暗区曲线"}
|
||||||
_CURVES = {**_LESION_CURVES, "roi_pan": "紫圈曲线", "background_circle": "背景圆曲线"}
|
# 库里存的病例号是三位数字;视频号是「U + 三位病例号」(交付包检查 C05 核对过)。跳过的病例也按
|
||||||
_PERFUSION = {
|
# 这条规则写,在 C05 之前就被跳过的,交付里实际的 CEUS视频ID 没有核对过。认不出编号的跳过记录
|
||||||
"signal_domain": "灌注指标 信号域",
|
# 视频号空着。
|
||||||
"time_origin": "灌注指标 时间零点",
|
|
||||||
"area_curve": "灌注指标 面积所用曲线",
|
|
||||||
"values.PE_au": "峰值增强 PE",
|
|
||||||
"values.WiAUC_au_s": "洗入段曲线下面积 WiAUC",
|
|
||||||
"values.RT_s": "上升时间 RT(秒)",
|
|
||||||
"values.mTTl_s": "平均渡越时间 mTTl(秒)",
|
|
||||||
"values.TTP_s": "达峰时间 TTP(秒)",
|
|
||||||
"values.WiR_au_per_s": "最大洗入斜率 WiR",
|
|
||||||
"values.WiPI_au": "洗入灌注指数 WiPI",
|
|
||||||
"values.WoAUC_au_s": "洗出段曲线下面积 WoAUC",
|
|
||||||
"values.WiWoAUC_au_s": "洗入洗出总面积 WiWoAUC",
|
|
||||||
"values.FT_s": "下降时间 FT(秒)",
|
|
||||||
"values.WoR_au_per_s": "最大洗出斜率 WoR",
|
|
||||||
"supporting_times_s.TI": "辅助时刻 TI(秒)",
|
|
||||||
"supporting_times_s.mTT": "辅助时刻 mTT(秒)",
|
|
||||||
"supporting_times_s.TO": "辅助时刻 TO(秒)",
|
|
||||||
}
|
|
||||||
# TIC结果.csv 的行:tic.json 里的字段路径 → 中文名。
|
|
||||||
TIC_ITEMS = {
|
|
||||||
**{f"fits.{c}.success": f"{n} 拟合成功" for c, n in _CURVES.items()},
|
|
||||||
**{f"fits.{c}.r2": f"{n} 拟合优度 R²" for c, n in _CURVES.items()},
|
|
||||||
**{
|
|
||||||
f"perfusion_metrics.{c}.{k}": f"{n} {label}"
|
|
||||||
for c, n in _LESION_CURVES.items()
|
|
||||||
for k, label in _PERFUSION.items()
|
|
||||||
},
|
|
||||||
"pan_curve_usable": "紫圈曲线可用",
|
|
||||||
**{f"keyframes.{s}.located": f"关键帧 {s} 取到" for s in SLOTS},
|
|
||||||
**{f"keyframes.{s}.video_time_s": f"关键帧 {s} 视频时间(秒)" for s in SLOTS},
|
|
||||||
**{f"keyframes.{s}.reason": f"关键帧 {s} 未取到的原因" for s in SLOTS},
|
|
||||||
}
|
|
||||||
# 病例号是交付包临床表 Case_ID 的写法「P + 三位数字」,库里存的是三位数字。
|
|
||||||
# 视频号是「U + 三位病例号」(交付包检查 C05 核对过);跳过的病例也按这条规则写,在 C05 之前
|
|
||||||
# 就被跳过的,交付里实际的 CEUS视频ID 没有核对过。认不出编号的跳过记录(「文件 第 N 行」等)
|
|
||||||
# 病例号原样写,视频号空着。
|
|
||||||
_CASE_NO = re.compile(r"[0-9]{3}")
|
_CASE_NO = re.compile(r"[0-9]{3}")
|
||||||
_VIDEO_ID = re.compile(r"U([0-9]{3})")
|
_VIDEO_ID = re.compile(r"U([0-9]{3})")
|
||||||
|
|
||||||
@@ -115,12 +113,15 @@ class PackSummary:
|
|||||||
video_bytes: int
|
video_bytes: int
|
||||||
|
|
||||||
|
|
||||||
def _doctor_no(case_no: str) -> str:
|
type Problem = tuple[str, str, str, str] # 病例号、视频号、环节、问题
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_no(case_no: str) -> str:
|
||||||
return f"P{case_no}" if _CASE_NO.fullmatch(case_no) else case_no
|
return f"P{case_no}" if _CASE_NO.fullmatch(case_no) else case_no
|
||||||
|
|
||||||
|
|
||||||
def _cell(value: object) -> str:
|
def _cell(value: object) -> str:
|
||||||
"""CSV 的格:None 写空串,布尔写 true/false,数值写 JSON 的写法,字符串原样。"""
|
"""CSV 的格:None 写空串,数值写 JSON 的写法,字符串原样。"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return ""
|
return ""
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
@@ -135,33 +136,40 @@ def _write_csv(path: Path, header: Sequence[str], rows: Sequence[Sequence[str]])
|
|||||||
writer.writerows(rows)
|
writer.writerows(rows)
|
||||||
|
|
||||||
|
|
||||||
def _leaves(node: object, prefix: str) -> list[str]:
|
def _metric(value: object, unit: str) -> str:
|
||||||
if isinstance(node, dict):
|
if value is None:
|
||||||
items: dict[str, object] = node # pyright: ignore[reportUnknownVariableType]
|
return ""
|
||||||
return [leaf for key, value in items.items() for leaf in _leaves(value, f"{prefix}{key}.")]
|
if isinstance(value, bool) or not isinstance(value, int | float):
|
||||||
return [prefix.removesuffix(".")]
|
raise ValueError(f"tic.json 里的灌注指标应为数值或 null,是 {value!r}")
|
||||||
|
return f"{value:.1f}" if unit == "秒" else f"{value:.2f}"
|
||||||
|
|
||||||
|
|
||||||
def _lookup(tic: dict[str, Any], path: str) -> object:
|
def tic_param_rows(tic: dict[str, Any], reasons: dict[str, str]) -> list[list[str]]:
|
||||||
"""按路径取值;路径上某一段是 null(如拟合没过时的灌注指标)则这一项为 None。"""
|
"""TIC参数.csv 的行:11 个指标各一行,两组各一列;`reasons` 是整组为 null 的组 → 没算出的原因,
|
||||||
node: Any = tic
|
有这样的组时最后加一行「没算出的原因」。tic.json 的字段与这里列的不一致就报错。"""
|
||||||
for key in path.split("."):
|
if tuple(sorted(tic)) != tuple(sorted(_TIC_KEYS)):
|
||||||
if node is None:
|
raise ValueError(f"tic.json 的字段是 {sorted(tic)},核对包只认得 {list(_TIC_KEYS)}")
|
||||||
return None
|
groups: dict[str, dict[str, Any] | None] = tic["perfusion_metrics"]
|
||||||
node = node[key]
|
if sorted(groups) != sorted(TIC_GROUPS):
|
||||||
return node
|
raise ValueError(f"tic.json 的灌注指标分组是 {sorted(groups)},应为 {list(TIC_GROUPS)}")
|
||||||
|
keys = sorted(key for key, _, _ in TIC_METRICS)
|
||||||
|
for name, values in groups.items():
|
||||||
def tic_rows(tic: dict[str, Any]) -> list[tuple[str, str]]:
|
if values is not None and sorted(values) != keys:
|
||||||
"""TIC结果.csv 的行;tic.json 里有 `TIC_ITEMS` 没列的字段就报错(字段变了要同步中文名)。"""
|
raise ValueError(f"tic.json 里 {name} 的灌注指标是 {sorted(values)},应为 {keys}")
|
||||||
unknown = [
|
empty = sorted(name for name, values in groups.items() if values is None)
|
||||||
p
|
if sorted(reasons) != empty:
|
||||||
for p in _leaves(tic, "")
|
raise ValueError(f"整组为空的是 {empty},给的原因却是 {sorted(reasons)}")
|
||||||
if p not in TIC_ITEMS and not any(q.startswith(f"{p}.") for q in TIC_ITEMS)
|
rows = [
|
||||||
|
[
|
||||||
|
label,
|
||||||
|
unit,
|
||||||
|
*(_metric(None if (g := groups[n]) is None else g[key], unit) for n in TIC_GROUPS),
|
||||||
]
|
]
|
||||||
if unknown:
|
for key, label, unit in TIC_METRICS
|
||||||
raise ValueError(f"tic.json 里有 TIC结果.csv 没有中文名的字段:{unknown}")
|
]
|
||||||
return [(f"{name}({path})", _cell(_lookup(tic, path))) for path, name in TIC_ITEMS.items()]
|
if reasons:
|
||||||
|
rows.append([NO_GROUP_ROW, "", *(reasons.get(name, "") for name in TIC_GROUPS)])
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
def _ceus_run(answers_dir: Path, run_id: str, repo_root: Path) -> CeusRun:
|
def _ceus_run(answers_dir: Path, run_id: str, repo_root: Path) -> CeusRun:
|
||||||
@@ -187,23 +195,20 @@ def _template_text(
|
|||||||
)
|
)
|
||||||
if run is None:
|
if run is None:
|
||||||
ceus_note = (
|
ceus_note = (
|
||||||
"**这次构建没有装入 CEUS 定量结果**:病例目录里没有关键帧、TIC曲线.png、"
|
"**这一版没有造影视频的处理结果**:病例文件夹里只有医生截图,没有程序取的关键帧、"
|
||||||
"TIC结果.csv、回放视频.mp4,病例总表的 CEUS 各列空着,"
|
"TIC 曲线图、TIC 参数和回放视频,病例总表里造影视频那几列空着。"
|
||||||
"问题清单里没有 CEUS、TIC 拟合、关键帧、紫圈几类。"
|
"下面讲关键帧、TIC 的几节可以先跳过。"
|
||||||
)
|
)
|
||||||
f1 = f2 = tolerance = "(这次没有 CEUS 结果,不适用)"
|
f1 = f2 = tolerance = "—"
|
||||||
else:
|
else:
|
||||||
ceus_note = f"这次构建装入了 CEUS 定量运行 {run.run_id} 的结果。"
|
ceus_note = ""
|
||||||
anchors = run.config["ceus"]["tic_analysis"]["anchor_frames"]
|
anchors = run.config["ceus"]["tic_analysis"]["anchor_frames"]
|
||||||
f1, f2 = f"{anchors['f1_anchor_s']:g}", f"{anchors['late_phase']['preferred_time_s']:g}"
|
f1, f2 = f"{anchors['f1_anchor_s']:g}", f"{anchors['late_phase']['preferred_time_s']:g}"
|
||||||
tolerance = f"{anchors['late_phase']['tolerance_s']:g}"
|
tolerance = f"{anchors['late_phase']['tolerance_s']:g}"
|
||||||
codes_note = (
|
codes_note = (
|
||||||
"临床信息.csv 里的编码列(性别、症状、既往史、化验是否异常)"
|
"编码列(性别、症状等)已按编码对照表换成文字。"
|
||||||
"已按贵方提供的编码对照表解码成文字。"
|
|
||||||
if codes_decoded
|
if codes_decoded
|
||||||
else "临床信息.csv 里用数字表示的列(性别、症状、既往史、化验是否异常)"
|
else "编码列(性别、症状等)照你们交来的数字原样写,还没换成文字。"
|
||||||
"这次**未解码**,照交付的原始数字写;数字的含义要等贵方提供编码对照表后再解码"
|
|
||||||
"(构建配置 build.yaml 的 decode_codes 开关)。"
|
|
||||||
)
|
)
|
||||||
return template.substitute(
|
return template.substitute(
|
||||||
build_id=build_id,
|
build_id=build_id,
|
||||||
@@ -225,9 +230,6 @@ def _copy(source: Path, target: Path) -> int:
|
|||||||
return target.stat().st_size
|
return target.stat().st_size
|
||||||
|
|
||||||
|
|
||||||
type Problem = tuple[str, str, str, str] # 病例号、视频号、环节、问题
|
|
||||||
|
|
||||||
|
|
||||||
def _ceus_status(run: CeusRun | None, video_id: str) -> tuple[str, str]:
|
def _ceus_status(run: CeusRun | None, video_id: str) -> tuple[str, str]:
|
||||||
"""病例总表的「CEUS 定量是否成功」「CEUS 失败在第几步」:在 failed.csv 里为否和步号,
|
"""病例总表的「CEUS 定量是否成功」「CEUS 失败在第几步」:在 failed.csv 里为否和步号,
|
||||||
运行里有这段视频的结果目录为是;都没有、没有视频号或没装 CEUS 运行时都空着。"""
|
运行里有这段视频的结果目录为是;都没有、没有视频号或没装 CEUS 运行时都空着。"""
|
||||||
@@ -238,6 +240,42 @@ def _ceus_status(run: CeusRun | None, video_id: str) -> tuple[str, str]:
|
|||||||
return ("是", "") if run.has_result(video_id) else ("", "")
|
return ("是", "") if run.has_result(video_id) else ("", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_ceus_problems(no: str, video_id: str, summary: dict[str, Any]) -> list[Problem]:
|
||||||
|
"""一例 CEUS 结果的程序原文问题(任务书 03 的写法),只进快照。"""
|
||||||
|
problems: list[Problem] = []
|
||||||
|
fits = summary["fits"]
|
||||||
|
for curve, name in _LESION_CURVES.items():
|
||||||
|
if not fits[curve]["success"]:
|
||||||
|
code = fits[curve]["failure_reason"]
|
||||||
|
problems.append((no, video_id, _RAW_FIT, f"{name}未完成拟合({code})"))
|
||||||
|
anchors = summary["anchor_frames"]
|
||||||
|
for slot in SLOTS:
|
||||||
|
if anchors[slot]["status"] != "ok":
|
||||||
|
problems.append(
|
||||||
|
(no, video_id, _RAW_KEYFRAME, f"{slot} 未取到:{anchors[slot]['reason']}")
|
||||||
|
)
|
||||||
|
if not summary["reference_valid"]:
|
||||||
|
problems.append((no, video_id, _RAW_PAN, "紫圈曲线不可用"))
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_skip_problem(record: SkippedRow, video_id: str) -> Problem:
|
||||||
|
parts = [f"{record.check} {record.description}"]
|
||||||
|
parts += [
|
||||||
|
f"{k}:{'; '.join(v)}" for k, v in (("文件", record.files), ("列", record.columns)) if v
|
||||||
|
]
|
||||||
|
parts.append(f"涉及值:{record.value_json}")
|
||||||
|
return (_raw_no(record.case), video_id, _RAW_ASSEMBLY, ";".join(parts))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _CaseCeus:
|
||||||
|
cells: list[str] # 病例总表的 7 个槽位格
|
||||||
|
problems: list[Problem]
|
||||||
|
raw_problems: list[Problem]
|
||||||
|
video_bytes: int
|
||||||
|
|
||||||
|
|
||||||
def _write_ceus(
|
def _write_ceus(
|
||||||
case_dir: Path,
|
case_dir: Path,
|
||||||
no: str,
|
no: str,
|
||||||
@@ -245,36 +283,28 @@ def _write_ceus(
|
|||||||
tic: dict[str, Any],
|
tic: dict[str, Any],
|
||||||
keyframes: dict[str, Path],
|
keyframes: dict[str, Path],
|
||||||
run: CeusRun,
|
run: CeusRun,
|
||||||
) -> tuple[list[str], list[Problem], int]:
|
) -> _CaseCeus:
|
||||||
"""一例的 CEUS 文件和问题;交出病例总表的 7 个槽位格、问题清单的行、回放视频的字节数。"""
|
"""一例的 CEUS 文件(关键帧、TIC 曲线图、TIC参数.csv、回放视频)和问题。"""
|
||||||
(case_dir / "关键帧").mkdir()
|
|
||||||
for slot, source in keyframes.items():
|
for slot, source in keyframes.items():
|
||||||
seconds = tic["keyframes"][slot]["video_time_s"]
|
if slot != KEY_SLOT:
|
||||||
|
seconds = tic["keyframe_times_s"][slot]
|
||||||
_copy(source, case_dir / "关键帧" / f"{slot}_第{seconds:.1f}秒.png")
|
_copy(source, case_dir / "关键帧" / f"{slot}_第{seconds:.1f}秒.png")
|
||||||
_copy(run.tic_png(video_id), case_dir / "TIC曲线.png")
|
_copy(run.tic_png(video_id), case_dir / "TIC曲线.png")
|
||||||
size = _copy(run.overlay(video_id), case_dir / "回放视频.mp4")
|
size = _copy(run.overlay(video_id), case_dir / "回放视频.mp4")
|
||||||
_write_csv(case_dir / "TIC结果.csv", TIC_HEADER, tic_rows(tic))
|
summary = run.summary(video_id)
|
||||||
|
reasons = {
|
||||||
problems: list[Problem] = []
|
name: check_plain(group_reason(summary, name))
|
||||||
fits = run.summary(video_id)["fits"]
|
for name in TIC_GROUPS
|
||||||
for curve, name in _LESION_CURVES.items():
|
if tic["perfusion_metrics"][name] is None
|
||||||
if not tic["fits"][curve]["success"]:
|
}
|
||||||
code = fits[curve]["failure_reason"]
|
_write_csv(case_dir / "TIC参数.csv", TIC_PARAMS_HEADER, tic_param_rows(tic, reasons))
|
||||||
if not isinstance(code, str):
|
times = tic["keyframe_times_s"]
|
||||||
raise ValueError(
|
return _CaseCeus(
|
||||||
f"{run.run_id}/{video_id}/summary.json 里 {curve} 拟合没成功却没有原因码"
|
cells=["未取到" if times[s] is None else _cell(times[s]) for s in SLOTS],
|
||||||
|
problems=[(no, video_id, stage, text) for stage, text in curve_problems(summary)],
|
||||||
|
raw_problems=_raw_ceus_problems(no, video_id, summary),
|
||||||
|
video_bytes=size,
|
||||||
)
|
)
|
||||||
problems.append((no, video_id, STAGE_FIT, f"{name}未完成拟合({code})"))
|
|
||||||
slots = tic["keyframes"]
|
|
||||||
for slot in SLOTS:
|
|
||||||
if not slots[slot]["located"]:
|
|
||||||
problems.append(
|
|
||||||
(no, video_id, STAGE_KEYFRAME, f"{slot} 未取到:{slots[slot]['reason']}")
|
|
||||||
)
|
|
||||||
if not tic["pan_curve_usable"]:
|
|
||||||
problems.append((no, video_id, STAGE_PAN, "紫圈曲线不可用"))
|
|
||||||
cells = [_cell(slots[s]["video_time_s"]) if slots[s]["located"] else "未取到" for s in SLOTS]
|
|
||||||
return cells, problems, size
|
|
||||||
|
|
||||||
|
|
||||||
def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSummary:
|
def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSummary:
|
||||||
@@ -319,7 +349,7 @@ def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSu
|
|||||||
for name, target in ANSWER_TABLES.items():
|
for name, target in ANSWER_TABLES.items():
|
||||||
_copy(verified_file(answers, name), pack / "标准答案" / target)
|
_copy(verified_file(answers, name), pack / "标准答案" / target)
|
||||||
|
|
||||||
# failed.csv 的视频号 → 病例号(三位数字)。
|
# failed.csv:病例号(三位数字)→(视频号,第几步,原因原文)。
|
||||||
failures: dict[str, tuple[str, int, str]] = {}
|
failures: dict[str, tuple[str, int, str]] = {}
|
||||||
for video_id, failure in ({} if run is None else run.failed).items():
|
for video_id, failure in ({} if run is None else run.failed).items():
|
||||||
match = _VIDEO_ID.fullmatch(video_id)
|
match = _VIDEO_ID.fullmatch(video_id)
|
||||||
@@ -329,12 +359,13 @@ def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSu
|
|||||||
|
|
||||||
summary: list[list[str]] = []
|
summary: list[list[str]] = []
|
||||||
problems: list[Problem] = []
|
problems: list[Problem] = []
|
||||||
|
raw_problems: list[Problem] = []
|
||||||
pdf_jobs: dict[Path, str] = {}
|
pdf_jobs: dict[Path, str] = {}
|
||||||
video_bytes = 0
|
video_bytes = 0
|
||||||
for row in aliases:
|
for row in aliases:
|
||||||
no = _doctor_no(row.case_no)
|
no = f"P{row.case_no}"
|
||||||
case_dir = pack / "病例" / no
|
case_dir = pack / "病例" / no
|
||||||
case_dir.mkdir(parents=True)
|
(case_dir / "关键帧").mkdir(parents=True)
|
||||||
clinical = read_clinical(library, row.alias).fields
|
clinical = read_clinical(library, row.alias).fields
|
||||||
_write_csv(
|
_write_csv(
|
||||||
case_dir / "临床信息.csv", list(clinical), [[_cell(v) for v in clinical.values()]]
|
case_dir / "临床信息.csv", list(clinical), [[_cell(v) for v in clinical.values()]]
|
||||||
@@ -346,41 +377,50 @@ def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSu
|
|||||||
):
|
):
|
||||||
title = f"病例号 {no} {route}(AI 专家看到的原文,不含结论段)"
|
title = f"病例号 {no} {route}(AI 专家看到的原文,不含结论段)"
|
||||||
pdf_jobs[case_dir / name] = report_markdown(title, text)
|
pdf_jobs[case_dir / name] = report_markdown(title, text)
|
||||||
_copy(ultrasound.key_image, case_dir / "关键图.png")
|
_copy(ultrasound.keyframes[KEY_SLOT], case_dir / "关键帧" / KEY_IMAGE_NAME)
|
||||||
cells = ["" for _ in SLOTS]
|
cells = ["" for _ in SLOTS]
|
||||||
if run is not None and ultrasound.tic is not None:
|
if run is not None and ultrasound.tic is not None:
|
||||||
cells, case_problems, size = _write_ceus(
|
ceus = _write_ceus(
|
||||||
case_dir, no, row.video_id, ultrasound.tic, ultrasound.keyframes, run
|
case_dir, no, row.video_id, ultrasound.tic, ultrasound.keyframes, run
|
||||||
)
|
)
|
||||||
problems.extend(case_problems)
|
cells = ceus.cells
|
||||||
video_bytes += size
|
problems.extend(ceus.problems)
|
||||||
|
raw_problems.extend(ceus.raw_problems)
|
||||||
|
video_bytes += ceus.video_bytes
|
||||||
summary.append(
|
summary.append(
|
||||||
[no, row.video_id, row.alias, "是", "", *_ceus_status(run, row.video_id), *cells]
|
[no, row.video_id, row.alias, "是", "", *_ceus_status(run, row.video_id), *cells]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
steps = {case: (step, reason) for case, (_, step, reason) in failures.items()}
|
||||||
|
merged: set[str] = set() # C15 跳过的病例:CEUS 失败与跳过只出一行
|
||||||
for record_ in skipped:
|
for record_ in skipped:
|
||||||
no = _doctor_no(record_.case)
|
label = case_label(record_.case)
|
||||||
video_id = f"U{record_.case}" if _CASE_NO.fullmatch(record_.case) else ""
|
video_id = f"U{record_.case}" if _CASE_NO.fullmatch(record_.case) else ""
|
||||||
reason = f"{record_.check} {record_.description}"
|
stage, reason, text = skip_problem(record_, steps)
|
||||||
|
if stage == STAGE_VIDEO:
|
||||||
|
merged.add(record_.case)
|
||||||
status = _ceus_status(run, video_id)
|
status = _ceus_status(run, video_id)
|
||||||
summary.append([no, video_id, "", "否", reason, *status, *["" for _ in SLOTS]])
|
summary.append([label, video_id, "", "否", reason, *status, *["" for _ in SLOTS]])
|
||||||
parts = [reason]
|
problems.append((label, video_id, stage, text))
|
||||||
parts += [
|
raw_problems.append(_raw_skip_problem(record_, video_id))
|
||||||
f"{k}:{'; '.join(v)}"
|
|
||||||
for k, v in (("文件", record_.files), ("列", record_.columns))
|
|
||||||
if v
|
|
||||||
]
|
|
||||||
parts.append(f"涉及值:{record_.value_json}")
|
|
||||||
problems.append((no, video_id, STAGE_ASSEMBLY, ";".join(parts)))
|
|
||||||
for case_no, (video_id, step, reason) in failures.items():
|
for case_no, (video_id, step, reason) in failures.items():
|
||||||
|
if case_no not in merged:
|
||||||
problems.append(
|
problems.append(
|
||||||
(_doctor_no(case_no), video_id, f"CEUS 第 {step} 步", f"第 {step} 步失败:{reason}")
|
(f"P{case_no}", video_id, STAGE_VIDEO, check_plain(ceus_failure(step, reason)))
|
||||||
|
)
|
||||||
|
raw_problems.append(
|
||||||
|
(_raw_no(case_no), video_id, f"CEUS 第 {step} 步", f"第 {step} 步失败:{reason}")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for problem in problems:
|
||||||
|
for value in problem:
|
||||||
|
check_plain(value)
|
||||||
summary.sort(key=lambda r: r[0])
|
summary.sort(key=lambda r: r[0])
|
||||||
problems.sort(key=lambda r: r[0])
|
problems.sort(key=lambda r: r[0])
|
||||||
|
raw_problems.sort(key=lambda r: r[0])
|
||||||
_write_csv(pack / "病例总表.csv", SUMMARY_HEADER, summary)
|
_write_csv(pack / "病例总表.csv", SUMMARY_HEADER, summary)
|
||||||
_write_csv(pack / "问题清单.csv", PROBLEM_HEADER, problems)
|
_write_csv(pack / "问题清单.csv", PROBLEM_HEADER, problems)
|
||||||
|
_write_csv(snapshot / RAW_PROBLEMS, PROBLEM_HEADER, raw_problems)
|
||||||
counts = (len(summary), len(aliases), len(skipped))
|
counts = (len(summary), len(aliases), len(skipped))
|
||||||
pdf_jobs[pack / "说明.pdf"] = _template_text(
|
pdf_jobs[pack / "说明.pdf"] = _template_text(
|
||||||
info.build_id, run, info.codes_decoded, now, counts
|
info.build_id, run, info.codes_decoded, now, counts
|
||||||
|
|||||||
@@ -0,0 +1,502 @@
|
|||||||
|
"""问题清单的医生写法:程序原因 → 医生能懂的一句话(对照表),以及按病例合并成行。
|
||||||
|
|
||||||
|
对照表按「原因的种类」写:跳过清单的每个检查编号(C01–C15)、CEUS `failed.csv` 里各步的失败原因、
|
||||||
|
拟合的每个原因码、关键帧每类未取到的原因、紫圈不可用。具体数值从原记录里取出填进句子,对医生
|
||||||
|
没意义的(谷值、像素偏移、窗口边界)不填。遇到对照表里没有的原因直接报错,不把程序原文漏给医生;
|
||||||
|
程序原文另存在快照里(`_pack.py`)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from cadx.answers import SkippedRow
|
||||||
|
|
||||||
|
STAGE_DATA = "资料核对"
|
||||||
|
STAGE_VIDEO = "造影视频处理"
|
||||||
|
STAGE_CURVE = "TIC 曲线"
|
||||||
|
STAGE_KEYFRAME = "关键帧"
|
||||||
|
STAGE_PAN = "紫圈"
|
||||||
|
|
||||||
|
DYNAMIC_SLOTS = ("B0", "D1", "D2", "D3", "D4")
|
||||||
|
SLOTS = (*DYNAMIC_SLOTS, "F1", "F2")
|
||||||
|
_NOT_IN_LIBRARY = ",这一例没进资料库"
|
||||||
|
# 问题清单里不能出现的东西:检查编号、字段名、下划线、JSON 和列表的括号、程序用词、运行号。
|
||||||
|
FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}")
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownReasonError(ValueError):
|
||||||
|
"""程序记录的原因在对照表里没有:先补对照表,不能把原文给医生。"""
|
||||||
|
|
||||||
|
|
||||||
|
def check_plain(text: str) -> str:
|
||||||
|
"""医生看到的一句话里不许有 `FORBIDDEN`;有就报错停下。"""
|
||||||
|
if (found := FORBIDDEN.search(text)) is not None:
|
||||||
|
raise ValueError(f"给医生的话里出现了程序用语「{found.group()}」:{text}")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _num(text: str) -> str:
|
||||||
|
"""原记录里的秒数统一写一位小数。"""
|
||||||
|
return f"{float(text):.1f}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 拟合原因码(_tic_fitting.py 的 reject 原因):说曲线本身的样子,不说算法。
|
||||||
|
FIT_REASONS = {
|
||||||
|
"insufficient_valid_samples": "能用的画面太少(很多帧认不出这块区域,或是重复帧),连不成曲线",
|
||||||
|
"candidate_grid_unavailable": "能用的画面时间点太少,连不成曲线",
|
||||||
|
"credible_peak_not_found": "曲线太平,没有明显的峰",
|
||||||
|
"candidate_arrival_not_found": "峰之前找不到亮度明确、持续升起来的时刻",
|
||||||
|
"insufficient_bolus_samples": "造影剂第一次流过的那一段能用的画面太少",
|
||||||
|
"long_gap_in_wash_in": "变亮的那一段中间缺了一大段画面",
|
||||||
|
"arrival_bounds_invalid": "峰离视频开头太近,或造影剂第一次流过的那一段太短",
|
||||||
|
"optimizer_failed": "曲线形状不规则,平滑曲线怎么也贴不上",
|
||||||
|
# 边界是峰高、峰出现的时间、曲线宽窄(对数正态的 sigma)、开始变亮的时刻、增强前水平五项
|
||||||
|
# (_tic_fitting.py 的 `_optimizer_bounds`、`_parameters_on_boundary`)。
|
||||||
|
"parameter_on_boundary": (
|
||||||
|
"试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上,"
|
||||||
|
"有一项卡在了程序允许范围的边上"
|
||||||
|
),
|
||||||
|
"model_peak_on_boundary": "试着贴出来的曲线,峰落在可用那一段的最开头或最末尾,峰的位置不可信",
|
||||||
|
"fit_quality_failed": "曲线起伏杂乱,平滑曲线和它差得太多",
|
||||||
|
}
|
||||||
|
PAN_UNUSABLE = "视频里大部分时间认不出紫圈"
|
||||||
|
|
||||||
|
|
||||||
|
def fit_reason(code: object) -> str:
|
||||||
|
if not isinstance(code, str) or code not in FIT_REASONS:
|
||||||
|
raise UnknownReasonError(f"拟合原因码不在对照表里:{code!r}")
|
||||||
|
return FIT_REASONS[code]
|
||||||
|
|
||||||
|
|
||||||
|
def group_reason(summary: dict[str, Any], curve: str) -> str:
|
||||||
|
"""TIC参数.csv 里一组整组空着时「没算出的原因」那一格。"""
|
||||||
|
if curve == "roi_pan" and not summary["reference_valid"]:
|
||||||
|
return PAN_UNUSABLE
|
||||||
|
fit = summary["fits"][curve]
|
||||||
|
if fit["success"]:
|
||||||
|
raise ValueError(f"{curve} 拟合成功,灌注指标却整组为空")
|
||||||
|
return f"{fit_reason(fit['failure_reason'])},没能拟合"
|
||||||
|
|
||||||
|
|
||||||
|
# ---- CEUS failed.csv 的失败原因(各步抛的 CeusCaseError、ceus_quant 的目视核对)。
|
||||||
|
_VIDEO_BROKEN = "视频文件本身有问题(帧率、帧数或时间信息不对),程序读不了"
|
||||||
|
_NO_SPLIT = "视频里找不到左右两屏的分界线"
|
||||||
|
_MISALIGNED = "视频左右两屏的位置对不齐"
|
||||||
|
CEUS_FAILURES: tuple[tuple[int, re.Pattern[str], str], ...] = tuple(
|
||||||
|
(step, re.compile(pattern), text)
|
||||||
|
for step, pattern, text in (
|
||||||
|
(1, r"配准目视核对未通过", f"{_MISALIGNED}(我们人工逐例看过,这一例没对上)"),
|
||||||
|
(1, r"cv2 打不开视频:", "视频文件打不开"),
|
||||||
|
(1, r"探测到非法 fps=|探测到非法元数据 ", _VIDEO_BROKEN),
|
||||||
|
(1, r"视频包含非法显示时间戳 |视频显示时间戳不严格递增:", _VIDEO_BROKEN),
|
||||||
|
(1, r"时间戳帧数与容器记录的帧数不一致:", _VIDEO_BROKEN),
|
||||||
|
(1, r"解码帧数 [0-9]+ 与容器记录的帧数 [0-9]+ 不一致", _VIDEO_BROKEN),
|
||||||
|
(1, r"帧尺寸不一致:", "视频前后画面大小不一致"),
|
||||||
|
(1, r"帧序列为空", "视频里一帧画面都没有"),
|
||||||
|
(1, r"中部 band 内未找到稳定暗缝", _NO_SPLIT),
|
||||||
|
(1, r"非法 search_band:|search_band 在宽度 ", _NO_SPLIT),
|
||||||
|
(1, r"检测到的 split_x=|切分线 split_x=", _NO_SPLIT),
|
||||||
|
(1, r"左右宽度比 ", "找到的左右两屏分界线不可信(两屏宽窄差太多)"),
|
||||||
|
(1, r"这一半画面里找不到扇形区", "视频有一屏里找不到超声扇形画面"),
|
||||||
|
(1, r"左半扇形内可用的模板块只有 ", f"{_MISALIGNED}(画面里能拿来比对的细节太少)"),
|
||||||
|
(1, r"两半配准不一致:", _MISALIGNED),
|
||||||
|
(2, r"左右两半都找到绿框", "视频左右两屏都有绿框,分不出哪一屏是造影画面"),
|
||||||
|
(2, r"左右两半都没有找到绿框", "视频里找不到画好的绿框"),
|
||||||
|
(4, r"逐帧 mask 未产出任何有效 ROI 轮廓", "整段视频里程序都没能画出病灶轮廓"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ceus_failure(step: int, reason: str) -> str:
|
||||||
|
"""failed.csv 一行(第几步、原因原文)→ 给医生的话。"""
|
||||||
|
for known_step, pattern, text in CEUS_FAILURES:
|
||||||
|
if known_step == step and pattern.match(reason):
|
||||||
|
return f"{text},程序没法处理这段视频"
|
||||||
|
raise UnknownReasonError(f"CEUS 第 {step} 步的失败原因不在对照表里:{reason}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 关键帧未取到的原因(_anchor_frames.py 的 reason)。每类:模式、共同的话、逐槽位的细节。
|
||||||
|
_SPAN = r"\[(?P<a>-?[0-9.]+), (?P<b>-?[0-9.]+)\] 秒"
|
||||||
|
|
||||||
|
|
||||||
|
def _covered(slot: str, m: re.Match[str]) -> str:
|
||||||
|
return f"{slot} 在视频第 {_num(m['t'])} 秒,能用的画面是第 {_num(m['a'])}–{_num(m['b'])} 秒"
|
||||||
|
|
||||||
|
|
||||||
|
def _injection(slot: str, m: re.Match[str]) -> str:
|
||||||
|
where = f"{slot} 是推注后第 {m['inj']} 秒,应在视频第 {_num(m['t'])} 秒"
|
||||||
|
if m["a"] is None:
|
||||||
|
return f"{where},视频里没有能用的画面"
|
||||||
|
if float(m["t"]) < float(m["a"]):
|
||||||
|
return f"{where},那时还没开始录"
|
||||||
|
return f"{where},视频只录到第 {_num(m['b'])} 秒"
|
||||||
|
|
||||||
|
|
||||||
|
_FIT_KIND = "fit"
|
||||||
|
type _Detail = Callable[[str, re.Match[str]], str] | None
|
||||||
|
ANCHOR_REASONS: tuple[tuple[str, re.Pattern[str], str, _Detail], ...] = tuple(
|
||||||
|
(kind, re.compile(pattern), text, detail)
|
||||||
|
for kind, pattern, text, detail in (
|
||||||
|
(_FIT_KIND, r"病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门$", "", None),
|
||||||
|
(_FIT_KIND, r"roi_perf 曲线的 Lognormal 拟合没过质量门([a-z_]+)$", "", None),
|
||||||
|
(
|
||||||
|
"b0_window",
|
||||||
|
r"B0 锚点 \S+ 秒的容差窗口 .* 内,观测到的基线段里、到达时刻之前没有可用帧$",
|
||||||
|
"开始增强之前的那一小段里没有能用的画面",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"no_arrival",
|
||||||
|
r"拟合曲线升不过到达门槛,没有到达时刻$|拟合曲线上没有 D[1-3] 时刻(升不过到达门槛)$",
|
||||||
|
"曲线上升得太少,定不出造影剂到达的时刻",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"estimated_baseline",
|
||||||
|
r"视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义$",
|
||||||
|
"视频一开始病灶就已经在变亮,没录到增强之前的那一段",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"no_d4",
|
||||||
|
r"峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4$",
|
||||||
|
"峰值之后录得不够长,找不到变暗最快的时刻",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"not_covered",
|
||||||
|
rf"D[1-4] 时刻 (?P<t>-?[0-9.]+) 秒落在视频有效帧范围 {_SPAN} 之外$",
|
||||||
|
"算出的时刻不在视频能用的画面范围里",
|
||||||
|
_covered,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"taken",
|
||||||
|
rf"D[1-4] 容差窗口 {_SPAN} 内的候选帧都被时间更早的槽位占用,无法保持先后顺序$",
|
||||||
|
"这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧"
|
||||||
|
"(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"no_frame",
|
||||||
|
rf"D[1-4] 容差窗口 {_SPAN} 内没有可用帧(拟合区间内、不重复)$",
|
||||||
|
"这个时刻附近没有能用的画面(附近都是重复帧,或已过了造影剂第一次流过的那一段)",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"injection_outside",
|
||||||
|
r"推注后第 (?P<inj>\S+) 秒在视频第 (?P<t>-?[0-9.]+) 秒(推注时间 X=\S+ 秒),"
|
||||||
|
rf"落在(?:视频有效帧范围 {_SPAN}|没有不重复的帧)之外$",
|
||||||
|
"换算到视频里的时刻不在录到的范围内",
|
||||||
|
_injection,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"injection_window",
|
||||||
|
r"视频第 \S+ 秒 ±\S+ 秒内没有不重复的帧$",
|
||||||
|
"这个时刻前后没有能用的画面(都是重复帧)",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"no_bolus",
|
||||||
|
r"没有推注时间,推注后时刻换算不到视频时间$",
|
||||||
|
"没有推注时间,换算不出视频里的时刻",
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _anchor_kind(slot: str, reason: object) -> tuple[str, str, str | None]:
|
||||||
|
"""一个未取到的槽位 →(种类,共同的话,这一槽位的细节)。"""
|
||||||
|
if isinstance(reason, str):
|
||||||
|
for kind, pattern, text, detail in ANCHOR_REASONS:
|
||||||
|
if (m := pattern.match(reason)) is not None:
|
||||||
|
return kind, text, None if detail is None else detail(slot, m)
|
||||||
|
raise UnknownReasonError(f"关键帧 {slot} 未取到的原因不在对照表里:{reason!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _slots(names: Sequence[str]) -> str:
|
||||||
|
return "B0、D1–D4" if tuple(names) == DYNAMIC_SLOTS else "、".join(names)
|
||||||
|
|
||||||
|
|
||||||
|
_LESION = {"roi_all": "病灶整体", "roi_perf": "病灶去暗区"}
|
||||||
|
|
||||||
|
|
||||||
|
def curve_problems(summary: dict[str, Any]) -> list[tuple[str, str]]:
|
||||||
|
"""一例 CEUS 结果的问题:(环节,给医生的话)。病灶曲线拟合失败连带的关键帧只出一行。"""
|
||||||
|
fits, anchors = summary["fits"], summary["anchor_frames"]
|
||||||
|
rows: list[tuple[str, str]] = []
|
||||||
|
failed = [c for c in _LESION if not fits[c]["success"]]
|
||||||
|
unlocated = [s for s in SLOTS if anchors[s]["status"] != "ok"]
|
||||||
|
kinds = {s: _anchor_kind(s, anchors[s]["reason"]) for s in unlocated}
|
||||||
|
fit_slots = [s for s in unlocated if kinds[s][0] == _FIT_KIND]
|
||||||
|
if bool(failed) != bool(fit_slots) or (failed and fit_slots != list(DYNAMIC_SLOTS)):
|
||||||
|
raise ValueError(
|
||||||
|
f"病灶曲线拟合失败 {failed} 与因此未取到的关键帧 {fit_slots} 对不上(应为 B0、D1–D4)"
|
||||||
|
)
|
||||||
|
if failed:
|
||||||
|
reasons = {c: fit_reason(fits[c]["failure_reason"]) for c in failed}
|
||||||
|
if len(failed) == 1:
|
||||||
|
what = f"{_LESION[failed[0]]}那条曲线没能拟合({reasons[failed[0]]})"
|
||||||
|
elif len(set(reasons.values())) == 1:
|
||||||
|
what = f"病灶整体、病灶去暗区两条曲线都没能拟合(都是{reasons['roi_all']})"
|
||||||
|
else:
|
||||||
|
parts = ";".join(f"{_LESION[c]}:{r}" for c, r in reasons.items())
|
||||||
|
what = f"病灶整体、病灶去暗区两条曲线都没能拟合({parts})"
|
||||||
|
tail = ",TIC 参数里病灶一组也空着" if "roi_perf" in failed else ""
|
||||||
|
rows.append(
|
||||||
|
(
|
||||||
|
STAGE_CURVE,
|
||||||
|
f"{what};B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,"
|
||||||
|
f"所以这 5 个关键帧没有取{tail}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
grouped: dict[str, tuple[str, list[str], list[str]]] = {}
|
||||||
|
for slot in unlocated:
|
||||||
|
kind, text, detail = kinds[slot]
|
||||||
|
if kind == _FIT_KIND:
|
||||||
|
continue
|
||||||
|
entry = grouped.setdefault(kind, (text, [], []))
|
||||||
|
entry[1].append(slot)
|
||||||
|
if detail is not None:
|
||||||
|
entry[2].append(detail)
|
||||||
|
for text, slots, details in grouped.values():
|
||||||
|
extra = f"({';'.join(details)})" if details else ""
|
||||||
|
rows.append((STAGE_KEYFRAME, f"{_slots(slots)} 没有取:{text}{extra}"))
|
||||||
|
|
||||||
|
if not summary["reference_valid"]:
|
||||||
|
rows.append((STAGE_PAN, f"{PAN_UNUSABLE},紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着"))
|
||||||
|
elif not fits["roi_pan"]["success"]:
|
||||||
|
reason = fit_reason(fits["roi_pan"]["failure_reason"])
|
||||||
|
rows.append((STAGE_CURVE, f"紫圈那条曲线没能拟合({reason}),TIC 参数里胰腺实质一组空着"))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 跳过清单(answers/skipped.csv):每个检查编号一种写法。
|
||||||
|
_TABLES = (
|
||||||
|
(re.compile(r"Clinical_agent/[^/]+\.xlsx"), "临床表"),
|
||||||
|
(re.compile(r"CEUS_agent/[^/]+\.xlsx"), "超声表"),
|
||||||
|
(re.compile(r"MR_agent/[^/]+\.xlsx"), "MR 表"),
|
||||||
|
(re.compile(r"CEUS_agent/[0-9]+/?"), "病例文件夹"),
|
||||||
|
(re.compile(r"CEUS_agent/[0-9]+/[^/]+_report\.xlsx"), "单例超声报告表"),
|
||||||
|
)
|
||||||
|
_COLUMNS = {
|
||||||
|
"Sex": "性别",
|
||||||
|
"Age": "年龄",
|
||||||
|
"Diagnosis": "病理诊断",
|
||||||
|
"Symptom_Pain": "腹痛",
|
||||||
|
"Symptom_Jaundice": "黄疸",
|
||||||
|
"Symptom_WeightLoss": "体重下降",
|
||||||
|
"Symptom_Fever": "发热",
|
||||||
|
"Symptom_Asymptomatic": "无症状",
|
||||||
|
"Hx_Pancreatitis": "胰腺炎病史",
|
||||||
|
"Hx_Alcohol": "饮酒史",
|
||||||
|
"Hx_Gallstones": "胆结石病史",
|
||||||
|
"Hx_Malignancy": "恶性肿瘤病史",
|
||||||
|
"Hx_Family_PC": "胰腺癌家族史",
|
||||||
|
"CA199": "CA19-9",
|
||||||
|
"CA199_Level": "CA19-9 是否异常",
|
||||||
|
"CEA": "CEA",
|
||||||
|
"CEA_Level": "CEA 是否异常",
|
||||||
|
"Amylase": "淀粉酶",
|
||||||
|
"Amylase_Level": "淀粉酶是否异常",
|
||||||
|
"Lipase": "脂肪酶",
|
||||||
|
"Lipase_Level": "脂肪酶是否异常",
|
||||||
|
"TBil": "总胆红素",
|
||||||
|
"TBil_Level": "总胆红素是否异常",
|
||||||
|
"患者ID": "患者ID",
|
||||||
|
"CEUS视频ID": "CEUS视频ID",
|
||||||
|
"性别": "性别",
|
||||||
|
"年龄": "年龄",
|
||||||
|
"病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)": "病理诊断",
|
||||||
|
"推注开始时间s": "推注开始时间",
|
||||||
|
"超声描述": "超声描述",
|
||||||
|
"MR描述": "MR描述",
|
||||||
|
}
|
||||||
|
_REPORTS = {"超声描述": "超声报告", "MR描述": "MR报告"}
|
||||||
|
_KEY_IMAGE = {
|
||||||
|
"不是 PNG": "不是 PNG 格式",
|
||||||
|
"带 tRNS 透明色,重编码会丢透明": "带透明色,另存会丢掉透明信息",
|
||||||
|
}
|
||||||
|
_KEY_IMAGE_DEPTH = re.compile(r"位深 [0-9]+、颜色类型 [0-9]+ 不能逐像素保真重编码$")
|
||||||
|
_ROW = re.compile(r"(?P<path>.+) 第 (?P<row>[0-9]+) 行$")
|
||||||
|
_DIR = re.compile(r"病例目录 (?P<name>.+)$")
|
||||||
|
_KEYFRAME_FILE = re.compile(r"[^/]+/U[0-9]{3}/keyframes/(?P<slot>B0|D[1-4]|F[12])\.png$")
|
||||||
|
|
||||||
|
|
||||||
|
def _table(path: str) -> str:
|
||||||
|
for pattern, name in _TABLES:
|
||||||
|
if pattern.fullmatch(path):
|
||||||
|
return name
|
||||||
|
raise UnknownReasonError(f"跳过清单里的文件认不出是哪张表:{path}")
|
||||||
|
|
||||||
|
|
||||||
|
def _column(column: str) -> str:
|
||||||
|
if column not in _COLUMNS:
|
||||||
|
raise UnknownReasonError(f"跳过清单里的列没有中文名:{column}")
|
||||||
|
return _COLUMNS[column]
|
||||||
|
|
||||||
|
|
||||||
|
# 跳过清单的涉及值经 json.dumps(default=repr) 存下(delivery/_cases.py 的 `_skip`):
|
||||||
|
# Excel 的日期、时间格读出来是 datetime 等对象,存成它们的 repr 字符串。
|
||||||
|
_DATE_REPR = re.compile(r"datetime\.(datetime|date|time|timedelta)\(")
|
||||||
|
|
||||||
|
|
||||||
|
def _show(value: object) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "空着"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return f"这一格填的是 {'TRUE' if value else 'FALSE'}"
|
||||||
|
if isinstance(value, str):
|
||||||
|
if _DATE_REPR.match(value):
|
||||||
|
return "这一格是日期或时间格式"
|
||||||
|
return f"「{value}」"
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def case_label(case: str) -> str:
|
||||||
|
"""病例号一格:三位病例号写成 P001;认不出编号的写成「临床表第 5 行」「病例文件夹 x」。"""
|
||||||
|
if re.fullmatch(r"[0-9]{3}", case):
|
||||||
|
return f"P{case}"
|
||||||
|
if (m := _ROW.fullmatch(case)) is not None:
|
||||||
|
return f"{_table(m['path'])}第 {m['row']} 行"
|
||||||
|
if (m := _DIR.fullmatch(case)) is not None:
|
||||||
|
return f"病例文件夹「{m['name']}」"
|
||||||
|
raise UnknownReasonError(f"跳过清单的病例一格认不出:{case}")
|
||||||
|
|
||||||
|
|
||||||
|
def _bad_values(table: str, bad: dict[str, object]) -> str:
|
||||||
|
items = "、".join(f"{_column(c)}({_show(v)})" for c, v in bad.items())
|
||||||
|
return f"{table}这几项的取值不合规:{items}"
|
||||||
|
|
||||||
|
|
||||||
|
def _c06(value: dict[str, Any]) -> str:
|
||||||
|
if "工作表" in value:
|
||||||
|
return "单例超声报告表的工作表不对(应只有一张)"
|
||||||
|
if "表头" in value:
|
||||||
|
return "单例超声报告表的表头和超声汇总表不一样"
|
||||||
|
if "数据行数" in value:
|
||||||
|
return f"单例超声报告表应只有一行数据,实际有 {value['数据行数']} 行"
|
||||||
|
parts: list[str] = []
|
||||||
|
for column, pair in value.items():
|
||||||
|
mine, summary = pair["单例报告表"], pair["超声汇总表"]
|
||||||
|
long = any(isinstance(v, str) and len(v) > 30 for v in (mine, summary))
|
||||||
|
detail = "内容不一样" if long else f"单例表 {_show(mine)},汇总表 {_show(summary)}"
|
||||||
|
parts.append(f"{_column(column)}({detail})")
|
||||||
|
return f"单例超声报告表和超声汇总表对不上:{';'.join(parts)}"
|
||||||
|
|
||||||
|
|
||||||
|
def _age(value: object) -> str:
|
||||||
|
text = str(value).removesuffix("岁") if isinstance(value, str) else json.dumps(value)
|
||||||
|
return f"{text} 岁"
|
||||||
|
|
||||||
|
|
||||||
|
def _c10(value: dict[str, dict[str, Any]]) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for field, by_table in value.items():
|
||||||
|
tables = {name.removesuffix("(解码后)"): v for name, v in by_table.items()}
|
||||||
|
if field == "年龄":
|
||||||
|
shown = ",".join(f"{t} {_age(v)}" for t, v in tables.items())
|
||||||
|
elif field == "诊断":
|
||||||
|
shown = ",".join(f"{t}写 {v}" for t, v in tables.items())
|
||||||
|
field = "病理诊断编码"
|
||||||
|
elif field == "性别":
|
||||||
|
shown = ",".join(f"{t} {v}" for t, v in tables.items())
|
||||||
|
if len(tables) == 2:
|
||||||
|
# 不解码时临床表的性别是数字编码,C10 只比超声表和 MR 表(delivery/_cases.py)。
|
||||||
|
shown += "(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)"
|
||||||
|
else:
|
||||||
|
raise UnknownReasonError(f"C10 跨表不一致的字段不在对照表里:{field}")
|
||||||
|
which = "三张表" if len(tables) == 3 else "和".join(tables).replace("和MR", "和 MR")
|
||||||
|
parts.append(f"{which}的{field}对不上:{shown}")
|
||||||
|
return ";".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _text_hits(check: str, value: dict[str, list[Any]]) -> str:
|
||||||
|
parts: list[str] = []
|
||||||
|
for column, hits in value.items():
|
||||||
|
report = _REPORTS[column]
|
||||||
|
if check == "C11":
|
||||||
|
shown = ";".join(f"「{h['关键词']}:」(所在句子:{h['句子']})" for h in hits)
|
||||||
|
parts.append(f"{report}的描述里有像结论段标题的写法{shown}")
|
||||||
|
elif check == "C12":
|
||||||
|
parts.append(f"{report}的描述里出现了亚型名{'、'.join(map(_show, hits))}")
|
||||||
|
else:
|
||||||
|
parts.append(f"{report}的描述里出现了性别或年龄{'、'.join(map(_show, hits))}")
|
||||||
|
return ";".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _c14(row: SkippedRow, reason: str) -> str:
|
||||||
|
if reason in _KEY_IMAGE:
|
||||||
|
what = _KEY_IMAGE[reason]
|
||||||
|
elif _KEY_IMAGE_DEPTH.match(reason):
|
||||||
|
what = "的颜色格式特殊,程序没法一点不差地另存"
|
||||||
|
else:
|
||||||
|
raise UnknownReasonError(f"C14 的原因不在对照表里:{reason}")
|
||||||
|
(file,) = row.files
|
||||||
|
if (m := _KEYFRAME_FILE.fullmatch(file)) is not None:
|
||||||
|
return f"程序从视频里取的 {m['slot']} 关键帧{what}"
|
||||||
|
return f"医生截图{what}"
|
||||||
|
|
||||||
|
|
||||||
|
def skip_reason(row: SkippedRow) -> str:
|
||||||
|
"""跳过清单一行(C01–C14)→ 给医生的话,不带「没进资料库」。C15 由调用方和 failed.csv 合并。"""
|
||||||
|
value = json.loads(row.value_json)
|
||||||
|
check = row.check
|
||||||
|
if check == "C01":
|
||||||
|
if "目录名" in value:
|
||||||
|
return f"病例文件夹名{_show(value['目录名'])}认不出是哪个病例"
|
||||||
|
raw = _show(value["原始写法"])
|
||||||
|
return f"{_table(row.files[0])}第 {value['行号']} 行的病例编号认不出({raw})"
|
||||||
|
if check == "C02":
|
||||||
|
rows = "、".join(map(str, value["行号"]))
|
||||||
|
return f"{_table(row.files[0])}里这个病例号出现了 {len(value['行号'])} 次(第 {rows} 行)"
|
||||||
|
if check == "C03":
|
||||||
|
return f"在{'、'.join(_table(p) for p in value['缺在'])}里找不到这一例"
|
||||||
|
if check == "C04":
|
||||||
|
return (
|
||||||
|
"病例文件夹里应当恰好有截图、造影视频、单例超声报告表三个文件,"
|
||||||
|
f"实际有 {len(value['文件'])} 个"
|
||||||
|
)
|
||||||
|
if check == "C05":
|
||||||
|
return f"超声表里这一例的 CEUS视频ID 写的是{_show(value['CEUS视频ID'])},和病例号对不上"
|
||||||
|
if check == "C06":
|
||||||
|
return _c06(value)
|
||||||
|
if check in ("C07", "C08", "C09"):
|
||||||
|
return _bad_values({"C07": "临床表", "C08": "超声表", "C09": "MR 表"}[check], value)
|
||||||
|
if check == "C10":
|
||||||
|
return _c10(value)
|
||||||
|
if check in ("C11", "C12", "C13"):
|
||||||
|
return _text_hits(check, value)
|
||||||
|
if check == "C14":
|
||||||
|
return _c14(row, value)
|
||||||
|
raise UnknownReasonError(f"跳过清单的检查编号不在对照表里:{check}")
|
||||||
|
|
||||||
|
|
||||||
|
C15 = "C15"
|
||||||
|
_C15_FAILED = re.compile(r"第 (?P<step>[0-9]+) 步失败:(?P<reason>.*)$", re.DOTALL)
|
||||||
|
_C15_MISSING = "CEUS 运行目录里没有这一例的结果目录"
|
||||||
|
|
||||||
|
|
||||||
|
def c15_reason(row: SkippedRow, failures: dict[str, tuple[int, str]]) -> str:
|
||||||
|
"""C15 跳过 → 给医生的话;在 failed.csv 里的,原因与 failed.csv 那一行核对一致。"""
|
||||||
|
value = json.loads(row.value_json)
|
||||||
|
if value == _C15_MISSING:
|
||||||
|
return "造影视频没有处理结果"
|
||||||
|
m = _C15_FAILED.match(value) if isinstance(value, str) else None
|
||||||
|
if m is None:
|
||||||
|
raise UnknownReasonError(f"C15 的原因不在对照表里:{value!r}")
|
||||||
|
if failures.get(row.case) != (int(m["step"]), m["reason"]):
|
||||||
|
raise ValueError(f"跳过清单里 {row.case} 的 C15 原因与 CEUS 运行的 failed.csv 对不上")
|
||||||
|
return ceus_failure(int(m["step"]), m["reason"])
|
||||||
|
|
||||||
|
|
||||||
|
def skip_problem(row: SkippedRow, failures: dict[str, tuple[int, str]]) -> tuple[str, str, str]:
|
||||||
|
"""跳过清单一行 →(环节,病例总表「没进的原因」,问题清单的话)。"""
|
||||||
|
if row.check == C15:
|
||||||
|
reason = c15_reason(row, failures)
|
||||||
|
return STAGE_VIDEO, reason, check_plain(reason + _NOT_IN_LIBRARY)
|
||||||
|
reason = skip_reason(row)
|
||||||
|
return STAGE_DATA, reason, check_plain(reason + _NOT_IN_LIBRARY)
|
||||||
@@ -1,202 +1,91 @@
|
|||||||
# 医生核对包说明
|
# 核对包说明
|
||||||
|
|
||||||
## 一、这个包是什么
|
这个包是我们用程序处理你们交来的资料之后的结果,想请你们帮忙看一看:程序读到的报告和临床信息对不对,在造影视频上圈的病灶、取的关键帧、算出的 TIC 参数是否合理,问题清单里列的情况是否属实。不用填表,有意见直接告诉我们就好。
|
||||||
|
|
||||||
这是我们用程序处理贵方交付资料之后的结果,整理出来请您核对:程序读到的内容对不对、CEUS(超声造影)视频的自动定量做得对不对、哪些病例没能进入资料库以及原因。包里只有我们的处理结果,不需要填写或回复任何表格。
|
我们的系统给每个病例配了三位 AI 专家,分别只看临床信息、超声(报告和造影视频)、MR 报告,再让它们会诊。三位专家能看到的全部资料,我们叫「资料库」;包里的报告、临床信息、关键帧和 TIC 参数,就是它们看到的原样。这一版共 $total_cases 例:$passed_cases 例进了资料库,$skipped_cases 例因为资料或视频上的问题没进,原因写在问题清单里。
|
||||||
|
|
||||||
- 构建号:$build_id(资料库和标准答案库是同一次构建产出的,共用这个编号)
|
|
||||||
- CEUS 定量运行号:$ceus_run_id
|
|
||||||
- 生成日期:$generated_date
|
|
||||||
- 病例数:病例总表共 $total_cases 行,其中进入资料库 $passed_cases 例,没进入 $skipped_cases 例。
|
|
||||||
|
|
||||||
$ceus_note
|
$ceus_note
|
||||||
|
|
||||||
**包里含患者资料(报告原文、临床信息、超声图像与视频、病理诊断),只在临床方与我们之间传递,请勿转发给其他人。**
|
**包里有患者资料(报告、临床信息、超声图像和视频、病理诊断),只在我们双方之间传,请不要转给别人。**
|
||||||
|
|
||||||
## 二、目录结构
|
## 一、包里有什么
|
||||||
|
|
||||||
- **医生核对包_\<构建号\>/**
|
- **说明.pdf**(这份说明)、**问题清单.csv**、**病例总表.csv**、**标准答案/**(4 张表):几张表见第六节。
|
||||||
- **说明.pdf**:本文件。
|
- **病例/**:每个进了资料库的病例一个文件夹,名字是病例号(如 P001)。打开能看到:
|
||||||
- **问题清单.csv**:程序在各环节观察到的现象,一行一条。
|
- **临床信息.csv**:临床那位 AI 专家看到的临床信息,列名沿用你们临床工作簿的原列名。$codes_note
|
||||||
- **病例总表.csv**:这次涉及的全部病例,一例一行。
|
- **超声报告.pdf**、**MR报告.pdf**:报告里的描述部分,一字未改。结论(印象)部分写着诊断倾向,没有给 AI 专家,这里也就没有。
|
||||||
- **标准答案/**
|
- **关键帧/**:「医生截图.png」是你们截的图;其余是程序从造影视频里取的画面,文件名如「D3_第12.4秒.png」,秒数是视频里的时间;没取到的时刻没有图。
|
||||||
- **化名对照.csv**:进入资料库的病例的化名、病例号、视频号。
|
- **TIC曲线.png**:时间-强度曲线图,见第四节。
|
||||||
- **诊断.csv**:进入资料库的病例在三张表里各自的病理诊断编码。
|
- **TIC参数.csv**:11 个灌注参数,胰腺实质和病灶各一组,见第五节。
|
||||||
- **跳过病例.csv**:没进入资料库的病例,以及第一项没通过的检查。
|
- **回放视频.mp4**:原视频上叠加了程序画的轮廓。
|
||||||
- **报告复核句.csv**:报告里含「考虑」「可能」「倾向」「提示」的句子。
|
|
||||||
- **病例/\<病例号\>/**:只有进入资料库的病例才有目录,目录名是病例号(如 P001)。
|
|
||||||
- **临床信息.csv**:临床一路的字段,一行。
|
|
||||||
- **超声报告.pdf**:超声报告的描述段,就是 AI 专家看到的原文。
|
|
||||||
- **MR报告.pdf**:MR 报告的描述段,就是 AI 专家看到的原文。
|
|
||||||
- **关键图.png**:贵方截的超声关键图。
|
|
||||||
- **关键帧/**:程序从 CEUS 视频里取的关键帧,文件名是「槽位_第几秒.png」(如 B0_第1.6秒.png),秒数是视频时间、保留一位小数;没取到的槽位没有文件。
|
|
||||||
- **TIC曲线.png**:时间-强度曲线图。
|
|
||||||
- **TIC结果.csv**:曲线拟合、灌注指标、关键帧的逐项结果。
|
|
||||||
- **回放视频.mp4**:原视频逐帧叠加程序画出的病灶轮廓等。
|
|
||||||
|
|
||||||
所有图片和视频都没有压缩、缩放或重新编码,与我们处理时用的文件逐字节相同。关键图和关键帧是写进资料库时那一份:程序重新保存时去掉了文件里附带的文字信息(如拍摄参数),像素没有改变。
|
图片和视频都是原样,没有压缩或缩放。
|
||||||
|
|
||||||
## 三、表格每一列的意思
|
```{=openxml}
|
||||||
|
<w:p><w:pPr><w:spacing w:before="240"/></w:pPr><w:r><w:rPr><w:sz w:val="15"/><w:color w:val="777777"/></w:rPr><w:t xml:space="preserve">供我们追溯用:构建号 $build_id · 视频处理运行号 $ceus_run_id · 生成于 $generated_date</w:t></w:r></w:p>
|
||||||
|
```
|
||||||
|
|
||||||
表格都是 CSV 文件,可以直接用 Excel 打开。空着的格子表示没有这项值。
|
## 二、看一个病例的建议顺序
|
||||||
|
|
||||||
**病例总表.csv**(这次交付涉及的全部病例,按病例号排序)
|
1. **先读超声报告和 MR 报告**,心里有个病灶的样子。
|
||||||
|
2. **看关键帧文件夹里的医生截图**,确认是哪个病灶。
|
||||||
|
3. **看回放视频。** 绿框和紫圈是你们在视频上画的:绿框圈病灶,紫圈圈一块正常的胰腺实质。黄色线(造影那一屏)和绿色线(灰阶那一屏)是程序画的病灶轮廓;蓝色线圈出的是程序认为一直不进造影剂的部分(如囊液)。病灶轮廓内去掉蓝色部分,剩下的一般是囊壁、分隔、实性成分这些会进造影剂的部分,后面叫「病灶(去暗区)」。青色小圆圈是程序在病灶旁边挑的一小块背景,用来检查整段视频里机器的整体亮度(增益)有没有变化。请看轮廓是否一直跟着病灶、蓝色部分是否确实不增强。
|
||||||
|
4. **再看 TIC 曲线图**(第四节)**和关键帧**(第三节,每张是不是对应的时刻),**最后看 TIC 参数**(第五节)。
|
||||||
|
|
||||||
| 列 | 意思 |
|
## 三、7 个关键帧是什么时刻
|
||||||
| --- | --- |
|
|
||||||
| 病例号 | 贵方的病例编号,如 P001。表里认不出编号的行写成「文件 第 N 行」或「病例目录 名字」 |
|
|
||||||
| 视频号 | CEUS 视频编号,按「U + 三位病例号」写,如 U001。没进入资料库的病例也这样写;其中在核对视频编号那项检查(C05)之前就被跳过的,交付里实际的视频编号没有核对过。认不出编号的行空着 |
|
|
||||||
| 化名 | 资料库里代替病例号的 4 位随机字母数字(见第七节);没进入资料库的空着 |
|
|
||||||
| 是否进入资料库 | 是 / 否 |
|
|
||||||
| 没进的原因 | 第一项没通过的检查编号和说明(见第九节);进入的空着 |
|
|
||||||
| CEUS 定量是否成功 | 这段视频在 CEUS 定量的失败记录里写「否」;定量运行里有这段视频的结果写「是」(没进入资料库的病例也可能是「是」,它是因为别的检查被跳过的);两者都没有(这段视频没做定量)或这次没装 CEUS 结果时空着 |
|
|
||||||
| CEUS 失败在第几步 | CEUS 定量失败在第 1–9 步中的哪一步(见第四节);没失败的空着 |
|
|
||||||
| B0、D1、D2、D3、D4、F1、F2 | 7 个关键帧(见第五节):取到的写它在视频里的时间(秒);没取到写「未取到」;CEUS 失败或没装 CEUS 结果时空着 |
|
|
||||||
|
|
||||||
**问题清单.csv**(按病例号排序,一个病例可以有多行)
|
- **B0**:增强之前,病灶还没变亮。
|
||||||
|
- **D1**:造影剂到达,病灶开始变亮。
|
||||||
|
- **D2**:变亮最快的时刻。
|
||||||
|
- **D3**:病灶最亮的时刻(峰值)。
|
||||||
|
- **D4**:变暗最快的时刻(廓清最快)。
|
||||||
|
- **F1**:推注后第 $f1_s 秒。
|
||||||
|
- **F2**:推注后第 $f2_s 秒;前后 $f2_tol_s 秒内没有能用的画面就不取。
|
||||||
|
|
||||||
| 列 | 意思 |
|
B0 和 D1–D4 的找法:程序用一条平滑曲线分别去贴合「病灶整体」和「病灶(去暗区)」两条亮度曲线。两条都贴合上,才在病灶(去暗区)那条平滑曲线上定出这 5 个时刻,各取离它最近的一帧;其中 D1–D4 按时间先后取,后一个必须比前一个晚。两条里有一条没贴合上,这 5 个关键帧就都没有。
|
||||||
| --- | --- |
|
|
||||||
| 病例号 | 同上 |
|
|
||||||
| 视频号 | 同上 |
|
|
||||||
| 环节 | 现象出在处理流程的哪一环,取值见第四节末尾的表 |
|
|
||||||
| 问题 | 程序观察到的现象,照程序记录的原文写,不含推测 |
|
|
||||||
|
|
||||||
**标准答案/化名对照.csv**:化名、病例号、视频号。这里的病例号是三位数字(如 001),对应病例目录名 P001。
|
F1、F2 按超声表里的「推注开始时间s」换算。这个数我们理解为「视频第 0 秒时已经推注了多少秒」,所以推注后第 $f1_s 秒就在视频第 $f1_s − 这个数 秒。**如果你们的意思不是这样,请一定告诉我们**,否则 F1、F2 取的都是错的时刻。
|
||||||
|
|
||||||
**标准答案/诊断.csv**:化名、病例号、临床表诊断编码、超声表诊断编码、MR表诊断编码、诊断名。三张表的诊断编码分别取自临床工作簿、超声报告工作簿、MR 报告工作簿,进入资料库的病例三者一致。编码含义见第八节。诊断名一列在编码没有解码时空着。
|
<!-- 推注时间的含义出自负责人 2026-10-16(design 0002 决定 5)。 -->
|
||||||
|
|
||||||
**标准答案/跳过病例.csv**:病例、检查编号、检查说明、文件、列、涉及值。病例是三位数字病例号,或认不出编号时的「文件 第 N 行」。文件和列是这项检查涉及的交付文件和列名,多个时用分号隔开。涉及值是对不上的原始取值,以程序记录的格式写出(带引号和括号)。
|
「重复帧」是病灶那块画面和前一帧几乎一模一样的帧:视频录制时同一张画面被存了好几遍,它不是新的时刻,所以不用。「能用的画面」指不是重复帧、而且这一帧里程序认得出病灶区域的帧。
|
||||||
|
|
||||||
**标准答案/报告复核句.csv**:化名、路(超声或 MR)、命中的词、所在句子。进入资料库的病例中,报告描述段里含「考虑」「可能」「倾向」「提示」的句子,一句一行。这些词可能带出诊断倾向,所以单独列出;这些句子仍留在资料库里。
|
## 四、TIC 曲线图上的英文
|
||||||
|
|
||||||
**病例/\<病例号\>/临床信息.csv**:一行,列名是交付的临床工作簿原列名(性别、年龄、5 个症状、5 项既往史、5 项化验的数值与是否异常)。病例号和诊断两列不在这里。
|
- **TIC: Lognormal fit**:图的标题,时间-强度曲线和贴合它的平滑曲线。
|
||||||
|
- **Time (s)**、**Grayscale intensity**:横轴是视频时间(秒),纵轴是画面亮度(灰度)。
|
||||||
|
- **roi_all raw**:蓝色小点,病灶整体(轮廓以内全部)每一帧的亮度。
|
||||||
|
- **roi_perf (dark removed) raw**:橙色小点,病灶(去暗区)每一帧的亮度。
|
||||||
|
- **roi_pan reference raw**:紫色小点,紫圈(胰腺实质)每一帧的亮度。
|
||||||
|
- **名字后面是 Lognormal fit 的粗实线**:程序贴合出的平滑曲线,只画贴合上的。
|
||||||
|
- **roi_pan reference Lognormal fit [QC rejected]**:紫色虚线,紫圈曲线没贴合上,仍画出来供参考;这时 TIC 参数里胰腺实质那一组是空的。
|
||||||
|
- **D1 model … D4 model**:竖虚线,平滑曲线上 D1–D4 的时刻。
|
||||||
|
- **竖线名字后面带方括号的**,表示这个时刻没取到关键帧:[no_frame_in_window] 这个时刻附近没有能用的画面;[no_ordered_frame] 附近能用的画面都不晚于前一个关键帧取的那一帧;[not_covered] 这个时刻不在视频里;[anchor_undefined] 两条病灶曲线没有都贴合上。
|
||||||
|
- **No plottable TIC data**:没有能画的数据。
|
||||||
|
|
||||||
**病例/\<病例号\>/TIC结果.csv**:两列「项目」「值」。项目名是中文加程序里的字段名(括号里),值里的 true 表示是、false 表示否。各项的意思见第六节。
|
## 五、TIC 参数怎么看
|
||||||
|
|
||||||
## 四、我们的处理流程
|
TIC参数.csv 每行一个参数,两组数并排:**胰腺实质(紫圈)**和**病灶(去暗区)**,用意是让病灶和正常胰腺对着看。下面说的「开始变亮」「退回增强前水平」都是按切线定的:在变亮最快那一点(D2)画切线,与增强前水平的交点算开始变亮;在变暗最快那一点(D4)画切线,与增强前水平的交点算退回增强前水平。
|
||||||
|
|
||||||
处理分三段:先读交付资料并逐例检查,再对 CEUS 视频做定量,最后把通过的病例装进资料库。
|
- **PE 峰值增强**:最亮时比增强前亮了多少。
|
||||||
|
- **TTP 达峰时间**:最亮的时刻。
|
||||||
|
- **WiR 最大洗入斜率**:变亮最快时每秒亮多少。
|
||||||
|
- **RT 上升时间**:从开始变亮到最亮用了多久。
|
||||||
|
- **WiAUC 洗入曲线下面积**:从开始变亮到最亮这一段曲线下的面积,反映这段时间进来的造影剂多少。
|
||||||
|
- **WiPI 洗入灌注指数**:WiAUC 除以 RT,即变亮这一段的平均增强程度。
|
||||||
|
- **WoR 最大洗出斜率**:变暗最快时每秒暗多少,是负数。
|
||||||
|
- **FT 下降时间**:从最亮到退回增强前水平用了多久。
|
||||||
|
- **WoAUC 洗出曲线下面积**:从最亮到退回增强前水平这一段的面积。
|
||||||
|
- **WiWoAUC 洗入洗出曲线下面积**:上面两段面积加起来。
|
||||||
|
- **mTTl 平均渡越时间**:从开始变亮到退回增强前水平这一段里,造影剂平均停留了多久,从开始变亮算起;l 表示只在这一段(local)里算。
|
||||||
|
|
||||||
**第一段:读交付资料、逐例检查。** 程序读临床工作簿、超声报告工作簿、MR 报告工作簿和每例的病例目录,逐例做一串检查(编号能认出、三张表都有这一例、病例目录文件齐全、取值合规、三张表的诊断年龄性别一致、报告描述里没有结论段标题、没有亚型名、没有性别年龄等)。一例遇到第一项没通过的检查就整例跳过(见第九节)。关键图要能原样重新保存(检查 C14)。
|
看这些数时请注意三点。一是数值在视频画面的亮度(灰度,0–255)上算,不是超声机内部的回声强度,单看一个数意义不大,主要看两组的对比。二是时间从视频第一帧算起,不是从推注算起。三是参数都从贴合出的平滑曲线上算:某一组曲线没贴合上,这一组整列空着,表的最后一行写了原因;个别格子空着,是视频录得不够长(如还没退回增强前水平就结束了),这一项算不出来,就空着,不去估。
|
||||||
|
|
||||||
**第二段:CEUS 定量。** 每段 CEUS 视频依次做下面 9 步。第 1、2、4 步遇到视频不满足前提时,这一例整例失败、记下失败在哪一步和原因,接着处理下一例;第 7、8 步做不出来不算整例失败,只是相应结果空缺。
|
## 六、几张表
|
||||||
|
|
||||||
1. **解码与分屏、两半配准。** 读视频;超声机录造影时屏幕分左右两半,一半是造影画面,一半是普通灰阶画面,程序找出分界线,再估出两半画面之间的位置偏移。每段视频的偏移都经过我们人工目视核对,没通过核对的病例在这一步失败。视频读不出、找不到分界线、两半对不齐,也在这一步失败。
|
- **问题清单.csv**:一个问题一行:病例号、视频号(你们交付包里视频的编号,如 U001)、环节(资料核对、造影视频处理、TIC 曲线、关键帧、紫圈)和一句话说明;同一个原因引起的几件事合成一行。「资料核对」是资料本身的问题,你们改好重交就行;其余是程序处理视频和曲线时遇到的,想请你们看看那段视频本身有没有异常,告诉我们就好。
|
||||||
2. **找绿框、选提示帧。** 在两半上找贵方画的绿框:恰好一半有绿框,那一半就是造影画面;两半都有或都没有,这一例在这一步失败。然后挑出绿框位置或形状有明显变化的几帧作为「提示帧」,把绿框换算到灰阶那一半上。
|
- **病例总表.csv**:全部病例一例一行:进没进资料库、没进的原因、造影视频处理成功没有(没成功时写停在第几步)、7 个关键帧各在视频第几秒。
|
||||||
3. **分割。** 用 MedSAM2 分割模型在灰阶那一半上,从提示帧的绿框出发,逐帧划出病灶范围。
|
- **标准答案/化名对照.csv**:资料库里代替病例号的化名与病例号的对照。
|
||||||
4. **病灶轮廓。** 把逐帧的分割结果变成平滑的轮廓,与绿框的位置和形状融合,再换算回造影那一半。分割结果一帧都没有可用轮廓时,这一例在这一步失败。
|
- **标准答案/诊断.csv**:三张表的病理诊断编码(1 IPMN,2 SPN,3 MCN,4 SCN)。
|
||||||
5. **暗区。** 在病灶轮廓内找出一直发黑、不进造影剂的部分(如囊液)。
|
- **标准答案/跳过病例.csv**:内容和问题清单里没进资料库的那几条一样,是程序原文版本,一般不用看。
|
||||||
6. **TIC 采样。** TIC(时间-强度曲线)是某个区域的平均亮度随时间的变化。程序在造影那一半上逐帧取四条曲线:病灶整体(轮廓内,去掉绿框和紫圈的颜色)、病灶去暗区(再去掉暗区)、紫圈(贵方画的紫色圈内部,一块正常胰腺作对照)、背景圆(程序在病灶附近自动挑的一小块背景,只用来看整机亮度有没有漂移)。紫圈只有在足够多的帧里都认出来,紫圈曲线才算可用。
|
- **标准答案/报告复核句.csv**:报告描述里含「考虑」「可能」「倾向」「提示」的句子。这些句子目前仍留在给 AI 专家的报告里,想请你们看看它们算不算带出了诊断倾向、该不该删掉。
|
||||||
7. **曲线拟合。** 用 Lognormal(对数正态)曲线拟合每条 TIC,拟合要通过一组质量检查(下称质量门)才算成功。没通过时记下原因码,含义见第六节。
|
|
||||||
8. **关键帧。** 定位 7 个关键帧,见第五节。
|
|
||||||
9. **灌注指标。** 在病灶两条曲线拟合成功时,从拟合曲线算 11 项灌注指标(口径仿照商用造影定量软件 VueBOX),见第六节。
|
|
||||||
|
|
||||||
**第三段:装进资料库。** CEUS 定量失败或没有结果的病例整例跳过(检查 C15);关键帧也要能原样重新保存(检查 C14)。通过的病例取化名,把临床、超声、MR 三路资料分开存放,每一路的 AI 专家只看得到自己那一路。
|
|
||||||
|
|
||||||
问题清单里「环节」一列的取值:
|
|
||||||
|
|
||||||
| 环节 | 对应哪一步 | 问题的写法 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| 装配资料库 | 第一段的检查和第三段的 C14、C15 | 检查编号、检查说明、涉及的文件、列和值,照跳过病例表原样写 |
|
|
||||||
| CEUS 第 N 步 | 第二段第 N 步整例失败 | 「第 N 步失败:」加程序记录的原因 |
|
|
||||||
| TIC 拟合 | 第二段第 7 步,病灶整体或病灶去暗区曲线没通过质量门 | 「病灶整体曲线未完成拟合」或「病灶去暗区曲线未完成拟合」,括号里是原因码 |
|
|
||||||
| 关键帧 | 第二段第 8 步,某个关键帧没取到 | 「槽位 未取到:」加程序记录的原因 |
|
|
||||||
| 紫圈 | 第二段第 6 步,紫圈曲线不可用 | 「紫圈曲线不可用」 |
|
|
||||||
|
|
||||||
一个 CEUS 失败的病例会有一行「CEUS 第 N 步」,一般还同时有一行「装配资料库」(C15),两行来自不同的记录文件;但如果它在 C15 之前就因别的检查被跳过,「装配资料库」那一行写的是那项检查。
|
|
||||||
|
|
||||||
## 五、7 个关键帧
|
|
||||||
|
|
||||||
| 槽位 | 代表的时刻 | 怎么取 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| B0 | 病灶刚开始变亮之前 | 病灶去暗区曲线的拟合曲线上的到达时刻,再往前退一点,在到达之前的基线段里取帧 |
|
|
||||||
| D1 | 病灶开始变亮(造影剂到达) | 拟合曲线上的到达时刻 |
|
|
||||||
| D2 | 变亮最快的时刻(洗入拐点) | 拟合曲线 |
|
|
||||||
| D3 | 最亮的时刻(峰值) | 拟合曲线 |
|
|
||||||
| D4 | 变暗最快的时刻(洗出拐点) | 拟合曲线 |
|
|
||||||
| F1 | 推注后第 $f1_s 秒 | 视频时间 = 推注后时刻 − X |
|
|
||||||
| F2 | 推注后第 $f2_s 秒 | 视频时间 = 推注后时刻 − X |
|
|
||||||
|
|
||||||
B0、D1–D4 都来自病灶去暗区曲线的拟合曲线,在各自时刻附近的允许范围内找最近的可用帧,并保持先后顺序(D2 的帧不会早于 D1 的帧)。F1、F2 按推注时间取:X 是超声报告工作簿的「推注开始时间s」,意思是视频第 0 秒时已经推注了 X 秒,所以推注后第 T 秒在视频第 T − X 秒。F1 取离这个时刻最近的帧;F2 只在这个时刻前后 $f2_tol_s 秒内取。
|
|
||||||
|
|
||||||
「未取到」时,问题清单里写的是程序记录的原因原文,有以下几种:
|
|
||||||
|
|
||||||
- 「病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门」:病灶整体、病灶去暗区两条曲线没有都拟合成功,B0、D1–D4 都不取。
|
|
||||||
- 「roi_perf 曲线的 Lognormal 拟合没过质量门(原因码)」:病灶去暗区曲线拟合没成功,B0、D1–D4 都不取。
|
|
||||||
- 「拟合曲线升不过到达门槛,没有到达时刻」(B0)、「拟合曲线上没有 D1 时刻(升不过到达门槛)」之类:拟合曲线上升的幅度不够,定不出到达时刻。
|
|
||||||
- 「视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义」:视频一开始病灶就已经在变亮,没有变亮之前的那一段,所以没有 B0。
|
|
||||||
- 「峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4」:峰值之后录得不够长,拟合曲线上找不到洗出拐点。
|
|
||||||
- 「某时刻 … 秒落在视频有效帧范围 … 之外」:算出的时刻早于视频第一帧或晚于最后一帧。
|
|
||||||
- 「推注后第 T 秒在视频第 … 秒(推注时间 X=… 秒),落在 … 之外」(F1、F2):同上,按推注时间换算出的时刻不在视频里。
|
|
||||||
- 「… 容差窗口 … 内没有可用帧」:时刻附近的允许范围里没有可用的帧(重复帧不算可用)。
|
|
||||||
- 「… 容差窗口 … 内的候选帧都被时间更早的槽位占用,无法保持先后顺序」:附近的帧已经被前一个关键帧用掉了。
|
|
||||||
- 「视频第 … 秒 ±… 秒内没有不重复的帧」(F1、F2):同上,附近只有重复帧。
|
|
||||||
- 「没有推注时间,推注后时刻换算不到视频时间」(F1、F2):没有推注时间。
|
|
||||||
|
|
||||||
## 六、TIC 曲线图和 TIC 结果怎么看
|
|
||||||
|
|
||||||
**TIC曲线.png**:横轴是视频时间(秒),纵轴是灰度亮度。图上的英文标注:
|
|
||||||
|
|
||||||
| 图上的英文 | 中文意思 |
|
|
||||||
| --- | --- |
|
|
||||||
| TIC: Lognormal fit | 图标题:时间-强度曲线及其对数正态拟合 |
|
|
||||||
| Time (s) | 横轴:视频时间(秒) |
|
|
||||||
| Grayscale intensity | 纵轴:灰度亮度 |
|
|
||||||
| roi_all raw | 病灶整体曲线的逐帧原始值(蓝色小点) |
|
|
||||||
| roi_perf (dark removed) raw | 病灶去暗区曲线的逐帧原始值(橙色小点) |
|
|
||||||
| roi_pan reference raw | 紫圈曲线(正常胰腺对照)的逐帧原始值(紫色小点) |
|
|
||||||
| … Lognormal fit | 对应曲线的拟合曲线(粗实线),只画拟合成功的 |
|
|
||||||
| roi_pan reference Lognormal fit [QC rejected] | 紫圈曲线没通过质量门的拟合(紫色虚线),仍画出来供参考;病灶曲线拟合没通过时不画 |
|
|
||||||
| D1 model、D2 model、D3 model、D4 model | 竖虚线:拟合曲线上 D1–D4 的时刻(见第五节) |
|
|
||||||
| [no_frame_in_window] | 跟在竖线标签后面:这个时刻附近的允许范围里没有可用帧,关键帧没取到 |
|
|
||||||
| [no_ordered_frame] | 附近的帧都被时间更早的关键帧占用,关键帧没取到 |
|
|
||||||
| [not_covered] | 这个时刻落在视频有效帧范围之外,关键帧没取到 |
|
|
||||||
| [anchor_undefined] | 病灶两条曲线没有都拟合成功,这个时刻不用来取关键帧 |
|
|
||||||
| No plottable TIC data | 没有能画的曲线数据 |
|
|
||||||
|
|
||||||
背景圆曲线不画在图上。竖线没有标状态的,表示这个关键帧取到了。
|
|
||||||
|
|
||||||
**TIC结果.csv 的各项**:
|
|
||||||
|
|
||||||
- 四条曲线(病灶整体 roi_all、病灶去暗区 roi_perf、紫圈 roi_pan、背景圆 background_circle)各自「拟合成功」(success)和「拟合优度 R²」(r2,越接近 1 拟合越贴合;算不出时空着)。
|
|
||||||
- 病灶两条曲线的灌注指标(perfusion_metrics;拟合没成功时整组空着)。观测时间不够算不出的单项空着,不外推。
|
|
||||||
- 信号域 signal_domain = db_display:在视频显示的灰度上算,不是换算后的回声功率;时间零点 time_origin = video_start:时间从视频开头算起;面积所用曲线 area_curve = fitted_minus_baseline:面积用拟合曲线减去基线。
|
|
||||||
- PE:峰值增强(峰值减基线);TTP:达峰时间(秒);WiR:最大洗入斜率;WiAUC:洗入段曲线下面积(TI 到 TTP);RT:上升时间(TTP − TI);WiPI:洗入灌注指数(WiAUC / RT);WoR:最大洗出斜率;WoAUC:洗出段曲线下面积(TTP 到 TO);FT:下降时间(TO − TTP);WiWoAUC:洗入洗出总面积;mTTl:平均渡越时间(mTT − TI)。
|
|
||||||
- 辅助时刻:TI 是洗入切线与基线的交点,TO 是洗出切线与基线的交点,mTT 是 TI 到 TO 之间的平均渡越时刻(都是视频时间,秒)。
|
|
||||||
- 紫圈曲线可用(pan_curve_usable):紫圈在足够多的帧里认出来时为 true。
|
|
||||||
- 7 个关键帧各自「取到」(located)、「视频时间」(video_time_s,秒)、「未取到的原因」(reason)。
|
|
||||||
|
|
||||||
**拟合没通过质量门的原因码**(问题清单「TIC 拟合」一行括号里的英文):
|
|
||||||
|
|
||||||
| 原因码 | 意思 |
|
|
||||||
| --- | --- |
|
|
||||||
| insufficient_valid_samples | 可用的帧(亮度能取到、不是重复帧)太少 |
|
|
||||||
| candidate_grid_unavailable | 可用帧的时间点无法排成拟合用的等间隔时间轴 |
|
|
||||||
| credible_peak_not_found | 平滑后的曲线上找不到够高、够突出的峰 |
|
|
||||||
| candidate_arrival_not_found | 峰之前找不到亮度持续越过到达门槛的时刻 |
|
|
||||||
| insufficient_bolus_samples | 第一次团注(造影剂第一次通过)那一段里可用帧太少 |
|
|
||||||
| long_gap_in_wash_in | 峰之前的上升段里有一段太长的时间缺口(中间缺帧) |
|
|
||||||
| arrival_bounds_invalid | 按观测范围给到达时刻、峰时刻划的允许区间不成立(区间为空) |
|
|
||||||
| optimizer_failed | 拟合计算从各组起始值出发都没有算出结果 |
|
|
||||||
| parameter_on_boundary | 拟合出的曲线参数贴在允许范围的边上 |
|
|
||||||
| model_peak_on_boundary | 拟合曲线的峰落在拟合时间段的两端附近 |
|
|
||||||
| fit_quality_failed | 拟合曲线与原始数据贴合得不够(误差太大,且拟合优度和残差都没达标) |
|
|
||||||
|
|
||||||
**回放视频.mp4**:原视频逐帧叠加程序画的线:造影那一半的病灶轮廓是黄色,灰阶那一半的病灶轮廓是绿色,造影那一半的暗区轮廓是蓝色(灰阶那一半不画暗区),背景圆是青色圆圈。贵方画的绿框和紫圈是视频原有的。
|
|
||||||
|
|
||||||
## 七、化名与报告
|
|
||||||
|
|
||||||
**化名**:资料库里每例用一个 4 位随机字母数字代替病例号。交付文件名里带着病例号,而表格的行序可能与诊断分组有关;用随机化名并按化名排序,AI 专家就无法从编号或顺序猜出诊断。化名与病例号的对照只在标准答案里。
|
|
||||||
|
|
||||||
**报告只放描述段**:资料库里只放超声和 MR 报告的「所见」描述,不放「印象 / 结论」段,因为结论段写着医生的诊断倾向,AI 专家看到它就等于看到了答案。程序检查每份报告的描述文字里有没有结论段标题(如「超声提示:」「印象:」「诊断:」,检查 C11),有就整例跳过,不进资料库。超声报告.pdf 和 MR报告.pdf 就是资料库里 AI 专家看到的那段文字,一字未改;PDF 第一行的标题是我们加的。
|
|
||||||
|
|
||||||
## 八、编码列
|
|
||||||
|
|
||||||
诊断编码:1 IPMN(导管内乳头状黏液性肿瘤),2 SPN(实性假乳头状肿瘤),3 MCN(黏液性囊性肿瘤),4 SCN(浆液性囊性肿瘤)。出自交付的超声、MR 报告工作簿的诊断列表头。
|
|
||||||
|
|
||||||
$codes_note
|
|
||||||
|
|
||||||
## 九、跳过病例
|
|
||||||
|
|
||||||
一例只要有一项检查没通过,就整例跳过,不进资料库:缺了某一路资料或资料前后对不上,这一例就无法公平地交给三位 AI 专家会诊。跳过病例表里每例只记第一项没通过的检查:遇到它就跳过整例,后面的检查没有做过,所以表里没写的检查不代表没有问题。
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
数值全部编造,不调显卡。tic.png 和 overlay.mp4 只是带视频号的几个字节(核对包按字节拷贝,不打开)。
|
数值全部编造,不调显卡。tic.png 和 overlay.mp4 只是带视频号的几个字节(核对包按字节拷贝,不打开)。
|
||||||
|
|
||||||
summary.json 故意带上 tic.json 不该有的字段(视频号、视频 SHA-256、分割缓存键、帧号、拟合参数、
|
summary.json 故意带上 tic.json 不该有的字段(视频号、视频 SHA-256、分割缓存键、帧号、拟合参数、
|
||||||
失败原因代码、推注时间、紫圈检出率),用来测 build 只按白名单挑字段。视频 SHA-256 和推注时间照
|
四条曲线的拟合成败与 R²、失败原因代码、推注时间、紫圈检出率与是否可用、灌注指标的口径标签和辅助
|
||||||
|
时刻、关键帧状态与未定位原因),用来测 build 只按白名单挑字段。视频 SHA-256 和推注时间照
|
||||||
`fake_delivery` 的假交付包(视频内容 `VIDEO_BYTES`、推注 5 秒)。关键帧 PNG 带文本块和 EXIF。
|
`fake_delivery` 的假交付包(视频内容 `VIDEO_BYTES`、推注 5 秒)。关键帧 PNG 带文本块和 EXIF。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -24,7 +25,8 @@ CEUS_YAML = REPO_ROOT / "config" / "ceus.yaml"
|
|||||||
UNLOCATED_REASON = "拟合曲线上没有 D4 时刻(编造的原因)"
|
UNLOCATED_REASON = "拟合曲线上没有 D4 时刻(编造的原因)"
|
||||||
|
|
||||||
|
|
||||||
def _perfusion(scale: float) -> dict[str, Any]:
|
def perfusion(scale: float) -> dict[str, Any]:
|
||||||
|
"""一组灌注指标的 summary 段:11 个指标依次是 `scale` × 1, 2, …, 11。"""
|
||||||
names = ("PE_au", "WiAUC_au_s", "RT_s", "mTTl_s", "TTP_s", "WiR_au_per_s", "WiPI_au")
|
names = ("PE_au", "WiAUC_au_s", "RT_s", "mTTl_s", "TTP_s", "WiR_au_per_s", "WiPI_au")
|
||||||
more = ("WoAUC_au_s", "WiWoAUC_au_s", "FT_s", "WoR_au_per_s")
|
more = ("WoAUC_au_s", "WiWoAUC_au_s", "FT_s", "WoR_au_per_s")
|
||||||
return {
|
return {
|
||||||
@@ -88,7 +90,8 @@ def summary(video_id: str, located: tuple[str, ...]) -> dict[str, Any]:
|
|||||||
"roi_pan": _fit(False, None),
|
"roi_pan": _fit(False, None),
|
||||||
"background_circle": _fit(False, 0.2),
|
"background_circle": _fit(False, 0.2),
|
||||||
},
|
},
|
||||||
"perfusion_metrics": {"roi_all": _perfusion(1.0), "roi_perf": None},
|
# 紫圈拟合没过(reference_valid 为 False),整组为 null;病灶去暗区拟合过了,有指标。
|
||||||
|
"perfusion_metrics": {"roi_perf": perfusion(1.0), "roi_pan": None},
|
||||||
"anchor_frames": anchors,
|
"anchor_frames": anchors,
|
||||||
"available_dynamic_slots": [s for s in ("B0", "D1", "D2", "D3", "D4") if s in located],
|
"available_dynamic_slots": [s for s in ("B0", "D1", "D2", "D3", "D4") if s in located],
|
||||||
}
|
}
|
||||||
|
|||||||
+180
-83
@@ -1,8 +1,9 @@
|
|||||||
"""build 装入 CEUS 结果:C15 跳过、tic.json 只有白名单字段、关键帧去附加数据、运行目录不合规时停下。
|
"""build 装入 CEUS 结果:C15 跳过、tic.json 只有白名单字段、关键帧去附加数据、运行目录不合规时停下。
|
||||||
|
|
||||||
预期的 tic.json 键集合照任务书 02 第四阶段的文字手写:四条曲线各自拟合是否成功和拟合优度(R²);
|
tic.json 的预期内容照任务书 04 第二部分手写:紫圈(`roi_pan`)和病灶去暗区(`roi_perf`)两组各
|
||||||
病灶两条曲线的灌注指标(同旧 replay summary 的 perfusion_metrics 段);紫圈曲线是否可用;
|
11 个灌注指标(拟合没过整组为 null,单项算不出为 null),7 个槽位定位到的视频时间(没取到为
|
||||||
7 个槽位各自是否定位到、定位到的视频时间、未定位的原因。
|
null),此外什么都没有。超声一路的文件是 report.txt、tic.json 和 keyframes/ 下的 key.png 加定位到
|
||||||
|
的槽位。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
@@ -17,33 +18,13 @@ from PIL import Image
|
|||||||
from cadx.answers import read_aliases
|
from cadx.answers import read_aliases
|
||||||
from cadx.build import BuildSummary, run_build
|
from cadx.build import BuildSummary, run_build
|
||||||
from cadx.library import list_aliases, read_ultrasound, scan_for_leaks
|
from cadx.library import list_aliases, read_ultrasound, scan_for_leaks
|
||||||
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
|
from fake_ceus_run import perfusion, summary, write_run
|
||||||
from fake_delivery import REPO_ROOT, make_delivery, write_config
|
from fake_delivery import REPO_ROOT, make_delivery, write_config
|
||||||
|
|
||||||
NOW = datetime(2026, 10, 16, 11, 0, 0).astimezone()
|
NOW = datetime(2026, 10, 16, 11, 0, 0).astimezone()
|
||||||
RUN_ID = "20261016-100000-abcdef0"
|
RUN_ID = "20261016-100000-abcdef0"
|
||||||
LOCATED = ("D1", "D2", "F1")
|
LOCATED = ("D1", "D2", "F1")
|
||||||
|
ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||||
_PERFUSION_KEYS = {
|
|
||||||
"signal_domain",
|
|
||||||
"time_origin",
|
|
||||||
"area_curve",
|
|
||||||
"values",
|
|
||||||
"supporting_times_s",
|
|
||||||
}
|
|
||||||
_PERFUSION_VALUES = {
|
|
||||||
"PE_au",
|
|
||||||
"WiAUC_au_s",
|
|
||||||
"RT_s",
|
|
||||||
"mTTl_s",
|
|
||||||
"TTP_s",
|
|
||||||
"WiR_au_per_s",
|
|
||||||
"WiPI_au",
|
|
||||||
"WoAUC_au_s",
|
|
||||||
"WiWoAUC_au_s",
|
|
||||||
"FT_s",
|
|
||||||
"WoR_au_per_s",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _build(tmp_path: Path, count: int, run_dir: Path) -> BuildSummary:
|
def _build(tmp_path: Path, count: int, run_dir: Path) -> BuildSummary:
|
||||||
@@ -66,13 +47,6 @@ def _png_chunks(data: bytes) -> list[str]:
|
|||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
def _key_tree(node: Any) -> Any:
|
|
||||||
"""嵌套字典的键结构:字典换成「键 → 子结构」,其余值换成 None。"""
|
|
||||||
if isinstance(node, dict):
|
|
||||||
return {key: _key_tree(value) for key, value in cast(dict[str, Any], node).items()}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None:
|
def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None:
|
||||||
run = write_run(
|
run = write_run(
|
||||||
tmp_path / "runs" / RUN_ID,
|
tmp_path / "runs" / RUN_ID,
|
||||||
@@ -95,39 +69,173 @@ def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None:
|
|||||||
assert manifest["ceus_source"] == RUN_ID
|
assert manifest["ceus_source"] == RUN_ID
|
||||||
|
|
||||||
|
|
||||||
def test_tic_json_has_exactly_the_listed_fields(tmp_path: Path) -> None:
|
# 四例各自定位到的槽位和 summary.json 的改法:
|
||||||
result = _build(
|
# - U001:紫圈、病灶去暗区两组都有(紫圈 = perfusion(10.0)),7 个槽位都取到;
|
||||||
tmp_path,
|
# - U002:只有病灶去暗区一组(假运行的原样),其中 WoAUC、FT 两项算不出;只取到 D1、D2、F1;
|
||||||
1,
|
# - U003:两组都 null(病灶两条曲线拟合都没过,紫圈不可用);只取到 F1、F2;
|
||||||
write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, []),
|
# - U004:只有紫圈一组,其中 RT 一项算不出;F2 没取到。
|
||||||
)
|
_CASE_SLOTS: dict[str, tuple[str, ...]] = {
|
||||||
(alias,) = list_aliases(result.library_dir)
|
"U001": ALL_SLOTS,
|
||||||
tic = read_ultrasound(result.library_dir, alias).tic
|
"U002": LOCATED,
|
||||||
perfusion = {
|
"U003": ("F1", "F2"),
|
||||||
**dict.fromkeys(_PERFUSION_KEYS - {"values", "supporting_times_s"}),
|
"U004": ("B0", "D1", "D2", "D3", "D4", "F1"),
|
||||||
"values": dict.fromkeys(_PERFUSION_VALUES),
|
|
||||||
"supporting_times_s": dict.fromkeys(("TI", "mTT", "TO")),
|
|
||||||
}
|
}
|
||||||
slot = {"located": None, "video_time_s": None, "reason": None}
|
|
||||||
curves = ("roi_all", "roi_perf", "roi_pan", "background_circle")
|
|
||||||
assert _key_tree(tic) == {
|
def _both_groups(content: dict[str, Any]) -> None:
|
||||||
"fits": {name: {"success": None, "r2": None} for name in curves},
|
content["reference_valid"] = True
|
||||||
"perfusion_metrics": {"roi_all": perfusion, "roi_perf": None},
|
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||||
"pan_curve_usable": None,
|
content["perfusion_metrics"]["roi_pan"] = perfusion(10.0)
|
||||||
"keyframes": {s: slot for s in ("B0", "D1", "D2", "D3", "D4", "F1", "F2")},
|
|
||||||
|
|
||||||
|
def _perf_items_missing(content: dict[str, Any]) -> None:
|
||||||
|
values = content["perfusion_metrics"]["roi_perf"]["values"]
|
||||||
|
values["WoAUC_au_s"] = None
|
||||||
|
values["FT_s"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _no_groups(content: dict[str, Any]) -> None:
|
||||||
|
for curve in ("roi_all", "roi_perf"):
|
||||||
|
content["fits"][curve].update(success=False, failure_reason="credible_peak_not_found")
|
||||||
|
content["lesion_fit_ready"] = False
|
||||||
|
content["perfusion_metrics"]["roi_perf"] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _pan_only(content: dict[str, Any]) -> None:
|
||||||
|
_both_groups(content)
|
||||||
|
content["fits"]["roi_perf"].update(success=False, failure_reason="parameter_on_boundary")
|
||||||
|
content["lesion_fit_ready"] = False
|
||||||
|
content["perfusion_metrics"]["roi_perf"] = None
|
||||||
|
content["perfusion_metrics"]["roi_pan"]["values"]["RT_s"] = None
|
||||||
|
|
||||||
|
|
||||||
|
_EDITS = {"U001": _both_groups, "U002": _perf_items_missing, "U003": _no_groups, "U004": _pan_only}
|
||||||
|
|
||||||
|
_PAN = {
|
||||||
|
"PE_au": 10.0,
|
||||||
|
"WiAUC_au_s": 20.0,
|
||||||
|
"RT_s": 30.0,
|
||||||
|
"mTTl_s": 40.0,
|
||||||
|
"TTP_s": 50.0,
|
||||||
|
"WiR_au_per_s": 60.0,
|
||||||
|
"WiPI_au": 70.0,
|
||||||
|
"WoAUC_au_s": 80.0,
|
||||||
|
"WiWoAUC_au_s": 90.0,
|
||||||
|
"FT_s": 100.0,
|
||||||
|
"WoR_au_per_s": 110.0,
|
||||||
}
|
}
|
||||||
assert tic is not None
|
_PERF = {
|
||||||
assert tic["fits"]["roi_all"] == {"success": True, "r2": 0.93}
|
"PE_au": 1.0,
|
||||||
assert tic["fits"]["roi_pan"] == {"success": False, "r2": None}
|
"WiAUC_au_s": 2.0,
|
||||||
assert tic["pan_curve_usable"] is False
|
"RT_s": 3.0,
|
||||||
assert tic["perfusion_metrics"]["roi_all"]["values"]["PE_au"] == 1.0
|
"mTTl_s": 4.0,
|
||||||
# 假数据里 D1 是第 2 个槽位:视频时间 1 × 2.5 秒。
|
"TTP_s": 5.0,
|
||||||
assert tic["keyframes"]["D1"] == {"located": True, "video_time_s": 2.5, "reason": None}
|
"WiR_au_per_s": 6.0,
|
||||||
assert tic["keyframes"]["D4"] == {
|
"WiPI_au": 7.0,
|
||||||
"located": False,
|
"WoAUC_au_s": 8.0,
|
||||||
"video_time_s": None,
|
"WiWoAUC_au_s": 9.0,
|
||||||
"reason": UNLOCATED_REASON,
|
"FT_s": 10.0,
|
||||||
|
"WoR_au_per_s": 11.0,
|
||||||
}
|
}
|
||||||
|
# 假运行里定位到的槽位,视频时间 = 槽位序号 × 2.5 秒。
|
||||||
|
_ALL_TIMES = {"B0": 0.0, "D1": 2.5, "D2": 5.0, "D3": 7.5, "D4": 10.0, "F1": 12.5, "F2": 15.0}
|
||||||
|
|
||||||
|
EXPECTED_TIC = {
|
||||||
|
"U001": {
|
||||||
|
"perfusion_metrics": {"roi_pan": _PAN, "roi_perf": _PERF},
|
||||||
|
"keyframe_times_s": _ALL_TIMES,
|
||||||
|
},
|
||||||
|
"U002": {
|
||||||
|
"perfusion_metrics": {
|
||||||
|
"roi_pan": None,
|
||||||
|
"roi_perf": {**_PERF, "WoAUC_au_s": None, "FT_s": None},
|
||||||
|
},
|
||||||
|
"keyframe_times_s": {
|
||||||
|
"B0": None,
|
||||||
|
"D1": 2.5,
|
||||||
|
"D2": 5.0,
|
||||||
|
"D3": None,
|
||||||
|
"D4": None,
|
||||||
|
"F1": 12.5,
|
||||||
|
"F2": None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"U003": {
|
||||||
|
"perfusion_metrics": {"roi_pan": None, "roi_perf": None},
|
||||||
|
"keyframe_times_s": {
|
||||||
|
"B0": None,
|
||||||
|
"D1": None,
|
||||||
|
"D2": None,
|
||||||
|
"D3": None,
|
||||||
|
"D4": None,
|
||||||
|
"F1": 12.5,
|
||||||
|
"F2": 15.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"U004": {
|
||||||
|
"perfusion_metrics": {"roi_pan": {**_PAN, "RT_s": None}, "roi_perf": None},
|
||||||
|
"keyframe_times_s": {**_ALL_TIMES, "F2": None},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_FORBIDDEN_TIC_KEYS = {
|
||||||
|
"fits",
|
||||||
|
"r2",
|
||||||
|
"success",
|
||||||
|
"roi_all",
|
||||||
|
"background_circle",
|
||||||
|
"reason",
|
||||||
|
"pan_curve_usable",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def four_cases(tmp_path: Path) -> BuildSummary:
|
||||||
|
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", _CASE_SLOTS, [])
|
||||||
|
for video_id, located in _CASE_SLOTS.items():
|
||||||
|
content = summary(video_id, located)
|
||||||
|
_EDITS[video_id](content)
|
||||||
|
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
||||||
|
return _build(tmp_path, 4, run)
|
||||||
|
|
||||||
|
|
||||||
|
def _all_keys(node: object) -> set[str]:
|
||||||
|
if isinstance(node, dict):
|
||||||
|
items = cast(dict[str, Any], node)
|
||||||
|
return set(items) | {k for value in items.values() for k in _all_keys(value)}
|
||||||
|
return set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tic_json_is_exactly_the_agent_parameters(four_cases: BuildSummary) -> None:
|
||||||
|
by_video = {r.video_id: r.alias for r in read_aliases(four_cases.answers_dir)}
|
||||||
|
assert sorted(by_video) == sorted(EXPECTED_TIC)
|
||||||
|
for video_id, expected in EXPECTED_TIC.items():
|
||||||
|
tic_path = four_cases.library_dir / "cases" / by_video[video_id] / "ultrasound" / "tic.json"
|
||||||
|
assert json.loads(tic_path.read_text(encoding="utf-8")) == expected, video_id
|
||||||
|
assert read_ultrasound(four_cases.library_dir, by_video[video_id]).tic == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_tic_json_has_no_internal_keys(four_cases: BuildSummary) -> None:
|
||||||
|
for alias in list_aliases(four_cases.library_dir):
|
||||||
|
tic_path = four_cases.library_dir / "cases" / alias / "ultrasound" / "tic.json"
|
||||||
|
tic = json.loads(tic_path.read_text(encoding="utf-8"))
|
||||||
|
assert _all_keys(tic) & _FORBIDDEN_TIC_KEYS == set()
|
||||||
|
|
||||||
|
|
||||||
|
def test_ultrasound_files_are_exactly_report_tic_and_keyframes(
|
||||||
|
tmp_path: Path, four_cases: BuildSummary
|
||||||
|
) -> None:
|
||||||
|
for row in read_aliases(four_cases.answers_dir):
|
||||||
|
folder = four_cases.library_dir / "cases" / row.alias / "ultrasound"
|
||||||
|
files = sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file())
|
||||||
|
slots = [f"keyframes/{slot}.png" for slot in _CASE_SLOTS[row.video_id]]
|
||||||
|
assert files == sorted(["report.txt", "tic.json", "keyframes/key.png", *slots])
|
||||||
|
source = tmp_path / "delivery" / "CEUS_agent" / row.case_no / f"U{row.case_no}_key.png"
|
||||||
|
with Image.open(source) as original, Image.open(folder / "keyframes" / "key.png") as copy:
|
||||||
|
assert copy.mode == original.mode and copy.size == original.size
|
||||||
|
assert copy.tobytes() == original.tobytes()
|
||||||
|
route = read_ultrasound(four_cases.library_dir, row.alias)
|
||||||
|
assert route.keyframes["key"] == folder / "keyframes" / "key.png"
|
||||||
|
assert sorted(route.keyframes) == sorted(["key", *_CASE_SLOTS[row.video_id]])
|
||||||
|
|
||||||
|
|
||||||
def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
||||||
@@ -135,8 +243,9 @@ def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
|||||||
result = _build(tmp_path, 1, run)
|
result = _build(tmp_path, 1, run)
|
||||||
(alias,) = list_aliases(result.library_dir)
|
(alias,) = list_aliases(result.library_dir)
|
||||||
route = read_ultrasound(result.library_dir, alias)
|
route = read_ultrasound(result.library_dir, alias)
|
||||||
assert sorted(route.keyframes) == sorted(LOCATED)
|
assert sorted(route.keyframes) == sorted(["key", *LOCATED])
|
||||||
for slot, copied in route.keyframes.items():
|
for slot in LOCATED:
|
||||||
|
copied = route.keyframes[slot]
|
||||||
assert (
|
assert (
|
||||||
copied == result.library_dir / "cases" / alias / "ultrasound/keyframes" / f"{slot}.png"
|
copied == result.library_dir / "cases" / alias / "ultrasound/keyframes" / f"{slot}.png"
|
||||||
)
|
)
|
||||||
@@ -149,18 +258,16 @@ def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
|
|||||||
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
|
||||||
|
|
||||||
|
|
||||||
def test_null_ceus_run_has_no_ceus_files(tmp_path: Path) -> None:
|
def test_null_ceus_run_has_only_key_image_in_keyframes(tmp_path: Path) -> None:
|
||||||
root = make_delivery(2).write(tmp_path / "delivery")
|
root = make_delivery(2).write(tmp_path / "delivery")
|
||||||
result = run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
|
result = run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
|
||||||
for alias in list_aliases(result.library_dir):
|
for alias in list_aliases(result.library_dir):
|
||||||
route = read_ultrasound(result.library_dir, alias)
|
route = read_ultrasound(result.library_dir, alias)
|
||||||
assert route.tic is None and route.keyframes == {}
|
folder = result.library_dir / "cases" / alias / "ultrasound"
|
||||||
assert sorted(
|
assert route.tic is None
|
||||||
p.name for p in (result.library_dir / "cases" / alias / "ultrasound").iterdir()
|
assert route.keyframes == {"key": folder / "keyframes" / "key.png"}
|
||||||
) == [
|
files = sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file())
|
||||||
"key.png",
|
assert files == ["keyframes/key.png", "report.txt"]
|
||||||
"report.txt",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> None:
|
def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> None:
|
||||||
@@ -173,22 +280,12 @@ def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> No
|
|||||||
alias = list_aliases(result.library_dir)[0]
|
alias = list_aliases(result.library_dir)[0]
|
||||||
tic_path = result.library_dir / "cases" / alias / "ultrasound" / "tic.json"
|
tic_path = result.library_dir / "cases" / alias / "ultrasound" / "tic.json"
|
||||||
tic = json.loads(tic_path.read_text(encoding="utf-8"))
|
tic = json.loads(tic_path.read_text(encoding="utf-8"))
|
||||||
tic["keyframes"]["D4"]["reason"] = "见 U002 第 3 帧"
|
tic["keyframe_times_s"]["D4"] = "见 U002 第 3 帧"
|
||||||
tic_path.write_text(json.dumps(tic, ensure_ascii=False), encoding="utf-8")
|
tic_path.write_text(json.dumps(tic, ensure_ascii=False), encoding="utf-8")
|
||||||
hits = scan_for_leaks(result.library_dir, forbidden, ("name",))
|
hits = scan_for_leaks(result.library_dir, forbidden, ("name",))
|
||||||
assert [(Path(h.file).name, h.kind, h.term) for h in hits] == [("tic.json", "字符串", "U002")]
|
assert [(Path(h.file).name, h.kind, h.term) for h in hits] == [("tic.json", "字符串", "U002")]
|
||||||
|
|
||||||
|
|
||||||
def test_reason_with_video_id_stops_build(tmp_path: Path) -> None:
|
|
||||||
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
|
|
||||||
content = summary("U001", LOCATED)
|
|
||||||
content["anchor_frames"]["D4"]["reason"] = "U001 的 D4 不在视频里"
|
|
||||||
(run / "U001" / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
|
||||||
with pytest.raises(ValueError, match="视频号或路径"):
|
|
||||||
_build(tmp_path, 1, run)
|
|
||||||
assert not (tmp_path / "library").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_keyframe_that_cannot_be_reencoded_skips_case_as_c14(tmp_path: Path) -> None:
|
def test_keyframe_that_cannot_be_reencoded_skips_case_as_c14(tmp_path: Path) -> None:
|
||||||
run = write_run(
|
run = write_run(
|
||||||
tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED, "U002": LOCATED}, []
|
tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED, "U002": LOCATED}, []
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def test_visibility_sex_and_age_only_in_clinical_route(tmp_path: Path) -> None:
|
|||||||
assert files == [
|
assert files == [
|
||||||
"clinical/clinical.json",
|
"clinical/clinical.json",
|
||||||
"mr/report.txt",
|
"mr/report.txt",
|
||||||
"ultrasound/key.png",
|
"ultrasound/keyframes/key.png",
|
||||||
"ultrasound/report.txt",
|
"ultrasound/report.txt",
|
||||||
]
|
]
|
||||||
clinical = json.loads((case_dir / "clinical/clinical.json").read_text(encoding="utf-8"))
|
clinical = json.loads((case_dir / "clinical/clinical.json").read_text(encoding="utf-8"))
|
||||||
@@ -195,7 +195,7 @@ def test_key_image_pixels_kept_and_text_chunk_dropped(tmp_path: Path) -> None:
|
|||||||
with Image.open(source) as original:
|
with Image.open(source) as original:
|
||||||
assert original.info.get("PatientName") == "should-not-survive"
|
assert original.info.get("PatientName") == "should-not-survive"
|
||||||
original_mode, original_pixels = original.mode, original.tobytes()
|
original_mode, original_pixels = original.mode, original.tobytes()
|
||||||
copied = read_ultrasound(summary.library_dir, alias).key_image
|
copied = read_ultrasound(summary.library_dir, alias).keyframes["key"]
|
||||||
with Image.open(copied) as image:
|
with Image.open(copied) as image:
|
||||||
assert image.mode == original_mode
|
assert image.mode == original_mode
|
||||||
assert image.tobytes() == original_pixels
|
assert image.tobytes() == original_pixels
|
||||||
@@ -282,7 +282,7 @@ def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None:
|
|||||||
library_root = tmp_path / "library"
|
library_root = tmp_path / "library"
|
||||||
(library_dir,) = list(library_root.iterdir())
|
(library_dir,) = list(library_root.iterdir())
|
||||||
by_case = {r.case_no: r.alias for r in read_aliases(tmp_path / "answers" / library_dir.name)}
|
by_case = {r.case_no: r.alias for r in read_aliases(tmp_path / "answers" / library_dir.name)}
|
||||||
copied = read_ultrasound(library_dir, by_case["002"]).key_image
|
copied = read_ultrasound(library_dir, by_case["002"]).keyframes["key"]
|
||||||
with Image.open(copied) as image:
|
with Image.open(copied) as image:
|
||||||
assert image.mode == "1"
|
assert image.mode == "1"
|
||||||
assert image.tobytes() == source.tobytes()
|
assert image.tobytes() == source.tobytes()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
超过 40 秒的视频末尾,未定位。
|
超过 40 秒的视频末尾,未定位。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
|
||||||
@@ -22,10 +23,17 @@ import pytest
|
|||||||
from cadx.ceus import (
|
from cadx.ceus import (
|
||||||
ANCHOR_SLOTS,
|
ANCHOR_SLOTS,
|
||||||
CeusConfig,
|
CeusConfig,
|
||||||
|
LognormalFitResult,
|
||||||
|
LognormalParameters,
|
||||||
NoValidContourError,
|
NoValidContourError,
|
||||||
SegmentationInput,
|
SegmentationInput,
|
||||||
|
TicResult,
|
||||||
VideoFile,
|
VideoFile,
|
||||||
|
compute_lognormal_perfusion_metrics,
|
||||||
|
compute_tic_for_video,
|
||||||
crop_frame,
|
crop_frame,
|
||||||
|
evaluate_lognormal,
|
||||||
|
fit_lognormal_curve,
|
||||||
keyframe_images,
|
keyframe_images,
|
||||||
locate_contrast_side,
|
locate_contrast_side,
|
||||||
pack_tic_curves,
|
pack_tic_curves,
|
||||||
@@ -156,6 +164,9 @@ def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfi
|
|||||||
json.dumps(summary, allow_nan=False)
|
json.dumps(summary, allow_nan=False)
|
||||||
assert summary["anchor_frames"]["F1"]["frame_index"] == 150
|
assert summary["anchor_frames"]["F1"]["frame_index"] == 150
|
||||||
assert summary["perfusion_metrics"]["roi_perf"] is not None
|
assert summary["perfusion_metrics"]["roi_perf"] is not None
|
||||||
|
# 没有紫圈:紫圈那一组整组为 null;病灶整体不算指标。
|
||||||
|
assert set(summary["perfusion_metrics"]) == {"roi_perf", "roi_pan"}
|
||||||
|
assert summary["perfusion_metrics"]["roi_pan"] is None
|
||||||
|
|
||||||
markers = tic_plot_markers(result.anchors)
|
markers = tic_plot_markers(result.anchors)
|
||||||
assert [marker.label.split()[0] for marker in markers][:3] == ["D1", "D2", "D3"]
|
assert [marker.label.split()[0] for marker in markers][:3] == ["D1", "D2", "D3"]
|
||||||
@@ -179,3 +190,73 @@ def test_mask_shape_mismatch_is_rejected(prepared: SegmentationInput, config: Ce
|
|||||||
masks = _lesion_masks(prepared)[:-1]
|
masks = _lesion_masks(prepared)[:-1]
|
||||||
with pytest.raises(ValueError, match="分割掩膜"):
|
with pytest.raises(ValueError, match="分割掩膜"):
|
||||||
quantify_case(prepared, masks, _INJECTION_OFFSET_S, config)
|
quantify_case(prepared, masks, _INJECTION_OFFSET_S, config)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_fit(values: np.ndarray, times: np.ndarray, config: CeusConfig) -> LognormalFitResult:
|
||||||
|
"""直接调拟合函数拟一条曲线(基线 40、噪声 0.2,前 20 帧当基线段)。"""
|
||||||
|
return fit_lognormal_curve(
|
||||||
|
times,
|
||||||
|
values,
|
||||||
|
baseline=40.0,
|
||||||
|
baseline_noise=0.2,
|
||||||
|
baseline_end_index=20,
|
||||||
|
duplicate_mask=np.zeros(times.size, dtype=bool),
|
||||||
|
sample_mask=None,
|
||||||
|
cfg=config.tic_analysis.fitting,
|
||||||
|
anchor_cfg=config.tic_analysis.anchor_frames,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_metrics_computed_and_lesion_gate_unchanged(
|
||||||
|
prepared: SegmentationInput, config: CeusConfig, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""紫圈拟合过、病灶整体拟合没过:紫圈与病灶去暗区各算一组指标,病灶整体不算;
|
||||||
|
D1–D4 照「病灶两条曲线都过才定位」不定位。
|
||||||
|
|
||||||
|
合成视频里没有紫圈,所以把第 6–7 步交出的拟合换掉:紫圈换成一条干净 Lognormal 曲线的拟合
|
||||||
|
(峰与病灶不同),病灶整体换成一条平线的拟合(没有可信的峰,拟合不过)。
|
||||||
|
"""
|
||||||
|
times = np.arange(FRAMES) / FPS
|
||||||
|
pan_truth = LognormalParameters(
|
||||||
|
area=600.0, mu=2.0, sigma=0.5, arrival_offset_s=3.0, baseline=40.0
|
||||||
|
)
|
||||||
|
pan_fit = _clean_fit(evaluate_lognormal(times, pan_truth), times, config)
|
||||||
|
flat_fit = _clean_fit(np.full(times.size, 40.0), times, config)
|
||||||
|
assert pan_fit.success is True and flat_fit.success is False
|
||||||
|
|
||||||
|
def patched(*args: object, **kwargs: object) -> TicResult:
|
||||||
|
tic = compute_tic_for_video(*args, **kwargs) # pyright: ignore[reportArgumentType]
|
||||||
|
return dataclasses.replace(tic, fits={**tic.fits, "roi_pan": pan_fit, "roi_all": flat_fit})
|
||||||
|
|
||||||
|
monkeypatch.setattr("cadx.ceus._pipeline.compute_tic_for_video", patched)
|
||||||
|
result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config)
|
||||||
|
|
||||||
|
assert result.tic.fits["roi_perf"].success is True
|
||||||
|
assert result.lesion_fit_ready is False
|
||||||
|
for slot in ("B0", "D1", "D2", "D3", "D4"):
|
||||||
|
assert result.anchors[slot].status != "ok"
|
||||||
|
assert result.anchors[slot].frame_index is None
|
||||||
|
assert set(result.perfusion) == {"roi_perf", "roi_pan"}
|
||||||
|
assert result.perfusion["roi_pan"] == compute_lognormal_perfusion_metrics(pan_fit)
|
||||||
|
|
||||||
|
summary = quant_summary(result)
|
||||||
|
assert set(summary["perfusion_metrics"]) == {"roi_perf", "roi_pan"}
|
||||||
|
expected = compute_lognormal_perfusion_metrics(pan_fit)
|
||||||
|
assert expected is not None
|
||||||
|
# 11 项指标的键名与 LognormalPerfusionMetrics 字段的对应,照任务书 04 列的指标逐个手写。
|
||||||
|
assert summary["perfusion_metrics"]["roi_pan"]["values"] == {
|
||||||
|
"PE_au": expected.pe_au,
|
||||||
|
"WiAUC_au_s": expected.wi_auc_au_s,
|
||||||
|
"RT_s": expected.rt_s,
|
||||||
|
"mTTl_s": expected.mttl_s,
|
||||||
|
"TTP_s": expected.ttp_s,
|
||||||
|
"WiR_au_per_s": expected.wir_au_per_s,
|
||||||
|
"WiPI_au": expected.wipi_au,
|
||||||
|
"WoAUC_au_s": expected.wo_auc_au_s,
|
||||||
|
"WiWoAUC_au_s": expected.wiwo_auc_au_s,
|
||||||
|
"FT_s": expected.ft_s,
|
||||||
|
"WoR_au_per_s": expected.wor_au_per_s,
|
||||||
|
}
|
||||||
|
# 紫圈的峰与病灶不同:两组指标不是同一组数。
|
||||||
|
assert summary["perfusion_metrics"]["roi_pan"] != summary["perfusion_metrics"]["roi_perf"]
|
||||||
|
assert summary["lesion_fit_ready"] is False
|
||||||
|
|||||||
@@ -238,11 +238,14 @@ def test_build_reads_real_run_layout(
|
|||||||
route = read_ultrasound(result.library_dir, alias)
|
route = read_ultrasound(result.library_dir, alias)
|
||||||
summary = json.loads((run_dir / "U001" / "summary.json").read_text(encoding="utf-8"))
|
summary = json.loads((run_dir / "U001" / "summary.json").read_text(encoding="utf-8"))
|
||||||
located = {s for s, a in summary["anchor_frames"].items() if a["status"] == "ok"}
|
located = {s for s, a in summary["anchor_frames"].items() if a["status"] == "ok"}
|
||||||
assert set(route.keyframes) == located
|
assert set(route.keyframes) == {"key", *located}
|
||||||
assert route.tic is not None
|
assert route.tic is not None
|
||||||
assert set(route.tic) == {"fits", "perfusion_metrics", "pan_curve_usable", "keyframes"}
|
assert set(route.tic) == {"perfusion_metrics", "keyframe_times_s"}
|
||||||
assert route.tic["keyframes"]["F1"]["video_time_s"] == pytest.approx(15.0, abs=0.1)
|
# 合成视频里没有紫圈:紫圈一组为 null,病灶去暗区一组有 11 个指标。
|
||||||
assert route.tic["keyframes"]["F2"]["located"] is False
|
assert route.tic["perfusion_metrics"]["roi_pan"] is None
|
||||||
|
assert len(route.tic["perfusion_metrics"]["roi_perf"]) == 11
|
||||||
|
assert route.tic["keyframe_times_s"]["F1"] == pytest.approx(15.0, abs=0.1)
|
||||||
|
assert route.tic["keyframe_times_s"]["F2"] is None
|
||||||
|
|
||||||
# 医生核对包也读这个真实布局:TIC 曲线图和回放视频按字节拷进病例目录。
|
# 医生核对包也读这个真实布局:TIC 曲线图和回放视频按字节拷进病例目录。
|
||||||
pack_config = tmp_path / "review_pack.yaml"
|
pack_config = tmp_path / "review_pack.yaml"
|
||||||
|
|||||||
+351
-124
@@ -1,15 +1,20 @@
|
|||||||
"""医生核对包:目录结构、CSV 规格、问题清单的每种来源、逐字节拷贝、TIC结果、没装 CEUS 的构建、PDF。
|
"""医生核对包:目录结构、CSV 规格、TIC参数、问题清单的医生写法、程序原文快照、逐字节拷贝、PDF。
|
||||||
|
|
||||||
预期值照任务书 03 写:目录和文件名、列名、问题清单的「环节」与「问题」原文、关键帧文件名的秒数
|
预期值照任务书 03、04 写:目录和文件名、列名、问题清单的「环节」与「问题」、TIC参数.csv 的中文名、
|
||||||
(`video_time_s` 保留一位小数)都按任务书的文字手写,不从实现里推。
|
单位和小数位都按任务书的文字手写,不从实现里推。原因对照表的逐条预期在 `test_review_reasons.py`。
|
||||||
|
|
||||||
假数据(6 例):
|
假数据(8 例):
|
||||||
- P001:进库;定位到 D1、D2、F1,其余 4 个槽位未取到;病灶去暗区曲线拟合失败
|
- P001:进库;病灶去暗区曲线拟合失败(parameter_on_boundary),B0、D1–D4 因此未取到;F2 落在视频
|
||||||
(parameter_on_boundary);紫圈不可用。
|
之后;F1 取到;紫圈不可用。TIC参数两组都空。
|
||||||
- P002:CEUS 第 2 步失败(C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。
|
- P002:CEUS 第 2 步失败(两半都没有绿框,C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。
|
||||||
- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒(测一位小数);拟合都成功;紫圈可用。
|
- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒;拟合都成功;紫圈可用;两组指标都有,
|
||||||
- P005:MR 表诊断与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录(被跳过但定量成功)。
|
病灶一组观测不够的 4 项为 null。
|
||||||
- P006:进库;7 个槽位都取到;病灶整体曲线拟合失败(credible_peak_not_found);紫圈可用。
|
- P005:MR 表诊断、临床表年龄与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录。
|
||||||
|
- P006:进库;病灶整体曲线拟合失败(credible_peak_not_found),B0、D1–D4 未取到;紫圈可用但拟合失败
|
||||||
|
(fit_quality_failed)。TIC参数只有病灶一组。
|
||||||
|
- P007:MR 表性别与超声表不同(C10 跳过);CEUS 第 1 步失败(两半配准不一致)。
|
||||||
|
- P008:进库;拟合都成功;B0 基线是估计的、D2 和 D3 的候选帧被占、D4 落在视频之后、
|
||||||
|
F2 附近只有重复帧。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import csv
|
import csv
|
||||||
@@ -30,13 +35,27 @@ from cadx.build import BuildSummary, run_build
|
|||||||
from cadx.library import read_ultrasound
|
from cadx.library import read_ultrasound
|
||||||
from cadx.review import run_review_pack
|
from cadx.review import run_review_pack
|
||||||
from cadx.review._pdf import markdown_to_pdf, report_markdown
|
from cadx.review._pdf import markdown_to_pdf, report_markdown
|
||||||
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
|
from fake_ceus_run import perfusion, summary, write_run
|
||||||
from fake_delivery import DIAGNOSIS_COLUMN, REPO_ROOT, make_delivery, write_config
|
from fake_delivery import DIAGNOSIS_COLUMN, REPO_ROOT, make_delivery, write_config
|
||||||
|
|
||||||
BUILD_NOW = datetime(2026, 10, 17, 9, 0, 0).astimezone()
|
BUILD_NOW = datetime(2026, 10, 17, 9, 0, 0).astimezone()
|
||||||
PACK_NOW = datetime(2026, 10, 17, 10, 0, 0).astimezone()
|
PACK_NOW = datetime(2026, 10, 17, 10, 0, 0).astimezone()
|
||||||
RUN_ID = "20261017-080000-abcdef0"
|
RUN_ID = "20261017-080000-abcdef0"
|
||||||
ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||||
|
DYNAMIC = ("B0", "D1", "D2", "D3", "D4")
|
||||||
|
CONS = ",程序没法处理这段视频"
|
||||||
|
SEX_NOTE = "(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)"
|
||||||
|
LESION = ";B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,所以这 5 个关键帧没有取"
|
||||||
|
BOUNDARY = (
|
||||||
|
"试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上,"
|
||||||
|
"有一项卡在了程序允许范围的边上"
|
||||||
|
)
|
||||||
|
FIT_FAILED = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门"
|
||||||
|
F2_AFTER = (
|
||||||
|
"推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),落在视频有效帧范围 [0.00, 40.20] 秒之外"
|
||||||
|
)
|
||||||
|
STEP2 = "左右两半都没有找到绿框,选不出提示帧"
|
||||||
|
STEP1 = "两半配准不一致:互相关估计 (dx, dy) = (3, 4),模板匹配估计 (9, 4),相差 6 像素,超过门槛 4"
|
||||||
|
|
||||||
SUMMARY_HEADER = [
|
SUMMARY_HEADER = [
|
||||||
"病例号",
|
"病例号",
|
||||||
@@ -49,55 +68,132 @@ SUMMARY_HEADER = [
|
|||||||
*ALL_SLOTS,
|
*ALL_SLOTS,
|
||||||
]
|
]
|
||||||
PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"]
|
PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"]
|
||||||
|
TIC_HEADER = ["指标", "单位", "胰腺实质(紫圈)", "病灶(去暗区)"]
|
||||||
|
METRIC_NAMES = [
|
||||||
|
["峰值增强 PE", "灰度"],
|
||||||
|
["达峰时间 TTP", "秒"],
|
||||||
|
["最大洗入斜率 WiR", "灰度/秒"],
|
||||||
|
["上升时间 RT", "秒"],
|
||||||
|
["洗入曲线下面积 WiAUC", "灰度·秒"],
|
||||||
|
["洗入灌注指数 WiPI", "灰度"],
|
||||||
|
["最大洗出斜率 WoR", "灰度/秒"],
|
||||||
|
["下降时间 FT", "秒"],
|
||||||
|
["洗出曲线下面积 WoAUC", "灰度·秒"],
|
||||||
|
["洗入洗出曲线下面积 WiWoAUC", "灰度·秒"],
|
||||||
|
["平均渡越时间 mTTl", "秒"],
|
||||||
|
]
|
||||||
|
# fake_ceus_run.perfusion(1.0) 的 11 个值按 TIC参数.csv 的行序、按小数位写出:
|
||||||
|
# PE 1、WiAUC 2、RT 3、mTTl 4、TTP 5、WiR 6、WiPI 7、WoAUC 8、WiWoAUC 9、FT 10、WoR −11。
|
||||||
|
SCALE1 = ["1.00", "5.0", "6.00", "3.0", "2.00", "7.00", "-11.00", "10.0", "8.00", "9.00", "4.0"]
|
||||||
|
BLANK = [""] * 11
|
||||||
ANSWER_HEADERS = {
|
ANSWER_HEADERS = {
|
||||||
"化名对照.csv": ["化名", "病例号", "视频号"],
|
"化名对照.csv": ["化名", "病例号", "视频号"],
|
||||||
"诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"],
|
"诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"],
|
||||||
"跳过病例.csv": ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"],
|
"跳过病例.csv": ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"],
|
||||||
"报告复核句.csv": ["化名", "路", "命中的词", "所在句子"],
|
"报告复核句.csv": ["化名", "路", "命中的词", "所在句子"],
|
||||||
}
|
}
|
||||||
CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键图.png"}
|
CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键帧"}
|
||||||
CEUS_FILES = {"关键帧", "TIC曲线.png", "TIC结果.csv", "回放视频.mp4"}
|
CEUS_FILES = {"TIC曲线.png", "TIC参数.csv", "回放视频.mp4"}
|
||||||
|
# 任务书 04 验收第 3 条:问题清单里不许出现的东西。
|
||||||
|
FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}")
|
||||||
|
|
||||||
|
|
||||||
def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None:
|
def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None:
|
||||||
content = summary(video_id, located)
|
content = summary(video_id, located)
|
||||||
edit(content)
|
edit(content)
|
||||||
|
# 最大洗出斜率 WoR 在真实运行里是负数,假数据也照此写。
|
||||||
|
for group in content["perfusion_metrics"].values():
|
||||||
|
if group is not None and group["values"]["WoR_au_per_s"] is not None:
|
||||||
|
group["values"]["WoR_au_per_s"] = -abs(group["values"]["WoR_au_per_s"])
|
||||||
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def _fail_fit(curve: str, code: str) -> Any:
|
def _unlocated(content: dict[str, Any], reasons: dict[str, str]) -> None:
|
||||||
def edit(content: dict[str, Any]) -> None:
|
for slot, reason in reasons.items():
|
||||||
content["fits"][curve].update(success=False, failure_reason=code)
|
content["anchor_frames"][slot]["reason"] = reason
|
||||||
|
|
||||||
return edit
|
|
||||||
|
def _fail(content: dict[str, Any], curve: str, code: str) -> None:
|
||||||
|
content["fits"][curve].update(success=False, failure_reason=code)
|
||||||
|
if curve in content["perfusion_metrics"]:
|
||||||
|
content["perfusion_metrics"][curve] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _pan_ok(content: dict[str, Any]) -> None:
|
||||||
|
content["reference_valid"] = True
|
||||||
|
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||||
|
content["perfusion_metrics"]["roi_pan"] = perfusion(1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def _p001(content: dict[str, Any]) -> None:
|
||||||
|
_fail(content, "roi_perf", "parameter_on_boundary")
|
||||||
|
content["fits"]["roi_pan"]["failure_reason"] = "insufficient_valid_samples"
|
||||||
|
_unlocated(content, {**dict.fromkeys(DYNAMIC, FIT_FAILED), "F2": F2_AFTER})
|
||||||
|
|
||||||
|
|
||||||
def _p004(content: dict[str, Any]) -> None:
|
def _p004(content: dict[str, Any]) -> None:
|
||||||
content["reference_valid"] = True
|
_pan_ok(content)
|
||||||
content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04
|
content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04
|
||||||
content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46
|
content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46
|
||||||
|
values = content["perfusion_metrics"]["roi_perf"]["values"]
|
||||||
|
values.update(
|
||||||
|
PE_au=21.456,
|
||||||
|
TTP_s=13.04,
|
||||||
|
WiR_au_per_s=3.2,
|
||||||
|
RT_s=6.26,
|
||||||
|
WiAUC_au_s=80,
|
||||||
|
WiPI_au=12.777,
|
||||||
|
WoR_au_per_s=-1.234,
|
||||||
|
FT_s=None,
|
||||||
|
WoAUC_au_s=None,
|
||||||
|
WiWoAUC_au_s=None,
|
||||||
|
mTTl_s=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _p006(content: dict[str, Any]) -> None:
|
def _p006(content: dict[str, Any]) -> None:
|
||||||
_fail_fit("roi_all", "credible_peak_not_found")(content)
|
_fail(content, "roi_all", "credible_peak_not_found")
|
||||||
content["reference_valid"] = True
|
content["reference_valid"] = True
|
||||||
|
content["fits"]["roi_pan"]["failure_reason"] = "fit_quality_failed"
|
||||||
|
_unlocated(content, dict.fromkeys(DYNAMIC, FIT_FAILED))
|
||||||
|
|
||||||
|
|
||||||
|
def _p008(content: dict[str, Any]) -> None:
|
||||||
|
_pan_ok(content)
|
||||||
|
taken = "{} 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序"
|
||||||
|
_unlocated(
|
||||||
|
content,
|
||||||
|
{
|
||||||
|
"B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义",
|
||||||
|
"D2": taken.format("D2"),
|
||||||
|
"D3": taken.format("D3"),
|
||||||
|
"D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外",
|
||||||
|
"F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LOCATED: dict[str, tuple[str, ...]] = {
|
||||||
|
"U001": ("F1",),
|
||||||
|
"U004": ALL_SLOTS,
|
||||||
|
"U005": ALL_SLOTS,
|
||||||
|
"U006": ("F1", "F2"),
|
||||||
|
"U008": ("D1", "F1"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]:
|
def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]:
|
||||||
delivery = make_delivery(6)
|
delivery = make_delivery(8)
|
||||||
delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2
|
delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2
|
||||||
|
delivery.case(5).clinical["Age"] = 28
|
||||||
|
delivery.case(7).mr["性别"] = "男"
|
||||||
delivery_dir = delivery.write(root / "delivery")
|
delivery_dir = delivery.write(root / "delivery")
|
||||||
run = write_run(
|
run = write_run(
|
||||||
root / "runs" / RUN_ID,
|
root / "runs" / RUN_ID, delivery_dir, LOCATED, [("U002", 2, STEP2), ("U007", 1, STEP1)]
|
||||||
delivery_dir,
|
|
||||||
{"U001": ("D1", "D2", "F1"), "U004": ALL_SLOTS, "U005": ALL_SLOTS, "U006": ALL_SLOTS},
|
|
||||||
[("U002", 2, "两半都没有绿框")],
|
|
||||||
)
|
)
|
||||||
_rewrite_summary(
|
for video_id, edit in (("U001", _p001), ("U004", _p004), ("U006", _p006), ("U008", _p008)):
|
||||||
run, "U001", ("D1", "D2", "F1"), _fail_fit("roi_perf", "parameter_on_boundary")
|
_rewrite_summary(run, video_id, LOCATED[video_id], edit)
|
||||||
)
|
_rewrite_summary(run, "U005", ALL_SLOTS, _pan_ok)
|
||||||
_rewrite_summary(run, "U004", ALL_SLOTS, _p004)
|
|
||||||
_rewrite_summary(run, "U006", ALL_SLOTS, _p006)
|
|
||||||
return run_build(write_config(root, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW), run
|
return run_build(write_config(root, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW), run
|
||||||
|
|
||||||
|
|
||||||
@@ -160,15 +256,14 @@ def test_directory_layout(packed: Packed) -> None:
|
|||||||
"病例",
|
"病例",
|
||||||
}
|
}
|
||||||
assert {p.name for p in (pack / "标准答案").iterdir()} == set(ANSWER_HEADERS)
|
assert {p.name for p in (pack / "标准答案").iterdir()} == set(ANSWER_HEADERS)
|
||||||
assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006"}
|
assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006", "P008"}
|
||||||
for case in ("P001", "P004", "P006"):
|
for case in ("P001", "P004", "P006", "P008"):
|
||||||
assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES
|
assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES
|
||||||
assert {p.name for p in (pack / "病例" / "P001" / "关键帧").iterdir()} == {
|
assert not [p for p in pack.rglob("*") if p.name in ("TIC结果.csv", "关键图.png")]
|
||||||
"D1_第2.5秒.png",
|
keyframes = {
|
||||||
"D2_第5.0秒.png",
|
"P001": {"医生截图.png", "F1_第12.5秒.png"},
|
||||||
"F1_第12.5秒.png",
|
"P004": {
|
||||||
}
|
"医生截图.png",
|
||||||
assert {p.name for p in (pack / "病例" / "P004" / "关键帧").iterdir()} == {
|
|
||||||
"B0_第0.0秒.png",
|
"B0_第0.0秒.png",
|
||||||
"D1_第2.5秒.png",
|
"D1_第2.5秒.png",
|
||||||
"D2_第5.0秒.png",
|
"D2_第5.0秒.png",
|
||||||
@@ -176,12 +271,17 @@ def test_directory_layout(packed: Packed) -> None:
|
|||||||
"D4_第10.0秒.png",
|
"D4_第10.0秒.png",
|
||||||
"F1_第12.5秒.png",
|
"F1_第12.5秒.png",
|
||||||
"F2_第15.0秒.png",
|
"F2_第15.0秒.png",
|
||||||
|
},
|
||||||
|
"P006": {"医生截图.png", "F1_第12.5秒.png", "F2_第15.0秒.png"},
|
||||||
|
"P008": {"医生截图.png", "D1_第2.5秒.png", "F1_第12.5秒.png"},
|
||||||
}
|
}
|
||||||
|
for case, names in keyframes.items():
|
||||||
|
assert {p.name for p in (pack / "病例" / case / "关键帧").iterdir()} == names
|
||||||
|
|
||||||
|
|
||||||
def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None:
|
def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None:
|
||||||
csvs = sorted(packed.pack.rglob("*.csv"))
|
csvs = sorted(packed.pack.rglob("*.csv"))
|
||||||
assert len(csvs) == 2 + 4 + 3 * 2
|
assert len(csvs) == 2 + 4 + 4 * 2
|
||||||
for path in csvs:
|
for path in csvs:
|
||||||
for row in _csv(path):
|
for row in _csv(path):
|
||||||
assert not {"null", "None", "NaN", "nan"} & set(row), path
|
assert not {"null", "None", "NaN", "nan"} & set(row), path
|
||||||
@@ -200,25 +300,11 @@ def test_case_summary(packed: Packed) -> None:
|
|||||||
rows = _csv(packed.pack / "病例总表.csv")[1:]
|
rows = _csv(packed.pack / "病例总表.csv")[1:]
|
||||||
blank = [""] * 7
|
blank = [""] * 7
|
||||||
a = packed.alias
|
a = packed.alias
|
||||||
|
no = "未取到"
|
||||||
assert rows == [
|
assert rows == [
|
||||||
[
|
["P001", "U001", a["001"], "是", "", "是", "", no, no, no, no, no, "12.5", no],
|
||||||
"P001",
|
["P002", "U002", "", "否", f"视频里找不到画好的绿框{CONS}", "否", "2", *blank],
|
||||||
"U001",
|
["P003", "U003", "", "否", "造影视频没有处理结果", "", "", *blank],
|
||||||
a["001"],
|
|
||||||
"是",
|
|
||||||
"",
|
|
||||||
"是",
|
|
||||||
"",
|
|
||||||
"未取到",
|
|
||||||
"2.5",
|
|
||||||
"5.0",
|
|
||||||
"未取到",
|
|
||||||
"未取到",
|
|
||||||
"12.5",
|
|
||||||
"未取到",
|
|
||||||
],
|
|
||||||
["P002", "U002", "", "否", "C15 CEUS 定量结果齐全", "否", "2", *blank],
|
|
||||||
["P003", "U003", "", "否", "C15 CEUS 定量结果齐全", "", "", *blank],
|
|
||||||
[
|
[
|
||||||
"P004",
|
"P004",
|
||||||
"U004",
|
"U004",
|
||||||
@@ -235,45 +321,152 @@ def test_case_summary(packed: Packed) -> None:
|
|||||||
"12.46",
|
"12.46",
|
||||||
"15.0",
|
"15.0",
|
||||||
],
|
],
|
||||||
["P005", "U005", "", "否", "C10 跨表一致(诊断、年龄、性别)", "是", "", *blank],
|
[
|
||||||
|
"P005",
|
||||||
|
"U005",
|
||||||
|
"",
|
||||||
|
"否",
|
||||||
|
"三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;"
|
||||||
|
"三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁",
|
||||||
|
"是",
|
||||||
|
"",
|
||||||
|
*blank,
|
||||||
|
],
|
||||||
|
["P006", "U006", a["006"], "是", "", "是", "", no, no, no, no, no, "12.5", "15.0"],
|
||||||
|
[
|
||||||
|
"P007",
|
||||||
|
"U007",
|
||||||
|
"",
|
||||||
|
"否",
|
||||||
|
f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE}",
|
||||||
|
"否",
|
||||||
|
"1",
|
||||||
|
*blank,
|
||||||
|
],
|
||||||
|
["P008", "U008", a["008"], "是", "", "是", "", no, "2.5", no, no, no, "12.5", no],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
EXPECTED_PROBLEMS = [
|
||||||
|
[
|
||||||
|
"P001",
|
||||||
|
"U001",
|
||||||
|
"TIC 曲线",
|
||||||
|
f"病灶去暗区那条曲线没能拟合({BOUNDARY}){LESION},TIC 参数里病灶一组也空着",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"P001",
|
||||||
|
"U001",
|
||||||
|
"关键帧",
|
||||||
|
"F2 没有取:换算到视频里的时刻不在录到的范围内"
|
||||||
|
"(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频只录到第 40.2 秒)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"P001",
|
||||||
|
"U001",
|
||||||
|
"紫圈",
|
||||||
|
"视频里大部分时间认不出紫圈,紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着",
|
||||||
|
],
|
||||||
|
["P002", "U002", "造影视频处理", f"视频里找不到画好的绿框{CONS},这一例没进资料库"],
|
||||||
|
["P003", "U003", "造影视频处理", "造影视频没有处理结果,这一例没进资料库"],
|
||||||
|
[
|
||||||
|
"P005",
|
||||||
|
"U005",
|
||||||
|
"资料核对",
|
||||||
|
"三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;"
|
||||||
|
"三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁,这一例没进资料库",
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"P006",
|
"P006",
|
||||||
"U006",
|
"U006",
|
||||||
a["006"],
|
"TIC 曲线",
|
||||||
"是",
|
f"病灶整体那条曲线没能拟合(曲线太平,没有明显的峰){LESION}",
|
||||||
"",
|
|
||||||
"是",
|
|
||||||
"",
|
|
||||||
"0.0",
|
|
||||||
"2.5",
|
|
||||||
"5.0",
|
|
||||||
"7.5",
|
|
||||||
"10.0",
|
|
||||||
"12.5",
|
|
||||||
"15.0",
|
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"P006",
|
||||||
|
"U006",
|
||||||
|
"TIC 曲线",
|
||||||
|
"紫圈那条曲线没能拟合(曲线起伏杂乱,平滑曲线和它差得太多),TIC 参数里胰腺实质一组空着",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"P007",
|
||||||
|
"U007",
|
||||||
|
"资料核对",
|
||||||
|
f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE},这一例没进资料库",
|
||||||
|
],
|
||||||
|
["P007", "U007", "造影视频处理", f"视频左右两屏的位置对不齐{CONS}"],
|
||||||
|
[
|
||||||
|
"P008",
|
||||||
|
"U008",
|
||||||
|
"关键帧",
|
||||||
|
"B0 没有取:视频一开始病灶就已经在变亮,没录到增强之前的那一段",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"P008",
|
||||||
|
"U008",
|
||||||
|
"关键帧",
|
||||||
|
"D2、D3 没有取:这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧"
|
||||||
|
"(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"P008",
|
||||||
|
"U008",
|
||||||
|
"关键帧",
|
||||||
|
"D4 没有取:算出的时刻不在视频能用的画面范围里"
|
||||||
|
"(D4 在视频第 41.5 秒,能用的画面是第 0.0–40.2 秒)",
|
||||||
|
],
|
||||||
|
["P008", "U008", "关键帧", "F2 没有取:这个时刻前后没有能用的画面(都是重复帧)"],
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_problem_list_has_every_source(packed: Packed) -> None:
|
def test_problem_list_in_plain_words(packed: Packed) -> None:
|
||||||
rows = _csv(packed.pack / "问题清单.csv")[1:]
|
assert _csv(packed.pack / "问题清单.csv")[1:] == EXPECTED_PROBLEMS
|
||||||
|
counts = {"资料核对": 2, "造影视频处理": 3, "TIC 曲线": 3, "关键帧": 5, "紫圈": 1}
|
||||||
|
assert packed.result.problems_by_stage == dict(sorted(counts.items()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_problem_list_has_no_program_words(packed: Packed) -> None:
|
||||||
|
"""任务书 04 验收第 3 条:问题清单全文不含检查编号、字段名、下划线、括号、band、运行号。"""
|
||||||
|
text = (packed.pack / "问题清单.csv").read_text(encoding="utf-8-sig")
|
||||||
|
assert FORBIDDEN.findall(text) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_raw_problem_list_in_snapshot(packed: Packed) -> None:
|
||||||
|
"""程序原文照任务书 03 的写法留在快照里,不进医生的目录。"""
|
||||||
|
assert not (packed.pack / "问题清单_原文.csv").exists()
|
||||||
|
rows = _csv(packed.result.run_dir / "snapshot" / "问题清单_原文.csv")
|
||||||
skipped = {row[0]: row for row in _csv(packed.pack / "标准答案" / "跳过病例.csv")[1:]}
|
skipped = {row[0]: row for row in _csv(packed.pack / "标准答案" / "跳过病例.csv")[1:]}
|
||||||
unlocated = [
|
fit_failed = [f"{slot} 未取到:{FIT_FAILED}" for slot in DYNAMIC]
|
||||||
["P001", "U001", "关键帧", f"{slot} 未取到:{UNLOCATED_REASON}"]
|
p008 = {
|
||||||
for slot in ("B0", "D3", "D4", "F2")
|
"B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义",
|
||||||
|
"D2": "D2 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序",
|
||||||
|
"D3": "D3 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序",
|
||||||
|
"D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外",
|
||||||
|
"F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧",
|
||||||
|
}
|
||||||
|
|
||||||
|
def c10(no: str) -> list[str]:
|
||||||
|
row = skipped[no]
|
||||||
|
return [
|
||||||
|
f"P{no}",
|
||||||
|
f"U{no}",
|
||||||
|
"装配资料库",
|
||||||
|
f"C10 跨表一致(诊断、年龄、性别);文件:{row[3]};列:{row[4]};涉及值:{row[5]}",
|
||||||
]
|
]
|
||||||
|
|
||||||
assert rows == [
|
assert rows == [
|
||||||
|
PROBLEM_HEADER,
|
||||||
["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"],
|
["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"],
|
||||||
*unlocated,
|
*[["P001", "U001", "关键帧", text] for text in fit_failed],
|
||||||
|
["P001", "U001", "关键帧", f"F2 未取到:{F2_AFTER}"],
|
||||||
["P001", "U001", "紫圈", "紫圈曲线不可用"],
|
["P001", "U001", "紫圈", "紫圈曲线不可用"],
|
||||||
[
|
[
|
||||||
"P002",
|
"P002",
|
||||||
"U002",
|
"U002",
|
||||||
"装配资料库",
|
"装配资料库",
|
||||||
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;"
|
f'C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;涉及值:"第 2 步失败:{STEP2}"',
|
||||||
'涉及值:"第 2 步失败:两半都没有绿框"',
|
|
||||||
],
|
],
|
||||||
["P002", "U002", "CEUS 第 2 步", "第 2 步失败:两半都没有绿框"],
|
["P002", "U002", "CEUS 第 2 步", f"第 2 步失败:{STEP2}"],
|
||||||
[
|
[
|
||||||
"P003",
|
"P003",
|
||||||
"U003",
|
"U003",
|
||||||
@@ -281,30 +474,75 @@ def test_problem_list_has_every_source(packed: Packed) -> None:
|
|||||||
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;"
|
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;"
|
||||||
'涉及值:"CEUS 运行目录里没有这一例的结果目录"',
|
'涉及值:"CEUS 运行目录里没有这一例的结果目录"',
|
||||||
],
|
],
|
||||||
[
|
c10("005"),
|
||||||
"P005",
|
|
||||||
"U005",
|
|
||||||
"装配资料库",
|
|
||||||
f"C10 跨表一致(诊断、年龄、性别);文件:{skipped['005'][3]};"
|
|
||||||
f"列:{skipped['005'][4]};涉及值:{skipped['005'][5]}",
|
|
||||||
],
|
|
||||||
["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"],
|
["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"],
|
||||||
|
*[["P006", "U006", "关键帧", text] for text in fit_failed],
|
||||||
|
c10("007"),
|
||||||
|
["P007", "U007", "CEUS 第 1 步", f"第 1 步失败:{STEP1}"],
|
||||||
|
*[["P008", "U008", "关键帧", f"{slot} 未取到:{text}"] for slot, text in p008.items()],
|
||||||
]
|
]
|
||||||
counts = {"装配资料库": 3, "CEUS 第 2 步": 1, "TIC 拟合": 2, "关键帧": 4, "紫圈": 1}
|
|
||||||
assert packed.result.problems_by_stage == dict(sorted(counts.items()))
|
|
||||||
|
def test_tic_params(packed: Packed) -> None:
|
||||||
|
def table(case: str) -> list[list[str]]:
|
||||||
|
return _csv(packed.pack / "病例" / case / "TIC参数.csv")
|
||||||
|
|
||||||
|
def rows(pan: list[str], perf: list[str]) -> list[list[str]]:
|
||||||
|
return [[*m, a, b] for m, a, b in zip(METRIC_NAMES, pan, perf, strict=True)]
|
||||||
|
|
||||||
|
p004_perf = ["21.46", "13.0", "3.20", "6.3", "80.00", "12.78", "-1.23", "", "", "", ""]
|
||||||
|
assert table("P004") == [TIC_HEADER, *rows(SCALE1, p004_perf)]
|
||||||
|
assert table("P008") == [TIC_HEADER, *rows(SCALE1, SCALE1)]
|
||||||
|
assert table("P006") == [
|
||||||
|
TIC_HEADER,
|
||||||
|
*rows(BLANK, SCALE1),
|
||||||
|
["没算出的原因", "", "曲线起伏杂乱,平滑曲线和它差得太多,没能拟合", ""],
|
||||||
|
]
|
||||||
|
assert table("P001") == [
|
||||||
|
TIC_HEADER,
|
||||||
|
*rows(BLANK, BLANK),
|
||||||
|
[
|
||||||
|
"没算出的原因",
|
||||||
|
"",
|
||||||
|
"视频里大部分时间认不出紫圈",
|
||||||
|
f"{BOUNDARY},没能拟合",
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tic_params_match_library(packed: Packed) -> None:
|
||||||
|
"""TIC参数.csv 的每一格都是资料库 tic.json 里那个数按小数位写出(null 为空)。"""
|
||||||
|
keys = ["PE_au", "TTP_s", "WiR_au_per_s", "RT_s", "WiAUC_au_s", "WiPI_au"]
|
||||||
|
keys += ["WoR_au_per_s", "FT_s", "WoAUC_au_s", "WiWoAUC_au_s", "mTTl_s"]
|
||||||
|
for case_no in ("001", "004", "006", "008"):
|
||||||
|
tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic
|
||||||
|
assert tic is not None
|
||||||
|
rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC参数.csv")[1:12]
|
||||||
|
for row, key in zip(rows, keys, strict=True):
|
||||||
|
for cell, group in zip(row[2:], ("roi_pan", "roi_perf"), strict=True):
|
||||||
|
values = tic["perfusion_metrics"][group]
|
||||||
|
value = None if values is None else values[key]
|
||||||
|
digits = 1 if key in ("TTP_s", "RT_s", "FT_s", "mTTl_s") else 2
|
||||||
|
assert cell == ("" if value is None else f"{round(value, digits):.{digits}f}")
|
||||||
|
|
||||||
|
|
||||||
def test_copies_are_byte_identical(packed: Packed) -> None:
|
def test_copies_are_byte_identical(packed: Packed) -> None:
|
||||||
for case_no, video_id in (("001", "U001"), ("004", "U004")):
|
for case_no, video_id in (("001", "U001"), ("004", "U004")):
|
||||||
case_dir = packed.pack / "病例" / f"P{case_no}"
|
case_dir = packed.pack / "病例" / f"P{case_no}"
|
||||||
route = read_ultrasound(packed.build.library_dir, packed.alias[case_no])
|
route = read_ultrasound(packed.build.library_dir, packed.alias[case_no])
|
||||||
assert (case_dir / "关键图.png").read_bytes() == route.key_image.read_bytes()
|
key = (case_dir / "关键帧" / "医生截图.png").read_bytes()
|
||||||
|
assert key == route.keyframes["key"].read_bytes()
|
||||||
assert route.tic is not None
|
assert route.tic is not None
|
||||||
copied = {p.name.split("_")[0]: p for p in (case_dir / "关键帧").iterdir()}
|
copied = {
|
||||||
assert sorted(copied) == sorted(route.keyframes)
|
p.name.split("_")[0]: p
|
||||||
for slot, source in route.keyframes.items():
|
for p in (case_dir / "关键帧").iterdir()
|
||||||
|
if p.name != "医生截图.png"
|
||||||
|
}
|
||||||
|
slots = {slot: path for slot, path in route.keyframes.items() if slot != "key"}
|
||||||
|
assert sorted(copied) == sorted(slots)
|
||||||
|
for slot, source in slots.items():
|
||||||
assert copied[slot].read_bytes() == source.read_bytes()
|
assert copied[slot].read_bytes() == source.read_bytes()
|
||||||
seconds = route.tic["keyframes"][slot]["video_time_s"]
|
seconds = route.tic["keyframe_times_s"][slot]
|
||||||
assert copied[slot].name == f"{slot}_第{round(seconds, 1)}秒.png"
|
assert copied[slot].name == f"{slot}_第{round(seconds, 1)}秒.png"
|
||||||
run_case = packed.run / video_id
|
run_case = packed.run / video_id
|
||||||
assert (case_dir / "TIC曲线.png").read_bytes() == (run_case / "tic.png").read_bytes()
|
assert (case_dir / "TIC曲线.png").read_bytes() == (run_case / "tic.png").read_bytes()
|
||||||
@@ -325,45 +563,21 @@ def _tool_output(tool: str, *args: str) -> str:
|
|||||||
return subprocess.run([path, *args], check=True, capture_output=True, text=True).stdout # noqa: S603
|
return subprocess.run([path, *args], check=True, capture_output=True, text=True).stdout # noqa: S603
|
||||||
|
|
||||||
|
|
||||||
def _expected_cell(value: object) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return "true" if value else "false"
|
|
||||||
return value if isinstance(value, str) else repr(value)
|
|
||||||
|
|
||||||
|
|
||||||
def test_tic_results_match_tic_json(packed: Packed) -> None:
|
|
||||||
for case_no in ("001", "004", "006"):
|
|
||||||
tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic
|
|
||||||
assert tic is not None
|
|
||||||
rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC结果.csv")
|
|
||||||
assert rows[0] == ["项目", "值"]
|
|
||||||
# 4 条曲线 × 2 + 病灶 2 条 ×(3 个口径 + 11 个指标 + 3 个辅助时刻)+ 紫圈 1 + 7 槽位 × 3。
|
|
||||||
assert len(rows) - 1 == 8 + 34 + 1 + 21
|
|
||||||
for item, value in rows[1:]:
|
|
||||||
path = item[item.rindex("(") + 1 : -1]
|
|
||||||
node: Any = tic
|
|
||||||
for key in path.split("."):
|
|
||||||
node = None if node is None else node[key]
|
|
||||||
assert value == _expected_cell(node), (case_no, item)
|
|
||||||
rows = dict(_csv(packed.pack / "病例" / "P001" / "TIC结果.csv")[1:])
|
|
||||||
assert rows["病灶整体曲线 拟合成功(fits.roi_all.success)"] == "true"
|
|
||||||
assert rows["病灶去暗区曲线 拟合成功(fits.roi_perf.success)"] == "false"
|
|
||||||
assert rows["紫圈曲线可用(pan_curve_usable)"] == "false"
|
|
||||||
assert rows["关键帧 D4 未取到的原因(keyframes.D4.reason)"] == UNLOCATED_REASON
|
|
||||||
|
|
||||||
|
|
||||||
def test_pdfs_use_noto_cjk_and_carry_ids(packed: Packed) -> None:
|
def test_pdfs_use_noto_cjk_and_carry_ids(packed: Packed) -> None:
|
||||||
pdfs = sorted(packed.pack.rglob("*.pdf"))
|
pdfs = sorted(packed.pack.rglob("*.pdf"))
|
||||||
assert len(pdfs) == packed.result.pdfs == 1 + 3 * 2
|
assert len(pdfs) == packed.result.pdfs == 1 + 4 * 2
|
||||||
for pdf in pdfs:
|
for pdf in pdfs:
|
||||||
assert pdf.read_bytes().startswith(b"%PDF") and pdf.stat().st_size > 1000
|
assert pdf.read_bytes().startswith(b"%PDF") and pdf.stat().st_size > 1000
|
||||||
readme = packed.pack / "说明.pdf"
|
readme = packed.pack / "说明.pdf"
|
||||||
fonts = _tool_output("pdffonts", str(readme))
|
fonts = _tool_output("pdffonts", str(readme))
|
||||||
assert "NotoSerifCJK" in fonts and "NotoSansCJK" in fonts
|
assert "NotoSerifCJK" in fonts and "NotoSansCJK" in fonts
|
||||||
|
pages = re.search(r"^Pages:\s+([0-9]+)$", _tool_output("pdfinfo", str(readme)), re.M)
|
||||||
|
assert pages is not None and int(pages.group(1)) <= 4
|
||||||
text = _tool_output("pdftotext", str(readme), "-")
|
text = _tool_output("pdftotext", str(readme), "-")
|
||||||
assert packed.build.build_id in text and RUN_ID in text and "parameter_on_boundary" in text
|
assert packed.build.build_id in text and RUN_ID in text
|
||||||
|
# 说明里不写检查编号、原因码、字段名。
|
||||||
|
for word in ("C15", "parameter_on_boundary", "perfusion_metrics", "贵方", "MedSAM"):
|
||||||
|
assert word not in text
|
||||||
report = _tool_output("pdftotext", str(packed.pack / "病例" / "P001" / "超声报告.pdf"), "-")
|
report = _tool_output("pdftotext", str(packed.pack / "病例" / "P001" / "超声报告.pdf"), "-")
|
||||||
assert report.startswith("病例号 P001 超声描述")
|
assert report.startswith("病例号 P001 超声描述")
|
||||||
|
|
||||||
@@ -384,6 +598,17 @@ def test_existing_run_dir_stops(packed: Packed) -> None:
|
|||||||
run_review_pack(packed.config, REPO_ROOT, PACK_NOW)
|
run_review_pack(packed.config, REPO_ROOT, PACK_NOW)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_reason_stops(tmp_path: Path) -> None:
|
||||||
|
"""关键帧未取到的原因不在对照表里(假运行的默认原因是编造的):报错停下,不出包。"""
|
||||||
|
delivery_dir = make_delivery(1).write(tmp_path / "delivery")
|
||||||
|
run = write_run(tmp_path / "runs" / RUN_ID, delivery_dir, {"U001": ("D1",)}, [])
|
||||||
|
build = run_build(write_config(tmp_path, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW)
|
||||||
|
config = _pack_config(tmp_path, build.library_dir, build.answers_dir)
|
||||||
|
with pytest.raises(ValueError, match="不在对照表里"):
|
||||||
|
run_review_pack(config, REPO_ROOT, PACK_NOW)
|
||||||
|
assert not [p for p in (tmp_path / "review_pack").iterdir() if not p.name.endswith(".partial")]
|
||||||
|
|
||||||
|
|
||||||
def test_build_without_ceus(tmp_path: Path) -> None:
|
def test_build_without_ceus(tmp_path: Path) -> None:
|
||||||
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
|
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
|
||||||
build = run_build(write_config(tmp_path, delivery_dir, 1, None, None), REPO_ROOT, BUILD_NOW)
|
build = run_build(write_config(tmp_path, delivery_dir, 1, None, None), REPO_ROOT, BUILD_NOW)
|
||||||
@@ -391,7 +616,9 @@ def test_build_without_ceus(tmp_path: Path) -> None:
|
|||||||
_pack_config(tmp_path, build.library_dir, build.answers_dir), REPO_ROOT, PACK_NOW
|
_pack_config(tmp_path, build.library_dir, build.answers_dir), REPO_ROOT, PACK_NOW
|
||||||
)
|
)
|
||||||
for case in ("P001", "P002"):
|
for case in ("P001", "P002"):
|
||||||
assert {p.name for p in (result.pack_dir / "病例" / case).iterdir()} == CASE_FILES
|
case_dir = result.pack_dir / "病例" / case
|
||||||
|
assert {p.name for p in case_dir.iterdir()} == CASE_FILES
|
||||||
|
assert {p.name for p in (case_dir / "关键帧").iterdir()} == {"医生截图.png"}
|
||||||
alias = {r.case_no: r.alias for r in read_aliases(build.answers_dir)}
|
alias = {r.case_no: r.alias for r in read_aliases(build.answers_dir)}
|
||||||
rows = _csv(result.pack_dir / "病例总表.csv")[1:]
|
rows = _csv(result.pack_dir / "病例总表.csv")[1:]
|
||||||
assert [row[:5] for row in rows] == [
|
assert [row[:5] for row in rows] == [
|
||||||
|
|||||||
@@ -0,0 +1,595 @@
|
|||||||
|
"""原因对照表:每种程序原因 → 给医生的那句话,逐条手写预期(任务书 04「原因的写法」)。
|
||||||
|
|
||||||
|
程序原因按源码里的写法造(`_anchor_frames.py`、`_tic_fitting.py`、`ceus/` 各步的异常、
|
||||||
|
`ceus_quant.py` 的目视核对、delivery 的 C01–C13、build 的 C14、C15),数值是编的。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from cadx.answers import SkippedRow
|
||||||
|
from cadx.review._problems import (
|
||||||
|
UnknownReasonError,
|
||||||
|
case_label,
|
||||||
|
ceus_failure,
|
||||||
|
check_plain,
|
||||||
|
curve_problems,
|
||||||
|
group_reason,
|
||||||
|
skip_problem,
|
||||||
|
)
|
||||||
|
from fake_ceus_run import summary
|
||||||
|
|
||||||
|
FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}")
|
||||||
|
BROKEN = "视频文件本身有问题(帧率、帧数或时间信息不对),程序读不了"
|
||||||
|
NO_SPLIT = "视频里找不到左右两屏的分界线"
|
||||||
|
MISALIGNED = "视频左右两屏的位置对不齐"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("step", "reason", "expected"),
|
||||||
|
[
|
||||||
|
(1, "配准目视核对未通过", f"{MISALIGNED}(我们人工逐例看过,这一例没对上)"),
|
||||||
|
(1, "cv2 打不开视频:/x/U001_roi2.mp4", "视频文件打不开"),
|
||||||
|
(1, "探测到非法 fps=0.0:/x/U001.mp4", BROKEN),
|
||||||
|
(1, "探测到非法元数据 frame_count=0 width=1 height=1:/x/U001.mp4", BROKEN),
|
||||||
|
(1, "视频包含非法显示时间戳 -1.0:/x/U001.mp4", BROKEN),
|
||||||
|
(1, "视频显示时间戳不严格递增:第 3 帧 0.1,前一帧 0.2:/x/U001.mp4", BROKEN),
|
||||||
|
(1, "时间戳帧数与容器记录的帧数不一致:3 / 4:/x/U001.mp4", BROKEN),
|
||||||
|
(1, "解码帧数 3 与容器记录的帧数 4 不一致:/x/U001.mp4", BROKEN),
|
||||||
|
(1, "帧尺寸不一致:首帧 (4, 6, 3),第 2 帧 (4, 8, 3)。", "视频前后画面大小不一致"),
|
||||||
|
(1, "帧序列为空,无法求跨帧平均灰度。", "视频里一帧画面都没有"),
|
||||||
|
(1, "帧序列为空,无法找分屏线。", "视频里一帧画面都没有"),
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"中部 band 内未找到稳定暗缝(谷值 40.2 未显著低于 中位 51.0),疑为无缝或全黑画面。",
|
||||||
|
NO_SPLIT,
|
||||||
|
),
|
||||||
|
(1, "非法 search_band:要求 0<=lo<hi<=1,收到 (0.6, 0.4)。", NO_SPLIT),
|
||||||
|
(1, "search_band 在宽度 10 下退化为空区间 [4, 4)。", NO_SPLIT),
|
||||||
|
(1, "检测到的 split_x=9 落在搜索 band [3, 7) 外。", NO_SPLIT),
|
||||||
|
(1, "切分线 split_x=0 使某一侧宽度退化(左 0 / 右 5)。", NO_SPLIT),
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"左右宽度比 0.30 超出 [0.5, 2.0](左 100 / 右 330),切分线不可信。",
|
||||||
|
"找到的左右两屏分界线不可信(两屏宽窄差太多)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"这一半画面里找不到扇形区(没有随时间变化的区域)",
|
||||||
|
"视频有一屏里找不到超声扇形画面",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"左半扇形内可用的模板块只有 2 块,少于 4 块",
|
||||||
|
f"{MISALIGNED}(画面里能拿来比对的细节太少)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
1,
|
||||||
|
"两半配准不一致:互相关估计 (dx, dy) = (3, 4),模板匹配估计 (9, 4),"
|
||||||
|
"相差 6 像素,超过门槛 4",
|
||||||
|
MISALIGNED,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
2,
|
||||||
|
"左右两半都找到绿框(左半 3 个提示帧,右半 2 个),定不出造影画面在哪一半",
|
||||||
|
"视频左右两屏都有绿框,分不出哪一屏是造影画面",
|
||||||
|
),
|
||||||
|
(2, "左右两半都没有找到绿框,选不出提示帧", "视频里找不到画好的绿框"),
|
||||||
|
(
|
||||||
|
4,
|
||||||
|
"逐帧 mask 未产出任何有效 ROI 轮廓,无从时间插值。",
|
||||||
|
"整段视频里程序都没能画出病灶轮廓",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_ceus_failure(step: int, reason: str, expected: str) -> None:
|
||||||
|
assert ceus_failure(step, reason) == f"{expected},程序没法处理这段视频"
|
||||||
|
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[1]
|
||||||
|
# ceus 各步一例失败时抛的 CeusCaseError 子类 → 第几步(各类的 *_STEP 常量)。
|
||||||
|
_CASE_ERRORS = {
|
||||||
|
"VideoDecodeError": 1,
|
||||||
|
"SplitDetectionError": 1,
|
||||||
|
"RegistrationError": 1,
|
||||||
|
"ContrastSideError": 2,
|
||||||
|
"NoValidContourError": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _raised_messages() -> list[tuple[str, int, str]]:
|
||||||
|
"""源码里每处 raise 一例失败的地方:(位置,第几步,消息,格式化部分代成 1)。"""
|
||||||
|
found: list[tuple[str, int, str]] = []
|
||||||
|
files = [
|
||||||
|
*sorted((REPO / "src" / "cadx" / "ceus").glob("*.py")),
|
||||||
|
REPO / "scripts" / "ceus_quant.py",
|
||||||
|
]
|
||||||
|
for path in files:
|
||||||
|
for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))):
|
||||||
|
if not (isinstance(node, ast.Raise) and isinstance(node.exc, ast.Call)):
|
||||||
|
continue
|
||||||
|
func = node.exc.func
|
||||||
|
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
|
||||||
|
args = node.exc.args
|
||||||
|
if name in _CASE_ERRORS:
|
||||||
|
step, message = _CASE_ERRORS[name], args[0]
|
||||||
|
elif name == "CeusCaseError":
|
||||||
|
first = args[0]
|
||||||
|
assert isinstance(first, ast.Constant) and isinstance(first.value, int)
|
||||||
|
step, message = first.value, args[1]
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if isinstance(message, ast.Constant):
|
||||||
|
text = str(message.value)
|
||||||
|
else:
|
||||||
|
assert isinstance(message, ast.JoinedStr), f"{path}:{node.lineno}"
|
||||||
|
text = "".join(
|
||||||
|
str(v.value) if isinstance(v, ast.Constant) else "1" for v in message.values
|
||||||
|
)
|
||||||
|
found.append((f"{path.name}:{node.lineno}", step, text))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_raised_ceus_failure_is_in_the_table() -> None:
|
||||||
|
"""ceus 和 ceus_quant 里每一处 raise 一例失败的消息,对照表都认得(新加一处就会在这里报出)。"""
|
||||||
|
messages = _raised_messages()
|
||||||
|
assert len(messages) >= 20
|
||||||
|
for where, step, text in messages:
|
||||||
|
assert not FORBIDDEN.search(ceus_failure(step, text)), where
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("step", "reason"),
|
||||||
|
[(3, "分割模型报错"), (2, "配准目视核对未通过"), (1, "编造的原因")],
|
||||||
|
)
|
||||||
|
def test_unknown_ceus_failure_stops(step: int, reason: str) -> None:
|
||||||
|
with pytest.raises(UnknownReasonError):
|
||||||
|
ceus_failure(step, reason)
|
||||||
|
|
||||||
|
|
||||||
|
FIT_CASES = {
|
||||||
|
"insufficient_valid_samples": "能用的画面太少(很多帧认不出这块区域,或是重复帧),连不成曲线",
|
||||||
|
"candidate_grid_unavailable": "能用的画面时间点太少,连不成曲线",
|
||||||
|
"credible_peak_not_found": "曲线太平,没有明显的峰",
|
||||||
|
"candidate_arrival_not_found": "峰之前找不到亮度明确、持续升起来的时刻",
|
||||||
|
"insufficient_bolus_samples": "造影剂第一次流过的那一段能用的画面太少",
|
||||||
|
"long_gap_in_wash_in": "变亮的那一段中间缺了一大段画面",
|
||||||
|
"arrival_bounds_invalid": "峰离视频开头太近,或造影剂第一次流过的那一段太短",
|
||||||
|
"optimizer_failed": "曲线形状不规则,平滑曲线怎么也贴不上",
|
||||||
|
"parameter_on_boundary": (
|
||||||
|
"试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上,"
|
||||||
|
"有一项卡在了程序允许范围的边上"
|
||||||
|
),
|
||||||
|
"model_peak_on_boundary": "试着贴出来的曲线,峰落在可用那一段的最开头或最末尾,峰的位置不可信",
|
||||||
|
"fit_quality_failed": "曲线起伏杂乱,平滑曲线和它差得太多",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("code", "phrase"), FIT_CASES.items())
|
||||||
|
def test_fit_reason_codes(code: str, phrase: str) -> None:
|
||||||
|
content = summary("U001", ())
|
||||||
|
content["reference_valid"] = True
|
||||||
|
content["fits"]["roi_pan"].update(success=False, failure_reason=code)
|
||||||
|
assert group_reason(content, "roi_pan") == f"{phrase},没能拟合"
|
||||||
|
content["fits"]["roi_perf"].update(success=False, failure_reason=code)
|
||||||
|
assert group_reason(content, "roi_perf") == f"{phrase},没能拟合"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_fit_reject_code_is_in_the_table() -> None:
|
||||||
|
source = (REPO / "src" / "cadx" / "ceus" / "_tic_fitting.py").read_text(encoding="utf-8")
|
||||||
|
codes = set(re.findall(r'reject\("([a-z_]+)"\)', source))
|
||||||
|
assert codes == set(FIT_CASES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_group_reason_pan_unusable_and_unknown_code() -> None:
|
||||||
|
content = summary("U001", ())
|
||||||
|
assert group_reason(content, "roi_pan") == "视频里大部分时间认不出紫圈"
|
||||||
|
content["fits"]["roi_perf"].update(success=False, failure_reason="new_code")
|
||||||
|
with pytest.raises(UnknownReasonError):
|
||||||
|
group_reason(content, "roi_perf")
|
||||||
|
|
||||||
|
|
||||||
|
BOUNDARY = FIT_CASES["parameter_on_boundary"]
|
||||||
|
LESION = ";B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,所以这 5 个关键帧没有取"
|
||||||
|
FIT_FAILED = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门"
|
||||||
|
|
||||||
|
|
||||||
|
def _curves(located: tuple[str, ...], edit: dict[str, Any]) -> list[tuple[str, str]]:
|
||||||
|
content = summary("U001", located)
|
||||||
|
content["reference_valid"] = True
|
||||||
|
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||||
|
for slot, reason in edit.items():
|
||||||
|
content["anchor_frames"][slot]["reason"] = reason
|
||||||
|
return curve_problems(content)
|
||||||
|
|
||||||
|
|
||||||
|
ALL = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("slot", "reason", "expected"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"B0",
|
||||||
|
"B0 锚点 3.70 秒的容差窗口 [3.20, 4.20] 秒 内,"
|
||||||
|
"观测到的基线段里、到达时刻之前没有可用帧",
|
||||||
|
"B0 没有取:开始增强之前的那一小段里没有能用的画面",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"B0",
|
||||||
|
"拟合曲线升不过到达门槛,没有到达时刻",
|
||||||
|
"B0 没有取:曲线上升得太少,定不出造影剂到达的时刻",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"D1",
|
||||||
|
"拟合曲线上没有 D1 时刻(升不过到达门槛)",
|
||||||
|
"D1 没有取:曲线上升得太少,定不出造影剂到达的时刻",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"B0",
|
||||||
|
"视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义",
|
||||||
|
"B0 没有取:视频一开始病灶就已经在变亮,没录到增强之前的那一段",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"D4",
|
||||||
|
"峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4",
|
||||||
|
"D4 没有取:峰值之后录得不够长,找不到变暗最快的时刻",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"D3",
|
||||||
|
"D3 时刻 -0.40 秒落在视频有效帧范围 [0.04, 40.20] 秒 之外",
|
||||||
|
"D3 没有取:算出的时刻不在视频能用的画面范围里"
|
||||||
|
"(D3 在视频第 -0.4 秒,能用的画面是第 0.0–40.2 秒)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"D2",
|
||||||
|
"D2 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序",
|
||||||
|
"D2 没有取:这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧"
|
||||||
|
"(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"D2",
|
||||||
|
"D2 容差窗口 [4.70, 5.30] 秒 内没有可用帧(拟合区间内、不重复)",
|
||||||
|
"D2 没有取:这个时刻附近没有能用的画面"
|
||||||
|
"(附近都是重复帧,或已过了造影剂第一次流过的那一段)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"F1",
|
||||||
|
"推注后第 20 秒在视频第 -5.00 秒(推注时间 X=25 秒),"
|
||||||
|
"落在视频有效帧范围 [0.00, 40.20] 秒之外",
|
||||||
|
"F1 没有取:换算到视频里的时刻不在录到的范围内"
|
||||||
|
"(F1 是推注后第 20 秒,应在视频第 -5.0 秒,那时还没开始录)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"F2",
|
||||||
|
"推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),"
|
||||||
|
"落在视频有效帧范围 [0.00, 40.20] 秒之外",
|
||||||
|
"F2 没有取:换算到视频里的时刻不在录到的范围内"
|
||||||
|
"(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频只录到第 40.2 秒)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"F2",
|
||||||
|
"推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),落在没有不重复的帧之外",
|
||||||
|
"F2 没有取:换算到视频里的时刻不在录到的范围内"
|
||||||
|
"(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频里没有能用的画面)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"F2",
|
||||||
|
"视频第 55.00 秒 ±2 秒内没有不重复的帧",
|
||||||
|
"F2 没有取:这个时刻前后没有能用的画面(都是重复帧)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"F1",
|
||||||
|
"没有推注时间,推注后时刻换算不到视频时间",
|
||||||
|
"F1 没有取:没有推注时间,换算不出视频里的时刻",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_keyframe_reasons(slot: str, reason: str, expected: str) -> None:
|
||||||
|
located = tuple(s for s in ALL if s != slot)
|
||||||
|
assert _curves(located, {slot: reason}) == [("关键帧", expected)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_reason_merges_into_one_row() -> None:
|
||||||
|
reason = "视频第 {} 秒 ±2 秒内没有不重复的帧"
|
||||||
|
rows = _curves(("B0", "D1", "D2", "D3", "D4"), {"F1": reason.format(1), "F2": reason.format(2)})
|
||||||
|
assert rows == [("关键帧", "F1、F2 没有取:这个时刻前后没有能用的画面(都是重复帧)")]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("failed", "expected"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
{"roi_all": "parameter_on_boundary"},
|
||||||
|
f"病灶整体那条曲线没能拟合({BOUNDARY}){LESION}",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"roi_all": "parameter_on_boundary", "roi_perf": "parameter_on_boundary"},
|
||||||
|
f"病灶整体、病灶去暗区两条曲线都没能拟合(都是{BOUNDARY}){LESION}"
|
||||||
|
",TIC 参数里病灶一组也空着",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"roi_all": "credible_peak_not_found", "roi_perf": "fit_quality_failed"},
|
||||||
|
"病灶整体、病灶去暗区两条曲线都没能拟合(病灶整体:曲线太平,没有明显的峰;"
|
||||||
|
f"病灶去暗区:曲线起伏杂乱,平滑曲线和它差得太多){LESION}"
|
||||||
|
",TIC 参数里病灶一组也空着",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_lesion_fit_failure_is_one_row(failed: dict[str, str], expected: str) -> None:
|
||||||
|
content = summary("U001", ("F1", "F2"))
|
||||||
|
content["reference_valid"] = True
|
||||||
|
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||||
|
for curve, code in failed.items():
|
||||||
|
content["fits"][curve].update(success=False, failure_reason=code)
|
||||||
|
for slot in ("B0", "D1", "D2", "D3", "D4"):
|
||||||
|
content["anchor_frames"][slot]["reason"] = FIT_FAILED
|
||||||
|
assert curve_problems(content) == [("TIC 曲线", expected)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_roi_perf_fit_reason_counts_as_fit_failure() -> None:
|
||||||
|
content = summary("U001", ("F1", "F2"))
|
||||||
|
content["reference_valid"] = True
|
||||||
|
content["fits"]["roi_pan"].update(success=True, failure_reason=None)
|
||||||
|
content["fits"]["roi_perf"].update(success=False, failure_reason="optimizer_failed")
|
||||||
|
for slot in ("B0", "D1", "D2", "D3", "D4"):
|
||||||
|
content["anchor_frames"][slot]["reason"] = (
|
||||||
|
"roi_perf 曲线的 Lognormal 拟合没过质量门(optimizer_failed)"
|
||||||
|
)
|
||||||
|
assert [stage for stage, _ in curve_problems(content)] == ["TIC 曲线"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fit_failure_without_its_keyframes_stops() -> None:
|
||||||
|
content = summary("U001", ALL)
|
||||||
|
content["fits"]["roi_all"].update(success=False, failure_reason="optimizer_failed")
|
||||||
|
with pytest.raises(ValueError, match="对不上"):
|
||||||
|
curve_problems(content)
|
||||||
|
|
||||||
|
|
||||||
|
def test_pan_rows() -> None:
|
||||||
|
content = summary("U001", ALL)
|
||||||
|
assert curve_problems(content) == [
|
||||||
|
("紫圈", "视频里大部分时间认不出紫圈,紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着")
|
||||||
|
]
|
||||||
|
content["reference_valid"] = True
|
||||||
|
content["fits"]["roi_pan"]["failure_reason"] = "long_gap_in_wash_in"
|
||||||
|
assert curve_problems(content) == [
|
||||||
|
(
|
||||||
|
"TIC 曲线",
|
||||||
|
"紫圈那条曲线没能拟合(变亮的那一段中间缺了一大段画面),TIC 参数里胰腺实质一组空着",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_keyframe_reason_stops() -> None:
|
||||||
|
with pytest.raises(UnknownReasonError):
|
||||||
|
_curves(("B0",), {s: "编造的原因" for s in ALL if s != "B0"})
|
||||||
|
|
||||||
|
|
||||||
|
CLINICAL = "Clinical_agent/临床工作簿_40例.xlsx"
|
||||||
|
ULTRASOUND = "CEUS_agent/超声报告文本_40例.xlsx"
|
||||||
|
MR = "MR_agent/MR报告文本_40例.xlsx"
|
||||||
|
DIAGNOSIS = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)"
|
||||||
|
|
||||||
|
|
||||||
|
def _row(case: str, check: str, files: tuple[str, ...], value: object) -> SkippedRow:
|
||||||
|
return SkippedRow(
|
||||||
|
case=case,
|
||||||
|
check=check,
|
||||||
|
description="(检查说明不用)",
|
||||||
|
files=files,
|
||||||
|
columns=(),
|
||||||
|
value_json=json.dumps(value, ensure_ascii=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
NOT_IN = ",这一例没进资料库"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("row", "expected"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
_row(f"{CLINICAL} 第 5 行", "C01", (CLINICAL,), {"原始写法": "P1", "行号": 5}),
|
||||||
|
"临床表第 5 行的病例编号认不出(「P1」)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("病例目录 1000", "C01", ("CEUS_agent/1000",), {"目录名": "1000"}),
|
||||||
|
"病例文件夹名「1000」认不出是哪个病例",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C02", (MR,), {"行号": [4, 9]}),
|
||||||
|
"MR 表里这个病例号出现了 2 次(第 4、9 行)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003",
|
||||||
|
"C03",
|
||||||
|
(ULTRASOUND, "CEUS_agent/003/"),
|
||||||
|
{"缺在": [ULTRASOUND, "CEUS_agent/003/"]},
|
||||||
|
),
|
||||||
|
"在超声表、病例文件夹里找不到这一例",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C04", ("CEUS_agent/003",), {"文件": ["U003_key.png", "U003.mp4"]}),
|
||||||
|
"病例文件夹里应当恰好有截图、造影视频、单例超声报告表三个文件,实际有 2 个",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C05", (ULTRASOUND,), {"CEUS视频ID": "U030", "病例号": "003"}),
|
||||||
|
"超声表里这一例的 CEUS视频ID 写的是「U030」,和病例号对不上",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"工作表": ["A", "B"]}),
|
||||||
|
"单例超声报告表的工作表不对(应只有一张)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"表头": "与超声汇总表不一致"}
|
||||||
|
),
|
||||||
|
"单例超声报告表的表头和超声汇总表不一样",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"数据行数": 2}),
|
||||||
|
"单例超声报告表应只有一行数据,实际有 2 行",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003",
|
||||||
|
"C06",
|
||||||
|
("CEUS_agent/003/U003_report.xlsx", ULTRASOUND),
|
||||||
|
{
|
||||||
|
"年龄": {"单例报告表": "40岁", "超声汇总表": "41岁"},
|
||||||
|
"超声描述": {"单例报告表": "甲" * 31, "超声汇总表": "乙"},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"单例超声报告表和超声汇总表对不上:"
|
||||||
|
"年龄(单例表 「40岁」,汇总表 「41岁」);超声描述(内容不一样)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C07", (CLINICAL,), {"Sex": 3, "CA199_Level": None}),
|
||||||
|
"临床表这几项的取值不合规:性别(3)、CA19-9 是否异常(空着)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C08", (ULTRASOUND,), {"年龄": "四十", "推注开始时间s": -1}),
|
||||||
|
"超声表这几项的取值不合规:年龄(「四十」)、推注开始时间(-1)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C09", (MR,), {DIAGNOSIS: 5, "MR描述": None}),
|
||||||
|
"MR 表这几项的取值不合规:病理诊断(5)、MR描述(空着)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003",
|
||||||
|
"C10",
|
||||||
|
(CLINICAL, ULTRASOUND, MR),
|
||||||
|
{"年龄": {"临床表": 28, "超声表": "63岁", "MR 表": "63岁"}},
|
||||||
|
),
|
||||||
|
"三张表的年龄对不上:临床表 28 岁,超声表 63 岁,MR 表 63 岁",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003",
|
||||||
|
"C10",
|
||||||
|
(CLINICAL, ULTRASOUND, MR),
|
||||||
|
{"性别": {"超声表": "男", "MR 表": "男", "临床表(解码后)": "女"}},
|
||||||
|
),
|
||||||
|
"三张表的性别对不上:超声表 男,MR 表 男,临床表 女",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003",
|
||||||
|
"C10",
|
||||||
|
(CLINICAL, ULTRASOUND, MR),
|
||||||
|
{"性别": {"超声表": "女", "MR 表": "男"}},
|
||||||
|
),
|
||||||
|
"超声表和 MR 表的性别对不上:超声表 女,MR 表 男"
|
||||||
|
"(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C08", (ULTRASOUND,), {"年龄": "datetime.datetime(2026, 1, 1, 0, 0)"}),
|
||||||
|
"超声表这几项的取值不合规:年龄(这一格是日期或时间格式)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C07", (CLINICAL,), {"Symptom_Fever": True, "Hx_Alcohol": False}),
|
||||||
|
"临床表这几项的取值不合规:发热(这一格填的是 TRUE)、饮酒史(这一格填的是 FALSE)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003",
|
||||||
|
"C06",
|
||||||
|
("CEUS_agent/003/U003_report.xlsx", ULTRASOUND),
|
||||||
|
{"推注开始时间s": {"单例报告表": "datetime.time(0, 0, 5)", "超声汇总表": 5}},
|
||||||
|
),
|
||||||
|
"单例超声报告表和超声汇总表对不上:"
|
||||||
|
"推注开始时间(单例表 这一格是日期或时间格式,汇总表 5)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003",
|
||||||
|
"C11",
|
||||||
|
(ULTRASOUND,),
|
||||||
|
{"超声描述": [{"关键词": "超声提示", "句子": "超声提示:囊性灶"}]},
|
||||||
|
),
|
||||||
|
"超声报告的描述里有像结论段标题的写法「超声提示:」(所在句子:超声提示:囊性灶)",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C12", (MR,), {"MR描述": ["SCN", "浆液性囊"]}),
|
||||||
|
"MR报告的描述里出现了亚型名「SCN」、「浆液性囊」",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C13", (ULTRASOUND, MR), {"超声描述": ["女"], "MR描述": ["45岁"]}),
|
||||||
|
"超声报告的描述里出现了性别或年龄「女」;MR报告的描述里出现了性别或年龄「45岁」",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C14", ("CEUS_agent/003/U003_key.png",), "不是 PNG"),
|
||||||
|
"医生截图不是 PNG 格式",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row("003", "C14", ("CEUS_agent/003/U003_key.png",), "带 tRNS 透明色,重编码会丢透明"),
|
||||||
|
"医生截图带透明色,另存会丢掉透明信息",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
_row(
|
||||||
|
"003",
|
||||||
|
"C14",
|
||||||
|
("20261017-080000-abcdef0/U003/keyframes/D2.png",),
|
||||||
|
"位深 16、颜色类型 2 不能逐像素保真重编码",
|
||||||
|
),
|
||||||
|
"程序从视频里取的 D2 关键帧的颜色格式特殊,程序没法一点不差地另存",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_skip_reasons(row: SkippedRow, expected: str) -> None:
|
||||||
|
assert skip_problem(row, {}) == ("资料核对", expected, expected + NOT_IN)
|
||||||
|
|
||||||
|
|
||||||
|
def test_c15_merges_with_failed_csv() -> None:
|
||||||
|
failed = _row(
|
||||||
|
"002", "C15", ("RUN/failed.csv",), "第 2 步失败:左右两半都没有找到绿框,选不出提示帧"
|
||||||
|
)
|
||||||
|
reason = "视频里找不到画好的绿框,程序没法处理这段视频"
|
||||||
|
steps = {"002": (2, "左右两半都没有找到绿框,选不出提示帧")}
|
||||||
|
assert skip_problem(failed, steps) == ("造影视频处理", reason, reason + NOT_IN)
|
||||||
|
with pytest.raises(ValueError, match="对不上"):
|
||||||
|
skip_problem(failed, {"002": (1, "两半配准不一致:")})
|
||||||
|
missing = _row("003", "C15", ("RUN/U003",), "CEUS 运行目录里没有这一例的结果目录")
|
||||||
|
assert skip_problem(missing, {}) == (
|
||||||
|
"造影视频处理",
|
||||||
|
"造影视频没有处理结果",
|
||||||
|
"造影视频没有处理结果" + NOT_IN,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"row",
|
||||||
|
[
|
||||||
|
_row("003", "C99", (), {}),
|
||||||
|
_row("003", "C07", (CLINICAL,), {"NewColumn": 1}),
|
||||||
|
_row("003", "C14", ("CEUS_agent/003/U003_key.png",), "编造的原因"),
|
||||||
|
_row("003", "C15", ("RUN/U003",), "编造的原因"),
|
||||||
|
_row("003", "C10", (CLINICAL,), {"身高": {"临床表": 1}}),
|
||||||
|
_row("003", "C03", ("x/y.xlsx",), {"缺在": ["x/y.xlsx"]}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unknown_skip_reason_stops(row: SkippedRow) -> None:
|
||||||
|
with pytest.raises(UnknownReasonError):
|
||||||
|
skip_problem(row, {})
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_labels() -> None:
|
||||||
|
assert case_label("007") == "P007"
|
||||||
|
assert case_label(f"{CLINICAL} 第 5 行") == "临床表第 5 行"
|
||||||
|
assert case_label("病例目录 1000") == "病例文件夹「1000」"
|
||||||
|
|
||||||
|
|
||||||
|
def test_program_words_stop() -> None:
|
||||||
|
for text in ("C15 失败", "roi_pan", "a_b", "{", "[x]", "band", "20261017-080000"):
|
||||||
|
assert FORBIDDEN.search(text) is not None
|
||||||
|
with pytest.raises(ValueError, match="程序用语"):
|
||||||
|
check_plain(text)
|
||||||
|
assert check_plain("视频里找不到画好的绿框") == "视频里找不到画好的绿框"
|
||||||
Reference in New Issue
Block a user