feat(review): 医生核对包入口;CEUS 运行目录的读取收进 runs

- 新模块 review(组织层,只依赖 library、answers、runs):把一次构建和它装入的
  CEUS 运行整理成医生可读的文件夹(CSV 带 BOM、说明与报告转 PDF、图片视频逐字节拷贝),
  入口 scripts/review_pack.py + config/review_pack.yaml。任务书 03。
- 读 CEUS 运行目录(failed.csv、结果目录、关键帧文件)从 build 挪进 runs,build 与
  review 都经它读;failed.csv 读取在边界上收严(步号须为整数、视频号不重复、列数固定)。
- library、answers 补只读函数;import-linter 增 review 契约。
- 文档:guides/医生核对包.md(过一轮硕士生阅读),结构.md 第三至八部分同步。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 06:04:19 -04:00
parent 14d6f8e153
commit c7373efde2
23 changed files with 1718 additions and 83 deletions
+7
View File
@@ -0,0 +1,7 @@
# 出医生核对包(scripts/review_pack.py)的配置。字段定义与校验见 src/cadx/review/_config.py,
# 每个字段必填;相对路径相对仓库根目录解析。
# 某次构建的资料库和答案库,两者目录名(构建号)必须相同。用到的 CEUS 运行从资料库清单读,不在这里配。
library_dir: library/20261016-094248-1de88a5-dirty
answers_dir: answers/20261016-094248-1de88a5-dirty
# 每次出包在这里新建一个运行目录。
output_root: runs/review_pack
+18 -6
View File
@@ -88,6 +88,7 @@ name = "cadx 里只有 segment 引用 torch、sam2"
type = "forbidden"
source_modules = [
"cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build", "cadx.ceus",
"cadx.review",
]
forbidden_modules = ["torch", "sam2"]
@@ -97,7 +98,7 @@ name = "segment 不引用其他 cadx 模块"
type = "forbidden"
source_modules = ["cadx.segment"]
forbidden_modules = [
"cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build",
"cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build", "cadx.review",
"cadx.ceus", "cadx.llm", "cadx.ledger", "cadx.consult", "cadx.evaluate", "cadx.verdicts",
]
@@ -113,7 +114,7 @@ name = "delivery、library、answers、runs 不引用其他 cadx 模块"
type = "forbidden"
source_modules = ["cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs"]
forbidden_modules = [
"cadx.build", "cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger",
"cadx.build", "cadx.review", "cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger",
"cadx.consult", "cadx.evaluate", "cadx.verdicts",
]
@@ -123,7 +124,7 @@ name = "ceus 不引用其他 cadx 模块"
type = "forbidden"
source_modules = ["cadx.ceus"]
forbidden_modules = [
"cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build",
"cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build", "cadx.review",
"cadx.segment", "cadx.llm", "cadx.ledger", "cadx.consult", "cadx.evaluate", "cadx.verdicts",
]
@@ -133,16 +134,27 @@ name = "build 只引用 delivery、library、answers、runs"
type = "forbidden"
source_modules = ["cadx.build"]
forbidden_modules = [
"cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger",
"cadx.review", "cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger",
"cadx.consult", "cadx.evaluate", "cadx.verdicts",
]
allow_indirect_imports = true
[[tool.importlinter.contracts]]
# 任务书 03:review(医生核对包)只读已构建好的两个库和 CEUS 运行,只依赖 library、answers、runs,
# 不碰交付包(delivery)、不依赖 build 和 ceus。
name = "review 只引用 library、answers、runs"
type = "forbidden"
source_modules = ["cadx.review"]
forbidden_modules = [
"cadx.delivery", "cadx.build", "cadx.ceus", "cadx.segment", "cadx.llm", "cadx.ledger",
"cadx.consult", "cadx.evaluate", "cadx.verdicts",
]
[[tool.importlinter.contracts]]
# 结构.md 第五部分:交付包的 Excel 只有 delivery 碰。
name = "只有 delivery 引用 openpyxl"
type = "forbidden"
source_modules = ["cadx.library", "cadx.answers", "cadx.runs", "cadx.build"]
source_modules = ["cadx.library", "cadx.answers", "cadx.runs", "cadx.build", "cadx.review"]
forbidden_modules = ["openpyxl"]
allow_indirect_imports = true
@@ -150,6 +162,6 @@ allow_indirect_imports = true
# 结构.md 第五部分:写资料库的图片(去掉元数据)只有 library 碰。
name = "只有 library 引用 PIL"
type = "forbidden"
source_modules = ["cadx.delivery", "cadx.answers", "cadx.runs", "cadx.build"]
source_modules = ["cadx.delivery", "cadx.answers", "cadx.runs", "cadx.build", "cadx.review"]
forbidden_modules = ["PIL"]
allow_indirect_imports = true
+37 -14
View File
@@ -98,10 +98,11 @@ runs/ceus/<运行号>/ ────► ② 装配两个库 config/bu
| 层 | 模块 | 藏住的决定 | 旧项目里的对应内容 |
| --- | --- | --- | --- |
| 入口 | `scripts/`(不在包内) | 人怎么操作:每段一个脚本(`ceus_quant`、`build`、`consult`、`evaluate`),另有一个按顺序调用四段的总入口;读配置文件,把模块装配起来 | `debugs/t1_local_debug.py`、`debugs/tic_lognormal_replay.py` |
| 入口 | `scripts/`(不在包内) | 人怎么操作:每段一个脚本(`ceus_quant`、`build`、`consult`、`evaluate`),另有一个按顺序调用四段的总入口,以及出医生核对包的 `review_pack`;读配置文件,把模块装配起来 | `debugs/t1_local_debug.py`、`debugs/tic_lognormal_replay.py` |
| 组织 | build | 两个库怎么装配:每一路放哪些内容(可见性的唯一决定处)、化名怎么取(化名随机,资料库按化名排序,顺序就与交付顺序无关)、清单记什么。全项目只有它同时碰得到原始资料和答案 | `app/domain/input_readers.py`、`ingestion*.py` 的装配部分 |
| 组织 | consult | 会诊怎么进行:几个专家、谁先说、后发言的看得到前面的什么、什么时候结束;「调一次模型」的统一入口(先存请求、记开始、调用、存回复、记结束)也在这里 | 没有 |
| 组织 | evaluate | 评价口径:指标、统计方法、字段级对照怎么计分 | 没有(`core/eval` 已删) |
| 组织 | review | 给医生的核对包长什么样:目录布局、每个文件的格式(表格 CSV、文字 PDF、图和视频原样拷贝)、问题清单的来源与写法、说明模板。只读已构建好的两个库和那次 CEUS 运行,不写它们、不碰交付包 | 没有 |
| 格式 | library | 给专家的资料库的目录布局、文件格式和读取;按路分别读出,一路一个对象 | `app/domain/input_packages.py` 的数据形状 |
| 格式 | answers | 标准答案库的目录布局、文件格式和读取 | 旧库里的金标准表 |
| 格式 | verdicts | 会诊产物(各路意见、会诊结论)的文件格式和读取;consult 写、evaluate 读 | 没有 |
@@ -109,7 +110,7 @@ runs/ceus/<运行号>/ ────► ② 装配两个库 config/bu
| 外部 | segment | 用哪个分割模型、权重在哪、怎么在显卡上把提示传播成逐帧掩码 | `debugs/medsam2_local_track.py`、`roi_inference/` 的后端 |
| 外部 | llm | 经 PolyGateway(实验室的模型调用库,管外部错误的重试和超时)调模型;交出服务商返回的精确版本号、响应编号、用量、结束原因。只管调用,不写账本 | `app/providers/model_gateway_config.py`(只借思路);ClinDB 的 `llm/` |
| 外部 | ledger | 账本怎么存:SQLite 只追加、大块内容按哈希存成文件、同一目录同时只许一个进程写、找出开始了没结束的调用 | 没有;ClinDB 的 `ledger/` |
| 外部 | runs | 运行目录怎么命名、运行快照记什么(git 提交号、配置、所用构建、提示词哈希、模型版本、随机种子) | 没有 |
| 外部 | runs | 运行目录怎么命名、运行快照记什么(git 提交号、配置、所用构建、提示词哈希、模型版本、随机种子);一次 CEUS 定量运行目录(`runs/ceus/<运行号>/`)的布局和读取,build 和 review 都经它读 | 没有 |
| 纯计算 | ceus | CEUS 定量的全部算法和参数:分屏、绿框、坐标映射、多边形、暗区、TIC 采样、Lognormal 拟合、关键帧、灌注指标、出图;视频解码也在这里 | `app/domain/ceus_roi/`(除 `service.py`)、`app/providers/video_decoder.py` |
暂不设共用词汇模块。一样东西要被两个互相不能引用的模块用到,才另立一处放它;目前跨模块的类型各有归属(资料库格式归 library、答案格式归 answers、会诊产物格式归 verdicts)。配置的字段定义和校验归使用它的模块:CEUS 参数归 ceus(旧 `research_config.py`),会诊的实验配置归 consult;每个字段必填,不补默认值(`AGENTS.md`「不设默认值」)。
@@ -118,7 +119,7 @@ runs/ceus/<运行号>/ ────► ② 装配两个库 config/bu
```
入口 scripts/
组织 build consult evaluate
组织 build consult evaluate review
格式 library answers verdicts
外部 delivery segment llm ledger runs
纯计算 ceus
@@ -133,6 +134,7 @@ runs/ceus/<运行号>/ ────► ② 装配两个库 config/bu
| build | delivery、ceus(读 CEUS 结果的类型)、library、answers、runs |
| consult | library、verdicts、llm、ledger、runs |
| evaluate | answers、verdicts、runs |
| review | library、answers、runs |
| scripts/ | 任何模块(不在导入检查范围内,见第五部分) |
### 几条边界
@@ -142,6 +144,8 @@ runs/ceus/<运行号>/ ────► ② 装配两个库 config/bu
- **三路互相隔开。** 每个专家的提示词只用自己那一路的对象拼。consult 里拼某一路提示词的函数只接收那一路的对象,不接收整个病例。这是研究设计本身:模拟各科医生各看各的检查结果,再在会诊时交换意见。会诊时一路能看到另一路的什么,由 consult 的会诊流程决定,在会诊的任务书里定。
- **我们自己调模型只有一个入口:consult 的「调一次模型」。** 它按固定顺序做五步:把完整请求存进账本的大块存储、写「调用开始」、经 llm 调用、把完整回复存进大块存储、写「调用结束」。llm 只管调用,不写账本,所以任何一次模型调用都绕不开登记。中断后重启时,从账本找出开始了没结束的调用,记为「状态未知」,不重做已有结果的调用。做重复运行(同一病例、同一专家调多次,测一致性)时,每一次都恰好登记一次。
- **CEUS 算法不知道分割模型是谁,也不管中间文件放哪。** ceus 接收视频路径、参数和分割结果,交出曲线、拟合、关键帧和指标;中间文件写在哪、按什么复用,由 `scripts/ceus_quant.py` 决定(目前只有它一个使用者,出现第二个使用者再收进模块)。segment 只管「给提示、出逐帧掩码」,不认识分屏和绿框。
- **CEUS 运行目录只有 runs 认得。** `failed.csv`、`summary.json`、关键帧、TIC 曲线图、回放视频各在运行目录的哪里,由 runs 读出;build 和 review 都经它读,不各自拼路径(任务书 03:出现第二个使用者,就把读取收进模块)。`summary.json` 里挑哪些字段给专家仍只在 build 决定。
- **核对包只读不写。** review 读某次构建的 `library/`、`answers/` 和资料库记的那次 CEUS 运行,输出只写进 `runs/review_pack/<运行号>/`;不写两个库、不读交付包。核对包里有答案和真实病例号,是给医生的,不是给会诊的。
- **医生的判断不进 CEUS 定量。** 医生标注工作簿里的时刻(如造影剂到达时间 AT)属于标准答案库,ceus 和 build 都不读它;关键帧全部由程序自己算或按推注时间取(design 0002 决定 6)。交付视频里医生画的绿框是 CEUS 定量的输入,它能否作为输入、在最终病灶轮廓里占多大分量,记在 `../待定问题/01-医生画的绿框能否作为输入.md`。
- **评价只读文件,不重新调模型。** evaluate 读 `runs/` 里的会诊产物(经 verdicts)和 `answers/`,改指标或统计方法不需要重跑会诊。
@@ -192,6 +196,10 @@ library/<构建号>/
- 跳过清单:没进资料库的病例、没过的那一项检查、涉及的文件和列,以及对不上的各表原值,供医生核对。40 例里已知有 6 例临床表与超声表对不上(`../待定问题/04-交付数据的跨表不一致.md`),会出现在这份清单里;
- 将来加医生标注工作簿。
### 医生核对包(`runs/review_pack/<运行号>/`)
把一次构建的两个库和它装入的那次 CEUS 运行,整理成医生不用写代码就能看的一个文件夹(表格 CSV、文字 PDF、图和视频逐字节拷贝),由负责人打包交给临床方核对我们的处理结果。病例目录用医生的病例号,问题清单只写程序观察到的现象;包里有真实病例号、报告原文和诊断,只在临床方与我们之间传递。它不是流水线的一段:不产出任何后续段要读的东西,医生每交一版数据、跑完 CEUS 定量和装配之后出一次。包内布局和每个文件的格式见 `../任务书/03-医生核对包.md` 和包里的 `说明.pdf`,怎么跑见 `../guides/医生核对包.md`。
### CEUS 定量的产物(`runs/ceus/` 与 `work/`)
每次运行的产物写进 `runs/ceus/<运行号>/`,只追加;全量运行之前的配准目视核对写进 `runs/ceus_align/<运行号>/`(图、`key.csv`、判读、`review.csv`),ceus_quant 按 `review.csv` 拦下核对没通过的病例;第 3 步的分割结果按下表的键缓存在 `work/`,可以删掉重算。具体布局见 `../任务书/02-CEUS定量.md`。
@@ -240,7 +248,7 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
| `data/` | 医院交付的原始资料 | 否 |
| `library/`、`answers/` | 两个库,每次构建一个目录 | 否 |
| `work/` | 可以删掉重算的中间文件,目前只有分割缓存 | 否 |
| `runs/` | CEUS 定量、会诊与评价的运行,只追加不删 | 否 |
| `runs/` | CEUS 定量、医生核对包、会诊与评价的运行,只追加不删 | 否 |
| `models/` | 模型权重 | 否 |
| `reference/`、`reference-pdf/` | 旧项目副本、外部代码、文献原文 | 否 |
@@ -255,6 +263,7 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
| `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`、`scripts/ceus_align_review.py` | CEUS 定量的全部参数和配准目视核对的出图设置,每个参数用注释写明出处 |
| `review_pack.yaml` | `scripts/review_pack.py` | `library_dir`、`answers_dir`(同一次构建的两个库,构建号必须相同)、`output_root`(核对包运行目录的上级);用哪次 CEUS 运行从资料库清单读,不另配;字段定义和校验在 `src/cadx/review/_config.py`,逐项说明见 `../guides/医生核对包.md` |
| `consult.yaml` | `scripts/consult.py` | 会诊的实验配置与模型用途 |
| `evaluate.yaml` | `scripts/evaluate.py` | 评价的指标与统计设置 |
@@ -270,16 +279,17 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
文件格式:数组用 npz(读取时 `allow_pickle=False`,pickle 读取时会执行任意代码),每例的标量和摘要用 json,跨病例的表用 csv,图用 png。缺失值写 null,不写 NaN 或 0:0 是合法的测量值,NaN 在 json 里不合法。
导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 8 条),没有的保护不写:
导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 9 条),没有的保护不写:
- `cadx` 里只有 segment 引用 `torch`、`sam2`(契约逐个列出其余已落地的模块,新模块落地时加进去)。
- segment 不引用其他 `cadx` 模块。
- delivery、library、answers、runs 互不引用。
- delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。
- delivery、library、answers、runs 不引用其他 `cadx` 模块(build、review 和规划中的各模块)。
- ceus 不引用其他 `cadx` 模块(含 segment:分割结果由 `scripts/ceus_quant.py` 传进来)。
- build 不引用 delivery、library、answers、runs 以外的 `cadx` 模块(ceus 落地时放行)。
- review 不引用 library、answers、runs 以外的 `cadx` 模块(不碰交付包,不依赖 build 和 ceus)。
- 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。
- 只有 library 引用 `PIL`(写资料库的图片时去掉元数据)。
- 只有 library 引用 `PIL`(写资料库的图片时去掉元数据;review 拷图片只按字节拷,不打开)。
第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。
@@ -302,16 +312,17 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
| ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | 7103 | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 |
| segment | 150–300 | 300 | `medsam2_local_track.py` 240 |
| delivery | 400–700 | 739 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 |
| library | 150–300 | 354 | `input_packages.py` 104 |
| answers | 100–200 | 201 | — |
| library | 150–300 | 377 | `input_packages.py` 104 |
| answers | 100–200 | 227 | — |
| verdicts | 100–200 | — | — |
| build | 300–500 | 564(含装入 CEUS 结果的 `_ceus.py`) | `input_readers.py` 329 的装配部分 |
| build | 300–500 | 535(含装入 CEUS 结果的 `_ceus.py`;读 CEUS 运行目录已挪进 runs) | `input_readers.py` 329 的装配部分 |
| llm | 200–450 | — | ClinDB `llm/` 415 |
| ledger | 200–300 | — | ClinDB `ledger/` 274 |
| runs | 100–250 | 109(只含快照部分) | — |
| runs | 100–250 | 202(快照 `_snapshot.py` 98、读 CEUS 运行的 `_ceus_run.py` 83、`__init__.py` 21) | — |
| consult | 500–1000(会诊口径定后重估) | — | — |
| evaluate | 300–600(评价口径定后重估) | — | 已删的 `core/eval` 622 |
| scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88、`ceus_quant.py` 403(超出,含目视核对表的读取与核对、开跑即写快照)、`ceus_align_review.py` 243 | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 |
| review | 300–500 | 587(`_pack.py` 395、`_pdf.py` 118、`_config.py` 64、`__init__.py` 10;不含说明模板 `readme_template.md` 202 行) | — |
| scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88、`ceus_quant.py` 403(超出,含目视核对表的读取与核对、开跑即写快照)、`ceus_align_review.py` 243、`review_pack.py` 37 | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 |
---
@@ -351,6 +362,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
- `LibraryCase`:要写进资料库的一例:化名、临床一路的字段、超声描述、关键图源文件、MR 描述、CEUS 部分(`LibraryCeus` 或 None)。
- `LibraryCeus`:一例超声一路的 CEUS 部分:build 挑好的 tic.json 内容、槽位 → 关键帧源图。
- `list_aliases(build_dir)`:一次构建的全部化名(按化名排序)。
- `read_build_info(build_dir)`:读构建清单的概况,交出 `BuildInfo`:构建号、编码列是否解码、装入的 CEUS 运行号(没装为 None)。
- `read_clinical(build_dir, alias)`、`read_ultrasound(build_dir, alias)`、`read_mr(build_dir, alias)`:按路读出一例,读时核对 SHA-256,拒绝 `.partial` 目录。
- `ClinicalRoute`:临床一路:是否已解码、22 列字段。
- `UltrasoundRoute`:超声一路:报告「所见」原文、关键图路径、tic.json 的内容(没装 CEUS 时为 None)、槽位 → 关键帧路径(没装时为空)。
@@ -365,15 +377,19 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
- `DiagnosisRow`:化名、病例号、三张表的诊断编码、诊断名(不解码时为 None)。
- `SkippedRow`:跳过清单的一行,字段同 `SkipRecord`。
- `ReviewRow`:复核清单的一行:化名、路(「超声」或「MR」)、命中的词、所在句子。
- `read_aliases(answers_dir)`、`read_diagnoses(answers_dir)`:评价用的读取,读时核对 SHA-256 和表头,拒绝 `.partial` 目录。
- `read_aliases(answers_dir)`、`read_diagnoses(answers_dir)`、`read_skipped(answers_dir)`:读化名对照、诊断、跳过清单,读时核对 SHA-256 和表头,拒绝 `.partial` 目录;跳过清单的文件和列两栏拆回元组。
- `verified_file(answers_dir, name)`:答案库里某个文件(如 `snapshot/build.yaml`)的路径,先核对它与清单的 SHA-256 一致;给需要原样拷贝或读快照的使用者(review)用。
### runs(`src/cadx/runs/`,目前只有快照部分)
### runs(`src/cadx/runs/`)
- `read_git_state(repo_root)`:读出完整和短提交号、工作区是否干净(含未跟踪文件)、`git diff HEAD` 输出的 SHA-256。
- `GitState`:上面这四项。
- `make_build_id(now, git)`:构建号 `YYYYMMDD-HHMMSS-<git 短哈希>`,工作区不干净时末尾加 `-dirty`。
- `write_snapshot(snapshot_dir, build_id, git, started_at, uv_lock, configs)`:开跑时调。新建快照目录,复制配置原文并写 `snapshot.json`(构建号、git 状态、Python 版本、`uv.lock` 哈希、开始时间、各配置的哈希)。运行要跑很久时(CEUS 定量约一小时)在开跑时写,期间改了配置文件,快照里仍是这次用的那份。
- `finish_snapshot(snapshot_dir, finished_at, record)`:收尾时调。在快照目录里新建 `finished.json`:结束时间加 `record`(运行结束才知道的值,如显存峰值、缓存命中数);已存在就报错,不改开跑时写的文件。
- `open_ceus_run(run_dir)`:打开一次已完成的 CEUS 定量运行:核对目录存在、不是 `.partial`、有 `snapshot/snapshot.json`,读快照里的 `ceus.yaml` 和 `failed.csv`(表头、步号、视频号不重复),交出 `CeusRun`。
- `CeusRun`:运行号、目录、快照里的整份 `ceus.yaml`、`failed`(视频号 → `CeusFailure`);按视频号取文件:`has_result`(有没有结果目录)、`summary`(`summary.json`,核对里面记的视频号与目录一致)、`keyframes`(槽位 → 关键帧图路径)、`tic_png`、`overlay`。
- `CeusFailure`:`failed.csv` 的一行:失败在第几步、原因原文。
### ceus(`src/cadx/ceus/`)
@@ -413,3 +429,10 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
- `load_build_config(path, repo_root)`:读并校验 `build.yaml`,相对路径按仓库根目录解析。
- `load_code_maps(path)`:读映射表并做 T5(恰好覆盖 17 项,性别映射到「男」「女」,诊断映射到四个亚型缩写)。
- `make_aliases(case_nos, seed)`:病例号排序后依次抽 4 位化名,同种子同病例得到同样的化名。
### review(`src/cadx/review/`)
- `run_review_pack(config_path, repo_root, now)`:出一次医生核对包:读配置,经 library、answers 读这次构建,经 runs 打开资料库记的那次 CEUS 运行(路径取自答案库快照里的 `build.yaml`,运行号须与资料库清单一致),先写进 `<运行号>.partial/` 再改名,交出 `PackSummary`。`now` 是本地时间,定运行号。
- `PackSummary`:供终端打印的计数:运行目录、核对包目录、病例数(进库、跳过)、按环节分的问题清单行数、PDF 份数、拷贝的视频总字节数;不含病例号。
- `ReviewPackConfig`:`review_pack.yaml` 的字段定义(pydantic,字段必填、不许多写、读入后不可改)。
- `load_review_config(path, repo_root)`:读并校验 `review_pack.yaml`,相对路径按仓库根目录解析;两个库的目录名(构建号)不同就报错。
+123
View File
@@ -0,0 +1,123 @@
# 医生核对包
医生核对包是一个文件夹,交给临床方的医生,请他们核对我们对交付资料的处理结果。医生不用写代码就能打开:表格是 CSV,文字是 PDF,图和视频原样拷贝。本文写给出包的人:什么时候跑、怎么跑、产物在哪、怎么交出去、报错了怎么办。为什么做、包里每个文件的格式,见 `../任务书/03-医生核对包.md`;它在代码里的位置见 `../explanation/结构.md` 第三、四部分。
## 先认识几个词
- **病例号**:交付包里每个病例的真实编号,核对包里写成 `P` 加三位数字(如 `P001`),和临床工作簿里的写法相同。
- **病灶**:病人胰腺上的那个囊性肿瘤,CEUS 定量要在视频里把它圈出来。
- **构建**:跑一次 `scripts/build.py`(`装配两个库.md`),产出同号的资料库 `library/<构建号>/` 和标准答案库 `answers/<构建号>/`。
- **资料库**:给 AI 专家看的资料,按临床、超声、MR 三路分开。
- **答案库**:放病理诊断(金标准,即术后或穿刺的病理结论)、化名与病例号的对照、跳过清单(没进资料库的病例和原因)、复核清单(报告里可能带诊断倾向的句子)。
- **资料库清单 `manifest.json`**:资料库根目录下的一个文件,记这次构建的构建号、每个文件的哈希,以及 `ceus_source`:这次构建装入的那次 CEUS 运行的运行号(没装为 null)。它只记运行号,不记运行目录的路径。
- **化名**:资料库里代替病例号的 4 位随机名字,只用在资料库里。交付表按诊断分组录入,病例号的先后本身带着答案。专家看到病例号,就可能从编号推出诊断(`AGENTS.md` 硬性约束 1)。
- **核对包为什么可以用病例号**:核对包是给医生看的,不给 AI 专家;里面本来就有诊断(答案库的诊断表原样在包里),不进专家能读到的任何资料。所以病例目录直接用病例号,化名只在表里出现,方便以后对回资料库。答案库原本「只给评价用」,核对包是另一类用途:把答案库连同资料库和 CEUS 结果一起交给临床方核对,范围仍限于我们和临床方之间(`AGENTS.md` 硬性约束 3)。
- **CEUS 定量运行**:跑一次 `scripts/ceus_quant.py`(`CEUS定量.md`),产物在 `runs/ceus/<CEUS运行号>/`。
- **`failed.csv`**:这次运行里记各病例在第几步失败、失败原因的表。
- **视频号**:每例 CEUS 视频的编号,`U` 加三位病例号(如 `U001`),CEUS 运行的结果目录按它命名。
- **TIC、拟合**:TIC(时间-强度曲线)是病灶区域平均亮度随时间的变化。拟合是用一条光滑的模型曲线(Lognormal,对数正态形状)去贴 TIC。拟合要过一道质量门才算成功;没成功时,关键帧 D1–D4 和灌注指标都取不到。
- **紫圈**:临床方在视频上画的紫色圈,圈一块正常胰腺作对照,程序也为它画一条 TIC。
- **关键帧、槽位**:从视频里挑出的 7 个时刻的画面,每个时刻叫一个槽位。B0 是刚开始变亮之前,D1–D4 是从拟合曲线上取的开始变亮、变亮最快、最亮、变暗最快,F1、F2 是推注造影剂后第 20、60 秒。细节见包里的 `说明.pdf`。
- **回放视频**:原视频逐帧叠加程序画的病灶轮廓等线条,医生用它看程序圈得对不对。
- **问题清单**:包里的 `问题清单.csv`,列出程序在每个病例上观察到的现象:某例没进资料库、CEUS 在第几步失败、拟合没完成、关键帧没取到、紫圈曲线不可用。每条都抄自某个已有文件里的值,只写现象,不写原因推测和建议。这是负责人 2026-10-17 定的(任务书 03「不做什么」):包只请医生核对事实,推测会把医生的注意力引向我们的猜测。
- **三种编号**:格式都是 `YYYYMMDD-HHMMSS-<git 短哈希>`,工作区有未提交改动时末尾加 `-dirty`(`装配两个库.md` 第三节「构建号」)。三者各指一样东西,本文用不同的名字区分:
- **构建号**:一次构建,即 `library/<构建号>/`、`answers/<构建号>/`;
- **CEUS 运行号**:一次 CEUS 定量运行,即 `runs/ceus/<CEUS运行号>/`;
- **出包号**:出一次核对包,即 `runs/review_pack/<出包号>/`,取出包那一刻的时间。
## 一、什么时候跑
医生每交一版新数据,按顺序做完:
1. CEUS 定量 `scripts/ceus_quant.py`,得到新的 `runs/ceus/<CEUS运行号>/`;
2. 把 `config/build.yaml` 的 `ceus_run` 指向这个目录,跑 `scripts/build.py` 构建两个库,得到新的构建号;
3. 再跑本入口出核对包。
核对包只读已经构建好的两个库和那次 CEUS 运行,不碰交付包,也不改两个库。所以交付数据或处理规则变了,要先重新构建,再出包。一次构建可以出多次包,每次一个新的出包号。
没装 CEUS 结果的构建(`build.yaml` 的 `ceus_run` 为 null)也能出包。包里没有关键帧、TIC 曲线、TIC 结果和回放视频,病例总表的 CEUS 各列留空,`说明.pdf` 里写明这次没有 CEUS 结果。
## 二、怎么跑
### 改配置
配置是 `config/review_pack.yaml`,进 git:出包用了哪两个库、产物写到哪,看提交历史就能查到,运行可以复现;每次出包时,快照里还会存一份配置原文。字段定义和校验在 `src/cadx/review/_config.py` 的 `ReviewPackConfig`:每个字段必填,没有默认值;多写字段、拼错字段名、同一层重复的键都直接报错;相对路径**相对仓库根目录**解析,不能写空串。当前取值看配置文件本身,这里不抄。
| 字段 | 类型 | 含义 |
| --- | --- | --- |
| `library_dir` | 路径 | 某次构建的资料库目录(`library/<构建号>/`) |
| `answers_dir` | 路径 | 同一次构建的答案库目录(`answers/<构建号>/`)。两个目录名必须相同,不同就报错 |
| `output_root` | 路径 | 核对包运行目录的上级目录,每次出包在它下面新建 `<出包号>/` |
### 用哪次 CEUS 运行
用哪次 CEUS 运行不在这里配,从这次构建自己的记录里取:
- **CEUS 运行号**:取资料库 `manifest.json` 的 `ceus_source`。
- **运行目录的路径**:取答案库快照里的 `build.yaml`(`answers/<构建号>/snapshot/build.yaml`,构建时那份配置的原文副本)的 `ceus_run`。路径不从 `manifest.json` 拿,因为它只记了运行号、没记路径;任务书 03 又要求不在 `review_pack.yaml` 里另配。
- 两者必须一致:`ceus_run` 的目录名要等于 `ceus_source`,不一致就报错。这样包里的 TIC 曲线和回放视频,一定来自资料库实际装入的那次运行。
- `ceus_source` 为 null 时(构建时 `ceus_run` 为 null),程序不读快照里的 `build.yaml`,直接出不含 CEUS 结果的包。
### 跑
```bash
uv run python scripts/review_pack.py --config config/review_pack.yaml
```
脚本只接收 `--config` 一个参数(`../explanation/结构.md` 第五部分「配置」)。它调 `pandoc` 和 `soffice`(LibreOffice 的命令行程序),把说明和每例的两份报告转成 PDF。两个工具必须已装在 PATH 上(本机已装;中文字体用已装的 Noto CJK)。40 例的包要拷贝每例的回放视频、转几十份 PDF,耗时以实际运行为准;超过约一分钟时按 `AGENTS.md`「本机环境的坑」放进 tmux 跑。
终端只打印计数,不打印病例号,可以贴进汇报:
- 运行目录、核对包目录;
- 病例数(进库、跳过各几例);
- 问题清单行数(按「环节」分);
- PDF 份数;
- 拷贝的视频总大小(没装 CEUS 结果时显示 `0.0 MiB`);
- 耗时。
## 三、产物在哪
```
runs/review_pack/<出包号>/ (output_root 下)
snapshot/ 快照:不给医生
医生核对包_<构建号>/ 交给医生的就是这个目录
```
- `snapshot/`:记「这个包是怎么出来的」,给我们自己复现用。
- `snapshot.json`:git 状态、Python 版本、`uv.lock` 哈希、开始时间、配置哈希;
- `review_pack.yaml`:配置原文副本;
- `review.json`:构建号、CEUS 运行号、pandoc 与 soffice 的版本行;
- `finished.json`:结束时间、病例数、问题清单行数。
- `医生核对包_<构建号>/`:目录布局、每张 CSV 的列和每个文件的来源,见任务书 03「核对包的内容」。也可以直接打开包里的 `说明.pdf`:它是写给医生的完整说明,包括目录结构、每一列的意思、处理流程、关键帧和 TIC 曲线怎么看、编码含义。这里不重抄,免得两处说法不一致。`说明.pdf` 的源文件是 `src/cadx/review/readme_template.md`。
运行目录先写成 `<出包号>.partial/`,全部写完再改名成 `<出包号>/`。看到 `.partial`,说明那次出包中途出错。它不会被自动删除,留着查原因,查完由人决定删不删。已有的运行目录不删、不覆盖(`AGENTS.md`「两档标准」)。
## 四、打包交给医生
1. 只打包 `医生核对包_<构建号>/` 这一个目录,**不带 `snapshot/`**:快照里是仓库的 git 状态和配置,对医生没用。例如:
```bash
cd runs/review_pack/<出包号>
zip -r 医生核对包_<构建号>.zip 医生核对包_<构建号>
```
打包后最好在另一台电脑上解开看一眼,中文文件名是否正常显示(医生多半用 Windows)。
2. 核对包里有患者资料:病例号、报告原文、诊断、超声画面和视频(`AGENTS.md` 硬性约束 3)。只在临床方和我们之间传递,由负责人用认可的渠道(如 U 盘)转交。不贴进文档、提交说明、Linear,不发给外部模型服务。
3. 交出去的包以后不改。医生改好交付资料后,按第一节重新跑一遍,得到新的构建号和新的核对包。
## 五、常见报错
| 报错 | 原因 | 怎么办 |
| --- | --- | --- |
| `资料库与答案库不是同一次构建:A ≠ B` | `review_pack.yaml` 里两个目录名(构建号)不同 | 把两项改成同一个构建号 |
| `资料库清单里的构建号 … 与目录名 … 不同` | 资料库目录被改过名 | 用原目录名;构建目录不要改名 |
| `… 是没写完的构建(.partial),不能读` | 配置指向了没跑完的构建(名字以 `.partial` 结尾) | 改指向一次跑完的构建 |
| `… 的 SHA-256 与清单不一致` | 资料库或答案库里的文件在构建之后被改过 | 不要手改构建目录;重新构建一次 |
| `运行目录已存在,不覆盖:…` | `output_root` 下已有同名的 `<出包号>/` 或 `<出包号>.partial/`(出包号带到秒,通常是同一秒内跑了两次) | 隔一秒重跑;不要删已有的运行目录来腾位置 |
| `找不到 pandoc` / `找不到 soffice` | 转 PDF 的工具没装或不在 PATH 上 | 装上 pandoc 和 LibreOffice,再跑 |
| `pandoc 的参考样式里没有 …` | 中文 PDF 里有的字会显示成日文写法,程序为此改了 pandoc 自带的样式文件,把中文字体设成 Noto CJK 简体。这条报错说明 pandoc 版本变了,样式文件里要改的那段已经不在,程序设不了中文字体 | 先查 `pandoc --version` 是否被升级过。能退回原版本就退回;不能就打开 `src/cadx/review/_pdf.py` 顶部的注释和 `_STYLE_FONTS`,按新版样式文件改写要替换的内容 |
| `soffice 没有转出 …` | LibreOffice 转 PDF 失败 | 先单独用 `soffice --headless --convert-to pdf` 转一个 docx 试试,看 LibreOffice 本身是否正常 |
| `… 的 ceus_run 是 …,与资料库记的 CEUS 运行号 … 不符` | 答案库快照里 `build.yaml` 的 `ceus_run`(路径)与资料库 `manifest.json` 的 `ceus_source`(CEUS 运行号)对不上 | 两个库不是同一次构建写出的,检查配置是否指错 |
| `CEUS 运行目录不存在` / `是没跑完的 .partial` / `缺快照` | 构建所用的 CEUS 运行目录被移走或删了 | CEUS 运行目录只追加不删;找回原目录 |
| `CEUS 运行 failed.csv 里的视频号 … 对不上这次构建的任何病例` | 那次 CEUS 运行里失败的病例不在这次构建里,既没进库,也不在跳过清单上 | 检查 `build.yaml` 的 `ceus_run` 与 `delivery_dir`(交付包目录)是否配套,即那次 CEUS 运行是否在这份交付包上跑的 |
| `tic.json 里有 TIC结果.csv 没有中文名的字段` | build 往 `tic.json`(资料库里每例的 CEUS 结果摘要)里加了新字段,核对包还没给它起中文名 | 在 `src/cadx/review/_pack.py` 的 `TIC_ITEMS` 里补上中文名,同时补说明模板 |
+37
View File
@@ -0,0 +1,37 @@
"""出医生核对包:把一次构建(资料库 + 答案库)和它装入的 CEUS 运行整理成医生能直接看的文件夹。
用法:uv run python scripts/review_pack.py --config config/review_pack.yaml
产物写进 `<output_root>/<运行号>/`:`医生核对包_<构建号>/` 是要交给医生的,`snapshot/` 不给。
终端只打印计数,不打印病例号。
"""
import argparse
import time
from datetime import datetime
from pathlib import Path
from cadx.review import run_review_pack
REPO_ROOT = Path(__file__).resolve().parents[1]
def main() -> None:
parser = argparse.ArgumentParser(description="出医生核对包")
parser.add_argument("--config", type=Path, required=True, help="review_pack.yaml 的路径")
config: Path = parser.parse_args().config
clock = time.monotonic()
summary = run_review_pack(config, REPO_ROOT, datetime.now().astimezone())
print(f"运行目录:{summary.run_dir}")
print(f"核对包:{summary.pack_dir}")
print(f"病例数:{summary.cases}(进库 {summary.passed},跳过 {summary.skipped})")
by_stage = ",".join(f"{stage} {n}" for stage, n in summary.problems_by_stage.items())
print(f"问题清单行数:{sum(summary.problems_by_stage.values())}({by_stage or '无'})")
print(f"PDF 份数:{summary.pdfs}")
print(f"拷贝的视频总大小:{summary.video_bytes / 2**20:.1f} MiB")
print(f"耗时:{time.monotonic() - clock:.1f} 秒")
if __name__ == "__main__":
main()
+4
View File
@@ -10,6 +10,8 @@ from cadx.answers._files import (
SkippedRow,
read_aliases,
read_diagnoses,
read_skipped,
verified_file,
write_answers,
)
@@ -20,5 +22,7 @@ __all__ = [
"SkippedRow",
"read_aliases",
"read_diagnoses",
"read_skipped",
"verified_file",
"write_answers",
]
+23 -1
View File
@@ -139,7 +139,8 @@ def write_answers(
)
def _read_csv(answers_dir: Path, name: str, header: tuple[str, ...]) -> list[list[str]]:
def verified_file(answers_dir: Path, name: str) -> Path:
"""答案库里文件 `name`(如 `snapshot/build.yaml`)的路径;先核对清单里的 SHA-256。"""
if answers_dir.name.endswith(".partial"):
raise ValueError(f"{answers_dir} 是没写完的构建(.partial),不能读")
manifest = cast(
@@ -148,6 +149,11 @@ def _read_csv(answers_dir: Path, name: str, header: tuple[str, ...]) -> list[lis
path = answers_dir / name
if _sha256(path) != manifest["files"][name]:
raise ValueError(f"{name} 的 SHA-256 与清单不一致,答案库被改动过")
return path
def _read_csv(answers_dir: Path, name: str, header: tuple[str, ...]) -> list[list[str]]:
path = verified_file(answers_dir, name)
with path.open(encoding=_ENCODING, newline="") as handle:
rows = list(csv.reader(handle))
if tuple(rows[0]) != header:
@@ -175,3 +181,19 @@ def read_diagnoses(answers_dir: Path) -> tuple[DiagnosisRow, ...]:
)
for alias, case_no, clinical, ultrasound, mr, name in rows
)
def read_skipped(answers_dir: Path) -> tuple[SkippedRow, ...]:
"""读跳过清单。文件和列两栏按写入时的分隔符拆回元组,空栏为空元组。"""
rows = _read_csv(answers_dir, "skipped.csv", SKIPPED_HEADER)
return tuple(
SkippedRow(
case=case,
check=check,
description=description,
files=tuple(files.split(_JOIN)) if files else (),
columns=tuple(columns.split(_JOIN)) if columns else (),
value_json=value_json,
)
for case, check, description, files, columns, value_json in rows
)
+3 -3
View File
@@ -15,7 +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._ceus import case_ceus, open_matching_run
from cadx.build._config import load_build_config, load_code_maps
from cadx.delivery import (
CODED_COLUMNS,
@@ -27,7 +27,7 @@ from cadx.delivery import (
review_sentences,
)
from cadx.library import LibraryCase, LibraryCeus, check_key_image, write_library
from cadx.runs import finish_snapshot, make_build_id, read_git_state, write_snapshot
from cadx.runs import CeusRun, finish_snapshot, make_build_id, read_git_state, write_snapshot
ALIAS_ALPHABET = string.ascii_lowercase + string.digits
ALIAS_LENGTH = 4
@@ -158,7 +158,7 @@ def run_build(config_path: Path, repo_root: Path, now: datetime) -> BuildSummary
ceus_run = (
None
if config.ceus_run is None
else open_ceus_run(config.ceus_run, config.delivery_dir, repo_root)
else open_matching_run(config.ceus_run, config.delivery_dir, repo_root)
)
ceus_of: dict[str, LibraryCeus | None] = dict.fromkeys((c.case_no for c in passed), None)
if ceus_run is not None:
+25 -52
View File
@@ -1,27 +1,22 @@
"""读一次 CEUS 定量运行(`runs/ceus/<运行号>/`),按白名单挑出超声一路的 tic.json 和关键帧(C15)。
"""从一次 CEUS 定量运行按白名单挑出超声一路的 tic.json 和关键帧(C15)。
运行目录的布局由 `scripts/ceus_quant.py` 定:`failed.csv`、`snapshot/snapshot.json`、
`snapshot/ceus.yaml`、`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`。tic.json 的字段
只在这里列,视频号、路径、配置、帧号和拟合参数都不进去。快照里的交付包路径、summary.json 里记的
视频 SHA-256 和推注时间要与这份交付包一致,否则运行不是在这份交付包上跑的,整次停下。
运行目录的布局由 runs 读(`open_ceus_run`);tic.json 的字段只在这里列,视频号、路径、配置、帧号和
拟合参数都不进去。快照里的交付包路径、summary.json 里记的视频 SHA-256 和推注时间要与这份交付包
一致,否则运行不是在这份交付包上跑的,整次停下。
"""
import csv
import hashlib
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from cadx.delivery import CaseRecord, SkipRecord
from cadx.library import LibraryCeus
from cadx.runs import CeusRun, open_ceus_run
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")
@@ -46,41 +41,20 @@ _SUPPORTING_TIMES = ("TI", "mTT", "TO")
_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, delivery_dir: Path, repo_root: Path) -> CeusRun:
"""核对运行目录(存在、不是 `.partial`、有 failed.csv 和快照),读 failed.csv。
def open_matching_run(run_dir: Path, delivery_dir: Path, repo_root: Path) -> CeusRun:
"""经 runs 打开运行目录,再核对它是在这份交付包上跑的。
快照里 `ceus.yaml` 的 `paths.delivery_dir`(相对路径相对 `repo_root`,同 ceus_quant)与这次
build 的 `delivery_dir` 规范化后不是同一路径,说明运行不是在这份交付包上跑的,报错停下。
"""
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}")
ceus_yaml = run_dir / "snapshot" / "ceus.yaml"
ran_on = _typed(
yaml.safe_load(ceus_yaml.read_text(encoding="utf-8"))["paths"]["delivery_dir"], str
)
run = open_ceus_run(run_dir)
ran_on = _typed(run.config["paths"]["delivery_dir"], str)
if (repo_root / ran_on).resolve() != delivery_dir.resolve():
raise ValueError(
f"CEUS 运行不是在这份交付包上跑的:{ceus_yaml} 的 paths.delivery_dir 是 {ran_on},"
f"build 的 delivery_dir 是 {delivery_dir}"
f"CEUS 运行不是在这份交付包上跑的:{run_dir / 'snapshot' / 'ceus.yaml'} 的 "
f"paths.delivery_dir 是 {ran_on},build 的 delivery_dir 是 {delivery_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:]})
return run
def _number(value: object) -> float | int | None:
@@ -151,28 +125,27 @@ def case_ceus(run: CeusRun, case: CaseRecord) -> LibraryCeus | SkipRecord:
)
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():
failure = run.failed[case.video_id]
return c15("failed.csv", f"第 {failure.step} 步失败:{failure.reason}")
if not run.has_result(case.video_id):
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}")
summary = run.summary(case.video_id)
summary_path = f"{run.run_id}/{case.video_id}/summary.json"
if _typed(summary["video_sha256"], str) != hashlib.sha256(case.video.read_bytes()).hexdigest():
raise ValueError(
f"CEUS 运行不是在这份交付包上跑的:{case.video} 的 SHA-256 与 "
f"{case_dir / 'summary.json'} 的 video_sha256 不同"
f"{summary_path} 的 video_sha256 不同"
)
if _typed(summary["injection_offset_s"], float) != case.bolus_start_s:
raise ValueError(
f"CEUS 运行不是在这份交付包上跑的:交付包的推注时间 {case.bolus_start_s} 秒与 "
f"{case_dir / 'summary.json'} 的 injection_offset_s 不同"
f"{summary_path} 的 injection_offset_s 不同"
)
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)
files = run.keyframe_files(case.video_id)
# 文件必须恰好是定位到的各槽位的 `<槽位>.png`:多一个(含同名不同扩展名)、少一个都报错。
if sorted(p.name for p in files) != sorted(f"{slot}.png" for slot in located):
raise ValueError(f"keyframes/ 里的图与 summary.json 定位到的槽位不一致:{summary_path}")
by_name = {p.name: p for p in files}
return LibraryCeus(tic=tic, keyframe_sources={s: by_name[f"{s}.png"] for s in located})
+4
View File
@@ -4,10 +4,12 @@
"""
from cadx.library._read import (
BuildInfo,
ClinicalRoute,
MrRoute,
UltrasoundRoute,
list_aliases,
read_build_info,
read_clinical,
read_mr,
read_ultrasound,
@@ -16,6 +18,7 @@ from cadx.library._scan import LeakHit, scan_for_leaks
from cadx.library._write import LibraryCase, LibraryCeus, check_key_image, write_library
__all__ = [
"BuildInfo",
"ClinicalRoute",
"LeakHit",
"LibraryCase",
@@ -24,6 +27,7 @@ __all__ = [
"UltrasoundRoute",
"check_key_image",
"list_aliases",
"read_build_info",
"read_clinical",
"read_mr",
"read_ultrasound",
+19
View File
@@ -66,6 +66,25 @@ def _text(path: Path) -> str:
return path.read_bytes().decode("utf-8")
@dataclass(frozen=True)
class BuildInfo:
"""一次构建清单里的概况:构建号、编码列是否解码、装入的 CEUS 运行号(没装为 None)。"""
build_id: str
codes_decoded: bool
ceus_source: str | None
def read_build_info(build_dir: Path) -> BuildInfo:
"""读构建清单里的构建号、是否解码、CEUS 运行号。"""
manifest = _manifest(build_dir)
return BuildInfo(
build_id=manifest["build_id"],
codes_decoded=manifest["codes_decoded"],
ceus_source=manifest["ceus_source"],
)
def list_aliases(build_dir: Path) -> tuple[str, ...]:
"""一次构建的全部化名(按化名排序)。"""
return tuple(_manifest(build_dir)["aliases"])
+10
View File
@@ -0,0 +1,10 @@
"""review:医生核对包。目录布局、每个文件的格式、问题清单的来源与写法、说明模板只在这里决定。
只读已经构建好的资料库(library)、答案库(answers)和资料库记的那次 CEUS 运行(runs),不写它们;
不碰交付包。说明模板是同目录下的 `readme_template.md`,随代码走。
"""
from cadx.review._config import ReviewPackConfig, load_review_config
from cadx.review._pack import PackSummary, run_review_pack
__all__ = ["PackSummary", "ReviewPackConfig", "load_review_config", "run_review_pack"]
+64
View File
@@ -0,0 +1,64 @@
"""`config/review_pack.yaml` 的字段定义和读取。"""
from collections.abc import Hashable
from pathlib import Path
from typing import Annotated, Any
import yaml
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
def _reject_empty_path(value: object) -> object:
# Path("") 会变成 "."(当前目录),空串在转换前就拦下。
if value == "":
raise ValueError("路径不能是空串")
return value
# YAML 里的路径是字符串;路径字段是唯一允许从字符串转换类型的地方。
type _PathField = Annotated[Path, Field(strict=False), BeforeValidator(_reject_empty_path)]
class _UniqueKeyLoader(yaml.SafeLoader):
"""同一层出现重复键就报错:PyYAML 默认让后一个悄悄覆盖前一个。"""
# 签名(含 deep 的默认值)照抄 PyYAML 的基类方法,覆盖时必须一致。
def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Hashable, Any]:
seen: set[Hashable] = set()
for key_node, _ in node.value:
key = self.construct_object(key_node, deep=deep) # pyright: ignore[reportUnknownMemberType]
if key in seen:
raise ValueError(f"YAML 第 {key_node.start_mark.line + 1} 行重复的键:{key!r}")
seen.add(key)
return super().construct_mapping(node, deep=deep)
class ReviewPackConfig(BaseModel):
"""出核对包的配置。每个字段必填;相对路径相对仓库根目录解析。
`library_dir`、`answers_dir` 是同一次构建的资料库和答案库(目录名即构建号,必须相同);
`output_root` 下每次出包新建一个运行目录。
"""
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
library_dir: _PathField
answers_dir: _PathField
output_root: _PathField
def load_review_config(path: Path, repo_root: Path) -> ReviewPackConfig:
"""读并校验 `review_pack.yaml`,相对路径换成 `repo_root` 下的路径;两个库的构建号不同就报错。"""
text = path.read_text(encoding="utf-8")
config = ReviewPackConfig.model_validate(yaml.load(text, Loader=_UniqueKeyLoader)) # noqa: S506 _UniqueKeyLoader 继承 SafeLoader
if config.library_dir.name != config.answers_dir.name:
raise ValueError(
f"资料库与答案库不是同一次构建:{config.library_dir.name} ≠ {config.answers_dir.name}"
)
return config.model_copy(
update={
"library_dir": repo_root / config.library_dir,
"answers_dir": repo_root / config.answers_dir,
"output_root": repo_root / config.output_root,
}
)
+407
View File
@@ -0,0 +1,407 @@
"""出一次医生核对包:目录布局、每个文件的格式、问题清单的来源与写法。
```
<output_root>/<运行号>/
snapshot/ 快照(不给医生)
医生核对包_<构建号>/
说明.pdf 问题清单.csv 病例总表.csv
标准答案/化名对照.csv 诊断.csv 跳过病例.csv 报告复核句.csv
病例/<病例号>/临床信息.csv 超声报告.pdf MR报告.pdf 关键图.png
关键帧/<槽位>_第<秒>秒.png TIC曲线.png TIC结果.csv 回放视频.mp4
```
只读已经构建好的资料库、答案库和那次 CEUS 运行,不写它们;图片和视频按字节拷贝,不打开。
问题清单每一行都抄自某个已有文件里的某个值,不另做判断。
"""
import csv
import json
import re
import shutil
from collections import Counter
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime
from importlib import resources
from pathlib import Path
from string import Template
from typing import Any
import yaml
from cadx.answers import read_aliases, read_skipped, verified_file
from cadx.library import read_build_info, read_clinical, read_mr, read_ultrasound
from cadx.review._config import load_review_config
from cadx.review._pdf import markdown_to_pdf, report_markdown, tool_versions
from cadx.runs import (
CeusRun,
finish_snapshot,
make_build_id,
open_ceus_run,
read_git_state,
write_snapshot,
)
SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
_CASE_COLUMNS = ("病例号", "视频号", "化名", "是否进入资料库", "没进的原因")
SUMMARY_HEADER = (*_CASE_COLUMNS, "CEUS 定量是否成功", "CEUS 失败在第几步", *SLOTS)
PROBLEM_HEADER = ("病例号", "视频号", "环节", "问题")
TIC_HEADER = ("项目", "值")
# 答案库的表 → 包里的文件名;内容逐字节照抄(答案库写的就是带 BOM 的 UTF-8、中文列名、缺失为空)。
ANSWER_TABLES = {
"aliases.csv": "化名对照.csv",
"diagnoses.csv": "诊断.csv",
"skipped.csv": "跳过病例.csv",
"review.csv": "报告复核句.csv",
}
STAGE_ASSEMBLY = "装配资料库"
STAGE_FIT = "TIC 拟合"
STAGE_KEYFRAME = "关键帧"
STAGE_PAN = "紫圈"
_LESION_CURVES = {"roi_all": "病灶整体曲线", "roi_perf": "病灶去暗区曲线"}
_CURVES = {**_LESION_CURVES, "roi_pan": "紫圈曲线", "background_circle": "背景圆曲线"}
_PERFUSION = {
"signal_domain": "灌注指标 信号域",
"time_origin": "灌注指标 时间零点",
"area_curve": "灌注指标 面积所用曲线",
"values.PE_au": "峰值增强 PE",
"values.WiAUC_au_s": "洗入段曲线下面积 WiAUC",
"values.RT_s": "上升时间 RT(秒)",
"values.mTTl_s": "平均渡越时间 mTTl(秒)",
"values.TTP_s": "达峰时间 TTP(秒)",
"values.WiR_au_per_s": "最大洗入斜率 WiR",
"values.WiPI_au": "洗入灌注指数 WiPI",
"values.WoAUC_au_s": "洗出段曲线下面积 WoAUC",
"values.WiWoAUC_au_s": "洗入洗出总面积 WiWoAUC",
"values.FT_s": "下降时间 FT(秒)",
"values.WoR_au_per_s": "最大洗出斜率 WoR",
"supporting_times_s.TI": "辅助时刻 TI(秒)",
"supporting_times_s.mTT": "辅助时刻 mTT(秒)",
"supporting_times_s.TO": "辅助时刻 TO(秒)",
}
# TIC结果.csv 的行:tic.json 里的字段路径 → 中文名。
TIC_ITEMS = {
**{f"fits.{c}.success": f"{n} 拟合成功" for c, n in _CURVES.items()},
**{f"fits.{c}.r2": f"{n} 拟合优度 R²" for c, n in _CURVES.items()},
**{
f"perfusion_metrics.{c}.{k}": f"{n} {label}"
for c, n in _LESION_CURVES.items()
for k, label in _PERFUSION.items()
},
"pan_curve_usable": "紫圈曲线可用",
**{f"keyframes.{s}.located": f"关键帧 {s} 取到" for s in SLOTS},
**{f"keyframes.{s}.video_time_s": f"关键帧 {s} 视频时间(秒)" for s in SLOTS},
**{f"keyframes.{s}.reason": f"关键帧 {s} 未取到的原因" for s in SLOTS},
}
# 病例号是交付包临床表 Case_ID 的写法「P + 三位数字」,库里存的是三位数字。
# 视频号是「U + 三位病例号」(交付包检查 C05 核对过);跳过的病例也按这条规则写,在 C05 之前
# 就被跳过的,交付里实际的 CEUS视频ID 没有核对过。认不出编号的跳过记录(「文件 第 N 行」等)
# 病例号原样写,视频号空着。
_CASE_NO = re.compile(r"[0-9]{3}")
_VIDEO_ID = re.compile(r"U([0-9]{3})")
@dataclass(frozen=True)
class PackSummary:
"""一次出包的计数,供终端打印;不含病例号。"""
run_dir: Path
pack_dir: Path
cases: int
passed: int
skipped: int
problems_by_stage: dict[str, int]
pdfs: int
video_bytes: int
def _doctor_no(case_no: str) -> str:
return f"P{case_no}" if _CASE_NO.fullmatch(case_no) else case_no
def _cell(value: object) -> str:
"""CSV 的格:None 写空串,布尔写 true/false,数值写 JSON 的写法,字符串原样。"""
if value is None:
return ""
if isinstance(value, str):
return value
return json.dumps(value)
def _write_csv(path: Path, header: Sequence[str], rows: Sequence[Sequence[str]]) -> None:
with path.open("w", encoding="utf-8-sig", newline="") as handle:
writer = csv.writer(handle)
writer.writerow(header)
writer.writerows(rows)
def _leaves(node: object, prefix: str) -> list[str]:
if isinstance(node, dict):
items: dict[str, object] = node # pyright: ignore[reportUnknownVariableType]
return [leaf for key, value in items.items() for leaf in _leaves(value, f"{prefix}{key}.")]
return [prefix.removesuffix(".")]
def _lookup(tic: dict[str, Any], path: str) -> object:
"""按路径取值;路径上某一段是 null(如拟合没过时的灌注指标)则这一项为 None。"""
node: Any = tic
for key in path.split("."):
if node is None:
return None
node = node[key]
return node
def tic_rows(tic: dict[str, Any]) -> list[tuple[str, str]]:
"""TIC结果.csv 的行;tic.json 里有 `TIC_ITEMS` 没列的字段就报错(字段变了要同步中文名)。"""
unknown = [
p
for p in _leaves(tic, "")
if p not in TIC_ITEMS and not any(q.startswith(f"{p}.") for q in TIC_ITEMS)
]
if unknown:
raise ValueError(f"tic.json 里有 TIC结果.csv 没有中文名的字段:{unknown}")
return [(f"{name}({path})", _cell(_lookup(tic, path))) for path, name in TIC_ITEMS.items()]
def _ceus_run(answers_dir: Path, run_id: str, repo_root: Path) -> CeusRun:
"""这次构建用的 CEUS 运行:路径取自答案库快照里的 build.yaml,目录名必须是资料库记的运行号。"""
build_yaml = verified_file(answers_dir, "snapshot/build.yaml")
written = yaml.safe_load(build_yaml.read_text(encoding="utf-8"))["ceus_run"]
if not isinstance(written, str) or Path(written).name != run_id:
raise ValueError(
f"{build_yaml} 的 ceus_run 是 {written!r},与资料库记的 CEUS 运行号 {run_id} 不符"
)
return open_ceus_run(repo_root / written)
def _template_text(
build_id: str,
run: CeusRun | None,
codes_decoded: bool,
now: datetime,
counts: tuple[int, int, int],
) -> str:
template = Template(
resources.files("cadx.review").joinpath("readme_template.md").read_text(encoding="utf-8")
)
if run is None:
ceus_note = (
"**这次构建没有装入 CEUS 定量结果**:病例目录里没有关键帧、TIC曲线.png、"
"TIC结果.csv、回放视频.mp4,病例总表的 CEUS 各列空着,"
"问题清单里没有 CEUS、TIC 拟合、关键帧、紫圈几类。"
)
f1 = f2 = tolerance = "(这次没有 CEUS 结果,不适用)"
else:
ceus_note = f"这次构建装入了 CEUS 定量运行 {run.run_id} 的结果。"
anchors = run.config["ceus"]["tic_analysis"]["anchor_frames"]
f1, f2 = f"{anchors['f1_anchor_s']:g}", f"{anchors['late_phase']['preferred_time_s']:g}"
tolerance = f"{anchors['late_phase']['tolerance_s']:g}"
codes_note = (
"临床信息.csv 里的编码列(性别、症状、既往史、化验是否异常)"
"已按贵方提供的编码对照表解码成文字。"
if codes_decoded
else "临床信息.csv 里用数字表示的列(性别、症状、既往史、化验是否异常)"
"这次**未解码**,照交付的原始数字写;数字的含义要等贵方提供编码对照表后再解码"
"(构建配置 build.yaml 的 decode_codes 开关)。"
)
return template.substitute(
build_id=build_id,
ceus_run_id="无" if run is None else run.run_id,
generated_date=f"{now:%Y-%m-%d}",
total_cases=counts[0],
passed_cases=counts[1],
skipped_cases=counts[2],
ceus_note=ceus_note,
f1_s=f1,
f2_s=f2,
f2_tol_s=tolerance,
codes_note=codes_note,
)
def _copy(source: Path, target: Path) -> int:
shutil.copyfile(source, target)
return target.stat().st_size
type Problem = tuple[str, str, str, str] # 病例号、视频号、环节、问题
def _ceus_status(run: CeusRun | None, video_id: str) -> tuple[str, str]:
"""病例总表的「CEUS 定量是否成功」「CEUS 失败在第几步」:在 failed.csv 里为否和步号,
运行里有这段视频的结果目录为是;都没有、没有视频号或没装 CEUS 运行时都空着。"""
if run is None or not video_id:
return "", ""
if video_id in run.failed:
return "否", str(run.failed[video_id].step)
return ("是", "") if run.has_result(video_id) else ("", "")
def _write_ceus(
case_dir: Path,
no: str,
video_id: str,
tic: dict[str, Any],
keyframes: dict[str, Path],
run: CeusRun,
) -> tuple[list[str], list[Problem], int]:
"""一例的 CEUS 文件和问题;交出病例总表的 7 个槽位格、问题清单的行、回放视频的字节数。"""
(case_dir / "关键帧").mkdir()
for slot, source in keyframes.items():
seconds = tic["keyframes"][slot]["video_time_s"]
_copy(source, case_dir / "关键帧" / f"{slot}_第{seconds:.1f}秒.png")
_copy(run.tic_png(video_id), case_dir / "TIC曲线.png")
size = _copy(run.overlay(video_id), case_dir / "回放视频.mp4")
_write_csv(case_dir / "TIC结果.csv", TIC_HEADER, tic_rows(tic))
problems: list[Problem] = []
fits = run.summary(video_id)["fits"]
for curve, name in _LESION_CURVES.items():
if not tic["fits"][curve]["success"]:
code = fits[curve]["failure_reason"]
if not isinstance(code, str):
raise ValueError(
f"{run.run_id}/{video_id}/summary.json 里 {curve} 拟合没成功却没有原因码"
)
problems.append((no, video_id, STAGE_FIT, f"{name}未完成拟合({code})"))
slots = tic["keyframes"]
for slot in SLOTS:
if not slots[slot]["located"]:
problems.append(
(no, video_id, STAGE_KEYFRAME, f"{slot} 未取到:{slots[slot]['reason']}")
)
if not tic["pan_curve_usable"]:
problems.append((no, video_id, STAGE_PAN, "紫圈曲线不可用"))
cells = [_cell(slots[s]["video_time_s"]) if slots[s]["located"] else "未取到" for s in SLOTS]
return cells, problems, size
def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSummary:
"""读配置、两个库和资料库记的那次 CEUS 运行,写出一个新的核对包运行目录。
`now` 是本地时间,定运行号。
"""
config = load_review_config(config_path, repo_root)
library, answers = config.library_dir, config.answers_dir
info = read_build_info(library)
if info.build_id != library.name:
raise ValueError(f"资料库清单里的构建号 {info.build_id} 与目录名 {library.name} 不同")
run = None if info.ceus_source is None else _ceus_run(answers, info.ceus_source, repo_root)
versions = tool_versions()
aliases, skipped = read_aliases(answers), read_skipped(answers)
known = {a.case_no for a in aliases} | {s.case for s in skipped}
if len(known) != len(aliases) + len(skipped):
raise ValueError("答案库里同一个病例出现了两次(化名对照与跳过清单之间或之内)")
git = read_git_state(repo_root)
run_id = make_build_id(now, git)
final, partial = config.output_root / run_id, config.output_root / f"{run_id}.partial"
for path in (final, partial):
if path.exists():
raise FileExistsError(f"运行目录已存在,不覆盖:{path}")
partial.mkdir(parents=True)
snapshot = partial / "snapshot"
write_snapshot(
snapshot, run_id, git, now, repo_root / "uv.lock", {"review_pack.yaml": config_path}
)
record = {
"build_id": info.build_id,
"ceus_run_id": None if run is None else run.run_id,
**versions,
}
(snapshot / "review.json").write_text(
json.dumps(record, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
pack = partial / f"医生核对包_{info.build_id}"
(pack / "标准答案").mkdir(parents=True)
for name, target in ANSWER_TABLES.items():
_copy(verified_file(answers, name), pack / "标准答案" / target)
# failed.csv 的视频号 → 病例号(三位数字)。
failures: dict[str, tuple[str, int, str]] = {}
for video_id, failure in ({} if run is None else run.failed).items():
match = _VIDEO_ID.fullmatch(video_id)
if match is None or match.group(1) not in known:
raise ValueError(f"CEUS 运行 failed.csv 里的视频号 {video_id} 对不上这次构建的任何病例")
failures[match.group(1)] = (video_id, failure.step, failure.reason)
summary: list[list[str]] = []
problems: list[Problem] = []
pdf_jobs: dict[Path, str] = {}
video_bytes = 0
for row in aliases:
no = _doctor_no(row.case_no)
case_dir = pack / "病例" / no
case_dir.mkdir(parents=True)
clinical = read_clinical(library, row.alias).fields
_write_csv(
case_dir / "临床信息.csv", list(clinical), [[_cell(v) for v in clinical.values()]]
)
ultrasound = read_ultrasound(library, row.alias)
for name, route, text in (
("超声报告.pdf", "超声描述", ultrasound.report),
("MR报告.pdf", "MR描述", read_mr(library, row.alias).report),
):
title = f"病例号 {no} {route}(AI 专家看到的原文,不含结论段)"
pdf_jobs[case_dir / name] = report_markdown(title, text)
_copy(ultrasound.key_image, case_dir / "关键图.png")
cells = ["" for _ in SLOTS]
if run is not None and ultrasound.tic is not None:
cells, case_problems, size = _write_ceus(
case_dir, no, row.video_id, ultrasound.tic, ultrasound.keyframes, run
)
problems.extend(case_problems)
video_bytes += size
summary.append(
[no, row.video_id, row.alias, "是", "", *_ceus_status(run, row.video_id), *cells]
)
for record_ in skipped:
no = _doctor_no(record_.case)
video_id = f"U{record_.case}" if _CASE_NO.fullmatch(record_.case) else ""
reason = f"{record_.check} {record_.description}"
status = _ceus_status(run, video_id)
summary.append([no, video_id, "", "否", reason, *status, *["" for _ in SLOTS]])
parts = [reason]
parts += [
f"{k}:{'; '.join(v)}"
for k, v in (("文件", record_.files), ("列", record_.columns))
if v
]
parts.append(f"涉及值:{record_.value_json}")
problems.append((no, video_id, STAGE_ASSEMBLY, ";".join(parts)))
for case_no, (video_id, step, reason) in failures.items():
problems.append(
(_doctor_no(case_no), video_id, f"CEUS 第 {step} 步", f"第 {step} 步失败:{reason}")
)
summary.sort(key=lambda r: r[0])
problems.sort(key=lambda r: r[0])
_write_csv(pack / "病例总表.csv", SUMMARY_HEADER, summary)
_write_csv(pack / "问题清单.csv", PROBLEM_HEADER, problems)
counts = (len(summary), len(aliases), len(skipped))
pdf_jobs[pack / "说明.pdf"] = _template_text(
info.build_id, run, info.codes_decoded, now, counts
)
markdown_to_pdf(pdf_jobs)
finish_snapshot(
snapshot, datetime.now().astimezone(), {"cases": len(summary), "problems": len(problems)}
)
# POSIX 的 rename 会悄悄覆盖空目录,所以改名前再查一次。
if final.exists():
raise FileExistsError(f"运行目录已存在,不覆盖:{final}")
partial.rename(final)
by_stage = Counter(stage for _, _, stage, _ in problems)
return PackSummary(
run_dir=final,
pack_dir=final / pack.name,
cases=len(summary),
passed=len(aliases),
skipped=len(skipped),
problems_by_stage=dict(sorted(by_stage.items())),
pdfs=len(pdf_jobs),
video_bytes=video_bytes,
)
+129
View File
@@ -0,0 +1,129 @@
"""Markdown → PDF:pandoc 转 docx,再用 LibreOffice(soffice)一次批量转 PDF。
中文字体:pandoc 默认的 docx 样式没指定东亚字体,LibreOffice 会挑到 Noto CJK 的日文字形
(「门」「直」等字写法不同),所以从 pandoc 自带的样式文件改出一份参考文档,东亚字体指定为
Noto Sans/Serif CJK SC、东亚语言设为 zh-CN。pdffonts 列出的字体名仍是 `NotoSerifCJKjp-…`:
同一个 .ttc 文件里各语言的字形共用一个名字,实际嵌入的是简体字形(转成图片目视核对过)。
"""
import re
import shutil
import string
import subprocess
import tempfile
import zipfile
from collections.abc import Mapping
from pathlib import Path
_STYLE_FONTS = (
('w:eastAsiaTheme="majorEastAsia"', 'w:eastAsia="Noto Sans CJK SC"'), # 标题
('w:eastAsiaTheme="minorHAnsi"', 'w:eastAsia="Noto Serif CJK SC"'), # 正文
('w:eastAsia="en-US"', 'w:eastAsia="zh-CN"'), # 东亚语言
)
# smart 扩展会把引号、连字符、省略号改成排版符号,正文要照抄,关掉。
_MARKDOWN = "markdown-smart"
# 紧跟在一个空格后面的空格。
_REPEATED_SPACE = re.compile(r"(?<= ) ")
def _tool(name: str) -> str:
path = shutil.which(name)
if path is None:
raise FileNotFoundError(f"找不到 {name},出核对包要用它把文字转成 PDF")
return path
def _run(*args: str) -> str:
return subprocess.run(args, check=True, capture_output=True, text=True).stdout # noqa: S603 参数是写死的工具和本程序生成的路径
def tool_versions() -> dict[str, str]:
"""pandoc 和 soffice 的版本行,写进快照。"""
return {
"pandoc": _run(_tool("pandoc"), "--version").splitlines()[0],
"soffice": _run(_tool("soffice"), "--version").strip(),
}
def report_markdown(title: str, text: str) -> str:
"""报告原文的 Markdown:一级标题,正文逐行写成行块(保留换行),ASCII 标点全部转义,
这样 pandoc 不会把正文里的 `*`、`#`、`1.` 之类当成格式。
pandoc 会把连续空格并成一个,所以一行里连续的 ASCII 空格从第二个起换成不换行空格(U+00A0)。
Tab 在 PDF 里没法照原样排,真实报告里也没有,出现就报错停下(`title` 写明是哪例哪份报告)。
"""
if "\t" in text:
raise ValueError(f"{title}:报告原文里有 Tab,不能照原样转成 PDF")
escaped = (
_REPEATED_SPACE.sub(
"\u00a0", "".join(f"\\{c}" if c in string.punctuation else c for c in line)
)
for line in text.splitlines()
)
return f"# {title}\n\n" + "\n".join(f"| {line}" for line in escaped) + "\n"
def _reference_docx(work: Path) -> Path:
default = work / "pandoc_default.docx"
default.write_bytes(
subprocess.run( # noqa: S603 参数是写死的
[_tool("pandoc"), "--print-default-data-file", "reference.docx"],
check=True,
capture_output=True,
).stdout
)
reference = work / "reference.docx"
with (
zipfile.ZipFile(default) as source,
zipfile.ZipFile(reference, "w", zipfile.ZIP_DEFLATED) as out,
):
for item in source.infolist():
data = source.read(item.filename)
if item.filename == "word/styles.xml":
styles = data.decode("utf-8")
for old, new in _STYLE_FONTS:
if old not in styles:
raise ValueError(
f"pandoc 的参考样式里没有 {old},pandoc 版本变了,改 _STYLE_FONTS"
)
styles = styles.replace(old, new)
data = styles.encode("utf-8")
out.writestr(item, data)
return reference
def markdown_to_pdf(jobs: Mapping[Path, str]) -> None:
"""把每份 Markdown(目标 PDF 路径 → 内容)转成 PDF 写到目标路径。soffice 只启动一次。"""
pandoc, soffice = _tool("pandoc"), _tool("soffice")
with tempfile.TemporaryDirectory(prefix="cadx_pdf_") as temp:
work = Path(temp)
reference = _reference_docx(work)
docx = [work / f"{i}.docx" for i in range(len(jobs))]
for path, content in zip(docx, jobs.values(), strict=True):
path.with_suffix(".md").write_text(content, encoding="utf-8")
_run(
pandoc,
"-f",
_MARKDOWN,
"--reference-doc",
str(reference),
"-o",
str(path),
str(path.with_suffix(".md")),
)
# 独立的用户配置目录:本机开着 LibreOffice 时,headless 转换不会交给那个进程去做。
_run(
soffice,
f"-env:UserInstallation={(work / 'profile').as_uri()}",
"--headless",
"--convert-to",
"pdf",
"--outdir",
str(work / "pdf"),
*map(str, docx),
)
for path, target in zip(docx, jobs, strict=True):
pdf = work / "pdf" / f"{path.stem}.pdf"
if not pdf.is_file() or pdf.stat().st_size == 0:
raise RuntimeError(f"soffice 没有转出 {target.name}({pdf.name})")
shutil.move(pdf, target)
+202
View File
@@ -0,0 +1,202 @@
# 医生核对包说明
## 一、这个包是什么
这是我们用程序处理贵方交付资料之后的结果,整理出来请您核对:程序读到的内容对不对、CEUS(超声造影)视频的自动定量做得对不对、哪些病例没能进入资料库以及原因。包里只有我们的处理结果,不需要填写或回复任何表格。
- 构建号:$build_id(资料库和标准答案库是同一次构建产出的,共用这个编号)
- CEUS 定量运行号:$ceus_run_id
- 生成日期:$generated_date
- 病例数:病例总表共 $total_cases 行,其中进入资料库 $passed_cases 例,没进入 $skipped_cases 例。
$ceus_note
**包里含患者资料(报告原文、临床信息、超声图像与视频、病理诊断),只在临床方与我们之间传递,请勿转发给其他人。**
## 二、目录结构
- **医生核对包_\<构建号\>/**
- **说明.pdf**:本文件。
- **问题清单.csv**:程序在各环节观察到的现象,一行一条。
- **病例总表.csv**:这次涉及的全部病例,一例一行。
- **标准答案/**
- **化名对照.csv**:进入资料库的病例的化名、病例号、视频号。
- **诊断.csv**:进入资料库的病例在三张表里各自的病理诊断编码。
- **跳过病例.csv**:没进入资料库的病例,以及第一项没通过的检查。
- **报告复核句.csv**:报告里含「考虑」「可能」「倾向」「提示」的句子。
- **病例/\<病例号\>/**:只有进入资料库的病例才有目录,目录名是病例号(如 P001)。
- **临床信息.csv**:临床一路的字段,一行。
- **超声报告.pdf**:超声报告的描述段,就是 AI 专家看到的原文。
- **MR报告.pdf**:MR 报告的描述段,就是 AI 专家看到的原文。
- **关键图.png**:贵方截的超声关键图。
- **关键帧/**:程序从 CEUS 视频里取的关键帧,文件名是「槽位_第几秒.png」(如 B0_第1.6秒.png),秒数是视频时间、保留一位小数;没取到的槽位没有文件。
- **TIC曲线.png**:时间-强度曲线图。
- **TIC结果.csv**:曲线拟合、灌注指标、关键帧的逐项结果。
- **回放视频.mp4**:原视频逐帧叠加程序画出的病灶轮廓等。
所有图片和视频都没有压缩、缩放或重新编码,与我们处理时用的文件逐字节相同。关键图和关键帧是写进资料库时那一份:程序重新保存时去掉了文件里附带的文字信息(如拍摄参数),像素没有改变。
## 三、表格每一列的意思
表格都是 CSV 文件,可以直接用 Excel 打开。空着的格子表示没有这项值。
**病例总表.csv**(这次交付涉及的全部病例,按病例号排序)
| 列 | 意思 |
| --- | --- |
| 病例号 | 贵方的病例编号,如 P001。表里认不出编号的行写成「文件 第 N 行」或「病例目录 名字」 |
| 视频号 | CEUS 视频编号,按「U + 三位病例号」写,如 U001。没进入资料库的病例也这样写;其中在核对视频编号那项检查(C05)之前就被跳过的,交付里实际的视频编号没有核对过。认不出编号的行空着 |
| 化名 | 资料库里代替病例号的 4 位随机字母数字(见第七节);没进入资料库的空着 |
| 是否进入资料库 | 是 / 否 |
| 没进的原因 | 第一项没通过的检查编号和说明(见第九节);进入的空着 |
| CEUS 定量是否成功 | 这段视频在 CEUS 定量的失败记录里写「否」;定量运行里有这段视频的结果写「是」(没进入资料库的病例也可能是「是」,它是因为别的检查被跳过的);两者都没有(这段视频没做定量)或这次没装 CEUS 结果时空着 |
| CEUS 失败在第几步 | CEUS 定量失败在第 1–9 步中的哪一步(见第四节);没失败的空着 |
| B0、D1、D2、D3、D4、F1、F2 | 7 个关键帧(见第五节):取到的写它在视频里的时间(秒);没取到写「未取到」;CEUS 失败或没装 CEUS 结果时空着 |
**问题清单.csv**(按病例号排序,一个病例可以有多行)
| 列 | 意思 |
| --- | --- |
| 病例号 | 同上 |
| 视频号 | 同上 |
| 环节 | 现象出在处理流程的哪一环,取值见第四节末尾的表 |
| 问题 | 程序观察到的现象,照程序记录的原文写,不含推测 |
**标准答案/化名对照.csv**:化名、病例号、视频号。这里的病例号是三位数字(如 001),对应病例目录名 P001。
**标准答案/诊断.csv**:化名、病例号、临床表诊断编码、超声表诊断编码、MR表诊断编码、诊断名。三张表的诊断编码分别取自临床工作簿、超声报告工作簿、MR 报告工作簿,进入资料库的病例三者一致。编码含义见第八节。诊断名一列在编码没有解码时空着。
**标准答案/跳过病例.csv**:病例、检查编号、检查说明、文件、列、涉及值。病例是三位数字病例号,或认不出编号时的「文件 第 N 行」。文件和列是这项检查涉及的交付文件和列名,多个时用分号隔开。涉及值是对不上的原始取值,以程序记录的格式写出(带引号和括号)。
**标准答案/报告复核句.csv**:化名、路(超声或 MR)、命中的词、所在句子。进入资料库的病例中,报告描述段里含「考虑」「可能」「倾向」「提示」的句子,一句一行。这些词可能带出诊断倾向,所以单独列出;这些句子仍留在资料库里。
**病例/\<病例号\>/临床信息.csv**:一行,列名是交付的临床工作簿原列名(性别、年龄、5 个症状、5 项既往史、5 项化验的数值与是否异常)。病例号和诊断两列不在这里。
**病例/\<病例号\>/TIC结果.csv**:两列「项目」「值」。项目名是中文加程序里的字段名(括号里),值里的 true 表示是、false 表示否。各项的意思见第六节。
## 四、我们的处理流程
处理分三段:先读交付资料并逐例检查,再对 CEUS 视频做定量,最后把通过的病例装进资料库。
**第一段:读交付资料、逐例检查。** 程序读临床工作簿、超声报告工作簿、MR 报告工作簿和每例的病例目录,逐例做一串检查(编号能认出、三张表都有这一例、病例目录文件齐全、取值合规、三张表的诊断年龄性别一致、报告描述里没有结论段标题、没有亚型名、没有性别年龄等)。一例遇到第一项没通过的检查就整例跳过(见第九节)。关键图要能原样重新保存(检查 C14)。
**第二段:CEUS 定量。** 每段 CEUS 视频依次做下面 9 步。第 1、2、4 步遇到视频不满足前提时,这一例整例失败、记下失败在哪一步和原因,接着处理下一例;第 7、8 步做不出来不算整例失败,只是相应结果空缺。
1. **解码与分屏、两半配准。** 读视频;超声机录造影时屏幕分左右两半,一半是造影画面,一半是普通灰阶画面,程序找出分界线,再估出两半画面之间的位置偏移。每段视频的偏移都经过我们人工目视核对,没通过核对的病例在这一步失败。视频读不出、找不到分界线、两半对不齐,也在这一步失败。
2. **找绿框、选提示帧。** 在两半上找贵方画的绿框:恰好一半有绿框,那一半就是造影画面;两半都有或都没有,这一例在这一步失败。然后挑出绿框位置或形状有明显变化的几帧作为「提示帧」,把绿框换算到灰阶那一半上。
3. **分割。** 用 MedSAM2 分割模型在灰阶那一半上,从提示帧的绿框出发,逐帧划出病灶范围。
4. **病灶轮廓。** 把逐帧的分割结果变成平滑的轮廓,与绿框的位置和形状融合,再换算回造影那一半。分割结果一帧都没有可用轮廓时,这一例在这一步失败。
5. **暗区。** 在病灶轮廓内找出一直发黑、不进造影剂的部分(如囊液)。
6. **TIC 采样。** TIC(时间-强度曲线)是某个区域的平均亮度随时间的变化。程序在造影那一半上逐帧取四条曲线:病灶整体(轮廓内,去掉绿框和紫圈的颜色)、病灶去暗区(再去掉暗区)、紫圈(贵方画的紫色圈内部,一块正常胰腺作对照)、背景圆(程序在病灶附近自动挑的一小块背景,只用来看整机亮度有没有漂移)。紫圈只有在足够多的帧里都认出来,紫圈曲线才算可用。
7. **曲线拟合。** 用 Lognormal(对数正态)曲线拟合每条 TIC,拟合要通过一组质量检查(下称质量门)才算成功。没通过时记下原因码,含义见第六节。
8. **关键帧。** 定位 7 个关键帧,见第五节。
9. **灌注指标。** 在病灶两条曲线拟合成功时,从拟合曲线算 11 项灌注指标(口径仿照商用造影定量软件 VueBOX),见第六节。
**第三段:装进资料库。** CEUS 定量失败或没有结果的病例整例跳过(检查 C15);关键帧也要能原样重新保存(检查 C14)。通过的病例取化名,把临床、超声、MR 三路资料分开存放,每一路的 AI 专家只看得到自己那一路。
问题清单里「环节」一列的取值:
| 环节 | 对应哪一步 | 问题的写法 |
| --- | --- | --- |
| 装配资料库 | 第一段的检查和第三段的 C14、C15 | 检查编号、检查说明、涉及的文件、列和值,照跳过病例表原样写 |
| CEUS 第 N 步 | 第二段第 N 步整例失败 | 「第 N 步失败:」加程序记录的原因 |
| TIC 拟合 | 第二段第 7 步,病灶整体或病灶去暗区曲线没通过质量门 | 「病灶整体曲线未完成拟合」或「病灶去暗区曲线未完成拟合」,括号里是原因码 |
| 关键帧 | 第二段第 8 步,某个关键帧没取到 | 「槽位 未取到:」加程序记录的原因 |
| 紫圈 | 第二段第 6 步,紫圈曲线不可用 | 「紫圈曲线不可用」 |
一个 CEUS 失败的病例会有一行「CEUS 第 N 步」,一般还同时有一行「装配资料库」(C15),两行来自不同的记录文件;但如果它在 C15 之前就因别的检查被跳过,「装配资料库」那一行写的是那项检查。
## 五、7 个关键帧
| 槽位 | 代表的时刻 | 怎么取 |
| --- | --- | --- |
| B0 | 病灶刚开始变亮之前 | 病灶去暗区曲线的拟合曲线上的到达时刻,再往前退一点,在到达之前的基线段里取帧 |
| D1 | 病灶开始变亮(造影剂到达) | 拟合曲线上的到达时刻 |
| D2 | 变亮最快的时刻(洗入拐点) | 拟合曲线 |
| D3 | 最亮的时刻(峰值) | 拟合曲线 |
| D4 | 变暗最快的时刻(洗出拐点) | 拟合曲线 |
| F1 | 推注后第 $f1_s 秒 | 视频时间 = 推注后时刻 − X |
| F2 | 推注后第 $f2_s 秒 | 视频时间 = 推注后时刻 − X |
B0、D1–D4 都来自病灶去暗区曲线的拟合曲线,在各自时刻附近的允许范围内找最近的可用帧,并保持先后顺序(D2 的帧不会早于 D1 的帧)。F1、F2 按推注时间取:X 是超声报告工作簿的「推注开始时间s」,意思是视频第 0 秒时已经推注了 X 秒,所以推注后第 T 秒在视频第 T − X 秒。F1 取离这个时刻最近的帧;F2 只在这个时刻前后 $f2_tol_s 秒内取。
「未取到」时,问题清单里写的是程序记录的原因原文,有以下几种:
- 「病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门」:病灶整体、病灶去暗区两条曲线没有都拟合成功,B0、D1–D4 都不取。
- 「roi_perf 曲线的 Lognormal 拟合没过质量门(原因码)」:病灶去暗区曲线拟合没成功,B0、D1–D4 都不取。
- 「拟合曲线升不过到达门槛,没有到达时刻」(B0)、「拟合曲线上没有 D1 时刻(升不过到达门槛)」之类:拟合曲线上升的幅度不够,定不出到达时刻。
- 「视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义」:视频一开始病灶就已经在变亮,没有变亮之前的那一段,所以没有 B0。
- 「峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4」:峰值之后录得不够长,拟合曲线上找不到洗出拐点。
- 「某时刻 … 秒落在视频有效帧范围 … 之外」:算出的时刻早于视频第一帧或晚于最后一帧。
- 「推注后第 T 秒在视频第 … 秒(推注时间 X=… 秒),落在 … 之外」(F1、F2):同上,按推注时间换算出的时刻不在视频里。
- 「… 容差窗口 … 内没有可用帧」:时刻附近的允许范围里没有可用的帧(重复帧不算可用)。
- 「… 容差窗口 … 内的候选帧都被时间更早的槽位占用,无法保持先后顺序」:附近的帧已经被前一个关键帧用掉了。
- 「视频第 … 秒 ±… 秒内没有不重复的帧」(F1、F2):同上,附近只有重复帧。
- 「没有推注时间,推注后时刻换算不到视频时间」(F1、F2):没有推注时间。
## 六、TIC 曲线图和 TIC 结果怎么看
**TIC曲线.png**:横轴是视频时间(秒),纵轴是灰度亮度。图上的英文标注:
| 图上的英文 | 中文意思 |
| --- | --- |
| TIC: Lognormal fit | 图标题:时间-强度曲线及其对数正态拟合 |
| Time (s) | 横轴:视频时间(秒) |
| Grayscale intensity | 纵轴:灰度亮度 |
| roi_all raw | 病灶整体曲线的逐帧原始值(蓝色小点) |
| roi_perf (dark removed) raw | 病灶去暗区曲线的逐帧原始值(橙色小点) |
| roi_pan reference raw | 紫圈曲线(正常胰腺对照)的逐帧原始值(紫色小点) |
| … Lognormal fit | 对应曲线的拟合曲线(粗实线),只画拟合成功的 |
| roi_pan reference Lognormal fit [QC rejected] | 紫圈曲线没通过质量门的拟合(紫色虚线),仍画出来供参考;病灶曲线拟合没通过时不画 |
| D1 model、D2 model、D3 model、D4 model | 竖虚线:拟合曲线上 D1–D4 的时刻(见第五节) |
| [no_frame_in_window] | 跟在竖线标签后面:这个时刻附近的允许范围里没有可用帧,关键帧没取到 |
| [no_ordered_frame] | 附近的帧都被时间更早的关键帧占用,关键帧没取到 |
| [not_covered] | 这个时刻落在视频有效帧范围之外,关键帧没取到 |
| [anchor_undefined] | 病灶两条曲线没有都拟合成功,这个时刻不用来取关键帧 |
| No plottable TIC data | 没有能画的曲线数据 |
背景圆曲线不画在图上。竖线没有标状态的,表示这个关键帧取到了。
**TIC结果.csv 的各项**:
- 四条曲线(病灶整体 roi_all、病灶去暗区 roi_perf、紫圈 roi_pan、背景圆 background_circle)各自「拟合成功」(success)和「拟合优度 R²」(r2,越接近 1 拟合越贴合;算不出时空着)。
- 病灶两条曲线的灌注指标(perfusion_metrics;拟合没成功时整组空着)。观测时间不够算不出的单项空着,不外推。
- 信号域 signal_domain = db_display:在视频显示的灰度上算,不是换算后的回声功率;时间零点 time_origin = video_start:时间从视频开头算起;面积所用曲线 area_curve = fitted_minus_baseline:面积用拟合曲线减去基线。
- PE:峰值增强(峰值减基线);TTP:达峰时间(秒);WiR:最大洗入斜率;WiAUC:洗入段曲线下面积(TI 到 TTP);RT:上升时间(TTP − TI);WiPI:洗入灌注指数(WiAUC / RT);WoR:最大洗出斜率;WoAUC:洗出段曲线下面积(TTP 到 TO);FT:下降时间(TO − TTP);WiWoAUC:洗入洗出总面积;mTTl:平均渡越时间(mTT − TI)。
- 辅助时刻:TI 是洗入切线与基线的交点,TO 是洗出切线与基线的交点,mTT 是 TI 到 TO 之间的平均渡越时刻(都是视频时间,秒)。
- 紫圈曲线可用(pan_curve_usable):紫圈在足够多的帧里认出来时为 true。
- 7 个关键帧各自「取到」(located)、「视频时间」(video_time_s,秒)、「未取到的原因」(reason)。
**拟合没通过质量门的原因码**(问题清单「TIC 拟合」一行括号里的英文):
| 原因码 | 意思 |
| --- | --- |
| insufficient_valid_samples | 可用的帧(亮度能取到、不是重复帧)太少 |
| candidate_grid_unavailable | 可用帧的时间点无法排成拟合用的等间隔时间轴 |
| credible_peak_not_found | 平滑后的曲线上找不到够高、够突出的峰 |
| candidate_arrival_not_found | 峰之前找不到亮度持续越过到达门槛的时刻 |
| insufficient_bolus_samples | 第一次团注(造影剂第一次通过)那一段里可用帧太少 |
| long_gap_in_wash_in | 峰之前的上升段里有一段太长的时间缺口(中间缺帧) |
| arrival_bounds_invalid | 按观测范围给到达时刻、峰时刻划的允许区间不成立(区间为空) |
| optimizer_failed | 拟合计算从各组起始值出发都没有算出结果 |
| parameter_on_boundary | 拟合出的曲线参数贴在允许范围的边上 |
| model_peak_on_boundary | 拟合曲线的峰落在拟合时间段的两端附近 |
| fit_quality_failed | 拟合曲线与原始数据贴合得不够(误差太大,且拟合优度和残差都没达标) |
**回放视频.mp4**:原视频逐帧叠加程序画的线:造影那一半的病灶轮廓是黄色,灰阶那一半的病灶轮廓是绿色,造影那一半的暗区轮廓是蓝色(灰阶那一半不画暗区),背景圆是青色圆圈。贵方画的绿框和紫圈是视频原有的。
## 七、化名与报告
**化名**:资料库里每例用一个 4 位随机字母数字代替病例号。交付文件名里带着病例号,而表格的行序可能与诊断分组有关;用随机化名并按化名排序,AI 专家就无法从编号或顺序猜出诊断。化名与病例号的对照只在标准答案里。
**报告只放描述段**:资料库里只放超声和 MR 报告的「所见」描述,不放「印象 / 结论」段,因为结论段写着医生的诊断倾向,AI 专家看到它就等于看到了答案。程序检查每份报告的描述文字里有没有结论段标题(如「超声提示:」「印象:」「诊断:」,检查 C11),有就整例跳过,不进资料库。超声报告.pdf 和 MR报告.pdf 就是资料库里 AI 专家看到的那段文字,一字未改;PDF 第一行的标题是我们加的。
## 八、编码列
诊断编码:1 IPMN(导管内乳头状黏液性肿瘤),2 SPN(实性假乳头状肿瘤),3 MCN(黏液性囊性肿瘤),4 SCN(浆液性囊性肿瘤)。出自交付的超声、MR 报告工作簿的诊断列表头。
$codes_note
## 九、跳过病例
一例只要有一项检查没通过,就整例跳过,不进资料库:缺了某一路资料或资料前后对不上,这一例就无法公平地交给三位 AI 专家会诊。跳过病例表里每例只记第一项没通过的检查:遇到它就跳过整例,后面的检查没有做过,所以表里没写的检查不代表没有问题。
+12 -2
View File
@@ -1,5 +1,6 @@
"""runs:构建号怎么取、运行快照记什么。会诊运行目录等会诊那一步再加。"""
"""runs:构建号怎么取、运行快照记什么、怎么读一次 CEUS 定量运行。会诊运行目录等会诊那一步再加。"""
from cadx.runs._ceus_run import CeusFailure, CeusRun, open_ceus_run
from cadx.runs._snapshot import (
GitState,
finish_snapshot,
@@ -8,4 +9,13 @@ from cadx.runs._snapshot import (
write_snapshot,
)
__all__ = ["GitState", "finish_snapshot", "make_build_id", "read_git_state", "write_snapshot"]
__all__ = [
"CeusFailure",
"CeusRun",
"GitState",
"finish_snapshot",
"make_build_id",
"open_ceus_run",
"read_git_state",
"write_snapshot",
]
+82
View File
@@ -0,0 +1,82 @@
"""读一次已完成的 CEUS 定量运行(`runs/ceus/<运行号>/`)的目录布局。
布局由 `scripts/ceus_quant.py` 定:`failed.csv`、`snapshot/snapshot.json`、`snapshot/ceus.yaml`、
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`、`<视频号>/tic.png`、`<视频号>/overlay.mp4`。
这里只管「文件在哪、怎么读出来」;从 summary.json 挑哪些字段给专家由 build 决定。
"""
import csv
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, cast
import yaml
_FAILED_HEADER = ["video_id", "step", "reason"]
@dataclass(frozen=True)
class CeusFailure:
"""failed.csv 的一行:失败在第几步、原因原文。"""
step: int
reason: str
@dataclass(frozen=True)
class CeusRun:
"""一次已完成的 CEUS 运行:运行号、目录、快照里的整份 ceus.yaml、failed.csv。
`failed` 是视频号 → 失败。由 `open_ceus_run` 核对目录后交出。
"""
run_id: str
run_dir: Path
config: dict[str, Any]
failed: dict[str, CeusFailure]
def has_result(self, video_id: str) -> bool:
"""运行目录里有没有这段视频的结果目录。"""
return (self.run_dir / video_id).is_dir()
def summary(self, video_id: str) -> dict[str, Any]:
"""这段视频的 summary.json;里面记的视频号与所在目录不一致就报错。"""
path = self.run_dir / video_id / "summary.json"
content = cast(dict[str, Any], json.loads(path.read_text(encoding="utf-8")))
if content["video_id"] != video_id:
raise ValueError(f"summary.json 的视频号与所在目录不一致:{path}")
return content
def keyframe_files(self, video_id: str) -> tuple[Path, ...]:
"""`keyframes/` 里的全部文件(按文件名排序),不按扩展名筛选,由调用方核对。"""
return tuple(sorted((self.run_dir / video_id / "keyframes").iterdir()))
def tic_png(self, video_id: str) -> Path:
"""这段视频的 TIC 曲线图 tic.png。"""
return self.run_dir / video_id / "tic.png"
def overlay(self, video_id: str) -> Path:
"""这段视频的回放视频 overlay.mp4。"""
return self.run_dir / video_id / "overlay.mp4"
def open_ceus_run(run_dir: Path) -> CeusRun:
"""核对运行目录(存在、不是 `.partial`、有快照),读快照里的 ceus.yaml 和 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}")
config = yaml.safe_load((run_dir / "snapshot" / "ceus.yaml").read_text(encoding="utf-8"))
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}")
failed: dict[str, CeusFailure] = {}
for video_id, step, reason in rows[1:]:
if not step.isdigit() or video_id in failed:
raise ValueError(f"failed.csv 的行不合规(步号不是整数或视频号重复):{run_dir}")
failed[video_id] = CeusFailure(step=int(step), reason=reason)
return CeusRun(run_dir.name, run_dir, cast(dict[str, Any], config), failed)
+1 -1
View File
@@ -63,7 +63,7 @@ def write_snapshot(
uv_lock: Path,
configs: Mapping[str, Path],
) -> None:
"""建 `snapshot_dir`,写 snapshot.json,并把 `configs`(快照里的文件名 → 原文件)原样复制进去。"""
"""建 `snapshot_dir`,写 snapshot.json,把 `configs`(快照里的文件名 → 原文件)原样复制进去。"""
snapshot_dir.mkdir(exist_ok=False)
copied: dict[str, str] = {}
for name, source in configs.items():
+13 -4
View File
@@ -1,5 +1,6 @@
"""测试用假 CEUS 运行目录:布局照 `scripts/ceus_quant.py` 写出的(failed.csv、snapshot/、
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`),数值全部编造,不调显卡。
`<视频号>/summary.json`、`<视频号>/keyframes/<槽位>.png`、`<视频号>/tic.png`、`<视频号>/overlay.mp4`),
数值全部编造,不调显卡。tic.png 和 overlay.mp4 只是带视频号的几个字节(核对包按字节拷贝,不打开)。
summary.json 故意带上 tic.json 不该有的字段(视频号、视频 SHA-256、分割缓存键、帧号、拟合参数、
失败原因代码、推注时间、紫圈检出率),用来测 build 只按白名单挑字段。视频 SHA-256 和推注时间照
@@ -12,12 +13,14 @@ import json
from pathlib import Path
from typing import Any
import yaml
from PIL import Image, PngImagePlugin
from fake_delivery import VIDEO_BYTES
from fake_delivery import REPO_ROOT, VIDEO_BYTES
SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
CURVES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
CEUS_YAML = REPO_ROOT / "config" / "ceus.yaml"
UNLOCATED_REASON = "拟合曲线上没有 D4 时刻(编造的原因)"
@@ -112,8 +115,12 @@ def write_run(
槽位,`failed` 是 failed.csv 的行。"""
(run_dir / "snapshot").mkdir(parents=True)
(run_dir / "snapshot" / "snapshot.json").write_text("{}", encoding="utf-8")
ceus_yaml = f"paths:\n delivery_dir: {json.dumps(str(delivery_dir), ensure_ascii=False)}\n"
(run_dir / "snapshot" / "ceus.yaml").write_text(ceus_yaml, encoding="utf-8")
# 快照里的 ceus.yaml 照仓库里的那份,只把交付包路径换成这份假交付包。
ceus_yaml = yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8"))
ceus_yaml["paths"]["delivery_dir"] = str(delivery_dir)
(run_dir / "snapshot" / "ceus.yaml").write_text(
yaml.safe_dump(ceus_yaml, allow_unicode=True), 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"))
@@ -124,4 +131,6 @@ def write_run(
(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)
(run_dir / video_id / "tic.png").write_bytes(f"fake tic.png {video_id}".encode())
(run_dir / video_id / "overlay.mp4").write_bytes(f"fake overlay.mp4 {video_id}".encode())
return run_dir
+20
View File
@@ -277,3 +277,23 @@ def test_run_dir_without_snapshot_stops(tmp_path: Path) -> None:
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, [])
(run / "snapshot" / "snapshot.json").unlink()
_assert_stops(tmp_path, run, FileNotFoundError)
@pytest.mark.parametrize(
("extra", "remove"),
[
("B0.tmp", None), # B0.png 与同主干的 B0.tmp 并存:多一个文件
("B0.jpg", "B0.png"), # 只有 B0.jpg:扩展名不是 .png
],
)
def test_keyframe_files_must_be_exactly_slot_pngs(
tmp_path: Path, extra: str, remove: str | None
) -> None:
run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": ("B0", "D1")}, [])
folder = run / "U001" / "keyframes"
(folder / extra).write_bytes((folder / "B0.png").read_bytes())
if remove is not None:
(folder / remove).unlink()
with pytest.raises(ValueError, match="定位到的槽位不一致"):
_build(tmp_path, 1, run)
assert not (tmp_path / "library").exists()
+20
View File
@@ -29,6 +29,7 @@ from cadx.answers import read_aliases
from cadx.build import run_build
from cadx.ceus import ANCHOR_SLOTS
from cadx.library import list_aliases, read_ultrasound
from cadx.review import run_review_pack
from conftest import CEUS_YAML
from fake_ceus_video import HEIGHT, LESION, SPLIT, WIDTH, write_video
from fake_delivery import REPO_ROOT, make_delivery, write_config
@@ -243,6 +244,25 @@ def test_build_reads_real_run_layout(
assert route.tic["keyframes"]["F1"]["video_time_s"] == pytest.approx(15.0, abs=0.1)
assert route.tic["keyframes"]["F2"]["located"] is False
# 医生核对包也读这个真实布局:TIC 曲线图和回放视频按字节拷进病例目录。
pack_config = tmp_path / "review_pack.yaml"
pack_config.write_text(
yaml.safe_dump(
{
"library_dir": str(result.library_dir),
"answers_dir": str(result.answers_dir),
"output_root": str(tmp_path / "review_pack"),
}
),
encoding="utf-8",
)
pack = run_review_pack(pack_config, REPO_ROOT, datetime(2026, 10, 17, 12, 0, 0).astimezone())
case_dir = pack.pack_dir / "病例" / "P001"
assert (case_dir / "TIC曲线.png").read_bytes() == (run_dir / "U001" / "tic.png").read_bytes()
assert (case_dir / "回放视频.mp4").read_bytes() == (
run_dir / "U001" / "overlay.mp4"
).read_bytes()
def test_unreadable_video_fails_at_step_1_and_next_case_runs(
tmp_path: Path,
+458
View File
@@ -0,0 +1,458 @@
"""医生核对包:目录结构、CSV 规格、问题清单的每种来源、逐字节拷贝、TIC结果、没装 CEUS 的构建、PDF。
预期值照任务书 03 写:目录和文件名、列名、问题清单的「环节」与「问题」原文、关键帧文件名的秒数
(`video_time_s` 保留一位小数)都按任务书的文字手写,不从实现里推。
假数据(6 例):
- P001:进库;定位到 D1、D2、F1,其余 4 个槽位未取到;病灶去暗区曲线拟合失败
(parameter_on_boundary);紫圈不可用。
- P002:CEUS 第 2 步失败(C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。
- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒(测一位小数);拟合都成功;紫圈可用。
- P005:MR 表诊断与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录(被跳过但定量成功)。
- P006:进库;7 个槽位都取到;病灶整体曲线拟合失败(credible_peak_not_found);紫圈可用。
"""
import csv
import hashlib
import json
import re
import shutil
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Any
import pytest
import yaml
from cadx.answers import read_aliases
from cadx.build import BuildSummary, run_build
from cadx.library import read_ultrasound
from cadx.review import run_review_pack
from cadx.review._pdf import markdown_to_pdf, report_markdown
from fake_ceus_run import UNLOCATED_REASON, summary, write_run
from fake_delivery import DIAGNOSIS_COLUMN, REPO_ROOT, make_delivery, write_config
BUILD_NOW = datetime(2026, 10, 17, 9, 0, 0).astimezone()
PACK_NOW = datetime(2026, 10, 17, 10, 0, 0).astimezone()
RUN_ID = "20261017-080000-abcdef0"
ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
SUMMARY_HEADER = [
"病例号",
"视频号",
"化名",
"是否进入资料库",
"没进的原因",
"CEUS 定量是否成功",
"CEUS 失败在第几步",
*ALL_SLOTS,
]
PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"]
ANSWER_HEADERS = {
"化名对照.csv": ["化名", "病例号", "视频号"],
"诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"],
"跳过病例.csv": ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"],
"报告复核句.csv": ["化名", "路", "命中的词", "所在句子"],
}
CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键图.png"}
CEUS_FILES = {"关键帧", "TIC曲线.png", "TIC结果.csv", "回放视频.mp4"}
def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None:
content = summary(video_id, located)
edit(content)
(run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8")
def _fail_fit(curve: str, code: str) -> Any:
def edit(content: dict[str, Any]) -> None:
content["fits"][curve].update(success=False, failure_reason=code)
return edit
def _p004(content: dict[str, Any]) -> None:
content["reference_valid"] = True
content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04
content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46
def _p006(content: dict[str, Any]) -> None:
_fail_fit("roi_all", "credible_peak_not_found")(content)
content["reference_valid"] = True
def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]:
delivery = make_delivery(6)
delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2
delivery_dir = delivery.write(root / "delivery")
run = write_run(
root / "runs" / RUN_ID,
delivery_dir,
{"U001": ("D1", "D2", "F1"), "U004": ALL_SLOTS, "U005": ALL_SLOTS, "U006": ALL_SLOTS},
[("U002", 2, "两半都没有绿框")],
)
_rewrite_summary(
run, "U001", ("D1", "D2", "F1"), _fail_fit("roi_perf", "parameter_on_boundary")
)
_rewrite_summary(run, "U004", ALL_SLOTS, _p004)
_rewrite_summary(run, "U006", ALL_SLOTS, _p006)
return run_build(write_config(root, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW), run
def _pack_config(root: Path, library_dir: Path, answers_dir: Path) -> Path:
config = {
"library_dir": str(library_dir),
"answers_dir": str(answers_dir),
"output_root": str(root / "review_pack"),
}
path = root / "review_pack.yaml"
path.write_text(yaml.safe_dump(config, allow_unicode=True), encoding="utf-8")
return path
def _tree_hashes(folder: Path) -> dict[str, str]:
return {
p.relative_to(folder).as_posix(): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(folder.rglob("*"))
if p.is_file()
}
class Packed:
"""构建一次、出一次包,各条测试共用。"""
def __init__(self, root: Path) -> None:
self.build, self.run = _build_with_ceus(root)
self.library_before = _tree_hashes(self.build.library_dir)
self.answers_before = _tree_hashes(self.build.answers_dir)
config = _pack_config(root, self.build.library_dir, self.build.answers_dir)
self.config = config
self.result = run_review_pack(config, REPO_ROOT, PACK_NOW)
self.pack = self.result.pack_dir
self.alias = {r.case_no: r.alias for r in read_aliases(self.build.answers_dir)}
@pytest.fixture(scope="module")
def packed(tmp_path_factory: pytest.TempPathFactory) -> Packed:
return Packed(tmp_path_factory.mktemp("review"))
def _csv(path: Path) -> list[list[str]]:
raw = path.read_bytes()
assert raw.startswith(b"\xef\xbb\xbf"), path
with path.open(encoding="utf-8-sig", newline="") as handle:
return list(csv.reader(handle))
def test_directory_layout(packed: Packed) -> None:
pack = packed.pack
assert pack.name == f"医生核对包_{packed.build.build_id}"
assert sorted(p.name for p in packed.result.run_dir.iterdir()) == sorted(
["snapshot", pack.name]
)
assert {p.name for p in pack.iterdir()} == {
"说明.pdf",
"问题清单.csv",
"病例总表.csv",
"标准答案",
"病例",
}
assert {p.name for p in (pack / "标准答案").iterdir()} == set(ANSWER_HEADERS)
assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006"}
for case in ("P001", "P004", "P006"):
assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES
assert {p.name for p in (pack / "病例" / "P001" / "关键帧").iterdir()} == {
"D1_第2.5秒.png",
"D2_第5.0秒.png",
"F1_第12.5秒.png",
}
assert {p.name for p in (pack / "病例" / "P004" / "关键帧").iterdir()} == {
"B0_第0.0秒.png",
"D1_第2.5秒.png",
"D2_第5.0秒.png",
"D3_第7.5秒.png",
"D4_第10.0秒.png",
"F1_第12.5秒.png",
"F2_第15.0秒.png",
}
def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None:
csvs = sorted(packed.pack.rglob("*.csv"))
assert len(csvs) == 2 + 4 + 3 * 2
for path in csvs:
for row in _csv(path):
assert not {"null", "None", "NaN", "nan"} & set(row), path
assert _csv(packed.pack / "病例总表.csv")[0] == SUMMARY_HEADER
assert _csv(packed.pack / "问题清单.csv")[0] == PROBLEM_HEADER
for name, header in ANSWER_HEADERS.items():
assert _csv(packed.pack / "标准答案" / name)[0] == header
clinical = _csv(packed.pack / "病例" / "P001" / "临床信息.csv")
assert len(clinical) == 2 and "Case_ID" not in clinical[0] and "Diagnosis" not in clinical[0]
assert clinical[0][:3] == ["Sex", "Age", "Symptom_Pain"]
# 假交付包里没测的化验是 None:留空。
assert clinical[1][clinical[0].index("Amylase")] == ""
def test_case_summary(packed: Packed) -> None:
rows = _csv(packed.pack / "病例总表.csv")[1:]
blank = [""] * 7
a = packed.alias
assert rows == [
[
"P001",
"U001",
a["001"],
"是",
"",
"是",
"",
"未取到",
"2.5",
"5.0",
"未取到",
"未取到",
"12.5",
"未取到",
],
["P002", "U002", "", "否", "C15 CEUS 定量结果齐全", "否", "2", *blank],
["P003", "U003", "", "否", "C15 CEUS 定量结果齐全", "", "", *blank],
[
"P004",
"U004",
a["004"],
"是",
"",
"是",
"",
"0.0",
"2.5",
"5.04",
"7.5",
"10.0",
"12.46",
"15.0",
],
["P005", "U005", "", "否", "C10 跨表一致(诊断、年龄、性别)", "是", "", *blank],
[
"P006",
"U006",
a["006"],
"是",
"",
"是",
"",
"0.0",
"2.5",
"5.0",
"7.5",
"10.0",
"12.5",
"15.0",
],
]
def test_problem_list_has_every_source(packed: Packed) -> None:
rows = _csv(packed.pack / "问题清单.csv")[1:]
skipped = {row[0]: row for row in _csv(packed.pack / "标准答案" / "跳过病例.csv")[1:]}
unlocated = [
["P001", "U001", "关键帧", f"{slot} 未取到:{UNLOCATED_REASON}"]
for slot in ("B0", "D3", "D4", "F2")
]
assert rows == [
["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"],
*unlocated,
["P001", "U001", "紫圈", "紫圈曲线不可用"],
[
"P002",
"U002",
"装配资料库",
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;"
'涉及值:"第 2 步失败:两半都没有绿框"',
],
["P002", "U002", "CEUS 第 2 步", "第 2 步失败:两半都没有绿框"],
[
"P003",
"U003",
"装配资料库",
f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;"
'涉及值:"CEUS 运行目录里没有这一例的结果目录"',
],
[
"P005",
"U005",
"装配资料库",
f"C10 跨表一致(诊断、年龄、性别);文件:{skipped['005'][3]};"
f"列:{skipped['005'][4]};涉及值:{skipped['005'][5]}",
],
["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"],
]
counts = {"装配资料库": 3, "CEUS 第 2 步": 1, "TIC 拟合": 2, "关键帧": 4, "紫圈": 1}
assert packed.result.problems_by_stage == dict(sorted(counts.items()))
def test_copies_are_byte_identical(packed: Packed) -> None:
for case_no, video_id in (("001", "U001"), ("004", "U004")):
case_dir = packed.pack / "病例" / f"P{case_no}"
route = read_ultrasound(packed.build.library_dir, packed.alias[case_no])
assert (case_dir / "关键图.png").read_bytes() == route.key_image.read_bytes()
assert route.tic is not None
copied = {p.name.split("_")[0]: p for p in (case_dir / "关键帧").iterdir()}
assert sorted(copied) == sorted(route.keyframes)
for slot, source in route.keyframes.items():
assert copied[slot].read_bytes() == source.read_bytes()
seconds = route.tic["keyframes"][slot]["video_time_s"]
assert copied[slot].name == f"{slot}_第{round(seconds, 1)}秒.png"
run_case = packed.run / video_id
assert (case_dir / "TIC曲线.png").read_bytes() == (run_case / "tic.png").read_bytes()
assert (case_dir / "回放视频.mp4").read_bytes() == (run_case / "overlay.mp4").read_bytes()
answers = packed.build.answers_dir
for source, name in (
("aliases.csv", "化名对照.csv"),
("diagnoses.csv", "诊断.csv"),
("skipped.csv", "跳过病例.csv"),
("review.csv", "报告复核句.csv"),
):
assert (packed.pack / "标准答案" / name).read_bytes() == (answers / source).read_bytes()
def _tool_output(tool: str, *args: str) -> str:
path = shutil.which(tool)
assert path is not None, f"找不到 {tool}"
return subprocess.run([path, *args], check=True, capture_output=True, text=True).stdout # noqa: S603
def _expected_cell(value: object) -> str:
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return value if isinstance(value, str) else repr(value)
def test_tic_results_match_tic_json(packed: Packed) -> None:
for case_no in ("001", "004", "006"):
tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic
assert tic is not None
rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC结果.csv")
assert rows[0] == ["项目", "值"]
# 4 条曲线 × 2 + 病灶 2 条 ×(3 个口径 + 11 个指标 + 3 个辅助时刻)+ 紫圈 1 + 7 槽位 × 3。
assert len(rows) - 1 == 8 + 34 + 1 + 21
for item, value in rows[1:]:
path = item[item.rindex("(") + 1 : -1]
node: Any = tic
for key in path.split("."):
node = None if node is None else node[key]
assert value == _expected_cell(node), (case_no, item)
rows = dict(_csv(packed.pack / "病例" / "P001" / "TIC结果.csv")[1:])
assert rows["病灶整体曲线 拟合成功(fits.roi_all.success)"] == "true"
assert rows["病灶去暗区曲线 拟合成功(fits.roi_perf.success)"] == "false"
assert rows["紫圈曲线可用(pan_curve_usable)"] == "false"
assert rows["关键帧 D4 未取到的原因(keyframes.D4.reason)"] == UNLOCATED_REASON
def test_pdfs_use_noto_cjk_and_carry_ids(packed: Packed) -> None:
pdfs = sorted(packed.pack.rglob("*.pdf"))
assert len(pdfs) == packed.result.pdfs == 1 + 3 * 2
for pdf in pdfs:
assert pdf.read_bytes().startswith(b"%PDF") and pdf.stat().st_size > 1000
readme = packed.pack / "说明.pdf"
fonts = _tool_output("pdffonts", str(readme))
assert "NotoSerifCJK" in fonts and "NotoSansCJK" in fonts
text = _tool_output("pdftotext", str(readme), "-")
assert packed.build.build_id in text and RUN_ID in text and "parameter_on_boundary" in text
report = _tool_output("pdftotext", str(packed.pack / "病例" / "P001" / "超声报告.pdf"), "-")
assert report.startswith("病例号 P001 超声描述")
def test_snapshot_and_no_writes_to_libraries(packed: Packed) -> None:
snapshot = packed.result.run_dir / "snapshot"
assert (snapshot / "review_pack.yaml").read_bytes() == packed.config.read_bytes()
record = json.loads((snapshot / "review.json").read_text(encoding="utf-8"))
assert record["build_id"] == packed.build.build_id and record["ceus_run_id"] == RUN_ID
assert record["pandoc"].startswith("pandoc ") and "LibreOffice" in record["soffice"]
assert (snapshot / "finished.json").is_file()
assert _tree_hashes(packed.build.library_dir) == packed.library_before
assert _tree_hashes(packed.build.answers_dir) == packed.answers_before
def test_existing_run_dir_stops(packed: Packed) -> None:
with pytest.raises(FileExistsError):
run_review_pack(packed.config, REPO_ROOT, PACK_NOW)
def test_build_without_ceus(tmp_path: Path) -> None:
delivery_dir = make_delivery(2).write(tmp_path / "delivery")
build = run_build(write_config(tmp_path, delivery_dir, 1, None, None), REPO_ROOT, BUILD_NOW)
result = run_review_pack(
_pack_config(tmp_path, build.library_dir, build.answers_dir), REPO_ROOT, PACK_NOW
)
for case in ("P001", "P002"):
assert {p.name for p in (result.pack_dir / "病例" / case).iterdir()} == CASE_FILES
alias = {r.case_no: r.alias for r in read_aliases(build.answers_dir)}
rows = _csv(result.pack_dir / "病例总表.csv")[1:]
assert [row[:5] for row in rows] == [
["P001", "U001", alias["001"], "是", ""],
["P002", "U002", alias["002"], "是", ""],
]
assert all(row[5:] == [""] * 9 for row in rows)
assert _csv(result.pack_dir / "问题清单.csv") == [PROBLEM_HEADER]
assert result.problems_by_stage == {} and result.video_bytes == 0
def test_build_ids_differ_stops(tmp_path: Path) -> None:
config = _pack_config(tmp_path, tmp_path / "library" / "a", tmp_path / "answers" / "b")
with pytest.raises(ValueError, match="不是同一次构建"):
run_review_pack(config, REPO_ROOT, PACK_NOW)
assert not (tmp_path / "review_pack").exists()
def test_report_pdf_keeps_markdown_characters(tmp_path: Path) -> None:
"""报告正文里的 Markdown 记号、引号、连字符、换行照抄,不被 pandoc 当成格式。
行首空格抽回文字时只剩一个(pdftotext 按版面估空格),所以每行比较去掉首尾空白后的内容。
"""
text = (
'1. 囊壁*增厚* #2 a_b_c <b>x</b> [链接](y) -- "引号" ... `码` | 竖线\n 第二行行首两个空格'
)
target = tmp_path / "报告.pdf"
markdown_to_pdf({target: report_markdown("病例号 P001 超声描述", text)})
lines = _tool_output("pdftotext", "-layout", str(target), "-").splitlines()
assert [line.strip() for line in lines if line.strip()][1:] == [
line.strip() for line in text.splitlines()
]
def _gap_after_ab(pdf: Path) -> float:
"""pdftotext -bbox 里词「ab」右边界到下一个词左边界的距离(点)。"""
boxes = re.findall(
r'xMin="([0-9.]+)"[^>]*xMax="([0-9.]+)"[^>]*>([^<]*)</word>',
_tool_output("pdftotext", "-bbox", str(pdf), "-"),
)
words = [(float(x0), float(x1), w) for x0, x1, w in boxes]
index = next(i for i, (_, _, w) in enumerate(words) if w == "ab")
return words[index + 1][0] - words[index][1]
def test_report_pdf_keeps_repeated_spaces(tmp_path: Path) -> None:
"""一行里连续三个空格不被 pandoc 并成一个。
pdftotext 抽文字时按间距估空格,三个空格也只吐出一个,所以量版面:「ab」与「cd」之间的间距是
单个空格那份 PDF 的约三倍。
"""
one, three = tmp_path / "一个空格.pdf", tmp_path / "三个空格.pdf"
markdown_to_pdf(
{
one: report_markdown("病例号 P001 超声描述", "ab cd"),
three: report_markdown("病例号 P001 超声描述", "ab cd"),
}
)
assert _gap_after_ab(three) / _gap_after_ab(one) == pytest.approx(3.0, abs=0.3)
def test_report_with_tab_stops() -> None:
with pytest.raises(ValueError, match="病例号 P001 超声描述:报告原文里有 Tab"):
report_markdown("病例号 P001 超声描述", "囊壁厚\t约2mm")