feat(build): 任务书 02 第四阶段代码——build 装入 CEUS 结果

- build.yaml 加必填字段 ceus_run(CEUS 运行目录或 null)。
- 不为 null 时按视频号找结果;在 failed.csv 里或缺结果目录的整例跳过,记 C15;关键帧图沿用 C14 检查。
- 资料库超声一路加 keyframes/<槽位>.png(去附加数据重编码)和 tic.json(白名单逐字段构造:
  四条曲线的拟合成败与 R²、灌注指标、紫圈曲线是否可用、7 个槽位的定位与视频时间或原因)。
- manifest 的 CEUS 来源写运行号;泄露扫描已覆盖 tic.json,补命中测试。
- 真实构建等配准改完、全量 CEUS 运行之后再做。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 05:55:57 -04:00
parent e78d1b50b8
commit c582fe426f
15 changed files with 683 additions and 35 deletions
+2
View File
@@ -8,3 +8,5 @@ alias_seed: 20261016
# 编码列是否按映射表解码。映射表还没向临床方拿到,先不解码,code_maps 必须是 null。 # 编码列是否按映射表解码。映射表还没向临床方拿到,先不解码,code_maps 必须是 null。
decode_codes: false decode_codes: false
code_maps: null code_maps: null
# CEUS 定量的运行目录(runs/ceus/<运行号>/),或 null(不装 CEUS 结果)。
ceus_run: null
+15 -8
View File
@@ -153,14 +153,20 @@ runs/ceus/<运行号>/ ────► ② 装配两个库 config/bu
``` ```
library/<构建号>/ library/<构建号>/
manifest.json 构建清单:每个文件的 SHA-256、构建时的 git 提交、配置哈希、所用 CEUS 结果的来源 manifest.json 构建清单:每个文件的 SHA-256、构建时的 git 提交、配置哈希、所用 CEUS 运行号(没装为 null)
cases/<化名>/ cases/<化名>/
clinical/clinical.json 临床那一路 clinical/clinical.json 临床那一路
ultrasound/ 超声那一路:report.txt、key.png、keyframes/*.png、tic.json ultrasound/ 超声那一路:report.txt、key.png;装了 CEUS 结果时另有
(后两项等 CEUS 定量落地后由 build 加入,目前只有前两项) keyframes/<槽位>.png 和 tic.json
mr/report.txt MR 那一路 mr/report.txt MR 那一路
``` ```
`build.yaml` 的 `ceus_run` 指向某次 CEUS 定量的运行目录时,build 才往超声一路装 CEUS 结果,为 null 时不装。装的时候:
- `keyframes/<槽位>.png`:下文关键帧表里定位到的那几张(槽位即 B0、D1 等),和 `key.png` 一样去掉附加数据重新编码;每例最多 8 张图(7 张关键帧加 `key.png`)。
- `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` 里列。
- CEUS 在这一例上失败(在 `failed.csv` 里)或运行目录里没有这一例的结果,这一例整例跳过,记 C15。失败的病例要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。
每一路能看见什么: 每一路能看见什么:
| 内容 | 临床 | 超声 | MR | | 内容 | 临床 | 超声 | MR |
@@ -245,7 +251,7 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
| 文件 | 给谁用 | 内容 | | 文件 | 给谁用 | 内容 |
| --- | --- | --- | | --- | --- | --- |
| `build.yaml` | `scripts/build.py` | `delivery_dir`(交付包位置)、`library_root`、`answers_root`(两个库的上级目录)、`alias_seed`(生成化名的随机种子)、`decode_codes`(编码解码开关)、`code_maps`(映射表位置或 null);字段定义和校验在 `src/cadx/build/_config.py`,逐项说明见 `../guides/装配两个库.md` | | `build.yaml` | `scripts/build.py` | `delivery_dir`(交付包位置)、`library_root`、`answers_root`(两个库的上级目录)、`alias_seed`(生成化名的随机种子)、`decode_codes`(编码解码开关)、`code_maps`(映射表位置或 null)、`ceus_run`(CEUS 运行目录或 null);字段定义和校验在 `src/cadx/build/_config.py`,逐项说明见 `../guides/装配两个库.md` |
| `code_maps.yaml` | build(经 `build.yaml` 引用) | 编码映射表:交付包里每个用数字编码的列,每个数字代表什么(如性别的 1、2)。还没向临床方拿到,尚未建 | | `code_maps.yaml` | build(经 `build.yaml` 引用) | 编码映射表:交付包里每个用数字编码的列,每个数字代表什么(如性别的 1、2)。还没向临床方拿到,尚未建 |
| `ceus.yaml` | `scripts/ceus_quant.py` | CEUS 定量的全部参数,每个参数用注释写明出处 | | `ceus.yaml` | `scripts/ceus_quant.py` | CEUS 定量的全部参数,每个参数用注释写明出处 |
| `consult.yaml` | `scripts/consult.py` | 会诊的实验配置与模型用途 | | `consult.yaml` | `scripts/consult.py` | 会诊的实验配置与模型用途 |
@@ -339,13 +345,14 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
### library(`src/cadx/library/`) ### library(`src/cadx/library/`)
- `write_library(build_dir, build_id, git_commit, git_clean, config_sha256, codes_decoded, uncompared, ceus_source, cases)`:把一次构建的各例三路内容写进一个已存在的空目录,关键图去掉附加数据重新编码,最后写 `manifest.json`。关键图不能保真重编码时报错停下(说明 build 漏了 C14)。 - `write_library(build_dir, build_id, git_commit, git_clean, config_sha256, codes_decoded, uncompared, ceus_source, cases)`:把一次构建的各例三路内容写进一个已存在的空目录,关键图和关键帧去掉附加数据重新编码,有 CEUS 结果时写 `tic.json`,最后写 `manifest.json`(`ceus_source` 是 CEUS 运行号或 None,与每例的 `ceus` 是否为 None 必须配套)。图不能保真重编码时报错停下(说明 build 漏了 C14)。
- `check_key_image(path)`:关键图能逐像素保真重编码时返回 None,不能时(不是 PNG、位深或颜色类型不行、带 tRNS 透明色)返回原因。 - `check_key_image(path)`:关键图能逐像素保真重编码时返回 None,不能时(不是 PNG、位深或颜色类型不行、带 tRNS 透明色)返回原因。
- `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述。 - `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述、CEUS 部分(`LibraryCeus` 或 None)。
- `LibraryCeus`:一例超声一路的 CEUS 部分:build 挑好的 tic.json 内容、槽位 → 关键帧源图。
- `list_aliases(build_dir)`:一次构建的全部化名(按化名排序)。 - `list_aliases(build_dir)`:一次构建的全部化名(按化名排序)。
- `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`:超声一路:报告「所见」原文、关键图路径。 - `UltrasoundRoute`:超声一路:报告「所见」原文、关键图路径、tic.json 的内容(没装 CEUS 时为 None)、槽位 → 关键帧路径(没装时为空)。
- `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`:一处命中:文件(相对构建目录)、类别(「字符串」或「键名」)、命中的词。
@@ -390,7 +397,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
### build(`src/cadx/build/`) ### build(`src/cadx/build/`)
- `run_build(config_path, repo_root, now)`:完整做一次构建:读配置和映射表、读交付包、对每例做 C14(`check_key_image` 返回原因的整例跳过,记进跳过清单)、取化名、先写进 `.partial` 目录再改名,交出 `BuildSummary`。 - `run_build(config_path, repo_root, now)`:完整做一次构建:读配置和映射表、读交付包、对每例做 C14(`check_key_image` 返回原因的整例跳过,记进跳过清单);`ceus_run` 不为 null 时核对运行目录(不合规整次停下),对每例做 C15(CEUS 失败或没有结果的整例跳过)、关键帧也过 C14,按白名单构造 tic.json;取化名、先写进 `.partial` 目录再改名,交出 `BuildSummary`。
- `BuildSummary`:供终端打印的计数:构建号、两个库的路径、病例总数、进库数、跳过数、认不出编号的行数、按检查编号分的跳过数、复核清单条数、跳过清单说明句。 - `BuildSummary`:供终端打印的计数:构建号、两个库的路径、病例总数、进库数、跳过数、认不出编号的行数、按检查编号分的跳过数、复核清单条数、跳过清单说明句。
- `BuildConfig`:`build.yaml` 的字段定义(pydantic,字段必填、不许多写、读入后不可改)。 - `BuildConfig`:`build.yaml` 的字段定义(pydantic,字段必填、不许多写、读入后不可改)。
- `load_build_config(path, repo_root)`:读并校验 `build.yaml`,相对路径按仓库根目录解析。 - `load_build_config(path, repo_root)`:读并校验 `build.yaml`,相对路径按仓库根目录解析。
+21 -10
View File
@@ -6,7 +6,8 @@
- **四类胰腺囊性肿瘤**:IPMN(导管内乳头状黏液性肿瘤)、SPN(实性假乳头状肿瘤)、MCN(黏液性囊性肿瘤)、SCN(浆液性囊性肿瘤)。项目要专家判断病灶属于哪一类,所以资料库里出现这些名字就等于泄露答案。 - **四类胰腺囊性肿瘤**:IPMN(导管内乳头状黏液性肿瘤)、SPN(实性假乳头状肿瘤)、MCN(黏液性囊性肿瘤)、SCN(浆液性囊性肿瘤)。项目要专家判断病灶属于哪一类,所以资料库里出现这些名字就等于泄露答案。
- **金标准**:术后或穿刺的病理结论,交付表里叫「病理类型」或 `Diagnosis`,用 1–4 的数字记。 - **金标准**:术后或穿刺的病理结论,交付表里叫「病理类型」或 `Diagnosis`,用 1–4 的数字记。
- **CEUS、MR**:CEUS 是超声造影视频(往静脉打造影剂后录的超声);MR 是磁共振检查,本项目只有它的文字报告。这一步不处理 CEUS 视频,只检查它在不在。 - **CEUS、MR**:CEUS 是超声造影视频(往静脉打造影剂后录的超声);MR 是磁共振检查,本项目只有它的文字报告。这一步不处理 CEUS 视频,只检查它在不在;CEUS 定量(`scripts/ceus_quant.py`,见 `../任务书/02-CEUS定量.md`)另外先跑,这一步按 `ceus_run` 读它的结果。
- **TIC、关键帧、槽位**:TIC(时间-强度曲线)是病灶区域平均亮度随时间的变化;关键帧是按 TIC 挑出的 7 个时刻的画面,每个时刻叫一个槽位(B0、D1–D4、F1、F2,含义见 `../explanation/结构.md` 第四部分的关键帧表)。
- **交付包**:临床方交来的一批原始资料,目前是 `data/交付包_20260908_40例/`:三张汇总表(临床工作簿、超声报告、MR 报告)和每例一个病例目录(关键图、CEUS 视频、单例报告表)。 - **交付包**:临床方交来的一批原始资料,目前是 `data/交付包_20260908_40例/`:三张汇总表(临床工作簿、超声报告、MR 报告)和每例一个病例目录(关键图、CEUS 视频、单例报告表)。
- **单例报告表**:每个病例目录里的一份 Excel,只有一行,列与超声汇总表相同(7 列)。它和汇总表重复,用来核对两份是否一致。 - **单例报告表**:每个病例目录里的一份 Excel,只有一行,列与超声汇总表相同(7 列)。它和汇总表重复,用来核对两份是否一致。
- **所见、结论段**:一份影像报告通常分「所见」(描述看到了什么)和「印象 / 结论」(医生的诊断倾向)。交付包的报告文字只有所见,这一步检查里面没有混进结论段(C11)。 - **所见、结论段**:一份影像报告通常分「所见」(描述看到了什么)和「印象 / 结论」(医生的诊断倾向)。交付包的报告文字只有所见,这一步检查里面没有混进结论段(C11)。
@@ -32,6 +33,7 @@
| `alias_seed` | 整数 | 生成化名的随机种子。同一个种子、同一批通过的病例,得到同样的化名 | | `alias_seed` | 整数 | 生成化名的随机种子。同一个种子、同一批通过的病例,得到同样的化名 |
| `decode_codes` | true / false | 编码列是否按映射表解码 | | `decode_codes` | true / false | 编码列是否按映射表解码 |
| `code_maps` | 路径或 null | 映射表位置 | | `code_maps` | 路径或 null | 映射表位置 |
| `ceus_run` | 路径或 null | 某次 CEUS 定量的运行目录(`runs/ceus/<运行号>/`);null 表示不装 CEUS 结果,超声一路只有报告和关键图 |
相对路径**相对仓库根目录**解析,不相对配置文件所在目录,也不相对你敲命令时所在的目录。路径不能写空串(空串会变成当前目录)。 相对路径**相对仓库根目录**解析,不相对配置文件所在目录,也不相对你敲命令时所在的目录。路径不能写空串(空串会变成当前目录)。
@@ -43,6 +45,10 @@
为什么做成开关:映射表还没从临床方拿到,不能编造含义;先用 false 跑通,映射表到位后改成 true 重新构建一次(负责人 2026-10-16:解码做成可选开关,映射表是独立的配置文件)。诊断编码和其他编码列一样归这个开关管;诊断不进资料库,所以开关对诊断只影响答案库。 为什么做成开关:映射表还没从临床方拿到,不能编造含义;先用 false 跑通,映射表到位后改成 true 重新构建一次(负责人 2026-10-16:解码做成可选开关,映射表是独立的配置文件)。诊断编码和其他编码列一样归这个开关管;诊断不进资料库,所以开关对诊断只影响答案库。
### ceus_run
指向一次**跑完的** CEUS 运行目录。目录不存在、名字以 `.partial` 结尾(那次运行没跑完)、缺 `failed.csv` 或 `snapshot/snapshot.json`,整次构建在建目录之前停下。目录合规时,按视频号找每例的结果,找不到或 CEUS 在这一例上失败的按 C15 跳过(第四节)。
### 映射表的格式 ### 映射表的格式
映射表目前**还没有**(`config/code_maps.yaml` 尚未建)。拿到后按下面的格式写:每个编码列一项,列名映射到「数字 → 含义」。 映射表目前**还没有**(`config/code_maps.yaml` 尚未建)。拿到后按下面的格式写:每个编码列一项,列名映射到「数字 → 含义」。
@@ -73,7 +79,7 @@ uv run python scripts/build.py --config config/build.yaml
uv run python scripts/scan_library.py --library library/<构建号> --answers answers/<构建号> uv run python scripts/scan_library.py --library library/<构建号> --answers answers/<构建号>
``` ```
它从答案库读出这次构建的真实病例号和视频号,加上脚本里独立写的亚型名、诊断列名、交付文件名词表,扫资料库的全部 `.txt`、`.json`(不扫 PNG:PNG 的附加数据在写入时已经去掉,像素上烧录的信息栏按 `AGENTS.md` 硬性约束 3 不遮蔽,文字扫描也查不了像素),并检查 JSON 里有没有 `diagnosis`、`label`、`gold`、`pathology`、`name`、`row` 这类键名。只输出命中的条数和类别,不输出命中的内容;全部为 0 才说明资料库里没有这些东西。两个目录的构建号不同时直接报错。词表在脚本里另写一份、不取自 delivery 的常量,是为了让扫描不依赖被检查的代码本身:delivery 漏了一个词,扫描还能查出来。这个脚本同时读两个库,所以只能放在 `scripts/`,不能进 `src/cadx`(会诊代码不许碰答案库)。 它从答案库读出这次构建的真实病例号和视频号,加上脚本里独立写的亚型名、诊断列名、交付文件名词表,扫资料库的全部 `.txt`、`.json`(含 `tic.json`;不扫 PNG:PNG 的附加数据在写入时已经去掉,像素上烧录的信息栏按 `AGENTS.md` 硬性约束 3 不遮蔽,文字扫描也查不了像素),并检查 JSON 里有没有 `diagnosis`、`label`、`gold`、`pathology`、`name`、`row` 这类键名。只输出命中的条数和类别,不输出命中的内容;全部为 0 才说明资料库里没有这些东西。两个目录的构建号不同时直接报错。词表在脚本里另写一份、不取自 delivery 的常量,是为了让扫描不依赖被检查的代码本身:delivery 漏了一个词,扫描还能查出来。这个脚本同时读两个库,所以只能放在 `scripts/`,不能进 `src/cadx`(会诊代码不许碰答案库)。
## 三、产物在哪 ## 三、产物在哪
@@ -89,18 +95,22 @@ cases/<化名>/
clinical/clinical.json clinical/clinical.json
ultrasound/report.txt ultrasound/report.txt
ultrasound/key.png ultrasound/key.png
ultrasound/keyframes/<槽位>.png (ceus_run 不为 null 时;只有定位到的槽位)
ultrasound/tic.json (ceus_run 不为 null 时)
mr/report.txt mr/report.txt
``` ```
| 文件 | 内容 | | 文件 | 内容 |
| --- | --- | | --- | --- |
| `manifest.json` | 构建号、git 提交号、工作区是否干净、配置文件(解码时含映射表)的 SHA-256、`codes_decoded`、`uncompared`(没比对的项)、`ceus_source`(所用 CEUS 结果的来源,这一步是 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/key.png` | 医生截的超声关键图,只保留像素和颜色模式重新编码,不带文本块、EXIF、ICC 等附加数据(附加数据里可能有文件名、时间等线索)。超声帧上烧录的信息栏不遮蔽(`AGENTS.md` 硬性约束 3) |
| `ultrasound/keyframes/<槽位>.png` | CEUS 运行里定位到的关键帧整幅画面,和 `key.png` 一样去掉附加数据重新编码 |
| `ultrasound/tic.json` | 四条 TIC 曲线的拟合是否成功和 R²、病灶两条曲线的灌注指标、紫圈曲线是否可用、7 个槽位是否定位到及视频时间或未定位原因。字段逐个从 CEUS 的 `summary.json` 挑出,不含视频号、路径、配置、帧号(`结构.md` 第四部分) |
| `mr/report.txt` | MR 报告的「所见」原文 | | `mr/report.txt` | MR 报告的「所见」原文 |
性别、年龄只在临床一路:两份报告表里医院也复制了这两列,但影像两路先看到会被人口学先验带偏(`结构.md` 第四部分)。以后 CEUS 定量落地,超声一路再加关键帧和灌注指标。 性别、年龄只在临床一路:两份报告表里医院也复制了这两列,但影像两路先看到会被人口学先验带偏(`结构.md` 第四部分)。
读资料库要经 `cadx.library` 的 `read_clinical`、`read_ultrasound`、`read_mr`:读时逐个核对文件的 SHA-256 与清单一致,被改过就报错。 读资料库要经 `cadx.library` 的 `read_clinical`、`read_ultrasound`、`read_mr`:读时逐个核对文件的 SHA-256 与清单一致,被改过就报错。
@@ -147,18 +157,18 @@ CSV 用带 BOM 的 UTF-8(文件开头有一个不可见的标记),医生
交付资料里的问题分两种(`AGENTS.md`「错误怎么处理」): 交付资料里的问题分两种(`AGENTS.md`「错误怎么处理」):
- **整张表的问题**(T1–T5):一例也读不出来,整次构建停下,报错信息以检查编号开头,写明哪个文件哪一项不符。 - **整张表的问题**(T1–T5):一例也读不出来,整次构建停下,报错信息以检查编号开头,写明哪个文件哪一项不符。
- **只落在某一个病例上的问题**(C01–C14):跳过这一整例,接着处理下一例,在 `skipped.csv` 里记一行。这一例不进资料库,也不进答案库的 `aliases.csv`、`diagnoses.csv`。 - **只落在某一个病例上的问题**(C01–C15):跳过这一整例,接着处理下一例,在 `skipped.csv` 里记一行。这一例不进资料库,也不进答案库的 `aliases.csv`、`diagnoses.csv`。
先对三张表的每一行和每个病例目录做 C01(编号认不认得出);认得出的,再逐例按 C02 到 C13 的顺序检查,**遇到第一项没过就停下**,清单上每例只记这一项(C01 记的是认不出的那一行,不算某一例,见下文)。后面的检查没做过,不代表没问题:医生改好这一项后,重新构建时它可能卡在后面某一项。这句说明写在答案库 `manifest.json` 的 `skipped_note` 里,终端也会打印;CSV 文件开头不能加注释行,所以没写在 `skipped.csv` 里。为什么整例跳过而不是停下整次构建:负责人 2026-10-16「都是遇到整个病例跳过,让医生核对去吧」。评价时要报告跳过了几例、各因为什么。 先对三张表的每一行和每个病例目录做 C01(编号认不认得出);认得出的,再逐例按 C02 到 C13、C14(关键图)、C15(CEUS 结果)、C14(关键帧图)的顺序检查,**遇到第一项没过就停下**,清单上每例只记这一项(C01 记的是认不出的那一行,不算某一例,见下文)。后面的检查没做过,不代表没问题:医生改好这一项后,重新构建时它可能卡在后面某一项。这句说明写在答案库 `manifest.json` 的 `skipped_note` 里,终端也会打印;CSV 文件开头不能加注释行,所以没写在 `skipped.csv` 里。为什么整例跳过而不是停下整次构建:负责人 2026-10-16「都是遇到整个病例跳过,让医生核对去吧」。评价时要报告跳过了几例、各因为什么。
### skipped.csv 的列 ### skipped.csv 的列
| 列 | 含义 | | 列 | 含义 |
| --- | --- | | --- | --- |
| 病例 | 三位病例号。C01(编号认不出)的行不知道是哪个病人,写成「表的路径 第 N 行」(N 是 Excel 行号,第 1 行是表头);病例目录名不在 1–999 之间时写成「病例目录 目录名」 | | 病例 | 三位病例号。C01(编号认不出)的行不知道是哪个病人,写成「表的路径 第 N 行」(N 是 Excel 行号,第 1 行是表头);病例目录名不在 1–999 之间时写成「病例目录 目录名」 |
| 检查编号 | C01–C14 | | 检查编号 | C01–C15 |
| 检查说明 | 这项检查的一句话说明 | | 检查说明 | 这项检查的一句话说明 |
| 文件 | 涉及的文件,相对交付包根目录,多个用「; 」隔开 | | 文件 | 涉及的文件,相对交付包根目录,多个用「; 」隔开;C15 和关键帧图的 C14 相对 CEUS 运行的上级目录(以运行号开头) |
| 列 | 涉及的列,多个用「; 」隔开;不涉及具体列(如目录里的文件不对)时为空 | | 列 | 涉及的列,多个用「; 」隔开;不涉及具体列(如目录里的文件不对)时为空 |
| 涉及值 | JSON 字符串:不合规的原值、对不上的各表的值、命中的词和所在句子等,供医生核对 | | 涉及值 | JSON 字符串:不合规的原值、对不上的各表的值、命中的词和所在句子等,供医生核对 |
@@ -166,7 +176,7 @@ C01 的行不计入病例数,也不计入终端的「跳过病例」,单独
### 检查编号速查 ### 检查编号速查
规则细节以代码为准:T1–T4 在 `src/cadx/delivery/_tables.py`,T5 在 `src/cadx/build/_config.py`,C01–C10 在 `src/cadx/delivery/_cases.py`,C11–C13 的词表和正则在 `src/cadx/delivery/_text.py`,C14 由 build 调 library 的 `check_key_image`(`src/cadx/library/_write.py`)在写库前做,表名、工作表名、表头在 `src/cadx/delivery/_shape.py`。每条规则为什么这样定见任务书 `../任务书/01-装配两个库.md` 第二部分。 规则细节以代码为准:T1–T4 在 `src/cadx/delivery/_tables.py`,T5 在 `src/cadx/build/_config.py`,C01–C10 在 `src/cadx/delivery/_cases.py`,C11–C13 的词表和正则在 `src/cadx/delivery/_text.py`,C14 由 build 调 library 的 `check_key_image`(`src/cadx/library/_write.py`)在写库前做,C15 在 `src/cadx/build/_ceus.py`,表名、工作表名、表头在 `src/cadx/delivery/_shape.py`。每条规则为什么这样定见任务书 `../任务书/01-装配两个库.md` 第二部分。
| 编号 | 查什么 | 不过时 | | 编号 | 查什么 | 不过时 |
| --- | --- | --- | | --- | --- | --- |
@@ -188,7 +198,8 @@ C01 的行不计入病例数,也不计入终端的「跳过病例」,单独
| C11 | 报告文字里没有结论段标题:段首(全文开头或换行之后)出现「印象」「结论」「提示」等关键词加冒号 | 跳过这一例 | | C11 | 报告文字里没有结论段标题:段首(全文开头或换行之后)出现「印象」「结论」「提示」等关键词加冒号 | 跳过这一例 |
| C12 | 报告文字里没有亚型名(缩写、中文名及其常见变体) | 跳过这一例 | | C12 | 报告文字里没有亚型名(缩写、中文名及其常见变体) | 跳过这一例 |
| C13 | 报告文字里没有人口学信息:没有「数字岁」,没有「男」「女」两个字 | 跳过这一例 | | C13 | 报告文字里没有人口学信息:没有「数字岁」,没有「男」「女」两个字 | 跳过这一例 |
| C14 | 关键图能原样重编码:是 PNG,位深和颜色类型能逐像素保真(8 位灰度、RGB、灰度加透明通道、RGBA,或 1 位灰度),不带 tRNS 透明色;否则重编码会悄悄降位或丢透明。涉及值写不过的原因 | 跳过这一例 | | C14 | 关键图能原样重编码:是 PNG,位深和颜色类型能逐像素保真(8 位灰度、RGB、灰度加透明通道、RGBA,或 1 位灰度),不带 tRNS 透明色;否则重编码会悄悄降位或丢透明。装 CEUS 结果时,每张关键帧图也查这一条(规则相同,「文件」一栏是哪张关键帧)。涉及值写不过的原因 | 跳过这一例 |
| C15 | CEUS 定量结果齐全(只在 `ceus_run` 不为 null 时查):这一例的视频号不在那次运行的 `failed.csv` 里,且运行目录里有这一例的结果目录。涉及值写失败在第几步和原因,或「没有这一例的结果目录」。失败的病例以后要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定 | 跳过这一例 |
几点说明: 几点说明:
+44 -3
View File
@@ -15,6 +15,7 @@ from pathlib import Path
from typing import cast from typing import cast
from cadx.answers import AliasRow, DiagnosisRow, ReviewRow, SkippedRow, write_answers from cadx.answers import AliasRow, DiagnosisRow, ReviewRow, SkippedRow, write_answers
from cadx.build._ceus import CeusRun, case_ceus, open_ceus_run
from cadx.build._config import load_build_config, load_code_maps from cadx.build._config import load_build_config, load_code_maps
from cadx.delivery import ( from cadx.delivery import (
CODED_COLUMNS, CODED_COLUMNS,
@@ -25,7 +26,7 @@ from cadx.delivery import (
read_delivery, read_delivery,
review_sentences, review_sentences,
) )
from cadx.library import LibraryCase, check_key_image, write_library from cadx.library import LibraryCase, LibraryCeus, check_key_image, write_library
from cadx.runs import make_build_id, read_git_state, write_snapshot from cadx.runs import make_build_id, read_git_state, write_snapshot
ALIAS_ALPHABET = string.ascii_lowercase + string.digits ALIAS_ALPHABET = string.ascii_lowercase + string.digits
@@ -104,6 +105,35 @@ def _check_key_images(
return kept, skipped return kept, skipped
def _attach_ceus(
cases: Sequence[CaseRecord], run: CeusRun
) -> tuple[list[tuple[CaseRecord, LibraryCeus]], list[SkipRecord]]:
"""C15:CEUS 失败或没有结果的病例整例跳过;关键帧图同 key.png 一样过 C14。"""
kept: list[tuple[CaseRecord, LibraryCeus]] = []
skipped: list[SkipRecord] = []
for case in cases:
ceus = case_ceus(run, case)
if isinstance(ceus, SkipRecord):
skipped.append(ceus)
continue
reasons = {slot: check_key_image(path) for slot, path in ceus.keyframe_sources.items()}
bad = next(((slot, r) for slot, r in reasons.items() if r is not None), None)
if bad is None:
kept.append((case, ceus))
continue
skipped.append(
SkipRecord(
case=case.case_no,
check=C14,
description=C14_DESCRIPTION,
files=(f"{run.run_id}/{case.video_id}/keyframes/{bad[0]}.png",),
columns=(),
value_json=json.dumps(bad[1], ensure_ascii=False),
)
)
return kept, skipped
def _sha256(path: Path) -> str: def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest() return hashlib.sha256(path.read_bytes()).hexdigest()
@@ -116,12 +146,22 @@ def _rename(partial: Path, final: Path) -> None:
def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary: def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary:
"""读配置和交付包,装配一对同号的资料库与答案库。`now` 是本地时间,定构建号。""" """读配置和交付包(`ceus_run` 不为 null 时还读那次 CEUS 运行),装配一对同号的资料库与答案库。
`now` 是本地时间,定构建号。
"""
config = load_build_config(config_path, repo_root) config = load_build_config(config_path, repo_root)
code_maps = load_code_maps(config.code_maps) if config.code_maps is not None else None code_maps = load_code_maps(config.code_maps) if config.code_maps is not None else None
delivery = read_delivery(config.delivery_dir, code_maps) delivery = read_delivery(config.delivery_dir, code_maps)
passed, key_image_skips = _check_key_images(delivery.cases, config.delivery_dir) passed, key_image_skips = _check_key_images(delivery.cases, config.delivery_dir)
skipped = [*delivery.skipped, *key_image_skips] skipped = [*delivery.skipped, *key_image_skips]
ceus_run = None if config.ceus_run is None else open_ceus_run(config.ceus_run)
ceus_of: dict[str, LibraryCeus | None] = dict.fromkeys((c.case_no for c in passed), None)
if ceus_run is not None:
with_ceus, ceus_skips = _attach_ceus(passed, ceus_run)
passed = [case for case, _ in with_ceus]
ceus_of = {case.case_no: ceus for case, ceus in with_ceus}
skipped.extend(ceus_skips)
git = read_git_state(repo_root) git = read_git_state(repo_root)
build_id = make_build_id(now, git) build_id = make_build_id(now, git)
@@ -149,7 +189,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary
config_sha256={name: _sha256(path) for name, path in configs.items()}, config_sha256={name: _sha256(path) for name, path in configs.items()},
codes_decoded=code_maps is not None, codes_decoded=code_maps is not None,
uncompared=delivery.uncompared, uncompared=delivery.uncompared,
ceus_source=None, ceus_source=None if ceus_run is None else ceus_run.run_id,
cases=[ cases=[
LibraryCase( LibraryCase(
alias=aliases[case.case_no], alias=aliases[case.case_no],
@@ -157,6 +197,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary
ultrasound_report=case.ultrasound_report, ultrasound_report=case.ultrasound_report,
key_image_source=case.key_image, key_image_source=case.key_image,
mr_report=case.mr_report, mr_report=case.mr_report,
ceus=ceus_of[case.case_no],
) )
for case in by_alias for case in by_alias
], ],
+151
View File
@@ -0,0 +1,151 @@
"""读一次 CEUS 定量运行(`runs/ceus/<运行号>/`),按白名单挑出超声一路的 tic.json 和关键帧(C15)。
运行目录的布局由 `scripts/ceus_quant.py` 定:`failed.csv`、`snapshot/snapshot.json`、
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`。tic.json 的字段只在这里列,
视频号、路径、配置、帧号和拟合参数都不进去。
"""
import csv
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from cadx.delivery import CaseRecord, SkipRecord
from cadx.library import LibraryCeus
C15 = "C15"
C15_DESCRIPTION = "CEUS 定量结果齐全"
_FAILED_HEADER = ["video_id", "step", "reason"]
# 四条曲线:病灶整体、病灶去暗区、紫圈(医生圈的正常胰腺对照)、背景圆。
_CURVES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
_LESION_CURVES = ("roi_all", "roi_perf")
_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
_PERFUSION_LABELS = ("signal_domain", "time_origin", "area_curve")
# 11 个灌注指标(同旧 replay summary 的 perfusion_metrics.values)。
_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",
)
_SUPPORTING_TIMES = ("TI", "mTT", "TO")
# 未定位原因由 ceus 按固定模板加数值生成,不含路径;出现这些字符说明模板变了,停下检查。
_PATH_MARKS = ("/", "\\", ".mp4", ".png")
@dataclass(frozen=True)
class CeusRun:
"""一次已完成的 CEUS 运行:运行号、目录、failed.csv 里的失败(视频号 → 步号、原因)。"""
run_id: str
run_dir: Path
failed: dict[str, tuple[str, str]]
def open_ceus_run(run_dir: Path) -> CeusRun:
"""核对运行目录(存在、不是 `.partial`、有 failed.csv 和快照),读 failed.csv。"""
if run_dir.name.endswith(".partial"):
raise ValueError(f"CEUS 运行目录是没跑完的 .partial:{run_dir}")
if not run_dir.is_dir():
raise FileNotFoundError(f"CEUS 运行目录不存在:{run_dir}")
if not (run_dir / "snapshot" / "snapshot.json").is_file():
raise FileNotFoundError(f"CEUS 运行目录缺快照 snapshot/snapshot.json:{run_dir}")
with (run_dir / "failed.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.reader(handle))
if not rows or rows[0] != _FAILED_HEADER:
raise ValueError(f"failed.csv 的表头应为 {_FAILED_HEADER}:{run_dir}")
return CeusRun(run_dir.name, run_dir, {row[0]: (row[1], row[2]) for row in rows[1:]})
def _number(value: object) -> float | int | None:
if value is not None and (isinstance(value, bool) or not isinstance(value, int | float)):
raise ValueError(f"summary.json 里应为数值或 null 的字段是 {value!r}")
return value
def _typed[T](value: object, kind: type[T]) -> T:
if not isinstance(value, kind):
raise ValueError(f"summary.json 里应为 {kind.__name__} 的字段是 {value!r}")
return value
def _perfusion(section: dict[str, Any] | None) -> dict[str, Any] | None:
if section is None:
return None
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": {
name: _perfusion(summary["perfusion_metrics"][name]) for name in _LESION_CURVES
},
"pan_curve_usable": _typed(summary["reference_valid"], bool),
"keyframes": {slot: _keyframe(summary["anchor_frames"][slot], video_id) for slot in _SLOTS},
}
def case_ceus(run: CeusRun, case: CaseRecord) -> LibraryCeus | SkipRecord:
"""一例的 CEUS 结果;在 failed.csv 里或没有结果目录的,交出 C15 跳过记录。"""
def c15(files: str, value: str) -> SkipRecord:
return SkipRecord(
case=case.case_no,
check=C15,
description=C15_DESCRIPTION,
files=(f"{run.run_id}/{files}",),
columns=(),
value_json=json.dumps(value, ensure_ascii=False),
)
if case.video_id in run.failed:
step, reason = run.failed[case.video_id]
return c15("failed.csv", f"第 {step} 步失败:{reason}")
case_dir = run.run_dir / case.video_id
if not case_dir.is_dir():
return c15(case.video_id, "CEUS 运行目录里没有这一例的结果目录")
summary = json.loads((case_dir / "summary.json").read_text(encoding="utf-8"))
if summary["video_id"] != case.video_id:
raise ValueError(f"summary.json 的视频号与所在目录不一致:{case_dir}")
tic = tic_view(summary, case.video_id)
located = [slot for slot in _SLOTS if tic["keyframes"][slot]["located"]]
images = sorted(p.stem for p in (case_dir / "keyframes").iterdir())
if images != sorted(located):
raise ValueError(f"keyframes/ 里的图与 summary.json 定位到的槽位不一致:{case_dir}")
sources = {slot: case_dir / "keyframes" / f"{slot}.png" for slot in located}
return LibraryCeus(tic=tic, keyframe_sources=sources)
+3
View File
@@ -54,6 +54,8 @@ class BuildConfig(BaseModel):
alias_seed: int alias_seed: int
decode_codes: bool decode_codes: bool
code_maps: Annotated[Path | None, Field(strict=False), BeforeValidator(_reject_empty_path)] code_maps: Annotated[Path | None, Field(strict=False), BeforeValidator(_reject_empty_path)]
# 某次 CEUS 定量的运行目录(`runs/ceus/<运行号>/`);null 表示这次构建不装 CEUS 结果。
ceus_run: Annotated[Path | None, Field(strict=False), BeforeValidator(_reject_empty_path)]
@model_validator(mode="after") @model_validator(mode="after")
def _code_maps_match_switch(self) -> Self: def _code_maps_match_switch(self) -> Self:
@@ -73,6 +75,7 @@ def load_build_config(path: Path, repo_root: Path) -> BuildConfig:
"library_root": repo_root / config.library_root, "library_root": repo_root / config.library_root,
"answers_root": repo_root / config.answers_root, "answers_root": repo_root / config.answers_root,
"code_maps": None if config.code_maps is None else repo_root / config.code_maps, "code_maps": None if config.code_maps is None else repo_root / config.code_maps,
"ceus_run": None if config.ceus_run is None else repo_root / config.ceus_run,
} }
) )
+2 -1
View File
@@ -13,12 +13,13 @@ 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, check_key_image, write_library from cadx.library._write import LibraryCase, LibraryCeus, check_key_image, write_library
__all__ = [ __all__ = [
"ClinicalRoute", "ClinicalRoute",
"LeakHit", "LeakHit",
"LibraryCase", "LibraryCase",
"LibraryCeus",
"MrRoute", "MrRoute",
"UltrasoundRoute", "UltrasoundRoute",
"check_key_image", "check_key_image",
+18 -2
View File
@@ -8,8 +8,10 @@ from typing import Any, cast
from cadx.library._write import ( from cadx.library._write import (
CLINICAL_JSON, CLINICAL_JSON,
KEY_IMAGE, KEY_IMAGE,
KEYFRAMES_DIR,
MANIFEST, MANIFEST,
MR_REPORT, MR_REPORT,
TIC_JSON,
ULTRASOUND_REPORT, ULTRASOUND_REPORT,
case_file, case_file,
sha256_file, sha256_file,
@@ -26,10 +28,13 @@ class ClinicalRoute:
@dataclass(frozen=True) @dataclass(frozen=True)
class UltrasoundRoute: class UltrasoundRoute:
"""超声一路:报告「所见」原文和医生截的关键图(给路径)。""" """超声一路:报告「所见」原文、医生截的关键图(给路径);装了 CEUS 结果时还有 tic.json 的内容和
定位到的关键帧(槽位 → 路径),没装时 `tic` 为 None、`keyframes` 为空。"""
report: str report: str
key_image: Path key_image: Path
tic: dict[str, Any] | None
keyframes: dict[str, Path]
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -75,7 +80,18 @@ 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))
return UltrasoundRoute(report=report, key_image=_verified(build_dir, alias, KEY_IMAGE)) key_image = _verified(build_dir, alias, KEY_IMAGE)
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}/")
keyframes = {
Path(key).stem: _verified(build_dir, alias, key.removeprefix(f"cases/{alias}/"))
for key in manifest["files"]
if key.startswith(prefix)
}
tic = json.loads(_text(_verified(build_dir, alias, TIC_JSON)))
return UltrasoundRoute(report=report, key_image=key_image, tic=tic, keyframes=keyframes)
def read_mr(build_dir: Path, alias: str) -> MrRoute: def read_mr(build_dir: Path, alias: str) -> MrRoute:
+29 -3
View File
@@ -6,6 +6,8 @@
cases/<化名>/clinical/clinical.json cases/<化名>/clinical/clinical.json
cases/<化名>/ultrasound/report.txt cases/<化名>/ultrasound/report.txt
cases/<化名>/ultrasound/key.png cases/<化名>/ultrasound/key.png
cases/<化名>/ultrasound/keyframes/<槽位>.png (装了 CEUS 结果时;只有定位到的槽位)
cases/<化名>/ultrasound/tic.json (装了 CEUS 结果时)
cases/<化名>/mr/report.txt cases/<化名>/mr/report.txt
``` ```
""" """
@@ -22,6 +24,8 @@ 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" KEY_IMAGE = "ultrasound/key.png"
KEYFRAMES_DIR = "ultrasound/keyframes"
TIC_JSON = "ultrasound/tic.json"
MR_REPORT = "mr/report.txt" MR_REPORT = "mr/report.txt"
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
@@ -30,15 +34,24 @@ _PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_FAITHFUL = frozenset({(8, 0), (8, 2), (8, 4), (8, 6), (1, 0)}) _FAITHFUL = frozenset({(8, 0), (8, 2), (8, 4), (8, 6), (1, 0)})
@dataclass(frozen=True)
class LibraryCeus:
"""超声一路的 CEUS 部分:`tic` 由 build 按白名单挑好 写成 tic.json;槽位 → 关键帧源图。"""
tic: Mapping[str, object]
keyframe_sources: Mapping[str, Path]
@dataclass(frozen=True) @dataclass(frozen=True)
class LibraryCase: class LibraryCase:
"""要写进资料库的一例:三路各自的内容。""" """要写进资料库的一例:三路各自的内容。`ceus` 为 None 表示这次构建不装 CEUS 结果。"""
alias: str alias: str
clinical_fields: Mapping[str, str | int | float | None] clinical_fields: Mapping[str, str | int | float | None]
ultrasound_report: str ultrasound_report: str
key_image_source: Path key_image_source: Path
mr_report: str mr_report: str
ceus: LibraryCeus | None
def case_file(alias: str, relative: str) -> str: def case_file(alias: str, relative: str) -> str:
@@ -98,9 +111,14 @@ def write_library(
ceus_source: str | None, ceus_source: str | None,
cases: Sequence[LibraryCase], cases: Sequence[LibraryCase],
) -> None: ) -> None:
"""把一次构建写进已经存在的空目录 `build_dir`,最后写清单。""" """把一次构建写进已经存在的空目录 `build_dir`,最后写清单。
`ceus_source` 是所用 CEUS 运行号,为 None 时每例的 `ceus` 都必须是 None,反之都不能是 None。
"""
files: dict[str, str] = {} files: dict[str, str] = {}
for case in cases: for case in cases:
if (case.ceus is None) != (ceus_source is None):
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, ULTRASOUND_REPORT, MR_REPORT):
(case_dir / relative).parent.mkdir(parents=True, exist_ok=True) (case_dir / relative).parent.mkdir(parents=True, exist_ok=True)
@@ -111,7 +129,15 @@ def write_library(
(case_dir / ULTRASOUND_REPORT).write_text(case.ultrasound_report, encoding="utf-8") (case_dir / ULTRASOUND_REPORT).write_text(case.ultrasound_report, encoding="utf-8")
_write_clean_png(case.key_image_source, case_dir / KEY_IMAGE) _write_clean_png(case.key_image_source, case_dir / KEY_IMAGE)
(case_dir / MR_REPORT).write_text(case.mr_report, encoding="utf-8") (case_dir / MR_REPORT).write_text(case.mr_report, encoding="utf-8")
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT): written = [CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT]
if case.ceus is not None:
(case_dir / KEYFRAMES_DIR).mkdir()
for slot, source in case.ceus.keyframe_sources.items():
_write_clean_png(source, case_dir / KEYFRAMES_DIR / f"{slot}.png")
written.append(f"{KEYFRAMES_DIR}/{slot}.png")
_write_json(case_dir / TIC_JSON, dict(case.ceus.tic))
written.append(TIC_JSON)
for relative in written:
files[case_file(case.alias, relative)] = sha256_file(case_dir / relative) files[case_file(case.alias, relative)] = sha256_file(case_dir / relative)
_write_json( _write_json(
build_dir / MANIFEST, build_dir / MANIFEST,
+118
View File
@@ -0,0 +1,118 @@
"""测试用假 CEUS 运行目录:布局照 `scripts/ceus_quant.py` 写出的(failed.csv、snapshot/、
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`),数值全部编造,不调显卡。
summary.json 故意带上 tic.json 不该有的字段(视频号、分割缓存键、帧号、拟合参数、失败原因代码、
推注时间、紫圈检出率),用来测 build 只按白名单挑字段。关键帧 PNG 带文本块和 EXIF。
"""
import csv
import json
from pathlib import Path
from typing import Any
from PIL import Image, PngImagePlugin
SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
CURVES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
UNLOCATED_REASON = "拟合曲线上没有 D4 时刻(编造的原因)"
def _perfusion(scale: float) -> dict[str, Any]:
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")
return {
"signal_domain": "db_display",
"time_origin": "video_start",
"area_curve": "fitted_minus_baseline",
"values": {name: scale * (i + 1) for i, name in enumerate((*names, *more))},
"supporting_times_s": {"TI": 3.0, "mTT": 9.5, "TO": None},
}
def _fit(success: bool, r2: float | None) -> dict[str, Any]:
return {
"success": success,
"failure_reason": None if success else "credible_peak_not_found",
"curve_model": "lognormal",
"baseline_source": "observed",
"valid_samples": 120,
"duplicate_samples": 3,
"parameters": {
"area": 1.0,
"mu": 2.0,
"sigma": 0.5,
"arrival_offset_s": 4.0,
"baseline": 9,
},
"quality": {"r2": r2, "nrmse": 0.1, "residual_noise_ratio": 1.2, "multi_peak_flag": False},
"model_arrival_time_video_s": 4.0,
"model_landmark_times_video_s": {"D2": 6.0, "D3": 9.0, "D4": 15.0},
"model_landmark_intensities": {"D2": 1.0, "D3": 2.0, "D4": 1.5},
}
def summary(video_id: str, located: tuple[str, ...]) -> dict[str, Any]:
"""一例的 summary.json:`located` 里的槽位定位到(视频时间=槽位序号×2.5 秒),余下未定位。"""
anchors = {
slot: {
"frame_index": index * 25 if slot in located else None,
"frame_time_video_s": index * 2.5 if slot in located else None,
"time_since_injection_s": index * 2.5 + 5.0 if slot in located else None,
"status": "ok" if slot in located else "anchor_undefined",
"reason": None if slot in located else UNLOCATED_REASON,
"anchor_time_video_s": index * 2.5,
"time_error_s": 0.0 if slot in located else None,
"source_curve": "roi_perf",
}
for index, slot in enumerate(SLOTS)
}
return {
"video_id": video_id,
"segment_cache_key": "f" * 64,
"injection_offset_s": 5.0,
"reference_valid": False,
"pan_detected_fraction": 0.4,
"early_truncation_flag": False,
"lesion_fit_ready": True,
"fits": {
"roi_all": _fit(True, 0.93),
"roi_perf": _fit(True, 0.91),
"roi_pan": _fit(False, None),
"background_circle": _fit(False, 0.2),
},
"perfusion_metrics": {"roi_all": _perfusion(1.0), "roi_perf": None},
"anchor_frames": anchors,
"available_dynamic_slots": [s for s in ("B0", "D1", "D2", "D3", "D4") if s in located],
}
def keyframe_png(path: Path, shade: int) -> None:
"""一张 8 位 RGB 关键帧,带文本块和 EXIF(写进资料库前要去掉)。"""
pixels = bytes(v for i in range(12) for v in (shade, i * 20, 200 - i * 10))
image = Image.frombytes("RGB", (4, 3), pixels)
info = PngImagePlugin.PngInfo()
info.add_text("Source", "U999_roi2.mp4")
exif = Image.Exif()
exif[0x010E] = "U999" # ImageDescription
image.save(path, format="PNG", pnginfo=info, exif=exif.tobytes())
def write_run(
run_dir: Path,
results: dict[str, tuple[str, ...]],
failed: list[tuple[str, int, str]],
) -> Path:
"""写一个已完成的运行目录:`results` 是视频号 → 定位到的槽位,`failed` 是 failed.csv 的行。"""
(run_dir / "snapshot").mkdir(parents=True)
(run_dir / "snapshot" / "snapshot.json").write_text("{}", encoding="utf-8")
with (run_dir / "failed.csv").open("w", encoding="utf-8", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(("video_id", "step", "reason"))
writer.writerows(failed)
for video_id, located in results.items():
(run_dir / video_id / "keyframes").mkdir(parents=True)
content = json.dumps(summary(video_id, located), ensure_ascii=False, indent=2)
(run_dir / video_id / "summary.json").write_text(content, encoding="utf-8")
for shade, slot in enumerate(located):
keyframe_png(run_dir / video_id / "keyframes" / f"{slot}.png", shade * 30)
return run_dir
+8 -3
View File
@@ -235,7 +235,11 @@ def _write_png(path: Path, number: int) -> None:
def write_config( def write_config(
tmp_path: Path, delivery_dir: Path, seed: int, code_maps: dict[str, dict[int, str]] | None tmp_path: Path,
delivery_dir: Path,
seed: int,
code_maps: dict[str, dict[int, str]] | None,
ceus_run: Path | None,
) -> Path: ) -> Path:
"""在 tmp_path 写一份 build.yaml(路径都写绝对路径);给了映射表就同时写 code_maps.yaml。""" """在 tmp_path 写一份 build.yaml(路径都写绝对路径);给了映射表就同时写 code_maps.yaml。"""
maps_path = None maps_path = None
@@ -249,6 +253,7 @@ def write_config(
"alias_seed": seed, "alias_seed": seed,
"decode_codes": code_maps is not None, "decode_codes": code_maps is not None,
"code_maps": None if maps_path is None else str(maps_path), "code_maps": None if maps_path is None else str(maps_path),
"ceus_run": None if ceus_run is None else str(ceus_run),
} }
path = tmp_path / "build.yaml" path = tmp_path / "build.yaml"
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8") path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
@@ -262,6 +267,6 @@ def build(
code_maps: dict[str, dict[int, str]] | None, code_maps: dict[str, dict[int, str]] | None,
now: datetime, now: datetime,
) -> BuildSummary: ) -> BuildSummary:
"""写出假交付包和配置,跑一次构建。""" """写出假交付包和配置,跑一次不装 CEUS 结果的构建。"""
delivery_dir = delivery.write(tmp_path / "delivery") delivery_dir = delivery.write(tmp_path / "delivery")
return run_build(write_config(tmp_path, delivery_dir, seed, code_maps), REPO_ROOT, now) return run_build(write_config(tmp_path, delivery_dir, seed, code_maps, None), REPO_ROOT, now)
+220
View File
@@ -0,0 +1,220 @@
"""build 装入 CEUS 结果:C15 跳过、tic.json 只有白名单字段、关键帧去附加数据、运行目录不合规时停下。
预期的 tic.json 键集合照任务书 02 第四阶段的文字手写:四条曲线各自拟合是否成功和拟合优度(R²);
病灶两条曲线的灌注指标(同旧 replay summary 的 perfusion_metrics 段);紫圈曲线是否可用;
7 个槽位各自是否定位到、定位到的视频时间、未定位的原因。
"""
import csv
import json
from datetime import datetime
from pathlib import Path
from typing import Any, cast
import pytest
from PIL import Image
from cadx.answers import read_aliases
from cadx.build import BuildSummary, run_build
from cadx.library import list_aliases, read_ultrasound, scan_for_leaks
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
from fake_delivery import REPO_ROOT, make_delivery, write_config
NOW = datetime(2026, 10, 16, 11, 0, 0).astimezone()
RUN_ID = "20261016-100000-abcdef0"
LOCATED = ("D1", "D2", "F1")
_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:
root = make_delivery(count).write(tmp_path / "delivery")
return run_build(write_config(tmp_path, root, 1, None, run_dir), REPO_ROOT, NOW)
def _skipped(answers_dir: Path) -> list[list[str]]:
with (answers_dir / "skipped.csv").open(encoding="utf-8-sig", newline="") as handle:
return list(csv.reader(handle))[1:]
def _png_chunks(data: bytes) -> list[str]:
chunks: list[str] = []
offset = 8
while offset < len(data):
length = int.from_bytes(data[offset : offset + 4], "big")
chunks.append(data[offset + 4 : offset + 8].decode("ascii"))
offset += 12 + length
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:
run = write_run(
tmp_path / "runs" / RUN_ID,
{"U001": LOCATED, "U004": LOCATED},
[("U002", 2, "两半都没有绿框")],
)
result = _build(tmp_path, 4, run)
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001", "004"]
assert len(list_aliases(result.library_dir)) == 2
skipped = _skipped(result.answers_dir)
assert [row[:4] for row in skipped] == [
["002", "C15", "CEUS 定量结果齐全", f"{RUN_ID}/failed.csv"],
["003", "C15", "CEUS 定量结果齐全", f"{RUN_ID}/U003"],
]
assert json.loads(skipped[0][5]) == "第 2 步失败:两半都没有绿框"
assert "没有这一例的结果目录" in json.loads(skipped[1][5])
assert result.skipped_by_check == {"C15": 2}
manifest = json.loads((result.library_dir / "manifest.json").read_text(encoding="utf-8"))
assert manifest["ceus_source"] == RUN_ID
def test_tic_json_has_exactly_the_listed_fields(tmp_path: Path) -> None:
result = _build(tmp_path, 1, write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, []))
(alias,) = list_aliases(result.library_dir)
tic = read_ultrasound(result.library_dir, alias).tic
perfusion = {
**dict.fromkeys(_PERFUSION_KEYS - {"values", "supporting_times_s"}),
"values": dict.fromkeys(_PERFUSION_VALUES),
"supporting_times_s": dict.fromkeys(("TI", "mTT", "TO")),
}
slot = {"located": None, "video_time_s": None, "reason": None}
curves = ("roi_all", "roi_perf", "roi_pan", "background_circle")
assert _key_tree(tic) == {
"fits": {name: {"success": None, "r2": None} for name in curves},
"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")},
}
assert tic is not None
assert tic["fits"]["roi_all"] == {"success": True, "r2": 0.93}
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
# 假数据里 D1 是第 2 个槽位:视频时间 1 × 2.5 秒。
assert tic["keyframes"]["D1"] == {"located": True, "video_time_s": 2.5, "reason": None}
assert tic["keyframes"]["D4"] == {
"located": False,
"video_time_s": None,
"reason": UNLOCATED_REASON,
}
def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None:
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
result = _build(tmp_path, 1, run)
(alias,) = list_aliases(result.library_dir)
route = read_ultrasound(result.library_dir, alias)
assert sorted(route.keyframes) == sorted(LOCATED)
for slot, copied in route.keyframes.items():
assert (
copied == result.library_dir / "cases" / alias / "ultrasound/keyframes" / f"{slot}.png"
)
with Image.open(run / "U001" / "keyframes" / f"{slot}.png") as original:
assert "Source" in original.info and "exif" in original.info
pixels = original.tobytes()
with Image.open(copied) as image:
assert image.mode == "RGB" and image.tobytes() == pixels
assert "Source" not in image.info and "exif" not in image.info
assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"]
def test_null_ceus_run_has_no_ceus_files(tmp_path: Path) -> None:
root = make_delivery(2).write(tmp_path / "delivery")
result = run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
for alias in list_aliases(result.library_dir):
route = read_ultrasound(result.library_dir, alias)
assert route.tic is None and route.keyframes == {}
assert sorted(
p.name for p in (result.library_dir / "cases" / alias / "ultrasound").iterdir()
) == [
"key.png",
"report.txt",
]
def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> None:
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED, "U002": LOCATED}, [])
result = _build(tmp_path, 2, run)
forbidden = ["P001", "P002", "U001", "U002", "U001_roi2.mp4", "U002_roi2.mp4", "U999"]
assert scan_for_leaks(result.library_dir, forbidden, ("name",)) == []
alias = list_aliases(result.library_dir)[0]
tic_path = result.library_dir / "cases" / alias / "ultrasound" / "tic.json"
tic = json.loads(tic_path.read_text(encoding="utf-8"))
tic["keyframes"]["D4"]["reason"] = "见 U002 第 3 帧"
tic_path.write_text(json.dumps(tic, ensure_ascii=False), encoding="utf-8")
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")]
def test_reason_with_video_id_stops_build(tmp_path: Path) -> None:
run = write_run(tmp_path / "runs" / RUN_ID, {"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:
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED, "U002": LOCATED}, [])
Image.new("I;16", (4, 4)).save(run / "U002" / "keyframes" / "D2.png", format="PNG")
result = _build(tmp_path, 2, run)
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001"]
skipped = _skipped(result.answers_dir)
assert [row[:4] for row in skipped] == [
["002", "C14", "关键图能原样重编码", f"{RUN_ID}/U002/keyframes/D2.png"]
]
def _assert_stops(tmp_path: Path, run: Path, error: type[Exception]) -> None:
with pytest.raises(error):
_build(tmp_path, 1, run)
assert not (tmp_path / "library").exists() and not (tmp_path / "answers").exists()
def test_run_dir_missing_stops(tmp_path: Path) -> None:
_assert_stops(tmp_path, tmp_path / "runs" / RUN_ID, FileNotFoundError)
def test_partial_run_dir_stops(tmp_path: Path) -> None:
run = write_run(tmp_path / "runs" / f"{RUN_ID}.partial", {"U001": LOCATED}, [])
_assert_stops(tmp_path, run, ValueError)
def test_run_dir_without_failed_csv_stops(tmp_path: Path) -> None:
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
(run / "failed.csv").unlink()
_assert_stops(tmp_path, run, FileNotFoundError)
def test_run_dir_without_snapshot_stops(tmp_path: Path) -> None:
run = write_run(tmp_path / "runs" / RUN_ID, {"U001": LOCATED}, [])
(run / "snapshot" / "snapshot.json").unlink()
_assert_stops(tmp_path, run, FileNotFoundError)
+3 -2
View File
@@ -217,7 +217,7 @@ def _png_chunks(data: bytes) -> list[str]:
def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None: def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None:
root = make_delivery(2).write(tmp_path / "delivery") root = make_delivery(2).write(tmp_path / "delivery")
image.save(root / "CEUS_agent" / "002" / "U002_key.png", format="PNG") image.save(root / "CEUS_agent" / "002" / "U002_key.png", format="PNG")
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW) run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None: def _assert_key_image_skipped(tmp_path: Path, reason: str) -> None:
@@ -254,7 +254,7 @@ def test_key_image_palette_source_skips_case(tmp_path: Path) -> None:
def test_key_image_not_png_skips_case(tmp_path: Path) -> None: def test_key_image_not_png_skips_case(tmp_path: Path) -> None:
root = make_delivery(2).write(tmp_path / "delivery") root = make_delivery(2).write(tmp_path / "delivery")
Image.new("RGB", (4, 4)).save(root / "CEUS_agent" / "002" / "U002_key.png", format="JPEG") Image.new("RGB", (4, 4)).save(root / "CEUS_agent" / "002" / "U002_key.png", format="JPEG")
run_build(write_config(tmp_path, root, 1, None), REPO_ROOT, NOW) run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW)
_assert_key_image_skipped(tmp_path, "不是 PNG") _assert_key_image_skipped(tmp_path, "不是 PNG")
@@ -270,6 +270,7 @@ def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None:
ultrasound_report="", ultrasound_report="",
key_image_source=source, key_image_source=source,
mr_report="", mr_report="",
ceus=None,
) )
with pytest.raises(ValueError, match="check_key_image"): with pytest.raises(ValueError, match="check_key_image"):
write_library(target, "b", "c", True, {}, False, (), None, [case]) write_library(target, "b", "c", True, {}, False, (), None, [case])
+14 -2
View File
@@ -28,7 +28,7 @@ def _assert_no_finished_build(tmp_path: Path) -> None:
def _run(tmp_path: Path, root: Path, code_maps: dict[str, dict[int, str]] | None) -> None: def _run(tmp_path: Path, root: Path, code_maps: dict[str, dict[int, str]] | None) -> None:
run_build(write_config(tmp_path, root, 7, code_maps), REPO_ROOT, NOW) run_build(write_config(tmp_path, root, 7, code_maps, None), REPO_ROOT, NOW)
def test_t1_missing_summary_table(tmp_path: Path) -> None: def test_t1_missing_summary_table(tmp_path: Path) -> None:
@@ -111,7 +111,7 @@ def _write_yaml(tmp_path: Path, body: str) -> Path:
return path return path
_BASE = "delivery_dir: d\nlibrary_root: l\nanswers_root: a\nalias_seed: 1\n" _BASE = "delivery_dir: d\nlibrary_root: l\nanswers_root: a\nalias_seed: 1\nceus_run: null\n"
def test_config_decode_true_needs_code_maps(tmp_path: Path) -> None: def test_config_decode_true_needs_code_maps(tmp_path: Path) -> None:
@@ -152,12 +152,24 @@ def test_config_relative_paths_resolve_against_repo_root(tmp_path: Path) -> None
assert config.library_root == Path("/repo/l") assert config.library_root == Path("/repo/l")
assert config.answers_root == Path("/repo/a") assert config.answers_root == Path("/repo/a")
assert config.code_maps is None assert config.code_maps is None
assert config.ceus_run is None
body = _BASE.replace("ceus_run: null", "ceus_run: runs/ceus/r")
path = _write_yaml(tmp_path, body + "decode_codes: false\ncode_maps: null\n")
assert load_build_config(path, Path("/repo")).ceus_run == Path("/repo/runs/ceus/r")
def test_config_ceus_run_missing(tmp_path: Path) -> None:
body = _BASE.replace("ceus_run: null\n", "")
path = _write_yaml(tmp_path, body + "decode_codes: false\ncode_maps: null\n")
with pytest.raises(ValidationError, match="ceus_run"):
load_build_config(path, tmp_path)
def test_committed_config_is_valid() -> None: def test_committed_config_is_valid() -> None:
config = load_build_config(REPO_ROOT / "config" / "build.yaml", REPO_ROOT) config = load_build_config(REPO_ROOT / "config" / "build.yaml", REPO_ROOT)
assert config.decode_codes is False assert config.decode_codes is False
assert config.code_maps is None assert config.code_maps is None
assert config.ceus_run is None
def test_config_duplicate_key(tmp_path: Path) -> None: def test_config_duplicate_key(tmp_path: Path) -> None:
+35 -1
View File
@@ -13,6 +13,7 @@ import csv
import importlib.util import importlib.util
import json import json
from collections.abc import Callable, Mapping, Sequence from collections.abc import Callable, Mapping, Sequence
from datetime import datetime
from pathlib import Path from pathlib import Path
from types import ModuleType from types import ModuleType
from typing import Any from typing import Any
@@ -23,9 +24,12 @@ import numpy.typing as npt
import pytest import pytest
import yaml import yaml
from cadx.answers import read_aliases
from cadx.build import run_build
from cadx.ceus import ANCHOR_SLOTS, LognormalParameters, evaluate_lognormal from cadx.ceus import ANCHOR_SLOTS, LognormalParameters, evaluate_lognormal
from cadx.library import list_aliases, read_ultrasound
from conftest import CEUS_YAML from conftest import CEUS_YAML
from fake_delivery import make_delivery from fake_delivery import REPO_ROOT, make_delivery, write_config
_SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "ceus_quant.py" _SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "ceus_quant.py"
_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200 _WIDTH, _HEIGHT, _SPLIT = 400, 200, 200
@@ -177,6 +181,36 @@ def test_run_writes_outputs_and_failed_case(
assert "U001" not in out and "U002" not in out assert "U001" not in out and "U002" not in out
def test_build_reads_real_run_layout(
tmp_path: Path,
setup: tuple[ModuleType, _CountingSegment, dict[str, Any], Callable[[str], None]],
) -> None:
"""build 读这个脚本真实写出的运行目录:第 1 例带 tic.json 和关键帧进库,第 2 例记 C15。"""
module, _, document, next_run_id = setup
next_run_id("run-a")
module.main(["--config", str(_write_config(tmp_path, document, "ceus.yaml"))])
run_dir = tmp_path / "runs" / "run-a"
delivery_dir = Path(document["paths"]["delivery_dir"])
build_dir = tmp_path / "build"
build_dir.mkdir()
result = run_build(
write_config(build_dir, delivery_dir, 1, None, run_dir),
REPO_ROOT,
datetime(2026, 10, 16, 12, 0, 0).astimezone(),
)
assert [r.case_no for r in read_aliases(result.answers_dir)] == ["001"]
assert result.skipped_by_check == {"C15": 1}
(alias,) = list_aliases(result.library_dir)
route = read_ultrasound(result.library_dir, alias)
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"}
assert set(route.keyframes) == located
assert route.tic is not None
assert set(route.tic) == {"fits", "perfusion_metrics", "pan_curve_usable", "keyframes"}
assert route.tic["keyframes"]["F1"]["video_time_s"] == pytest.approx(15.0, abs=0.1)
assert route.tic["keyframes"]["F2"]["located"] is False
def test_unreadable_video_fails_at_step_1_and_next_case_runs( def test_unreadable_video_fails_at_step_1_and_next_case_runs(
tmp_path: Path, tmp_path: Path,
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,