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:
@@ -8,3 +8,5 @@ alias_seed: 20261016
|
||||
# 编码列是否按映射表解码。映射表还没向临床方拿到,先不解码,code_maps 必须是 null。
|
||||
decode_codes: false
|
||||
code_maps: null
|
||||
# CEUS 定量的运行目录(runs/ceus/<运行号>/),或 null(不装 CEUS 结果)。
|
||||
ceus_run: null
|
||||
|
||||
@@ -153,14 +153,20 @@ runs/ceus/<运行号>/ ────► ② 装配两个库 config/bu
|
||||
|
||||
```
|
||||
library/<构建号>/
|
||||
manifest.json 构建清单:每个文件的 SHA-256、构建时的 git 提交、配置哈希、所用 CEUS 结果的来源
|
||||
manifest.json 构建清单:每个文件的 SHA-256、构建时的 git 提交、配置哈希、所用 CEUS 运行号(没装为 null)
|
||||
cases/<化名>/
|
||||
clinical/clinical.json 临床那一路
|
||||
ultrasound/ 超声那一路:report.txt、key.png、keyframes/*.png、tic.json
|
||||
(后两项等 CEUS 定量落地后由 build 加入,目前只有前两项)
|
||||
ultrasound/ 超声那一路:report.txt、key.png;装了 CEUS 结果时另有
|
||||
keyframes/<槽位>.png 和 tic.json
|
||||
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 |
|
||||
@@ -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)。还没向临床方拿到,尚未建 |
|
||||
| `ceus.yaml` | `scripts/ceus_quant.py` | CEUS 定量的全部参数,每个参数用注释写明出处 |
|
||||
| `consult.yaml` | `scripts/consult.py` | 会诊的实验配置与模型用途 |
|
||||
@@ -339,13 +345,14 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
|
||||
|
||||
### 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 透明色)返回原因。
|
||||
- `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述。
|
||||
- `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述、CEUS 部分(`LibraryCeus` 或 None)。
|
||||
- `LibraryCeus`:一例超声一路的 CEUS 部分:build 挑好的 tic.json 内容、槽位 → 关键帧源图。
|
||||
- `list_aliases(build_dir)`:一次构建的全部化名(按化名排序)。
|
||||
- `read_clinical(build_dir, alias)`、`read_ultrasound(build_dir, alias)`、`read_mr(build_dir, alias)`:按路读出一例,读时核对 SHA-256,拒绝 `.partial` 目录。
|
||||
- `ClinicalRoute`:临床一路:是否已解码、22 列字段。
|
||||
- `UltrasoundRoute`:超声一路:报告「所见」原文、关键图路径。
|
||||
- `UltrasoundRoute`:超声一路:报告「所见」原文、关键图路径、tic.json 的内容(没装 CEUS 时为 None)、槽位 → 关键帧路径(没装时为空)。
|
||||
- `MrRoute`:MR 一路:报告「所见」原文。
|
||||
- `scan_for_leaks(build_dir, forbidden_strings, forbidden_keys)`:在构建目录的全部 `.txt`、`.json` 里找禁止的字符串(NFKC 归一、不分大小写、按子串)和禁止的 JSON 键名,返回命中列表。
|
||||
- `LeakHit`:一处命中:文件(相对构建目录)、类别(「字符串」或「键名」)、命中的词。
|
||||
@@ -390,7 +397,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
|
||||
|
||||
### 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`:供终端打印的计数:构建号、两个库的路径、病例总数、进库数、跳过数、认不出编号的行数、按检查编号分的跳过数、复核清单条数、跳过清单说明句。
|
||||
- `BuildConfig`:`build.yaml` 的字段定义(pydantic,字段必填、不许多写、读入后不可改)。
|
||||
- `load_build_config(path, repo_root)`:读并校验 `build.yaml`,相对路径按仓库根目录解析。
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
- **四类胰腺囊性肿瘤**:IPMN(导管内乳头状黏液性肿瘤)、SPN(实性假乳头状肿瘤)、MCN(黏液性囊性肿瘤)、SCN(浆液性囊性肿瘤)。项目要专家判断病灶属于哪一类,所以资料库里出现这些名字就等于泄露答案。
|
||||
- **金标准**:术后或穿刺的病理结论,交付表里叫「病理类型」或 `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 视频、单例报告表)。
|
||||
- **单例报告表**:每个病例目录里的一份 Excel,只有一行,列与超声汇总表相同(7 列)。它和汇总表重复,用来核对两份是否一致。
|
||||
- **所见、结论段**:一份影像报告通常分「所见」(描述看到了什么)和「印象 / 结论」(医生的诊断倾向)。交付包的报告文字只有所见,这一步检查里面没有混进结论段(C11)。
|
||||
@@ -32,6 +33,7 @@
|
||||
| `alias_seed` | 整数 | 生成化名的随机种子。同一个种子、同一批通过的病例,得到同样的化名 |
|
||||
| `decode_codes` | true / false | 编码列是否按映射表解码 |
|
||||
| `code_maps` | 路径或 null | 映射表位置 |
|
||||
| `ceus_run` | 路径或 null | 某次 CEUS 定量的运行目录(`runs/ceus/<运行号>/`);null 表示不装 CEUS 结果,超声一路只有报告和关键图 |
|
||||
|
||||
相对路径**相对仓库根目录**解析,不相对配置文件所在目录,也不相对你敲命令时所在的目录。路径不能写空串(空串会变成当前目录)。
|
||||
|
||||
@@ -43,6 +45,10 @@
|
||||
|
||||
为什么做成开关:映射表还没从临床方拿到,不能编造含义;先用 false 跑通,映射表到位后改成 true 重新构建一次(负责人 2026-10-16:解码做成可选开关,映射表是独立的配置文件)。诊断编码和其他编码列一样归这个开关管;诊断不进资料库,所以开关对诊断只影响答案库。
|
||||
|
||||
### ceus_run
|
||||
|
||||
指向一次**跑完的** CEUS 运行目录。目录不存在、名字以 `.partial` 结尾(那次运行没跑完)、缺 `failed.csv` 或 `snapshot/snapshot.json`,整次构建在建目录之前停下。目录合规时,按视频号找每例的结果,找不到或 CEUS 在这一例上失败的按 C15 跳过(第四节)。
|
||||
|
||||
### 映射表的格式
|
||||
|
||||
映射表目前**还没有**(`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/<构建号>
|
||||
```
|
||||
|
||||
它从答案库读出这次构建的真实病例号和视频号,加上脚本里独立写的亚型名、诊断列名、交付文件名词表,扫资料库的全部 `.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
|
||||
ultrasound/report.txt
|
||||
ultrasound/key.png
|
||||
ultrasound/keyframes/<槽位>.png (ceus_run 不为 null 时;只有定位到的槽位)
|
||||
ultrasound/tic.json (ceus_run 不为 null 时)
|
||||
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 |
|
||||
| `ultrasound/report.txt` | 超声报告的「所见」原文,UTF-8,不加标题 |
|
||||
| `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 报告的「所见」原文 |
|
||||
|
||||
性别、年龄只在临床一路:两份报告表里医院也复制了这两列,但影像两路先看到会被人口学先验带偏(`结构.md` 第四部分)。以后 CEUS 定量落地,超声一路再加关键帧和灌注指标。
|
||||
性别、年龄只在临床一路:两份报告表里医院也复制了这两列,但影像两路先看到会被人口学先验带偏(`结构.md` 第四部分)。
|
||||
|
||||
读资料库要经 `cadx.library` 的 `read_clinical`、`read_ultrasound`、`read_mr`:读时逐个核对文件的 SHA-256 与清单一致,被改过就报错。
|
||||
|
||||
@@ -147,18 +157,18 @@ CSV 用带 BOM 的 UTF-8(文件开头有一个不可见的标记),医生
|
||||
交付资料里的问题分两种(`AGENTS.md`「错误怎么处理」):
|
||||
|
||||
- **整张表的问题**(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 的列
|
||||
|
||||
| 列 | 含义 |
|
||||
| --- | --- |
|
||||
| 病例 | 三位病例号。C01(编号认不出)的行不知道是哪个病人,写成「表的路径 第 N 行」(N 是 Excel 行号,第 1 行是表头);病例目录名不在 1–999 之间时写成「病例目录 目录名」 |
|
||||
| 检查编号 | C01–C14 |
|
||||
| 检查编号 | C01–C15 |
|
||||
| 检查说明 | 这项检查的一句话说明 |
|
||||
| 文件 | 涉及的文件,相对交付包根目录,多个用「; 」隔开 |
|
||||
| 文件 | 涉及的文件,相对交付包根目录,多个用「; 」隔开;C15 和关键帧图的 C14 相对 CEUS 运行的上级目录(以运行号开头) |
|
||||
| 列 | 涉及的列,多个用「; 」隔开;不涉及具体列(如目录里的文件不对)时为空 |
|
||||
| 涉及值 | 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 | 报告文字里没有结论段标题:段首(全文开头或换行之后)出现「印象」「结论」「提示」等关键词加冒号 | 跳过这一例 |
|
||||
| C12 | 报告文字里没有亚型名(缩写、中文名及其常见变体) | 跳过这一例 |
|
||||
| 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` 定 | 跳过这一例 |
|
||||
|
||||
几点说明:
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
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.delivery import (
|
||||
CODED_COLUMNS,
|
||||
@@ -25,7 +26,7 @@ from cadx.delivery import (
|
||||
read_delivery,
|
||||
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
|
||||
|
||||
ALIAS_ALPHABET = string.ascii_lowercase + string.digits
|
||||
@@ -104,6 +105,35 @@ def _check_key_images(
|
||||
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:
|
||||
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:
|
||||
"""读配置和交付包,装配一对同号的资料库与答案库。`now` 是本地时间,定构建号。"""
|
||||
"""读配置和交付包(`ceus_run` 不为 null 时还读那次 CEUS 运行),装配一对同号的资料库与答案库。
|
||||
|
||||
`now` 是本地时间,定构建号。
|
||||
"""
|
||||
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
|
||||
delivery = read_delivery(config.delivery_dir, code_maps)
|
||||
passed, key_image_skips = _check_key_images(delivery.cases, config.delivery_dir)
|
||||
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)
|
||||
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()},
|
||||
codes_decoded=code_maps is not None,
|
||||
uncompared=delivery.uncompared,
|
||||
ceus_source=None,
|
||||
ceus_source=None if ceus_run is None else ceus_run.run_id,
|
||||
cases=[
|
||||
LibraryCase(
|
||||
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,
|
||||
key_image_source=case.key_image,
|
||||
mr_report=case.mr_report,
|
||||
ceus=ceus_of[case.case_no],
|
||||
)
|
||||
for case in by_alias
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
@@ -54,6 +54,8 @@ class BuildConfig(BaseModel):
|
||||
alias_seed: int
|
||||
decode_codes: bool
|
||||
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")
|
||||
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,
|
||||
"answers_root": repo_root / config.answers_root,
|
||||
"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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -13,12 +13,13 @@ from cadx.library._read import (
|
||||
read_ultrasound,
|
||||
)
|
||||
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__ = [
|
||||
"ClinicalRoute",
|
||||
"LeakHit",
|
||||
"LibraryCase",
|
||||
"LibraryCeus",
|
||||
"MrRoute",
|
||||
"UltrasoundRoute",
|
||||
"check_key_image",
|
||||
|
||||
@@ -8,8 +8,10 @@ from typing import Any, cast
|
||||
from cadx.library._write import (
|
||||
CLINICAL_JSON,
|
||||
KEY_IMAGE,
|
||||
KEYFRAMES_DIR,
|
||||
MANIFEST,
|
||||
MR_REPORT,
|
||||
TIC_JSON,
|
||||
ULTRASOUND_REPORT,
|
||||
case_file,
|
||||
sha256_file,
|
||||
@@ -26,10 +28,13 @@ class ClinicalRoute:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UltrasoundRoute:
|
||||
"""超声一路:报告「所见」原文和医生截的关键图(给路径)。"""
|
||||
"""超声一路:报告「所见」原文、医生截的关键图(给路径);装了 CEUS 结果时还有 tic.json 的内容和
|
||||
定位到的关键帧(槽位 → 路径),没装时 `tic` 为 None、`keyframes` 为空。"""
|
||||
|
||||
report: str
|
||||
key_image: Path
|
||||
tic: dict[str, Any] | None
|
||||
keyframes: dict[str, Path]
|
||||
|
||||
|
||||
@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:
|
||||
"""读一例的超声一路。"""
|
||||
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:
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
cases/<化名>/clinical/clinical.json
|
||||
cases/<化名>/ultrasound/report.txt
|
||||
cases/<化名>/ultrasound/key.png
|
||||
cases/<化名>/ultrasound/keyframes/<槽位>.png (装了 CEUS 结果时;只有定位到的槽位)
|
||||
cases/<化名>/ultrasound/tic.json (装了 CEUS 结果时)
|
||||
cases/<化名>/mr/report.txt
|
||||
```
|
||||
"""
|
||||
@@ -22,6 +24,8 @@ MANIFEST = "manifest.json"
|
||||
CLINICAL_JSON = "clinical/clinical.json"
|
||||
ULTRASOUND_REPORT = "ultrasound/report.txt"
|
||||
KEY_IMAGE = "ultrasound/key.png"
|
||||
KEYFRAMES_DIR = "ultrasound/keyframes"
|
||||
TIC_JSON = "ultrasound/tic.json"
|
||||
MR_REPORT = "mr/report.txt"
|
||||
|
||||
_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)})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LibraryCeus:
|
||||
"""超声一路的 CEUS 部分:`tic` 由 build 按白名单挑好 写成 tic.json;槽位 → 关键帧源图。"""
|
||||
|
||||
tic: Mapping[str, object]
|
||||
keyframe_sources: Mapping[str, Path]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LibraryCase:
|
||||
"""要写进资料库的一例:三路各自的内容。"""
|
||||
"""要写进资料库的一例:三路各自的内容。`ceus` 为 None 表示这次构建不装 CEUS 结果。"""
|
||||
|
||||
alias: str
|
||||
clinical_fields: Mapping[str, str | int | float | None]
|
||||
ultrasound_report: str
|
||||
key_image_source: Path
|
||||
mr_report: str
|
||||
ceus: LibraryCeus | None
|
||||
|
||||
|
||||
def case_file(alias: str, relative: str) -> str:
|
||||
@@ -98,9 +111,14 @@ def write_library(
|
||||
ceus_source: str | None,
|
||||
cases: Sequence[LibraryCase],
|
||||
) -> None:
|
||||
"""把一次构建写进已经存在的空目录 `build_dir`,最后写清单。"""
|
||||
"""把一次构建写进已经存在的空目录 `build_dir`,最后写清单。
|
||||
|
||||
`ceus_source` 是所用 CEUS 运行号,为 None 时每例的 `ceus` 都必须是 None,反之都不能是 None。
|
||||
"""
|
||||
files: dict[str, str] = {}
|
||||
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
|
||||
for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, MR_REPORT):
|
||||
(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")
|
||||
_write_clean_png(case.key_image_source, case_dir / KEY_IMAGE)
|
||||
(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)
|
||||
_write_json(
|
||||
build_dir / MANIFEST,
|
||||
|
||||
@@ -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
|
||||
@@ -235,7 +235,11 @@ def _write_png(path: Path, number: int) -> None:
|
||||
|
||||
|
||||
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:
|
||||
"""在 tmp_path 写一份 build.yaml(路径都写绝对路径);给了映射表就同时写 code_maps.yaml。"""
|
||||
maps_path = None
|
||||
@@ -249,6 +253,7 @@ def write_config(
|
||||
"alias_seed": seed,
|
||||
"decode_codes": code_maps is not None,
|
||||
"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.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,
|
||||
now: datetime,
|
||||
) -> BuildSummary:
|
||||
"""写出假交付包和配置,跑一次构建。"""
|
||||
"""写出假交付包和配置,跑一次不装 CEUS 结果的构建。"""
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
@@ -217,7 +217,7 @@ def _png_chunks(data: bytes) -> list[str]:
|
||||
def _build_with_key_image(tmp_path: Path, image: Image.Image) -> None:
|
||||
root = make_delivery(2).write(tmp_path / "delivery")
|
||||
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:
|
||||
@@ -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:
|
||||
root = make_delivery(2).write(tmp_path / "delivery")
|
||||
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")
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@ def test_write_library_rejects_unchecked_key_image(tmp_path: Path) -> None:
|
||||
ultrasound_report="",
|
||||
key_image_source=source,
|
||||
mr_report="",
|
||||
ceus=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="check_key_image"):
|
||||
write_library(target, "b", "c", True, {}, False, (), None, [case])
|
||||
|
||||
@@ -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:
|
||||
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:
|
||||
@@ -111,7 +111,7 @@ def _write_yaml(tmp_path: Path, body: str) -> 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:
|
||||
@@ -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.answers_root == Path("/repo/a")
|
||||
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:
|
||||
config = load_build_config(REPO_ROOT / "config" / "build.yaml", REPO_ROOT)
|
||||
assert config.decode_codes is False
|
||||
assert config.code_maps is None
|
||||
assert config.ceus_run is None
|
||||
|
||||
|
||||
def test_config_duplicate_key(tmp_path: Path) -> None:
|
||||
|
||||
@@ -13,6 +13,7 @@ import csv
|
||||
import importlib.util
|
||||
import json
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
@@ -23,9 +24,12 @@ import numpy.typing as npt
|
||||
import pytest
|
||||
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.library import list_aliases, read_ultrasound
|
||||
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"
|
||||
_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
|
||||
|
||||
|
||||
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(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user