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:
2026-10-17 10:22:15 -04:00
parent 77ee03f9a3
commit 7e7f32a13d
19 changed files with 2111 additions and 634 deletions
+42 -13
View File
@@ -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)`:任一帧里落在绿框或紫圈颜色范围内的像素。两者配准用,目视核对出图也用。
+3 -3
View File
@@ -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` 定。
+35 -10
View File
@@ -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 运行本身有问题,查那次运行,不要改核对包绕过 |
+3 -3
View File
@@ -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
View File
@@ -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):
+7 -4
View File
@@ -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": [
+2 -1
View File
@@ -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",
+6 -8
View File
@@ -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 -4
View File
@@ -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")
+176 -136
View File
@@ -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),
]
for key, label, unit in TIC_METRICS
] ]
if unknown: if reasons:
raise ValueError(f"tic.json 里有 TIC结果.csv 没有中文名的字段:{unknown}") rows.append([NO_GROUP_ROW, "", *(reasons.get(name, "") for name in TIC_GROUPS)])
return [(f"{name}({path})", _cell(_lookup(tic, path))) for path, name in TIC_ITEMS.items()] 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:
_copy(source, case_dir / "关键帧" / f"{slot}_第{seconds:.1f}秒.png") seconds = tic["keyframe_times_s"][slot]
_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)],
problems.append((no, video_id, STAGE_FIT, f"{name}未完成拟合({code})")) raw_problems=_raw_ceus_problems(no, video_id, summary),
slots = tic["keyframes"] video_bytes=size,
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():
problems.append( if case_no not in merged:
(_doctor_no(case_no), video_id, f"CEUS 第 {step} 步", f"第 {step} 步失败:{reason}") problems.append(
(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
+502
View File
@@ -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)
+66 -177
View File
@@ -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 专家会诊。跳过病例表里每例只记第一项没通过的检查:遇到它就跳过整例,后面的检查没有做过,所以表里没写的检查不代表没有问题。
+6 -3
View File
@@ -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],
} }
+183 -86
View File
@@ -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} def _both_groups(content: dict[str, Any]) -> None:
curves = ("roi_all", "roi_perf", "roi_pan", "background_circle") content["reference_valid"] = True
assert _key_tree(tic) == { content["fits"]["roi_pan"].update(success=True, failure_reason=None)
"fits": {name: {"success": None, "r2": None} for name in curves}, content["perfusion_metrics"]["roi_pan"] = perfusion(10.0)
"perfusion_metrics": {"roi_all": perfusion, "roi_perf": None},
"pan_curve_usable": None,
"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"]
assert tic is not None values["WoAUC_au_s"] = None
assert tic["fits"]["roi_all"] == {"success": True, "r2": 0.93} values["FT_s"] = None
assert tic["fits"]["roi_pan"] == {"success": False, "r2": None}
assert tic["pan_curve_usable"] is False
assert tic["perfusion_metrics"]["roi_all"]["values"]["PE_au"] == 1.0 def _no_groups(content: dict[str, Any]) -> None:
# 假数据里 D1 是第 2 个槽位:视频时间 1 × 2.5 秒。 for curve in ("roi_all", "roi_perf"):
assert tic["keyframes"]["D1"] == {"located": True, "video_time_s": 2.5, "reason": None} content["fits"][curve].update(success=False, failure_reason="credible_peak_not_found")
assert tic["keyframes"]["D4"] == { content["lesion_fit_ready"] = False
"located": False, content["perfusion_metrics"]["roi_perf"] = None
"video_time_s": None,
"reason": UNLOCATED_REASON,
} 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,
}
_PERF = {
"PE_au": 1.0,
"WiAUC_au_s": 2.0,
"RT_s": 3.0,
"mTTl_s": 4.0,
"TTP_s": 5.0,
"WiR_au_per_s": 6.0,
"WiPI_au": 7.0,
"WoAUC_au_s": 8.0,
"WiWoAUC_au_s": 9.0,
"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}, []
+3 -3
View File
@@ -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()
+81
View File
@@ -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
+7 -4
View File
@@ -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"
+359 -132
View File
@@ -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,28 +256,32 @@ 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", "D3_第7.5秒.png",
"D3_第7.5秒.png", "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],
[ [
"P006", "P005",
"U006", "U005",
a["006"], "",
"否",
"三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;"
"三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁",
"是", "是",
"", "",
"是", *blank,
"",
"0.0",
"2.5",
"5.0",
"7.5",
"10.0",
"12.5",
"15.0",
], ],
["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],
] ]
def test_problem_list_has_every_source(packed: Packed) -> None: EXPECTED_PROBLEMS = [
rows = _csv(packed.pack / "问题清单.csv")[1:] [
"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",
"U006",
"TIC 曲线",
f"病灶整体那条曲线没能拟合(曲线太平,没有明显的峰){LESION}",
],
[
"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_in_plain_words(packed: Packed) -> None:
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] == [
+595
View File
@@ -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("视频里找不到画好的绿框") == "视频里找不到画好的绿框"