diff --git a/config/review_pack.yaml b/config/review_pack.yaml new file mode 100644 index 0000000..f34b872 --- /dev/null +++ b/config/review_pack.yaml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 5daf4c1..68b446a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/research-wiki/explanation/结构.md b/research-wiki/explanation/结构.md index 8680191..9a2aa78 100644 --- a/research-wiki/explanation/结构.md +++ b/research-wiki/explanation/结构.md @@ -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-`,工作区不干净时末尾加 `-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`,相对路径按仓库根目录解析;两个库的目录名(构建号)不同就报错。 diff --git a/research-wiki/guides/医生核对包.md b/research-wiki/guides/医生核对包.md new file mode 100644 index 0000000..f6767ac --- /dev/null +++ b/research-wiki/guides/医生核对包.md @@ -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//`。 + - **`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-`,工作区有未提交改动时末尾加 `-dirty`(`装配两个库.md` 第三节「构建号」)。三者各指一样东西,本文用不同的名字区分: + - **构建号**:一次构建,即 `library/<构建号>/`、`answers/<构建号>/`; + - **CEUS 运行号**:一次 CEUS 定量运行,即 `runs/ceus//`; + - **出包号**:出一次核对包,即 `runs/review_pack/<出包号>/`,取出包那一刻的时间。 + +## 一、什么时候跑 + +医生每交一版新数据,按顺序做完: + +1. CEUS 定量 `scripts/ceus_quant.py`,得到新的 `runs/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` 里补上中文名,同时补说明模板 | diff --git a/scripts/review_pack.py b/scripts/review_pack.py new file mode 100644 index 0000000..ff6e424 --- /dev/null +++ b/scripts/review_pack.py @@ -0,0 +1,37 @@ +"""出医生核对包:把一次构建(资料库 + 答案库)和它装入的 CEUS 运行整理成医生能直接看的文件夹。 + +用法:uv run python scripts/review_pack.py --config config/review_pack.yaml +产物写进 `/<运行号>/`:`医生核对包_<构建号>/` 是要交给医生的,`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() diff --git a/src/cadx/answers/__init__.py b/src/cadx/answers/__init__.py index 04d3d6c..852b3ae 100644 --- a/src/cadx/answers/__init__.py +++ b/src/cadx/answers/__init__.py @@ -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", ] diff --git a/src/cadx/answers/_files.py b/src/cadx/answers/_files.py index 95c2651..a03893e 100644 --- a/src/cadx/answers/_files.py +++ b/src/cadx/answers/_files.py @@ -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 + ) diff --git a/src/cadx/build/_assemble.py b/src/cadx/build/_assemble.py index 73e11f0..ed30002 100644 --- a/src/cadx/build/_assemble.py +++ b/src/cadx/build/_assemble.py @@ -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: diff --git a/src/cadx/build/_ceus.py b/src/cadx/build/_ceus.py index 5baa14b..a007279 100644 --- a/src/cadx/build/_ceus.py +++ b/src/cadx/build/_ceus.py @@ -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}) diff --git a/src/cadx/library/__init__.py b/src/cadx/library/__init__.py index 9b85ea4..21a27a4 100644 --- a/src/cadx/library/__init__.py +++ b/src/cadx/library/__init__.py @@ -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", diff --git a/src/cadx/library/_read.py b/src/cadx/library/_read.py index 61cb490..4ca63d4 100644 --- a/src/cadx/library/_read.py +++ b/src/cadx/library/_read.py @@ -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"]) diff --git a/src/cadx/review/__init__.py b/src/cadx/review/__init__.py new file mode 100644 index 0000000..1e8268a --- /dev/null +++ b/src/cadx/review/__init__.py @@ -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"] diff --git a/src/cadx/review/_config.py b/src/cadx/review/_config.py new file mode 100644 index 0000000..ebd23cb --- /dev/null +++ b/src/cadx/review/_config.py @@ -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, + } + ) diff --git a/src/cadx/review/_pack.py b/src/cadx/review/_pack.py new file mode 100644 index 0000000..d1c0c46 --- /dev/null +++ b/src/cadx/review/_pack.py @@ -0,0 +1,407 @@ +"""出一次医生核对包:目录布局、每个文件的格式、问题清单的来源与写法。 + +``` +/<运行号>/ + 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, + ) diff --git a/src/cadx/review/_pdf.py b/src/cadx/review/_pdf.py new file mode 100644 index 0000000..3ce841a --- /dev/null +++ b/src/cadx/review/_pdf.py @@ -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) diff --git a/src/cadx/review/readme_template.md b/src/cadx/review/readme_template.md new file mode 100644 index 0000000..25aff1e --- /dev/null +++ b/src/cadx/review/readme_template.md @@ -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 专家会诊。跳过病例表里每例只记第一项没通过的检查:遇到它就跳过整例,后面的检查没有做过,所以表里没写的检查不代表没有问题。 diff --git a/src/cadx/runs/__init__.py b/src/cadx/runs/__init__.py index b7d9a38..821d4ed 100644 --- a/src/cadx/runs/__init__.py +++ b/src/cadx/runs/__init__.py @@ -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", +] diff --git a/src/cadx/runs/_ceus_run.py b/src/cadx/runs/_ceus_run.py new file mode 100644 index 0000000..9d2f411 --- /dev/null +++ b/src/cadx/runs/_ceus_run.py @@ -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) diff --git a/src/cadx/runs/_snapshot.py b/src/cadx/runs/_snapshot.py index 7d828cc..a7e37c1 100644 --- a/src/cadx/runs/_snapshot.py +++ b/src/cadx/runs/_snapshot.py @@ -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(): diff --git a/tests/fake_ceus_run.py b/tests/fake_ceus_run.py index 54f343b..acebba7 100644 --- a/tests/fake_ceus_run.py +++ b/tests/fake_ceus_run.py @@ -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 diff --git a/tests/test_build_ceus.py b/tests/test_build_ceus.py index e60dea7..84a9e5e 100644 --- a/tests/test_build_ceus.py +++ b/tests/test_build_ceus.py @@ -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() diff --git a/tests/test_ceus_quant.py b/tests/test_ceus_quant.py index 20fe47d..9bb445e 100644 --- a/tests/test_ceus_quant.py +++ b/tests/test_ceus_quant.py @@ -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, diff --git a/tests/test_review_pack.py b/tests/test_review_pack.py new file mode 100644 index 0000000..58ac784 --- /dev/null +++ b/tests/test_review_pack.py @@ -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 x [链接](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.]+)"[^>]*>([^<]*)', + _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")