diff --git a/research-wiki/explanation/结构.md b/research-wiki/explanation/结构.md index 430a1b4..53efc8b 100644 --- a/research-wiki/explanation/结构.md +++ b/research-wiki/explanation/结构.md @@ -160,15 +160,28 @@ library/<构建号>/ manifest.json 构建清单:每个文件的 SHA-256、构建时的 git 提交、配置哈希、所用 CEUS 运行号(没装为 null) cases/<化名>/ clinical/clinical.json 临床那一路 - ultrasound/ 超声那一路:report.txt、key.png;装了 CEUS 结果时另有 - keyframes/<槽位>.png 和 tic.json + ultrasound/ 超声那一路:report.txt、keyframes/key.png;装了 CEUS 结果时 + keyframes/ 里另有定位到的 <槽位>.png,另有 tic.json mr/report.txt MR 那一路 ``` -`build.yaml` 的 `ceus_run` 指向某次 CEUS 定量的运行目录时,build 才往超声一路装 CEUS 结果,为 null 时不装。装的时候: +超声一路的图都放在 `keyframes/` 一个文件夹里。医生截的关键图 `key.png` 来自交付包、不来自 CEUS,所以每例都有,没装 CEUS 时 `keyframes/` 里只有它一张;去掉文本块、EXIF 等附加数据,只按像素重新编码,不能逐像素保真重编码的整例跳过(C14)。 -- `keyframes/<槽位>.png`:下文关键帧表里定位到的那几张(槽位即 B0、D1 等),和 `key.png` 一样去掉附加数据重新编码;每例最多 8 张图(7 张关键帧加 `key.png`)。 -- `tic.json`:从这一例的 `summary.json` 里逐字段挑出,此外什么都不写:四条曲线(`roi_all` 病灶整体、`roi_perf` 病灶去暗区、`roi_pan` 紫圈即医生圈的一块正常胰腺、`background_circle` 背景圆)各自的拟合是否成功(`success`)和拟合优度 R²(`r2`);病灶两条曲线的灌注指标(`perfusion_metrics`,拟合没过质量门为 null);紫圈曲线是否可用(`pan_curve_usable`);7 个槽位各自是否定位到(`located`)、定位到的视频时间(`video_time_s`,秒)、没定位的原因(`reason`)。视频号、文件路径、配置、帧号、拟合参数都不写。挑哪些字段只在 `src/cadx/build/_ceus.py` 里列。 +`build.yaml` 的 `ceus_run` 指向某次 CEUS 定量的运行目录时,build 才往超声一路装 CEUS 结果,为 null 时不装(没有 `tic.json`)。装的时候: + +- `keyframes/<槽位>.png`:下文关键帧表里定位到的那几张(槽位即 B0、D1 等),和 `key.png` 一样去掉附加数据重新编码、过 C14;每例最多 8 张图(7 张关键帧加 `key.png`)。 +- `tic.json`:只放「给专家的参数」,此外什么都不写: + + ``` + { + "perfusion_metrics": {"roi_pan": {11 个指标} 或 null, "roi_perf": {11 个指标} 或 null}, + "keyframe_times_s": {"B0": 秒或 null, …, "F2": 秒或 null} + } + ``` + + `perfusion_metrics` 是两组灌注指标:`roi_pan` 是紫圈(医生在正常胰腺实质上圈的一块),`roi_perf` 是病灶去掉暗区(不进造影剂的囊液部分)后的区域,两组放在一起,是为了让专家把病灶和正常胰腺对着看(负责人 2026-10-17)。每组是下文第 9 步的 11 个指标,键名沿用 `summary.json` 的 `values`(`PE_au`、`TTP_s` …);这一组的拟合没过质量门整组为 null,观测不够算不出的单项为 null,不外推。`keyframe_times_s` 是 7 个槽位定位到的视频时间(秒),没定位到为 null。 + + 不放:拟合成败和 R²、病灶整体(`roi_all`)和背景圆的曲线、紫圈是否可用、关键帧没定位的原因、指标的信号域等口径标签和辅助时刻(TI、TO、mTT,只是算其他指标的中间量),以及视频号、文件路径、配置、帧号、拟合参数。资料库的定义是「专家能看到什么」,可见范围只在 build 一处决定:多放进去、等会诊那边再挑,就成了两处决定,改了一处忘了另一处就会悄悄漏给专家(任务书 04,负责人 2026-10-17 认可)。挑哪些字段只在 `src/cadx/build/_ceus.py` 里列。 - 核对这次 CEUS 运行是在这份交付包上跑的:运行快照里 `ceus.yaml` 的 `paths.delivery_dir` 与 `build.yaml` 的 `delivery_dir` 规范化后不是同一路径;或逐例比,交付包里这段视频文件的 SHA-256 与 `summary.json` 的 `video_sha256` 不同,或交付包的推注时间与 `summary.json` 的 `injection_offset_s` 不同,整次停下报错。这两个字段不进 `tic.json`。 - CEUS 在这一例上失败(在 `failed.csv` 里)或运行目录里没有这一例的结果,这一例整例跳过,记 C15。失败的病例要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。 @@ -179,7 +192,7 @@ library/<构建号>/ | 性别、年龄 | 看得见 | 看不见 | 看不见 | | 5 个症状、5 项既往史、5 项化验(原值与是否异常) | 看得见 | 看不见 | 看不见 | | 超声报告「所见」段、医生截的关键图 | 看不见 | 看得见 | 看不见 | -| CEUS 关键帧、TIC 灌注指标 | 看不见 | 看得见(给哪些指标、几张图由会诊任务书定) | 看不见 | +| CEUS 关键帧图与视频时间、紫圈和病灶去暗区两组灌注指标(上文 `tic.json`) | 看不见 | 看得见 | 看不见 | | MR 报告「所见」段 | 看不见 | 看不见 | 看得见 | | 病理诊断、报告「印象 / 结论」段、医生标注工作簿、真实病例号、Excel 行序、姓名 | 看不见 | 看不见 | 看不见 | @@ -198,7 +211,23 @@ library/<构建号>/ ### 医生核对包(`runs/review_pack/<运行号>/`) -把一次构建的两个库和它装入的那次 CEUS 运行,整理成医生不用写代码就能看的一个文件夹(表格 CSV、文字 PDF、图和视频逐字节拷贝),由负责人打包交给临床方核对我们的处理结果。病例目录用医生的病例号,问题清单只写程序观察到的现象;包里有真实病例号、报告原文和诊断,只在临床方与我们之间传递。它不是流水线的一段:不产出任何后续段要读的东西,医生每交一版数据、跑完 CEUS 定量和装配之后出一次。包内布局和每个文件的格式见 `../任务书/03-医生核对包.md` 和包里的 `说明.pdf`,怎么跑见 `../guides/医生核对包.md`。 +把一次构建的两个库和它装入的那次 CEUS 运行,整理成医生不用写代码就能看的一个文件夹(表格 CSV、文字 PDF、图和视频逐字节拷贝),由负责人打包交给临床方核对我们的处理结果。包里有真实病例号、报告原文和诊断,只在临床方与我们之间传递。它不是流水线的一段:不产出任何后续段要读的东西,医生每交一版数据、跑完 CEUS 定量和装配之后出一次。 + +``` +runs/review_pack/<运行号>/ + snapshot/ 快照,含程序原文的 问题清单_原文.csv(不给医生) + 医生核对包_<构建号>/ + 说明.pdf 问题清单.csv 病例总表.csv + 标准答案/ 答案库 4 张表原样拷贝 + 病例/<病例号>/ 临床信息.csv 超声报告.pdf MR报告.pdf + 关键帧/医生截图.png、<槽位>_第<秒>秒.png + TIC曲线.png TIC参数.csv 回放视频.mp4 +``` + +- 医生看到的超声参数就是 AI 专家看到的那份:`关键帧/医生截图.png` 拷自资料库 `ultrasound/keyframes/key.png`,`TIC参数.csv` 的数值取自资料库 `tic.json`(紫圈、病灶去暗区两组 × 11 个指标)。只有某组整组为空时的「没算出的原因」取自 CEUS 运行的 `summary.json`,这一行只给医生看。 +- 问题清单是给医生的大白话:程序记录的原因经 review 里的一张对照表(检查编号、CEUS 各步的失败原因、拟合原因码、关键帧未取到的原因、紫圈不可用)换成一句医生能懂的话,同一个原因引起的几条合成一行;对照表里没有的原因直接报错停下。程序原文留在快照里给我们自己追溯。来源:任务书 04,负责人 2026-10-17「合作方是医生,感觉他们也不一定看得懂」。 + +每个文件的格式见 `../任务书/03-医生核对包.md`(04 改过的以 `../任务书/04-核对包改成医生读得懂.md` 为准)和包里的 `说明.pdf`,怎么跑见 `../guides/医生核对包.md`。 ### CEUS 定量的产物(`runs/ceus/` 与 `work/`) @@ -216,7 +245,7 @@ CEUS 定量按下面的阶段进行(旧项目的阶段划分,迁移时逐模 | 6 TIC 采样 | 逐帧取病灶(去不去暗区两种)平均亮度,得到 TIC(时间-强度曲线) | 否 | | 7 拟合 | 用 Lognormal(对数正态)曲线拟合 TIC,过质量门 | 否 | | 8 关键帧 | 按下面的定义挑 7 张 | 否 | -| 9 灌注指标 | 从拟合曲线算 11 个指标(峰值强度、达峰时间、洗入速率等,口径仿照商用造影定量软件 VueBOX) | 否 | +| 9 灌注指标 | 对病灶去暗区(`roi_perf`)和紫圈(`roi_pan`)两条曲线各算一组 11 个指标(峰值强度、达峰时间、洗入速率等,口径仿照商用造影定量软件 VueBOX),只从过了质量门的拟合曲线算,没过的那组为空。两组是为了让病灶和正常胰腺对着看;病灶整体(`roi_all`)含暗区,不算(负责人 2026-10-17,医生要正常区与病灶对比看) | 否 | 只有阶段 3 跨运行复用,其余每次重算:阶段 3 要显卡,最贵;其余阶段在 CPU 上算。改了后段参数不必重跑显卡。 @@ -309,19 +338,19 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 | 模块 | 预期行数 | 实际行数 | 旧项目对应部分的行数 | | --- | --- | --- | --- | -| ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | 7103 | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 | +| ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | 7106 | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 | | segment | 150–300 | 300 | `medsam2_local_track.py` 240 | | delivery | 400–700 | 739 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 | | library | 150–300 | 377 | `input_packages.py` 104 | | answers | 100–200 | 227 | — | | verdicts | 100–200 | — | — | -| build | 300–500 | 537(含装入 CEUS 结果的 `_ceus.py`;读 CEUS 运行目录已挪进 runs) | `input_readers.py` 329 的装配部分 | +| build | 300–500 | 520(含装入 CEUS 结果的 `_ceus.py`;读 CEUS 运行目录已挪进 runs) | `input_readers.py` 329 的装配部分 | | llm | 200–450 | — | ClinDB `llm/` 415 | | ledger | 200–300 | — | ClinDB `ledger/` 274 | | runs | 100–250 | 201(快照 `_snapshot.py` 98、读 CEUS 运行的 `_ceus_run.py` 82、`__init__.py` 21) | — | | consult | 500–1000(会诊口径定后重估) | — | — | | evaluate | 300–600(评价口径定后重估) | — | 已删的 `core/eval` 622 | -| review | 300–500 | 610(`_pack.py` 407、`_pdf.py` 129、`_config.py` 64、`__init__.py` 10;不含说明模板 `readme_template.md` 202 行) | — | +| review | 300–500 | 1152(`_pack.py` 447、`_problems.py` 502、`_pdf.py` 129、`_config.py` 64、`__init__.py` 10;不含说明模板 `readme_template.md` 91 行)。超出一倍:任务书 04 要求的原因对照表(`_problems.py`)大半是逐条的原因与医生写法,待负责人确认是否接受 | — | | scripts/ 每个 | ≤ 300 | `build.py` 37、`scan_library.py` 88、`ceus_quant.py` 403(超出,含目视核对表的读取与核对、开跑即写快照)、`ceus_align_review.py` 243、`review_pack.py` 37 | `t1_local_debug.py` 762、`tic_lognormal_replay.py` 627 | --- @@ -365,7 +394,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 - `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)、槽位 → 关键帧路径(没装时为空)。 +- `UltrasoundRoute`:超声一路:报告「所见」原文、tic.json 的内容(没装 CEUS 时为 None)、`keyframes`(`keyframes/` 下的图,文件名主干 → 路径:总有医生截的关键图 `key`,即 `KEY_SLOT`;装了 CEUS 时另有定位到的槽位)。 - `MrRoute`:MR 一路:报告「所见」原文。 - `scan_for_leaks(build_dir, forbidden_strings, forbidden_keys)`:在构建目录的全部 `.txt`、`.json` 里找禁止的字符串(NFKC 归一、不分大小写、按子串)和禁止的 JSON 键名,返回命中列表。 - `LeakHit`:一处命中:文件(相对构建目录)、类别(「字符串」或「键名」)、命中的词。 @@ -399,7 +428,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。 - `register_halves(frames, left_view, right_view, cfg.registration, cfg.prompt_source, cfg.pan_source)`:第 1 步的两半配准,交出 `HalfRegistration`:左半局部坐标 → 右半局部坐标的平移,`correlation`(两半扇形区边缘图的归一化互相关,采用它)、`template`(左半扇形内多块模板逐帧匹配后取中位数,只用来核对)、各次匹配明细、两者各分量之差的最大值;`contrast_to_target(side)` 换成造影那一半 → 另一半的平移。 - `SegmentationInput`:视频的 fps、帧数、宽高、逐帧时间戳,分屏线,`registration`(`HalfRegistration`),`offset`(造影那一半局部坐标 + `offset` = `target_view` 局部坐标),`contrast`(`ContrastSide`:哪一半是造影画面、造影视图和分割那一半视图 `target_view`、提示帧),`target_frames()`(重新解码,逐帧交出分割那一半的 BGR 图像;不存整段),`prompt_masks`(提示帧帧号 → 绿框按 `offset` 挪到 `target_view` 局部坐标的掩膜),造影那一半的第一帧。 - `quantify_case(prepared, target_masks, injection_offset_s, cfg)`:第 4–9 步。`target_masks` 是分割在 `target_view` 局部坐标上的 `(帧数, 高, 宽)` 布尔数组,`injection_offset_s` 是推注时间 X;分割一帧有效轮廓都没有抛 `NoValidContourError`(第 4 步)。拟合没过质量门不抛错。 -- `QuantResult`:`trajectory`(`Trajectory`)、`tic`(`TicResult`)、`curves`(`TicCurves`)、推注时间、病灶两条曲线拟合是否都过质量门、`anchors`(7 个槽位的 `AnchorFrameLocation`)、`perfusion`(`roi_all`、`roi_perf` 各自的 `LognormalPerfusionMetrics`,拟合没过为 None)、紫圈检出率。 +- `QuantResult`:`trajectory`(`Trajectory`)、`tic`(`TicResult`)、`curves`(`TicCurves`)、推注时间、病灶两条曲线拟合是否都过质量门、`anchors`(7 个槽位的 `AnchorFrameLocation`)、`perfusion`(`METRIC_CURVES` 即 `roi_perf`、`roi_pan` 各自的 `LognormalPerfusionMetrics`,拟合没过为 None)、紫圈检出率。 - `AnchorFrameLocation`:一个关键帧槽位的帧号、视频时间、推注后时刻、状态(`ok` 或未定位的类别)、未定位的原因、锚点时刻。 - 写产物用:`pack_trajectory(trajectory)`(trajectory.npz 字节)、`pack_tic_curves(curves)`(tic_curves.npz 字节)、`render_tic_png(tic, markers=tic_plot_markers(anchors))`(tic.png,标出 D1–D4)、`render_case_overlay(prepared, result)`(overlay.mp4 字节)、`keyframe_images(prepared, anchors)`(定位到的关键帧整幅原图,槽位 → 图像)、`quant_summary(result)`(拟合、灌注指标、紫圈检出率、7 个关键帧定位,可直接写成 JSON);读回用 `unpack_trajectory`、`unpack_tic_curves`。 - `fan_mask(std, cfg.registration)`:由一半画面的逐像素时间标准差求扇形区掩膜;`color_marks(frames, prompt_cfg, pan_cfg, dilate)`:任一帧里落在绿框或紫圈颜色范围内的像素。两者配准用,目视核对出图也用。 diff --git a/research-wiki/guides/CEUS定量.md b/research-wiki/guides/CEUS定量.md index 295e77a..61e1309 100644 --- a/research-wiki/guides/CEUS定量.md +++ b/research-wiki/guides/CEUS定量.md @@ -12,8 +12,8 @@ - **配准**:估出左半画面局部坐标到右半画面局部坐标的平移 (dx, dy)(方向固定是左到右,与哪一半是造影画面无关),让两半里同一个解剖位置对上。程序在造影画面上找绿框,挪到灰阶那一半交给分割模型,分割完再把轮廓挪回造影那一半采亮度,挪的就是这个平移。在灰阶那一半分割,是因为造影画面里病灶边界随造影剂进出时明时暗,灰阶画面上边界稳定(沿用旧代码,任务书 02「先认识几个词」)。配准错了不会报错,只会让亮度采在病灶旁边,所以全量运行之前要做一轮**目视核对**(第二节)。 - **提示帧**:绿框位置或形状明显变化的那几帧,第 2 步挑出,交给分割模型告诉它「病灶在这里」。 - **MedSAM2**:医学图像分割模型,给它几帧提示帧上的病灶框,它把病灶轮廓传播到整段视频的每一帧。要用显卡,是最慢的一步(第 3 步)。怎么装见 `开发环境.md`。 -- **TIC**(时间-强度曲线):ROI 平均亮度随时间的变化,造影剂进来变亮、洗出变暗。程序用 Lognormal(对数正态)形状的光滑曲线去拟合它,拟合要过一道**质量门**(拟合优度等门槛,见 `ceus.yaml` 的 `tic_analysis.fitting`)才算成功。病灶有两条曲线(整体、去掉暗区),两条都过了质量门才算**病灶拟合成功**,D1–D4 和灌注指标都要它。 -- **灌注指标**:从拟合曲线算出的 11 个数(峰值强度、达峰时间、洗入速率等),口径仿照商用造影定量软件 VueBOX(`结构.md` 第四部分)。 +- **TIC**(时间-强度曲线):ROI 平均亮度随时间的变化,造影剂进来变亮、洗出变暗。程序用 Lognormal(对数正态)形状的光滑曲线去拟合它,拟合要过一道**质量门**(拟合优度等门槛,见 `ceus.yaml` 的 `tic_analysis.fitting`)才算成功。病灶有两条曲线(整体、去掉暗区),两条都过了质量门才算**病灶拟合成功**,B0、D1–D4 要它。 +- **灌注指标**:从拟合曲线算出的 11 个数(峰值强度、达峰时间、洗入速率等),口径仿照商用造影定量软件 VueBOX(`结构.md` 第四部分)。对病灶去暗区(`roi_perf`)和紫圈(`roi_pan`)两条曲线各算一组,哪条的拟合过了质量门就算哪条,没过的那组为 null。 - **关键帧与槽位**:按 TIC 挑出的 7 个时刻的画面,每个时刻叫一个槽位:B0(刚开始变亮之前)、D1(开始变亮)、D2(变亮最快)、D3(最亮)、D4(变暗最快)从拟合曲线上取;F1、F2 是推注后第 20、60 秒。详细定义见 `../explanation/结构.md` 第四部分的关键帧表。 - **推注时间 X**:超声表的「推注开始时间s」,含义是视频第 0 秒时已经推注了多少秒(不是「第几秒开始推注」),交付时已检查是非负整数。F1 取视频第 20 − X 秒,F2 取第 60 − X 秒。 - **第 N 步**:一段视频依次经过的 9 步:1 解码、分屏与两半配准,2 找绿框、选提示帧,3 MedSAM2 分割,4 整理轮廓,5 找暗区,6 TIC 采样,7 拟合,8 关键帧,9 灌注指标(`结构.md` 第四部分的表)。 @@ -162,7 +162,7 @@ snapshot/ 几点说明: -- 病灶拟合没过质量门**不算失败**:这一例照常有结果目录,D1–D4 记为未定位,灌注指标写 null,原因写在 `summary.json`;F1、F2 照常按推注时间取。个别槽位未定位也不算失败。 +- 病灶拟合没过质量门**不算失败**:这一例照常有结果目录,B0、D1–D4 记为未定位,拟合没过的那条曲线(`roi_perf`、`roi_pan`)的灌注指标写 null,原因写在 `summary.json`;F1、F2 照常按推注时间取。个别槽位未定位也不算失败。 - 不进 `failed.csv`、而是整次停下的:配准核对表对不上(第二节末)、MedSAM2 报错(如显存不够、权重找不到)、重新解码时读不到关键帧、配置写错。报错信息在终端和日志里,留下 `.partial`。 - 失败的病例构建时按 C15 整例跳过(`装配两个库.md` 第四节);要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。 diff --git a/research-wiki/guides/医生核对包.md b/research-wiki/guides/医生核对包.md index f6767ac..5bec77d 100644 --- a/research-wiki/guides/医生核对包.md +++ b/research-wiki/guides/医生核对包.md @@ -1,6 +1,6 @@ # 医生核对包 -医生核对包是一个文件夹,交给临床方的医生,请他们核对我们对交付资料的处理结果。医生不用写代码就能打开:表格是 CSV,文字是 PDF,图和视频原样拷贝。本文写给出包的人:什么时候跑、怎么跑、产物在哪、怎么交出去、报错了怎么办。为什么做、包里每个文件的格式,见 `../任务书/03-医生核对包.md`;它在代码里的位置见 `../explanation/结构.md` 第三、四部分。 +医生核对包是一个文件夹,交给临床方的医生,请他们核对我们对交付资料的处理结果。医生不用写代码就能打开:表格是 CSV,文字是 PDF,图和视频原样拷贝。本文写给出包的人:什么时候跑、怎么跑、产物在哪、怎么交出去、报错了怎么办。为什么做、包里每个文件的格式,见 `../任务书/03-医生核对包.md` 和改成医生读得懂的 `../任务书/04-核对包改成医生读得懂.md`(两者不一致处以 04 为准);它在代码里的位置见 `../explanation/结构.md` 第三、四部分。 ## 先认识几个词 @@ -15,11 +15,13 @@ - **CEUS 定量运行**:跑一次 `scripts/ceus_quant.py`(`CEUS定量.md`),产物在 `runs/ceus//`。 - **`failed.csv`**:这次运行里记各病例在第几步失败、失败原因的表。 - **视频号**:每例 CEUS 视频的编号,`U` 加三位病例号(如 `U001`),CEUS 运行的结果目录按它命名。 -- **TIC、拟合**:TIC(时间-强度曲线)是病灶区域平均亮度随时间的变化。拟合是用一条光滑的模型曲线(Lognormal,对数正态形状)去贴 TIC。拟合要过一道质量门才算成功;没成功时,关键帧 D1–D4 和灌注指标都取不到。 +- **TIC、拟合**:TIC(时间-强度曲线)是一块区域平均亮度随时间的变化。拟合是用一条光滑的模型曲线(Lognormal,对数正态形状)去贴 TIC。拟合要过一道质量门才算成功;病灶曲线没成功时关键帧 B0、D1–D4 取不到,某条曲线没成功时它那一组灌注指标为空。 +- **TIC 参数**:从过了质量门的拟合曲线算出的 11 个灌注指标(PE、TTP 等),紫圈(胰腺实质)和病灶去暗区各一组,就是资料库 `tic.json` 里给 AI 专家的那份。 - **紫圈**:临床方在视频上画的紫色圈,圈一块正常胰腺作对照,程序也为它画一条 TIC。 - **关键帧、槽位**:从视频里挑出的 7 个时刻的画面,每个时刻叫一个槽位。B0 是刚开始变亮之前,D1–D4 是从拟合曲线上取的开始变亮、变亮最快、最亮、变暗最快,F1、F2 是推注造影剂后第 20、60 秒。细节见包里的 `说明.pdf`。 - **回放视频**:原视频逐帧叠加程序画的病灶轮廓等线条,医生用它看程序圈得对不对。 -- **问题清单**:包里的 `问题清单.csv`,列出程序在每个病例上观察到的现象:某例没进资料库、CEUS 在第几步失败、拟合没完成、关键帧没取到、紫圈曲线不可用。每条都抄自某个已有文件里的值,只写现象,不写原因推测和建议。这是负责人 2026-10-17 定的(任务书 03「不做什么」):包只请医生核对事实,推测会把医生的注意力引向我们的猜测。 +- **问题清单**:包里的 `问题清单.csv`,列出程序在每个病例上观察到的现象:某例没进资料库、造影视频处理失败、曲线没能拟合、关键帧没取到、紫圈不可用。每条都抄自某个已有文件里的值,只写现象,不写原因推测和建议(负责人 2026-10-17,任务书 03「不做什么」):包只请医生核对事实,推测会把医生的注意力引向我们的猜测。 + - **写成医生能懂的话**:程序记录的原因(检查编号、原因码、英文字段名)经 `src/cadx/review/_problems.py` 里的对照表换成一句大白话,同一个原因引起的几条合成一行(任务书 04)。对照表里没有的原因直接报错停下,不把程序原文给医生;程序原文另存在快照的 `问题清单_原文.csv` 里。 - **三种编号**:格式都是 `YYYYMMDD-HHMMSS-`,工作区有未提交改动时末尾加 `-dirty`(`装配两个库.md` 第三节「构建号」)。三者各指一样东西,本文用不同的名字区分: - **构建号**:一次构建,即 `library/<构建号>/`、`answers/<构建号>/`; - **CEUS 运行号**:一次 CEUS 定量运行,即 `runs/ceus//`; @@ -27,15 +29,16 @@ ## 一、什么时候跑 -医生每交一版新数据,按顺序做完: +医生每交一版新数据,或者处理规则改了,按顺序做完: -1. CEUS 定量 `scripts/ceus_quant.py`,得到新的 `runs/ceus//`; -2. 把 `config/build.yaml` 的 `ceus_run` 指向这个目录,跑 `scripts/build.py` 构建两个库,得到新的构建号; -3. 再跑本入口出核对包。 +1. **干净工作区**:先确认 `git status` 没有任何改动(包括未跟踪文件),有就先提交或请改动的人处理。工作区不干净时三个运行号末尾都会带 `-dirty`,记下的提交号复现不出这次结果; +2. **ceus_quant**:CEUS 定量 `scripts/ceus_quant.py`,得到新的 `runs/ceus//`; +3. **build**:把 `config/build.yaml` 的 `ceus_run` 指向这个目录,跑 `scripts/build.py` 构建两个库,得到新的构建号; +4. **review_pack**:再跑本入口出核对包。 核对包只读已经构建好的两个库和那次 CEUS 运行,不碰交付包,也不改两个库。所以交付数据或处理规则变了,要先重新构建,再出包。一次构建可以出多次包,每次一个新的出包号。 -没装 CEUS 结果的构建(`build.yaml` 的 `ceus_run` 为 null)也能出包。包里没有关键帧、TIC 曲线、TIC 结果和回放视频,病例总表的 CEUS 各列留空,`说明.pdf` 里写明这次没有 CEUS 结果。 +没装 CEUS 结果的构建(`build.yaml` 的 `ceus_run` 为 null)也能出包。病例的 `关键帧/` 里只有医生截图,没有程序取的关键帧、TIC 曲线图、TIC 参数和回放视频,病例总表的 CEUS 各列留空,`说明.pdf` 里写明这一版没有造影视频的处理结果。 ## 二、怎么跑 @@ -87,8 +90,27 @@ runs/review_pack/<出包号>/ (output_root 下) - `snapshot.json`:git 状态、Python 版本、`uv.lock` 哈希、开始时间、配置哈希; - `review_pack.yaml`:配置原文副本; - `review.json`:构建号、CEUS 运行号、pandoc 与 soffice 的版本行; + - `问题清单_原文.csv`:问题清单的程序原文(检查编号、原因码、程序记的原因),列同包里的问题清单,给我们自己追溯; - `finished.json`:结束时间、病例数、问题清单行数。 -- `医生核对包_<构建号>/`:目录布局、每张 CSV 的列和每个文件的来源,见任务书 03「核对包的内容」。也可以直接打开包里的 `说明.pdf`:它是写给医生的完整说明,包括目录结构、每一列的意思、处理流程、关键帧和 TIC 曲线怎么看、编码含义。这里不重抄,免得两处说法不一致。`说明.pdf` 的源文件是 `src/cadx/review/readme_template.md`。 +- `医生核对包_<构建号>/`: + + ``` + 说明.pdf 给医生的说明,源文件是 src/cadx/review/readme_template.md + 问题清单.csv 病例号、视频号、环节、问题(一句话) + 病例总表.csv 全部病例一例一行 + 标准答案/ 化名对照.csv、诊断.csv、跳过病例.csv、报告复核句.csv(答案库原样拷贝) + 病例/<病例号>/ 只有进了资料库的病例才有 + 临床信息.csv 临床一路的字段 + 超声报告.pdf、MR报告.pdf 两路报告的描述段原文 + 关键帧/ + 医生截图.png 资料库 ultrasound/keyframes/key.png + <槽位>_第<秒>秒.png 程序定位到的关键帧,秒数取 tic.json 的 keyframe_times_s + TIC曲线.png CEUS 运行的 tic.png + TIC参数.csv 11 个灌注指标 × 紫圈、病灶去暗区两组,数值取自资料库 tic.json + 回放视频.mp4 CEUS 运行的 overlay.mp4 + ``` + + CSV 都是带 BOM 的 UTF-8、中文列名、缺失为空。`TIC参数.csv` 的时间类指标保留 1 位小数、其余 2 位;某一组整组为空时最后加一行「没算出的原因」,原因从 CEUS 运行的 `summary.json` 读,经对照表写成医生的话(资料库里已经没有原因,AI 专家拿到的只是 null)。图片和视频逐字节拷贝。每列的意思和怎么看写在 `说明.pdf` 里,这里不重抄,免得两处说法不一致。 运行目录先写成 `<出包号>.partial/`,全部写完再改名成 `<出包号>/`。看到 `.partial`,说明那次出包中途出错。它不会被自动删除,留着查原因,查完由人决定删不删。已有的运行目录不删、不覆盖(`AGENTS.md`「两档标准」)。 @@ -120,4 +142,7 @@ runs/review_pack/<出包号>/ (output_root 下) | `… 的 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` 里补上中文名,同时补说明模板 | +| `tic.json 的字段是 …,核对包只认得 …` / `灌注指标分组是 …` / `灌注指标是 …` | build 改了 `tic.json`(资料库里每例给 AI 专家的 CEUS 参数)的字段,核对包还没跟上 | 改 `src/cadx/review/_pack.py` 的 `TIC_GROUPS`、`TIC_METRICS`,同时补说明模板 | +| `… 不在对照表里:…` | 程序记录了一种新的原因(新的检查编号、新的失败原因、新的原因码、新的列),对照表里还没有给医生的写法 | 在 `src/cadx/review/_problems.py` 的对照表里补一句医生能懂的话,并在 `tests/test_review_reasons.py` 补一条手写预期 | +| `给医生的话里出现了程序用语「…」` | 某句给医生的话里带出了检查编号、字段名、下划线、括号或运行号(多半是交付资料里的取值本身带下划线等字符) | 看是哪一句:对照表写法的问题就改写法;是交付取值带出来的,问负责人怎么写 | +| `病灶曲线拟合失败 … 与因此未取到的关键帧 … 对不上` | CEUS 运行的 `summary.json` 里拟合结果和关键帧状态互相矛盾 | CEUS 运行本身有问题,查那次运行,不要改核对包绕过 | diff --git a/research-wiki/guides/装配两个库.md b/research-wiki/guides/装配两个库.md index e816482..ff6f6a1 100644 --- a/research-wiki/guides/装配两个库.md +++ b/research-wiki/guides/装配两个库.md @@ -94,7 +94,7 @@ manifest.json cases/<化名>/ clinical/clinical.json ultrasound/report.txt - ultrasound/key.png + ultrasound/keyframes/key.png ultrasound/keyframes/<槽位>.png (ceus_run 不为 null 时;只有定位到的槽位) ultrasound/tic.json (ceus_run 不为 null 时) mr/report.txt @@ -105,9 +105,9 @@ cases/<化名>/ | `manifest.json` | 构建号、git 提交号、工作区是否干净、配置文件(解码时含映射表)的 SHA-256、`codes_decoded`、`uncompared`(没比对的项)、`ceus_source`(所用 CEUS 运行号,`ceus_run` 为 null 时是 null)、化名列表(按化名排序)、每个文件的相对路径和 SHA-256。不含真实病例号、交付文件名和诊断 | | `clinical.json` | `{"codes_decoded": ..., "fields": {列名: 值}}`。字段是临床表去掉编号和诊断后的 22 列:性别、年龄、5 个症状、5 项既往史、5 项化验的原值和档位。没测的化验写 null | | `ultrasound/report.txt` | 超声报告的「所见」原文,UTF-8,不加标题 | -| `ultrasound/key.png` | 医生截的超声关键图,只保留像素和颜色模式重新编码,不带文本块、EXIF、ICC 等附加数据(附加数据里可能有文件名、时间等线索)。超声帧上烧录的信息栏不遮蔽(`AGENTS.md` 硬性约束 3) | +| `ultrasound/keyframes/key.png` | 医生截的超声关键图(来自交付包,每例都有,和关键帧放在同一个文件夹),只保留像素和颜色模式重新编码,不带文本块、EXIF、ICC 等附加数据(附加数据里可能有文件名、时间等线索)。超声帧上烧录的信息栏不遮蔽(`AGENTS.md` 硬性约束 3) | | `ultrasound/keyframes/<槽位>.png` | CEUS 运行里定位到的关键帧整幅画面,和 `key.png` 一样去掉附加数据重新编码 | -| `ultrasound/tic.json` | 四条 TIC 曲线的拟合是否成功和 R²、病灶两条曲线的灌注指标、紫圈曲线是否可用、7 个槽位是否定位到及视频时间或未定位原因。字段逐个从 CEUS 的 `summary.json` 挑出,不含视频号、路径、配置、帧号(`结构.md` 第四部分) | +| `ultrasound/tic.json` | 只有两项:紫圈(`roi_pan`)和病灶去暗区(`roi_perf`)两组各 11 个灌注指标(拟合没过整组为 null,单项算不出为 null),7 个槽位定位到的视频时间(秒,没定位到为 null)。字段逐个从 CEUS 的 `summary.json` 挑出,拟合成败、R²、没定位的原因、视频号、路径、配置、帧号都不进(`结构.md` 第四部分) | | `mr/report.txt` | MR 报告的「所见」原文 | 性别、年龄只在临床一路:两份报告表里医院也复制了这两列,但影像两路先看到会被人口学先验带偏(`结构.md` 第四部分)。 diff --git a/src/cadx/build/_ceus.py b/src/cadx/build/_ceus.py index a007279..cbeab11 100644 --- a/src/cadx/build/_ceus.py +++ b/src/cadx/build/_ceus.py @@ -1,8 +1,11 @@ """从一次 CEUS 定量运行按白名单挑出超声一路的 tic.json 和关键帧(C15)。 -运行目录的布局由 runs 读(`open_ceus_run`);tic.json 的字段只在这里列,视频号、路径、配置、帧号和 -拟合参数都不进去。快照里的交付包路径、summary.json 里记的视频 SHA-256 和推注时间要与这份交付包 -一致,否则运行不是在这份交付包上跑的,整次停下。 +运行目录的布局由 runs 读(`open_ceus_run`);tic.json 的字段只在这里列:紫圈和病灶去暗区两组的 +11 个灌注指标、7 个关键帧槽位的视频时间。拟合成败与 R²、病灶整体和背景圆、紫圈是否可用、 +关键帧未定位的原因、视频号、路径、配置、帧号和拟合参数都不进去。 + +快照里的交付包路径、summary.json 里记的视频 SHA-256 和推注时间要与这份交付包一致,否则运行 +不是在这份交付包上跑的,整次停下。 """ import hashlib @@ -17,12 +20,10 @@ from cadx.runs import CeusRun, open_ceus_run C15 = "C15" C15_DESCRIPTION = "CEUS 定量结果齐全" -# 四条曲线:病灶整体、病灶去暗区、紫圈(医生圈的正常胰腺对照)、背景圆。 -_CURVES = ("roi_all", "roi_perf", "roi_pan", "background_circle") -_LESION_CURVES = ("roi_all", "roi_perf") +# 给专家的两组灌注指标:紫圈(医生圈的正常胰腺实质)与病灶去暗区,让病灶和正常胰腺对着看。 +_METRIC_CURVES = ("roi_pan", "roi_perf") _SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2") -_PERFUSION_LABELS = ("signal_domain", "time_origin", "area_curve") -# 11 个灌注指标(同旧 replay summary 的 perfusion_metrics.values)。 +# 11 个灌注指标(summary.json 的 perfusion_metrics.<曲线>.values 下的键)。 _PERFUSION_VALUES = ( "PE_au", "WiAUC_au_s", @@ -36,9 +37,6 @@ _PERFUSION_VALUES = ( "FT_s", "WoR_au_per_s", ) -_SUPPORTING_TIMES = ("TI", "mTT", "TO") -# 未定位原因由 ceus 按固定模板加数值生成,不含路径;出现这些字符说明模板变了,停下检查。 -_PATH_MARKS = ("/", "\\", ".mp4", ".png") def open_matching_run(run_dir: Path, delivery_dir: Path, repo_root: Path) -> CeusRun: @@ -69,45 +67,30 @@ def _typed[T](value: object, kind: type[T]) -> T: return value -def _perfusion(section: dict[str, Any] | None) -> dict[str, Any] | None: +def _perfusion(section: dict[str, Any] | None) -> dict[str, float | int | None] | None: + """一组的 11 个指标;拟合没过质量门(summary 里整组为 null)时整组为 None。""" if section is None: return None + return {key: _number(section["values"][key]) for key in _PERFUSION_VALUES} + + +def _keyframe_time(anchor: dict[str, Any]) -> float | int | None: + """一个槽位定位到的视频时间(秒),没定位到为 None;状态与时间互相矛盾时报错。""" + time_s = _number(anchor["frame_time_video_s"]) + if (anchor["status"] == "ok") != (time_s is not None): + raise ValueError(f"summary.json 的关键帧状态与时间互相矛盾:{anchor}") + return time_s + + +def tic_view(summary: dict[str, Any]) -> dict[str, Any]: + """从一例的 summary.json 按白名单构造 tic.json:两组灌注指标、7 个槽位的视频时间,别的不写。""" return { - **{key: _typed(section[key], str) for key in _PERFUSION_LABELS}, - "values": {key: _number(section["values"][key]) for key in _PERFUSION_VALUES}, - "supporting_times_s": { - key: _number(section["supporting_times_s"][key]) for key in _SUPPORTING_TIMES - }, - } - - -def _keyframe(anchor: dict[str, Any], video_id: str) -> dict[str, Any]: - located = anchor["status"] == "ok" - time_s, reason = _number(anchor["frame_time_video_s"]), anchor["reason"] - if located != (time_s is not None) or located != (reason is None): - raise ValueError(f"summary.json 的关键帧状态、时间、原因互相矛盾:{anchor}") - if reason is not None and ( - not isinstance(reason, str) or video_id in reason or any(m in reason for m in _PATH_MARKS) - ): - raise ValueError(f"关键帧未定位原因含视频号或路径样的字符,不能给专家:{reason!r}") - return {"located": located, "video_time_s": time_s, "reason": reason} - - -def tic_view(summary: dict[str, Any], video_id: str) -> dict[str, Any]: - """从一例的 summary.json 按白名单逐字段构造 tic.json 的内容。""" - return { - "fits": { - name: { - "success": _typed(summary["fits"][name]["success"], bool), - "r2": _number(summary["fits"][name]["quality"]["r2"]), - } - for name in _CURVES - }, "perfusion_metrics": { - name: _perfusion(summary["perfusion_metrics"][name]) for name in _LESION_CURVES + name: _perfusion(summary["perfusion_metrics"][name]) for name in _METRIC_CURVES + }, + "keyframe_times_s": { + slot: _keyframe_time(summary["anchor_frames"][slot]) for slot in _SLOTS }, - "pan_curve_usable": _typed(summary["reference_valid"], bool), - "keyframes": {slot: _keyframe(summary["anchor_frames"][slot], video_id) for slot in _SLOTS}, } @@ -141,8 +124,8 @@ def case_ceus(run: CeusRun, case: CaseRecord) -> LibraryCeus | SkipRecord: f"CEUS 运行不是在这份交付包上跑的:交付包的推注时间 {case.bolus_start_s} 秒与 " f"{summary_path} 的 injection_offset_s 不同" ) - tic = tic_view(summary, case.video_id) - located = [slot for slot in _SLOTS if tic["keyframes"][slot]["located"]] + tic = tic_view(summary) + located = [slot for slot in _SLOTS if tic["keyframe_times_s"][slot] is not None] files = run.keyframe_files(case.video_id) # 文件必须恰好是定位到的各槽位的 `<槽位>.png`:多一个(含同名不同扩展名)、少一个都报错。 if sorted(p.name for p in files) != sorted(f"{slot}.png" for slot in located): diff --git a/src/cadx/ceus/_pipeline.py b/src/cadx/ceus/_pipeline.py index 3b69bd2..6e447bd 100644 --- a/src/cadx/ceus/_pipeline.py +++ b/src/cadx/ceus/_pipeline.py @@ -55,8 +55,11 @@ from cadx.ceus._tic_plot import TicPlotMarker from cadx.ceus._trajectory import Trajectory from cadx.ceus._video import VideoDecodeError, VideoFile -# 灌注指标只对病灶两条曲线算(同旧 replay 的 `_LESION_CURVES`)。 +# 病灶两条曲线:两条的拟合都过了质量门,才定位 B0、D1–D4(同旧 replay 的 `_LESION_CURVES`)。 LESION_CURVES = ("roi_all", "roi_perf") +# 算灌注指标的两条曲线:病灶去暗区与紫圈(医生圈的正常胰腺实质),让病灶和正常胰腺对着看。 +# 病灶整体(roi_all)含不进造影剂的暗区,不算指标。来源:负责人 2026-10-17 决定。 +METRIC_CURVES = ("roi_perf", "roi_pan") # tic.png 上 D1–D4 竖线的颜色(BGR,同旧 replay)。 _ANCHOR_COLORS = { @@ -119,7 +122,7 @@ class QuantResult: - `injection_offset_s`:推注时间 X(秒,视频第 0 秒时已推注的时长); - `lesion_fit_ready`:病灶两条曲线的拟合是否都过了质量门; - `anchors`:7 个关键帧槽位的定位(按 `ANCHOR_SLOTS` 顺序); - - `perfusion`:`roi_all`、`roi_perf` 各自的灌注指标,拟合没过质量门为 None; + - `perfusion`:`METRIC_CURVES`(`roi_perf`、`roi_pan`)各自的灌注指标,拟合没过质量门为 None; - `pan_detected_fraction`:紫圈检出率(非重复帧里认出紫圈的帧占比)。 """ @@ -311,7 +314,7 @@ def quantify_case( # 第 9 步:灌注指标。 perfusion = { - name: compute_lognormal_perfusion_metrics(tic.fits[name]) for name in LESION_CURVES + name: compute_lognormal_perfusion_metrics(tic.fits[name]) for name in METRIC_CURVES } trajectory = Trajectory( @@ -516,7 +519,7 @@ def quant_summary(result: QuantResult) -> dict[str, Any]: "lesion_fit_ready": result.lesion_fit_ready, "fits": {name: _fit_summary(tic.fits[name]) for name in CURVE_NAMES}, "perfusion_metrics": { - name: _perfusion_summary(result.perfusion[name]) for name in LESION_CURVES + name: _perfusion_summary(result.perfusion[name]) for name in METRIC_CURVES }, "anchor_frames": {slot: _anchor_summary(item) for slot, item in result.anchors.items()}, "available_dynamic_slots": [ diff --git a/src/cadx/library/__init__.py b/src/cadx/library/__init__.py index 21a27a4..201b0a7 100644 --- a/src/cadx/library/__init__.py +++ b/src/cadx/library/__init__.py @@ -15,9 +15,10 @@ from cadx.library._read import ( read_ultrasound, ) from cadx.library._scan import LeakHit, scan_for_leaks -from cadx.library._write import LibraryCase, LibraryCeus, check_key_image, write_library +from cadx.library._write import KEY_SLOT, LibraryCase, LibraryCeus, check_key_image, write_library __all__ = [ + "KEY_SLOT", "BuildInfo", "ClinicalRoute", "LeakHit", diff --git a/src/cadx/library/_read.py b/src/cadx/library/_read.py index 4ca63d4..e47fbde 100644 --- a/src/cadx/library/_read.py +++ b/src/cadx/library/_read.py @@ -7,7 +7,6 @@ from typing import Any, cast from cadx.library._write import ( CLINICAL_JSON, - KEY_IMAGE, KEYFRAMES_DIR, MANIFEST, MR_REPORT, @@ -28,11 +27,11 @@ class ClinicalRoute: @dataclass(frozen=True) class UltrasoundRoute: - """超声一路:报告「所见」原文、医生截的关键图(给路径);装了 CEUS 结果时还有 tic.json 的内容和 - 定位到的关键帧(槽位 → 路径),没装时 `tic` 为 None、`keyframes` 为空。""" + """超声一路:报告「所见」原文;`keyframes` 是 keyframes/ 下的图(主干 → 路径),总有医生截的 + 关键图 `key`(`KEY_SLOT`),装了 CEUS 结果时还有定位到的各槽位;`tic` 是 tic.json 的内容, + 没装 CEUS 时为 None。""" report: str - key_image: Path tic: dict[str, Any] | None keyframes: dict[str, Path] @@ -99,18 +98,17 @@ def read_clinical(build_dir: Path, alias: str) -> ClinicalRoute: def read_ultrasound(build_dir: Path, alias: str) -> UltrasoundRoute: """读一例的超声一路。""" report = _text(_verified(build_dir, alias, ULTRASOUND_REPORT)) - key_image = _verified(build_dir, alias, KEY_IMAGE) manifest = _manifest(build_dir) - if manifest["ceus_source"] is None: - return UltrasoundRoute(report=report, key_image=key_image, tic=None, keyframes={}) prefix = case_file(alias, f"{KEYFRAMES_DIR}/") keyframes = { Path(key).stem: _verified(build_dir, alias, key.removeprefix(f"cases/{alias}/")) for key in manifest["files"] if key.startswith(prefix) } + if manifest["ceus_source"] is None: + return UltrasoundRoute(report=report, tic=None, keyframes=keyframes) tic = json.loads(_text(_verified(build_dir, alias, TIC_JSON))) - return UltrasoundRoute(report=report, key_image=key_image, tic=tic, keyframes=keyframes) + return UltrasoundRoute(report=report, tic=tic, keyframes=keyframes) def read_mr(build_dir: Path, alias: str) -> MrRoute: diff --git a/src/cadx/library/_write.py b/src/cadx/library/_write.py index 7a3524f..22fa206 100644 --- a/src/cadx/library/_write.py +++ b/src/cadx/library/_write.py @@ -5,7 +5,7 @@ manifest.json cases/<化名>/clinical/clinical.json cases/<化名>/ultrasound/report.txt - cases/<化名>/ultrasound/key.png + cases/<化名>/ultrasound/keyframes/key.png 医生截的关键图(来自交付包,总有) cases/<化名>/ultrasound/keyframes/<槽位>.png (装了 CEUS 结果时;只有定位到的槽位) cases/<化名>/ultrasound/tic.json (装了 CEUS 结果时) cases/<化名>/mr/report.txt @@ -23,8 +23,10 @@ from PIL import Image MANIFEST = "manifest.json" CLINICAL_JSON = "clinical/clinical.json" ULTRASOUND_REPORT = "ultrasound/report.txt" -KEY_IMAGE = "ultrasound/key.png" KEYFRAMES_DIR = "ultrasound/keyframes" +# 医生截的关键图与 CEUS 定位的关键帧放在同一个文件夹,文件名主干 `key` 不与槽位名重。 +KEY_SLOT = "key" +KEY_IMAGE = f"{KEYFRAMES_DIR}/{KEY_SLOT}.png" TIC_JSON = "ultrasound/tic.json" MR_REPORT = "mr/report.txt" @@ -120,7 +122,7 @@ def write_library( if (case.ceus is None) != (ceus_source is None): raise ValueError(f"化名 {case.alias} 的 CEUS 结果与 ceus_source={ceus_source!r} 不配套") case_dir = build_dir / "cases" / case.alias - for relative in (CLINICAL_JSON, ULTRASOUND_REPORT, MR_REPORT): + for relative in (CLINICAL_JSON, KEY_IMAGE, MR_REPORT): (case_dir / relative).parent.mkdir(parents=True, exist_ok=True) _write_json( case_dir / CLINICAL_JSON, @@ -131,7 +133,6 @@ def write_library( (case_dir / MR_REPORT).write_text(case.mr_report, encoding="utf-8") written = [CLINICAL_JSON, ULTRASOUND_REPORT, KEY_IMAGE, MR_REPORT] if case.ceus is not None: - (case_dir / KEYFRAMES_DIR).mkdir() for slot, source in case.ceus.keyframe_sources.items(): _write_clean_png(source, case_dir / KEYFRAMES_DIR / f"{slot}.png") written.append(f"{KEYFRAMES_DIR}/{slot}.png") diff --git a/src/cadx/review/_pack.py b/src/cadx/review/_pack.py index d1c0c46..c7ef077 100644 --- a/src/cadx/review/_pack.py +++ b/src/cadx/review/_pack.py @@ -1,17 +1,20 @@ -"""出一次医生核对包:目录布局、每个文件的格式、问题清单的来源与写法。 +"""出一次医生核对包:目录布局、每个文件的格式、问题清单的来源。 ``` /<运行号>/ - snapshot/ 快照(不给医生) + snapshot/ 快照(不给医生),含程序原文的 问题清单_原文.csv 医生核对包_<构建号>/ 说明.pdf 问题清单.csv 病例总表.csv 标准答案/化名对照.csv 诊断.csv 跳过病例.csv 报告复核句.csv - 病例/<病例号>/临床信息.csv 超声报告.pdf MR报告.pdf 关键图.png - 关键帧/<槽位>_第<秒>秒.png TIC曲线.png TIC结果.csv 回放视频.mp4 + 病例/<病例号>/临床信息.csv 超声报告.pdf MR报告.pdf + 关键帧/医生截图.png <槽位>_第<秒>秒.png + TIC曲线.png TIC参数.csv 回放视频.mp4 ``` 只读已经构建好的资料库、答案库和那次 CEUS 运行,不写它们;图片和视频按字节拷贝,不打开。 -问题清单每一行都抄自某个已有文件里的某个值,不另做判断。 +TIC参数.csv 的数值取自资料库的 tic.json(医生核对的就是 AI 专家看到的那份),只有整组没算出的 +原因取自 CEUS 运行的 summary.json。问题清单每一行都抄自某个已有文件里的某个值,经 `_problems` +的对照表换成医生能懂的话;程序原文另存在快照里。 """ import csv @@ -29,10 +32,19 @@ 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.answers import SkippedRow, read_aliases, read_skipped, verified_file +from cadx.library import KEY_SLOT, read_build_info, read_clinical, read_mr, read_ultrasound from cadx.review._config import load_review_config from cadx.review._pdf import markdown_to_pdf, report_markdown, tool_versions +from cadx.review._problems import ( + STAGE_VIDEO, + case_label, + ceus_failure, + check_plain, + curve_problems, + group_reason, + skip_problem, +) from cadx.runs import ( CeusRun, finish_snapshot, @@ -46,7 +58,10 @@ SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2") _CASE_COLUMNS = ("病例号", "视频号", "化名", "是否进入资料库", "没进的原因") SUMMARY_HEADER = (*_CASE_COLUMNS, "CEUS 定量是否成功", "CEUS 失败在第几步", *SLOTS) PROBLEM_HEADER = ("病例号", "视频号", "环节", "问题") -TIC_HEADER = ("项目", "值") +TIC_PARAMS_HEADER = ("指标", "单位", "胰腺实质(紫圈)", "病灶(去暗区)") +NO_GROUP_ROW = "没算出的原因" +KEY_IMAGE_NAME = "医生截图.png" +RAW_PROBLEMS = "问题清单_原文.csv" # 答案库的表 → 包里的文件名;内容逐字节照抄(答案库写的就是带 BOM 的 UTF-8、中文列名、缺失为空)。 ANSWER_TABLES = { "aliases.csv": "化名对照.csv", @@ -54,49 +69,32 @@ ANSWER_TABLES = { "skipped.csv": "跳过病例.csv", "review.csv": "报告复核句.csv", } -STAGE_ASSEMBLY = "装配资料库" -STAGE_FIT = "TIC 拟合" -STAGE_KEYFRAME = "关键帧" -STAGE_PAN = "紫圈" +# TIC参数.csv 的两组(tic.json 里 perfusion_metrics 的键,按列的顺序)和 11 行: +# tic.json 里的键、中文名加缩写、单位。时间类(单位秒)保留 1 位小数,其余 2 位。 +TIC_GROUPS = ("roi_pan", "roi_perf") +TIC_METRICS = ( + ("PE_au", "峰值增强 PE", "灰度"), + ("TTP_s", "达峰时间 TTP", "秒"), + ("WiR_au_per_s", "最大洗入斜率 WiR", "灰度/秒"), + ("RT_s", "上升时间 RT", "秒"), + ("WiAUC_au_s", "洗入曲线下面积 WiAUC", "灰度·秒"), + ("WiPI_au", "洗入灌注指数 WiPI", "灰度"), + ("WoR_au_per_s", "最大洗出斜率 WoR", "灰度/秒"), + ("FT_s", "下降时间 FT", "秒"), + ("WoAUC_au_s", "洗出曲线下面积 WoAUC", "灰度·秒"), + ("WiWoAUC_au_s", "洗入洗出曲线下面积 WiWoAUC", "灰度·秒"), + ("mTTl_s", "平均渡越时间 mTTl", "秒"), +) +_TIC_KEYS = ("perfusion_metrics", "keyframe_times_s") +# 快照里程序原文问题清单(给我们自己追溯,写法同任务书 03)的「环节」。 +_RAW_ASSEMBLY = "装配资料库" +_RAW_FIT = "TIC 拟合" +_RAW_KEYFRAME = "关键帧" +_RAW_PAN = "紫圈" _LESION_CURVES = {"roi_all": "病灶整体曲线", "roi_perf": "病灶去暗区曲线"} -_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 行」等) -# 病例号原样写,视频号空着。 +# 库里存的病例号是三位数字;视频号是「U + 三位病例号」(交付包检查 C05 核对过)。跳过的病例也按 +# 这条规则写,在 C05 之前就被跳过的,交付里实际的 CEUS视频ID 没有核对过。认不出编号的跳过记录 +# 视频号空着。 _CASE_NO = re.compile(r"[0-9]{3}") _VIDEO_ID = re.compile(r"U([0-9]{3})") @@ -115,12 +113,15 @@ class PackSummary: video_bytes: int -def _doctor_no(case_no: str) -> str: +type Problem = tuple[str, str, str, str] # 病例号、视频号、环节、问题 + + +def _raw_no(case_no: str) -> str: return f"P{case_no}" if _CASE_NO.fullmatch(case_no) else case_no def _cell(value: object) -> str: - """CSV 的格:None 写空串,布尔写 true/false,数值写 JSON 的写法,字符串原样。""" + """CSV 的格:None 写空串,数值写 JSON 的写法,字符串原样。""" if value is None: return "" if isinstance(value, str): @@ -135,33 +136,40 @@ def _write_csv(path: Path, header: Sequence[str], rows: Sequence[Sequence[str]]) 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 _metric(value: object, unit: str) -> str: + if value is None: + return "" + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError(f"tic.json 里的灌注指标应为数值或 null,是 {value!r}") + return f"{value:.1f}" if unit == "秒" else f"{value:.2f}" -def _lookup(tic: dict[str, Any], path: str) -> object: - """按路径取值;路径上某一段是 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) +def tic_param_rows(tic: dict[str, Any], reasons: dict[str, str]) -> list[list[str]]: + """TIC参数.csv 的行:11 个指标各一行,两组各一列;`reasons` 是整组为 null 的组 → 没算出的原因, + 有这样的组时最后加一行「没算出的原因」。tic.json 的字段与这里列的不一致就报错。""" + if tuple(sorted(tic)) != tuple(sorted(_TIC_KEYS)): + raise ValueError(f"tic.json 的字段是 {sorted(tic)},核对包只认得 {list(_TIC_KEYS)}") + groups: dict[str, dict[str, Any] | None] = tic["perfusion_metrics"] + if sorted(groups) != sorted(TIC_GROUPS): + raise ValueError(f"tic.json 的灌注指标分组是 {sorted(groups)},应为 {list(TIC_GROUPS)}") + keys = sorted(key for key, _, _ in TIC_METRICS) + for name, values in groups.items(): + if values is not None and sorted(values) != keys: + raise ValueError(f"tic.json 里 {name} 的灌注指标是 {sorted(values)},应为 {keys}") + empty = sorted(name for name, values in groups.items() if values is None) + if sorted(reasons) != empty: + raise ValueError(f"整组为空的是 {empty},给的原因却是 {sorted(reasons)}") + rows = [ + [ + label, + unit, + *(_metric(None if (g := groups[n]) is None else g[key], unit) for n in TIC_GROUPS), + ] + for key, label, unit in TIC_METRICS ] - if unknown: - raise ValueError(f"tic.json 里有 TIC结果.csv 没有中文名的字段:{unknown}") - return [(f"{name}({path})", _cell(_lookup(tic, path))) for path, name in TIC_ITEMS.items()] + if reasons: + rows.append([NO_GROUP_ROW, "", *(reasons.get(name, "") for name in TIC_GROUPS)]) + return rows def _ceus_run(answers_dir: Path, run_id: str, repo_root: Path) -> CeusRun: @@ -187,23 +195,20 @@ def _template_text( ) if run is None: ceus_note = ( - "**这次构建没有装入 CEUS 定量结果**:病例目录里没有关键帧、TIC曲线.png、" - "TIC结果.csv、回放视频.mp4,病例总表的 CEUS 各列空着," - "问题清单里没有 CEUS、TIC 拟合、关键帧、紫圈几类。" + "**这一版没有造影视频的处理结果**:病例文件夹里只有医生截图,没有程序取的关键帧、" + "TIC 曲线图、TIC 参数和回放视频,病例总表里造影视频那几列空着。" + "下面讲关键帧、TIC 的几节可以先跳过。" ) - f1 = f2 = tolerance = "(这次没有 CEUS 结果,不适用)" + f1 = f2 = tolerance = "—" else: - ceus_note = f"这次构建装入了 CEUS 定量运行 {run.run_id} 的结果。" + ceus_note = "" 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 开关)。" + else "编码列(性别、症状等)照你们交来的数字原样写,还没换成文字。" ) return template.substitute( build_id=build_id, @@ -225,9 +230,6 @@ def _copy(source: Path, target: Path) -> int: 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 运行时都空着。""" @@ -238,6 +240,42 @@ def _ceus_status(run: CeusRun | None, video_id: str) -> tuple[str, str]: return ("是", "") if run.has_result(video_id) else ("", "") +def _raw_ceus_problems(no: str, video_id: str, summary: dict[str, Any]) -> list[Problem]: + """一例 CEUS 结果的程序原文问题(任务书 03 的写法),只进快照。""" + problems: list[Problem] = [] + fits = summary["fits"] + for curve, name in _LESION_CURVES.items(): + if not fits[curve]["success"]: + code = fits[curve]["failure_reason"] + problems.append((no, video_id, _RAW_FIT, f"{name}未完成拟合({code})")) + anchors = summary["anchor_frames"] + for slot in SLOTS: + if anchors[slot]["status"] != "ok": + problems.append( + (no, video_id, _RAW_KEYFRAME, f"{slot} 未取到:{anchors[slot]['reason']}") + ) + if not summary["reference_valid"]: + problems.append((no, video_id, _RAW_PAN, "紫圈曲线不可用")) + return problems + + +def _raw_skip_problem(record: SkippedRow, video_id: str) -> Problem: + parts = [f"{record.check} {record.description}"] + parts += [ + f"{k}:{'; '.join(v)}" for k, v in (("文件", record.files), ("列", record.columns)) if v + ] + parts.append(f"涉及值:{record.value_json}") + return (_raw_no(record.case), video_id, _RAW_ASSEMBLY, ";".join(parts)) + + +@dataclass(frozen=True) +class _CaseCeus: + cells: list[str] # 病例总表的 7 个槽位格 + problems: list[Problem] + raw_problems: list[Problem] + video_bytes: int + + def _write_ceus( case_dir: Path, no: str, @@ -245,36 +283,28 @@ def _write_ceus( tic: dict[str, Any], keyframes: dict[str, Path], run: CeusRun, -) -> tuple[list[str], list[Problem], int]: - """一例的 CEUS 文件和问题;交出病例总表的 7 个槽位格、问题清单的行、回放视频的字节数。""" - (case_dir / "关键帧").mkdir() +) -> _CaseCeus: + """一例的 CEUS 文件(关键帧、TIC 曲线图、TIC参数.csv、回放视频)和问题。""" for slot, source in keyframes.items(): - seconds = tic["keyframes"][slot]["video_time_s"] - _copy(source, case_dir / "关键帧" / f"{slot}_第{seconds:.1f}秒.png") + if slot != KEY_SLOT: + seconds = tic["keyframe_times_s"][slot] + _copy(source, case_dir / "关键帧" / f"{slot}_第{seconds:.1f}秒.png") _copy(run.tic_png(video_id), case_dir / "TIC曲线.png") 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 + summary = run.summary(video_id) + reasons = { + name: check_plain(group_reason(summary, name)) + for name in TIC_GROUPS + if tic["perfusion_metrics"][name] is None + } + _write_csv(case_dir / "TIC参数.csv", TIC_PARAMS_HEADER, tic_param_rows(tic, reasons)) + times = tic["keyframe_times_s"] + return _CaseCeus( + cells=["未取到" if times[s] is None else _cell(times[s]) for s in SLOTS], + problems=[(no, video_id, stage, text) for stage, text in curve_problems(summary)], + raw_problems=_raw_ceus_problems(no, video_id, summary), + video_bytes=size, + ) def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSummary: @@ -319,7 +349,7 @@ def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSu for name, target in ANSWER_TABLES.items(): _copy(verified_file(answers, name), pack / "标准答案" / target) - # failed.csv 的视频号 → 病例号(三位数字)。 + # 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) @@ -329,12 +359,13 @@ def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSu summary: list[list[str]] = [] problems: list[Problem] = [] + raw_problems: list[Problem] = [] pdf_jobs: dict[Path, str] = {} video_bytes = 0 for row in aliases: - no = _doctor_no(row.case_no) + no = f"P{row.case_no}" case_dir = pack / "病例" / no - case_dir.mkdir(parents=True) + (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()]] @@ -346,41 +377,50 @@ def run_review_pack(config_path: Path, repo_root: Path, now: datetime) -> PackSu ): title = f"病例号 {no} {route}(AI 专家看到的原文,不含结论段)" pdf_jobs[case_dir / name] = report_markdown(title, text) - _copy(ultrasound.key_image, case_dir / "关键图.png") + _copy(ultrasound.keyframes[KEY_SLOT], case_dir / "关键帧" / KEY_IMAGE_NAME) cells = ["" for _ in SLOTS] if run is not None and ultrasound.tic is not None: - cells, case_problems, size = _write_ceus( + ceus = _write_ceus( case_dir, no, row.video_id, ultrasound.tic, ultrasound.keyframes, run ) - problems.extend(case_problems) - video_bytes += size + cells = ceus.cells + problems.extend(ceus.problems) + raw_problems.extend(ceus.raw_problems) + video_bytes += ceus.video_bytes summary.append( [no, row.video_id, row.alias, "是", "", *_ceus_status(run, row.video_id), *cells] ) + steps = {case: (step, reason) for case, (_, step, reason) in failures.items()} + merged: set[str] = set() # C15 跳过的病例:CEUS 失败与跳过只出一行 for record_ in skipped: - no = _doctor_no(record_.case) + label = case_label(record_.case) video_id = f"U{record_.case}" if _CASE_NO.fullmatch(record_.case) else "" - reason = f"{record_.check} {record_.description}" + stage, reason, text = skip_problem(record_, steps) + if stage == STAGE_VIDEO: + merged.add(record_.case) status = _ceus_status(run, video_id) - 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))) + summary.append([label, video_id, "", "否", reason, *status, *["" for _ in SLOTS]]) + problems.append((label, video_id, stage, text)) + raw_problems.append(_raw_skip_problem(record_, video_id)) for case_no, (video_id, step, reason) in failures.items(): - problems.append( - (_doctor_no(case_no), video_id, f"CEUS 第 {step} 步", f"第 {step} 步失败:{reason}") + if case_no not in merged: + problems.append( + (f"P{case_no}", video_id, STAGE_VIDEO, check_plain(ceus_failure(step, reason))) + ) + raw_problems.append( + (_raw_no(case_no), video_id, f"CEUS 第 {step} 步", f"第 {step} 步失败:{reason}") ) + for problem in problems: + for value in problem: + check_plain(value) summary.sort(key=lambda r: r[0]) problems.sort(key=lambda r: r[0]) + raw_problems.sort(key=lambda r: r[0]) _write_csv(pack / "病例总表.csv", SUMMARY_HEADER, summary) _write_csv(pack / "问题清单.csv", PROBLEM_HEADER, problems) + _write_csv(snapshot / RAW_PROBLEMS, PROBLEM_HEADER, raw_problems) counts = (len(summary), len(aliases), len(skipped)) pdf_jobs[pack / "说明.pdf"] = _template_text( info.build_id, run, info.codes_decoded, now, counts diff --git a/src/cadx/review/_problems.py b/src/cadx/review/_problems.py new file mode 100644 index 0000000..62cdac4 --- /dev/null +++ b/src/cadx/review/_problems.py @@ -0,0 +1,502 @@ +"""问题清单的医生写法:程序原因 → 医生能懂的一句话(对照表),以及按病例合并成行。 + +对照表按「原因的种类」写:跳过清单的每个检查编号(C01–C15)、CEUS `failed.csv` 里各步的失败原因、 +拟合的每个原因码、关键帧每类未取到的原因、紫圈不可用。具体数值从原记录里取出填进句子,对医生 +没意义的(谷值、像素偏移、窗口边界)不填。遇到对照表里没有的原因直接报错,不把程序原文漏给医生; +程序原文另存在快照里(`_pack.py`)。 +""" + +import json +import re +from collections.abc import Callable, Sequence +from typing import Any + +from cadx.answers import SkippedRow + +STAGE_DATA = "资料核对" +STAGE_VIDEO = "造影视频处理" +STAGE_CURVE = "TIC 曲线" +STAGE_KEYFRAME = "关键帧" +STAGE_PAN = "紫圈" + +DYNAMIC_SLOTS = ("B0", "D1", "D2", "D3", "D4") +SLOTS = (*DYNAMIC_SLOTS, "F1", "F2") +_NOT_IN_LIBRARY = ",这一例没进资料库" +# 问题清单里不能出现的东西:检查编号、字段名、下划线、JSON 和列表的括号、程序用词、运行号。 +FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}") + + +class UnknownReasonError(ValueError): + """程序记录的原因在对照表里没有:先补对照表,不能把原文给医生。""" + + +def check_plain(text: str) -> str: + """医生看到的一句话里不许有 `FORBIDDEN`;有就报错停下。""" + if (found := FORBIDDEN.search(text)) is not None: + raise ValueError(f"给医生的话里出现了程序用语「{found.group()}」:{text}") + return text + + +def _num(text: str) -> str: + """原记录里的秒数统一写一位小数。""" + return f"{float(text):.1f}" + + +# ---- 拟合原因码(_tic_fitting.py 的 reject 原因):说曲线本身的样子,不说算法。 +FIT_REASONS = { + "insufficient_valid_samples": "能用的画面太少(很多帧认不出这块区域,或是重复帧),连不成曲线", + "candidate_grid_unavailable": "能用的画面时间点太少,连不成曲线", + "credible_peak_not_found": "曲线太平,没有明显的峰", + "candidate_arrival_not_found": "峰之前找不到亮度明确、持续升起来的时刻", + "insufficient_bolus_samples": "造影剂第一次流过的那一段能用的画面太少", + "long_gap_in_wash_in": "变亮的那一段中间缺了一大段画面", + "arrival_bounds_invalid": "峰离视频开头太近,或造影剂第一次流过的那一段太短", + "optimizer_failed": "曲线形状不规则,平滑曲线怎么也贴不上", + # 边界是峰高、峰出现的时间、曲线宽窄(对数正态的 sigma)、开始变亮的时刻、增强前水平五项 + # (_tic_fitting.py 的 `_optimizer_bounds`、`_parameters_on_boundary`)。 + "parameter_on_boundary": ( + "试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上," + "有一项卡在了程序允许范围的边上" + ), + "model_peak_on_boundary": "试着贴出来的曲线,峰落在可用那一段的最开头或最末尾,峰的位置不可信", + "fit_quality_failed": "曲线起伏杂乱,平滑曲线和它差得太多", +} +PAN_UNUSABLE = "视频里大部分时间认不出紫圈" + + +def fit_reason(code: object) -> str: + if not isinstance(code, str) or code not in FIT_REASONS: + raise UnknownReasonError(f"拟合原因码不在对照表里:{code!r}") + return FIT_REASONS[code] + + +def group_reason(summary: dict[str, Any], curve: str) -> str: + """TIC参数.csv 里一组整组空着时「没算出的原因」那一格。""" + if curve == "roi_pan" and not summary["reference_valid"]: + return PAN_UNUSABLE + fit = summary["fits"][curve] + if fit["success"]: + raise ValueError(f"{curve} 拟合成功,灌注指标却整组为空") + return f"{fit_reason(fit['failure_reason'])},没能拟合" + + +# ---- CEUS failed.csv 的失败原因(各步抛的 CeusCaseError、ceus_quant 的目视核对)。 +_VIDEO_BROKEN = "视频文件本身有问题(帧率、帧数或时间信息不对),程序读不了" +_NO_SPLIT = "视频里找不到左右两屏的分界线" +_MISALIGNED = "视频左右两屏的位置对不齐" +CEUS_FAILURES: tuple[tuple[int, re.Pattern[str], str], ...] = tuple( + (step, re.compile(pattern), text) + for step, pattern, text in ( + (1, r"配准目视核对未通过", f"{_MISALIGNED}(我们人工逐例看过,这一例没对上)"), + (1, r"cv2 打不开视频:", "视频文件打不开"), + (1, r"探测到非法 fps=|探测到非法元数据 ", _VIDEO_BROKEN), + (1, r"视频包含非法显示时间戳 |视频显示时间戳不严格递增:", _VIDEO_BROKEN), + (1, r"时间戳帧数与容器记录的帧数不一致:", _VIDEO_BROKEN), + (1, r"解码帧数 [0-9]+ 与容器记录的帧数 [0-9]+ 不一致", _VIDEO_BROKEN), + (1, r"帧尺寸不一致:", "视频前后画面大小不一致"), + (1, r"帧序列为空", "视频里一帧画面都没有"), + (1, r"中部 band 内未找到稳定暗缝", _NO_SPLIT), + (1, r"非法 search_band:|search_band 在宽度 ", _NO_SPLIT), + (1, r"检测到的 split_x=|切分线 split_x=", _NO_SPLIT), + (1, r"左右宽度比 ", "找到的左右两屏分界线不可信(两屏宽窄差太多)"), + (1, r"这一半画面里找不到扇形区", "视频有一屏里找不到超声扇形画面"), + (1, r"左半扇形内可用的模板块只有 ", f"{_MISALIGNED}(画面里能拿来比对的细节太少)"), + (1, r"两半配准不一致:", _MISALIGNED), + (2, r"左右两半都找到绿框", "视频左右两屏都有绿框,分不出哪一屏是造影画面"), + (2, r"左右两半都没有找到绿框", "视频里找不到画好的绿框"), + (4, r"逐帧 mask 未产出任何有效 ROI 轮廓", "整段视频里程序都没能画出病灶轮廓"), + ) +) + + +def ceus_failure(step: int, reason: str) -> str: + """failed.csv 一行(第几步、原因原文)→ 给医生的话。""" + for known_step, pattern, text in CEUS_FAILURES: + if known_step == step and pattern.match(reason): + return f"{text},程序没法处理这段视频" + raise UnknownReasonError(f"CEUS 第 {step} 步的失败原因不在对照表里:{reason}") + + +# ---- 关键帧未取到的原因(_anchor_frames.py 的 reason)。每类:模式、共同的话、逐槽位的细节。 +_SPAN = r"\[(?P-?[0-9.]+), (?P-?[0-9.]+)\] 秒" + + +def _covered(slot: str, m: re.Match[str]) -> str: + return f"{slot} 在视频第 {_num(m['t'])} 秒,能用的画面是第 {_num(m['a'])}–{_num(m['b'])} 秒" + + +def _injection(slot: str, m: re.Match[str]) -> str: + where = f"{slot} 是推注后第 {m['inj']} 秒,应在视频第 {_num(m['t'])} 秒" + if m["a"] is None: + return f"{where},视频里没有能用的画面" + if float(m["t"]) < float(m["a"]): + return f"{where},那时还没开始录" + return f"{where},视频只录到第 {_num(m['b'])} 秒" + + +_FIT_KIND = "fit" +type _Detail = Callable[[str, re.Match[str]], str] | None +ANCHOR_REASONS: tuple[tuple[str, re.Pattern[str], str, _Detail], ...] = tuple( + (kind, re.compile(pattern), text, detail) + for kind, pattern, text, detail in ( + (_FIT_KIND, r"病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门$", "", None), + (_FIT_KIND, r"roi_perf 曲线的 Lognormal 拟合没过质量门([a-z_]+)$", "", None), + ( + "b0_window", + r"B0 锚点 \S+ 秒的容差窗口 .* 内,观测到的基线段里、到达时刻之前没有可用帧$", + "开始增强之前的那一小段里没有能用的画面", + None, + ), + ( + "no_arrival", + r"拟合曲线升不过到达门槛,没有到达时刻$|拟合曲线上没有 D[1-3] 时刻(升不过到达门槛)$", + "曲线上升得太少,定不出造影剂到达的时刻", + None, + ), + ( + "estimated_baseline", + r"视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义$", + "视频一开始病灶就已经在变亮,没录到增强之前的那一段", + None, + ), + ( + "no_d4", + r"峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4$", + "峰值之后录得不够长,找不到变暗最快的时刻", + None, + ), + ( + "not_covered", + rf"D[1-4] 时刻 (?P-?[0-9.]+) 秒落在视频有效帧范围 {_SPAN} 之外$", + "算出的时刻不在视频能用的画面范围里", + _covered, + ), + ( + "taken", + rf"D[1-4] 容差窗口 {_SPAN} 内的候选帧都被时间更早的槽位占用,无法保持先后顺序$", + "这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧" + "(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)", + None, + ), + ( + "no_frame", + rf"D[1-4] 容差窗口 {_SPAN} 内没有可用帧(拟合区间内、不重复)$", + "这个时刻附近没有能用的画面(附近都是重复帧,或已过了造影剂第一次流过的那一段)", + None, + ), + ( + "injection_outside", + r"推注后第 (?P\S+) 秒在视频第 (?P-?[0-9.]+) 秒(推注时间 X=\S+ 秒)," + rf"落在(?:视频有效帧范围 {_SPAN}|没有不重复的帧)之外$", + "换算到视频里的时刻不在录到的范围内", + _injection, + ), + ( + "injection_window", + r"视频第 \S+ 秒 ±\S+ 秒内没有不重复的帧$", + "这个时刻前后没有能用的画面(都是重复帧)", + None, + ), + ( + "no_bolus", + r"没有推注时间,推注后时刻换算不到视频时间$", + "没有推注时间,换算不出视频里的时刻", + None, + ), + ) +) + + +def _anchor_kind(slot: str, reason: object) -> tuple[str, str, str | None]: + """一个未取到的槽位 →(种类,共同的话,这一槽位的细节)。""" + if isinstance(reason, str): + for kind, pattern, text, detail in ANCHOR_REASONS: + if (m := pattern.match(reason)) is not None: + return kind, text, None if detail is None else detail(slot, m) + raise UnknownReasonError(f"关键帧 {slot} 未取到的原因不在对照表里:{reason!r}") + + +def _slots(names: Sequence[str]) -> str: + return "B0、D1–D4" if tuple(names) == DYNAMIC_SLOTS else "、".join(names) + + +_LESION = {"roi_all": "病灶整体", "roi_perf": "病灶去暗区"} + + +def curve_problems(summary: dict[str, Any]) -> list[tuple[str, str]]: + """一例 CEUS 结果的问题:(环节,给医生的话)。病灶曲线拟合失败连带的关键帧只出一行。""" + fits, anchors = summary["fits"], summary["anchor_frames"] + rows: list[tuple[str, str]] = [] + failed = [c for c in _LESION if not fits[c]["success"]] + unlocated = [s for s in SLOTS if anchors[s]["status"] != "ok"] + kinds = {s: _anchor_kind(s, anchors[s]["reason"]) for s in unlocated} + fit_slots = [s for s in unlocated if kinds[s][0] == _FIT_KIND] + if bool(failed) != bool(fit_slots) or (failed and fit_slots != list(DYNAMIC_SLOTS)): + raise ValueError( + f"病灶曲线拟合失败 {failed} 与因此未取到的关键帧 {fit_slots} 对不上(应为 B0、D1–D4)" + ) + if failed: + reasons = {c: fit_reason(fits[c]["failure_reason"]) for c in failed} + if len(failed) == 1: + what = f"{_LESION[failed[0]]}那条曲线没能拟合({reasons[failed[0]]})" + elif len(set(reasons.values())) == 1: + what = f"病灶整体、病灶去暗区两条曲线都没能拟合(都是{reasons['roi_all']})" + else: + parts = ";".join(f"{_LESION[c]}:{r}" for c, r in reasons.items()) + what = f"病灶整体、病灶去暗区两条曲线都没能拟合({parts})" + tail = ",TIC 参数里病灶一组也空着" if "roi_perf" in failed else "" + rows.append( + ( + STAGE_CURVE, + f"{what};B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取," + f"所以这 5 个关键帧没有取{tail}", + ) + ) + + grouped: dict[str, tuple[str, list[str], list[str]]] = {} + for slot in unlocated: + kind, text, detail = kinds[slot] + if kind == _FIT_KIND: + continue + entry = grouped.setdefault(kind, (text, [], [])) + entry[1].append(slot) + if detail is not None: + entry[2].append(detail) + for text, slots, details in grouped.values(): + extra = f"({';'.join(details)})" if details else "" + rows.append((STAGE_KEYFRAME, f"{_slots(slots)} 没有取:{text}{extra}")) + + if not summary["reference_valid"]: + rows.append((STAGE_PAN, f"{PAN_UNUSABLE},紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着")) + elif not fits["roi_pan"]["success"]: + reason = fit_reason(fits["roi_pan"]["failure_reason"]) + rows.append((STAGE_CURVE, f"紫圈那条曲线没能拟合({reason}),TIC 参数里胰腺实质一组空着")) + return rows + + +# ---- 跳过清单(answers/skipped.csv):每个检查编号一种写法。 +_TABLES = ( + (re.compile(r"Clinical_agent/[^/]+\.xlsx"), "临床表"), + (re.compile(r"CEUS_agent/[^/]+\.xlsx"), "超声表"), + (re.compile(r"MR_agent/[^/]+\.xlsx"), "MR 表"), + (re.compile(r"CEUS_agent/[0-9]+/?"), "病例文件夹"), + (re.compile(r"CEUS_agent/[0-9]+/[^/]+_report\.xlsx"), "单例超声报告表"), +) +_COLUMNS = { + "Sex": "性别", + "Age": "年龄", + "Diagnosis": "病理诊断", + "Symptom_Pain": "腹痛", + "Symptom_Jaundice": "黄疸", + "Symptom_WeightLoss": "体重下降", + "Symptom_Fever": "发热", + "Symptom_Asymptomatic": "无症状", + "Hx_Pancreatitis": "胰腺炎病史", + "Hx_Alcohol": "饮酒史", + "Hx_Gallstones": "胆结石病史", + "Hx_Malignancy": "恶性肿瘤病史", + "Hx_Family_PC": "胰腺癌家族史", + "CA199": "CA19-9", + "CA199_Level": "CA19-9 是否异常", + "CEA": "CEA", + "CEA_Level": "CEA 是否异常", + "Amylase": "淀粉酶", + "Amylase_Level": "淀粉酶是否异常", + "Lipase": "脂肪酶", + "Lipase_Level": "脂肪酶是否异常", + "TBil": "总胆红素", + "TBil_Level": "总胆红素是否异常", + "患者ID": "患者ID", + "CEUS视频ID": "CEUS视频ID", + "性别": "性别", + "年龄": "年龄", + "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)": "病理诊断", + "推注开始时间s": "推注开始时间", + "超声描述": "超声描述", + "MR描述": "MR描述", +} +_REPORTS = {"超声描述": "超声报告", "MR描述": "MR报告"} +_KEY_IMAGE = { + "不是 PNG": "不是 PNG 格式", + "带 tRNS 透明色,重编码会丢透明": "带透明色,另存会丢掉透明信息", +} +_KEY_IMAGE_DEPTH = re.compile(r"位深 [0-9]+、颜色类型 [0-9]+ 不能逐像素保真重编码$") +_ROW = re.compile(r"(?P.+) 第 (?P[0-9]+) 行$") +_DIR = re.compile(r"病例目录 (?P.+)$") +_KEYFRAME_FILE = re.compile(r"[^/]+/U[0-9]{3}/keyframes/(?PB0|D[1-4]|F[12])\.png$") + + +def _table(path: str) -> str: + for pattern, name in _TABLES: + if pattern.fullmatch(path): + return name + raise UnknownReasonError(f"跳过清单里的文件认不出是哪张表:{path}") + + +def _column(column: str) -> str: + if column not in _COLUMNS: + raise UnknownReasonError(f"跳过清单里的列没有中文名:{column}") + return _COLUMNS[column] + + +# 跳过清单的涉及值经 json.dumps(default=repr) 存下(delivery/_cases.py 的 `_skip`): +# Excel 的日期、时间格读出来是 datetime 等对象,存成它们的 repr 字符串。 +_DATE_REPR = re.compile(r"datetime\.(datetime|date|time|timedelta)\(") + + +def _show(value: object) -> str: + if value is None: + return "空着" + if isinstance(value, bool): + return f"这一格填的是 {'TRUE' if value else 'FALSE'}" + if isinstance(value, str): + if _DATE_REPR.match(value): + return "这一格是日期或时间格式" + return f"「{value}」" + return json.dumps(value, ensure_ascii=False) + + +def case_label(case: str) -> str: + """病例号一格:三位病例号写成 P001;认不出编号的写成「临床表第 5 行」「病例文件夹 x」。""" + if re.fullmatch(r"[0-9]{3}", case): + return f"P{case}" + if (m := _ROW.fullmatch(case)) is not None: + return f"{_table(m['path'])}第 {m['row']} 行" + if (m := _DIR.fullmatch(case)) is not None: + return f"病例文件夹「{m['name']}」" + raise UnknownReasonError(f"跳过清单的病例一格认不出:{case}") + + +def _bad_values(table: str, bad: dict[str, object]) -> str: + items = "、".join(f"{_column(c)}({_show(v)})" for c, v in bad.items()) + return f"{table}这几项的取值不合规:{items}" + + +def _c06(value: dict[str, Any]) -> str: + if "工作表" in value: + return "单例超声报告表的工作表不对(应只有一张)" + if "表头" in value: + return "单例超声报告表的表头和超声汇总表不一样" + if "数据行数" in value: + return f"单例超声报告表应只有一行数据,实际有 {value['数据行数']} 行" + parts: list[str] = [] + for column, pair in value.items(): + mine, summary = pair["单例报告表"], pair["超声汇总表"] + long = any(isinstance(v, str) and len(v) > 30 for v in (mine, summary)) + detail = "内容不一样" if long else f"单例表 {_show(mine)},汇总表 {_show(summary)}" + parts.append(f"{_column(column)}({detail})") + return f"单例超声报告表和超声汇总表对不上:{';'.join(parts)}" + + +def _age(value: object) -> str: + text = str(value).removesuffix("岁") if isinstance(value, str) else json.dumps(value) + return f"{text} 岁" + + +def _c10(value: dict[str, dict[str, Any]]) -> str: + parts: list[str] = [] + for field, by_table in value.items(): + tables = {name.removesuffix("(解码后)"): v for name, v in by_table.items()} + if field == "年龄": + shown = ",".join(f"{t} {_age(v)}" for t, v in tables.items()) + elif field == "诊断": + shown = ",".join(f"{t}写 {v}" for t, v in tables.items()) + field = "病理诊断编码" + elif field == "性别": + shown = ",".join(f"{t} {v}" for t, v in tables.items()) + if len(tables) == 2: + # 不解码时临床表的性别是数字编码,C10 只比超声表和 MR 表(delivery/_cases.py)。 + shown += "(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)" + else: + raise UnknownReasonError(f"C10 跨表不一致的字段不在对照表里:{field}") + which = "三张表" if len(tables) == 3 else "和".join(tables).replace("和MR", "和 MR") + parts.append(f"{which}的{field}对不上:{shown}") + return ";".join(parts) + + +def _text_hits(check: str, value: dict[str, list[Any]]) -> str: + parts: list[str] = [] + for column, hits in value.items(): + report = _REPORTS[column] + if check == "C11": + shown = ";".join(f"「{h['关键词']}:」(所在句子:{h['句子']})" for h in hits) + parts.append(f"{report}的描述里有像结论段标题的写法{shown}") + elif check == "C12": + parts.append(f"{report}的描述里出现了亚型名{'、'.join(map(_show, hits))}") + else: + parts.append(f"{report}的描述里出现了性别或年龄{'、'.join(map(_show, hits))}") + return ";".join(parts) + + +def _c14(row: SkippedRow, reason: str) -> str: + if reason in _KEY_IMAGE: + what = _KEY_IMAGE[reason] + elif _KEY_IMAGE_DEPTH.match(reason): + what = "的颜色格式特殊,程序没法一点不差地另存" + else: + raise UnknownReasonError(f"C14 的原因不在对照表里:{reason}") + (file,) = row.files + if (m := _KEYFRAME_FILE.fullmatch(file)) is not None: + return f"程序从视频里取的 {m['slot']} 关键帧{what}" + return f"医生截图{what}" + + +def skip_reason(row: SkippedRow) -> str: + """跳过清单一行(C01–C14)→ 给医生的话,不带「没进资料库」。C15 由调用方和 failed.csv 合并。""" + value = json.loads(row.value_json) + check = row.check + if check == "C01": + if "目录名" in value: + return f"病例文件夹名{_show(value['目录名'])}认不出是哪个病例" + raw = _show(value["原始写法"]) + return f"{_table(row.files[0])}第 {value['行号']} 行的病例编号认不出({raw})" + if check == "C02": + rows = "、".join(map(str, value["行号"])) + return f"{_table(row.files[0])}里这个病例号出现了 {len(value['行号'])} 次(第 {rows} 行)" + if check == "C03": + return f"在{'、'.join(_table(p) for p in value['缺在'])}里找不到这一例" + if check == "C04": + return ( + "病例文件夹里应当恰好有截图、造影视频、单例超声报告表三个文件," + f"实际有 {len(value['文件'])} 个" + ) + if check == "C05": + return f"超声表里这一例的 CEUS视频ID 写的是{_show(value['CEUS视频ID'])},和病例号对不上" + if check == "C06": + return _c06(value) + if check in ("C07", "C08", "C09"): + return _bad_values({"C07": "临床表", "C08": "超声表", "C09": "MR 表"}[check], value) + if check == "C10": + return _c10(value) + if check in ("C11", "C12", "C13"): + return _text_hits(check, value) + if check == "C14": + return _c14(row, value) + raise UnknownReasonError(f"跳过清单的检查编号不在对照表里:{check}") + + +C15 = "C15" +_C15_FAILED = re.compile(r"第 (?P[0-9]+) 步失败:(?P.*)$", re.DOTALL) +_C15_MISSING = "CEUS 运行目录里没有这一例的结果目录" + + +def c15_reason(row: SkippedRow, failures: dict[str, tuple[int, str]]) -> str: + """C15 跳过 → 给医生的话;在 failed.csv 里的,原因与 failed.csv 那一行核对一致。""" + value = json.loads(row.value_json) + if value == _C15_MISSING: + return "造影视频没有处理结果" + m = _C15_FAILED.match(value) if isinstance(value, str) else None + if m is None: + raise UnknownReasonError(f"C15 的原因不在对照表里:{value!r}") + if failures.get(row.case) != (int(m["step"]), m["reason"]): + raise ValueError(f"跳过清单里 {row.case} 的 C15 原因与 CEUS 运行的 failed.csv 对不上") + return ceus_failure(int(m["step"]), m["reason"]) + + +def skip_problem(row: SkippedRow, failures: dict[str, tuple[int, str]]) -> tuple[str, str, str]: + """跳过清单一行 →(环节,病例总表「没进的原因」,问题清单的话)。""" + if row.check == C15: + reason = c15_reason(row, failures) + return STAGE_VIDEO, reason, check_plain(reason + _NOT_IN_LIBRARY) + reason = skip_reason(row) + return STAGE_DATA, reason, check_plain(reason + _NOT_IN_LIBRARY) diff --git a/src/cadx/review/readme_template.md b/src/cadx/review/readme_template.md index 25aff1e..5474cd0 100644 --- a/src/cadx/review/readme_template.md +++ b/src/cadx/review/readme_template.md @@ -1,202 +1,91 @@ -# 医生核对包说明 +# 核对包说明 -## 一、这个包是什么 +这个包是我们用程序处理你们交来的资料之后的结果,想请你们帮忙看一看:程序读到的报告和临床信息对不对,在造影视频上圈的病灶、取的关键帧、算出的 TIC 参数是否合理,问题清单里列的情况是否属实。不用填表,有意见直接告诉我们就好。 -这是我们用程序处理贵方交付资料之后的结果,整理出来请您核对:程序读到的内容对不对、CEUS(超声造影)视频的自动定量做得对不对、哪些病例没能进入资料库以及原因。包里只有我们的处理结果,不需要填写或回复任何表格。 - -- 构建号:$build_id(资料库和标准答案库是同一次构建产出的,共用这个编号) -- CEUS 定量运行号:$ceus_run_id -- 生成日期:$generated_date -- 病例数:病例总表共 $total_cases 行,其中进入资料库 $passed_cases 例,没进入 $skipped_cases 例。 +我们的系统给每个病例配了三位 AI 专家,分别只看临床信息、超声(报告和造影视频)、MR 报告,再让它们会诊。三位专家能看到的全部资料,我们叫「资料库」;包里的报告、临床信息、关键帧和 TIC 参数,就是它们看到的原样。这一版共 $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**:原视频逐帧叠加程序画出的病灶轮廓等。 +- **说明.pdf**(这份说明)、**问题清单.csv**、**病例总表.csv**、**标准答案/**(4 张表):几张表见第六节。 +- **病例/**:每个进了资料库的病例一个文件夹,名字是病例号(如 P001)。打开能看到: + - **临床信息.csv**:临床那位 AI 专家看到的临床信息,列名沿用你们临床工作簿的原列名。$codes_note + - **超声报告.pdf**、**MR报告.pdf**:报告里的描述部分,一字未改。结论(印象)部分写着诊断倾向,没有给 AI 专家,这里也就没有。 + - **关键帧/**:「医生截图.png」是你们截的图;其余是程序从造影视频里取的画面,文件名如「D3_第12.4秒.png」,秒数是视频里的时间;没取到的时刻没有图。 + - **TIC曲线.png**:时间-强度曲线图,见第四节。 + - **TIC参数.csv**:11 个灌注参数,胰腺实质和病灶各一组,见第五节。 + - **回放视频.mp4**:原视频上叠加了程序画的轮廓。 -所有图片和视频都没有压缩、缩放或重新编码,与我们处理时用的文件逐字节相同。关键图和关键帧是写进资料库时那一份:程序重新保存时去掉了文件里附带的文字信息(如拍摄参数),像素没有改变。 +图片和视频都是原样,没有压缩或缩放。 -## 三、表格每一列的意思 +```{=openxml} +供我们追溯用:构建号 $build_id · 视频处理运行号 $ceus_run_id · 生成于 $generated_date +``` -表格都是 CSV 文件,可以直接用 Excel 打开。空着的格子表示没有这项值。 +## 二、看一个病例的建议顺序 -**病例总表.csv**(这次交付涉及的全部病例,按病例号排序) +1. **先读超声报告和 MR 报告**,心里有个病灶的样子。 +2. **看关键帧文件夹里的医生截图**,确认是哪个病灶。 +3. **看回放视频。** 绿框和紫圈是你们在视频上画的:绿框圈病灶,紫圈圈一块正常的胰腺实质。黄色线(造影那一屏)和绿色线(灰阶那一屏)是程序画的病灶轮廓;蓝色线圈出的是程序认为一直不进造影剂的部分(如囊液)。病灶轮廓内去掉蓝色部分,剩下的一般是囊壁、分隔、实性成分这些会进造影剂的部分,后面叫「病灶(去暗区)」。青色小圆圈是程序在病灶旁边挑的一小块背景,用来检查整段视频里机器的整体亮度(增益)有没有变化。请看轮廓是否一直跟着病灶、蓝色部分是否确实不增强。 +4. **再看 TIC 曲线图**(第四节)**和关键帧**(第三节,每张是不是对应的时刻),**最后看 TIC 参数**(第五节)。 -| 列 | 意思 | -| --- | --- | -| 病例号 | 贵方的病例编号,如 P001。表里认不出编号的行写成「文件 第 N 行」或「病例目录 名字」 | -| 视频号 | CEUS 视频编号,按「U + 三位病例号」写,如 U001。没进入资料库的病例也这样写;其中在核对视频编号那项检查(C05)之前就被跳过的,交付里实际的视频编号没有核对过。认不出编号的行空着 | -| 化名 | 资料库里代替病例号的 4 位随机字母数字(见第七节);没进入资料库的空着 | -| 是否进入资料库 | 是 / 否 | -| 没进的原因 | 第一项没通过的检查编号和说明(见第九节);进入的空着 | -| CEUS 定量是否成功 | 这段视频在 CEUS 定量的失败记录里写「否」;定量运行里有这段视频的结果写「是」(没进入资料库的病例也可能是「是」,它是因为别的检查被跳过的);两者都没有(这段视频没做定量)或这次没装 CEUS 结果时空着 | -| CEUS 失败在第几步 | CEUS 定量失败在第 1–9 步中的哪一步(见第四节);没失败的空着 | -| B0、D1、D2、D3、D4、F1、F2 | 7 个关键帧(见第五节):取到的写它在视频里的时间(秒);没取到写「未取到」;CEUS 失败或没装 CEUS 结果时空着 | +## 三、7 个关键帧是什么时刻 -**问题清单.csv**(按病例号排序,一个病例可以有多行) +- **B0**:增强之前,病灶还没变亮。 +- **D1**:造影剂到达,病灶开始变亮。 +- **D2**:变亮最快的时刻。 +- **D3**:病灶最亮的时刻(峰值)。 +- **D4**:变暗最快的时刻(廓清最快)。 +- **F1**:推注后第 $f1_s 秒。 +- **F2**:推注后第 $f2_s 秒;前后 $f2_tol_s 秒内没有能用的画面就不取。 -| 列 | 意思 | -| --- | --- | -| 病例号 | 同上 | -| 视频号 | 同上 | -| 环节 | 现象出在处理流程的哪一环,取值见第四节末尾的表 | -| 问题 | 程序观察到的现象,照程序记录的原文写,不含推测 | +B0 和 D1–D4 的找法:程序用一条平滑曲线分别去贴合「病灶整体」和「病灶(去暗区)」两条亮度曲线。两条都贴合上,才在病灶(去暗区)那条平滑曲线上定出这 5 个时刻,各取离它最近的一帧;其中 D1–D4 按时间先后取,后一个必须比前一个晚。两条里有一条没贴合上,这 5 个关键帧就都没有。 -**标准答案/化名对照.csv**:化名、病例号、视频号。这里的病例号是三位数字(如 001),对应病例目录名 P001。 +F1、F2 按超声表里的「推注开始时间s」换算。这个数我们理解为「视频第 0 秒时已经推注了多少秒」,所以推注后第 $f1_s 秒就在视频第 $f1_s − 这个数 秒。**如果你们的意思不是这样,请一定告诉我们**,否则 F1、F2 取的都是错的时刻。 -**标准答案/诊断.csv**:化名、病例号、临床表诊断编码、超声表诊断编码、MR表诊断编码、诊断名。三张表的诊断编码分别取自临床工作簿、超声报告工作簿、MR 报告工作簿,进入资料库的病例三者一致。编码含义见第八节。诊断名一列在编码没有解码时空着。 + -**标准答案/跳过病例.csv**:病例、检查编号、检查说明、文件、列、涉及值。病例是三位数字病例号,或认不出编号时的「文件 第 N 行」。文件和列是这项检查涉及的交付文件和列名,多个时用分号隔开。涉及值是对不上的原始取值,以程序记录的格式写出(带引号和括号)。 +「重复帧」是病灶那块画面和前一帧几乎一模一样的帧:视频录制时同一张画面被存了好几遍,它不是新的时刻,所以不用。「能用的画面」指不是重复帧、而且这一帧里程序认得出病灶区域的帧。 -**标准答案/报告复核句.csv**:化名、路(超声或 MR)、命中的词、所在句子。进入资料库的病例中,报告描述段里含「考虑」「可能」「倾向」「提示」的句子,一句一行。这些词可能带出诊断倾向,所以单独列出;这些句子仍留在资料库里。 +## 四、TIC 曲线图上的英文 -**病例/\<病例号\>/临床信息.csv**:一行,列名是交付的临床工作簿原列名(性别、年龄、5 个症状、5 项既往史、5 项化验的数值与是否异常)。病例号和诊断两列不在这里。 +- **TIC: Lognormal fit**:图的标题,时间-强度曲线和贴合它的平滑曲线。 +- **Time (s)**、**Grayscale intensity**:横轴是视频时间(秒),纵轴是画面亮度(灰度)。 +- **roi_all raw**:蓝色小点,病灶整体(轮廓以内全部)每一帧的亮度。 +- **roi_perf (dark removed) raw**:橙色小点,病灶(去暗区)每一帧的亮度。 +- **roi_pan reference raw**:紫色小点,紫圈(胰腺实质)每一帧的亮度。 +- **名字后面是 Lognormal fit 的粗实线**:程序贴合出的平滑曲线,只画贴合上的。 +- **roi_pan reference Lognormal fit [QC rejected]**:紫色虚线,紫圈曲线没贴合上,仍画出来供参考;这时 TIC 参数里胰腺实质那一组是空的。 +- **D1 model … D4 model**:竖虚线,平滑曲线上 D1–D4 的时刻。 +- **竖线名字后面带方括号的**,表示这个时刻没取到关键帧:[no_frame_in_window] 这个时刻附近没有能用的画面;[no_ordered_frame] 附近能用的画面都不晚于前一个关键帧取的那一帧;[not_covered] 这个时刻不在视频里;[anchor_undefined] 两条病灶曲线没有都贴合上。 +- **No plottable TIC data**:没有能画的数据。 -**病例/\<病例号\>/TIC结果.csv**:两列「项目」「值」。项目名是中文加程序里的字段名(括号里),值里的 true 表示是、false 表示否。各项的意思见第六节。 +## 五、TIC 参数怎么看 -## 四、我们的处理流程 +TIC参数.csv 每行一个参数,两组数并排:**胰腺实质(紫圈)**和**病灶(去暗区)**,用意是让病灶和正常胰腺对着看。下面说的「开始变亮」「退回增强前水平」都是按切线定的:在变亮最快那一点(D2)画切线,与增强前水平的交点算开始变亮;在变暗最快那一点(D4)画切线,与增强前水平的交点算退回增强前水平。 -处理分三段:先读交付资料并逐例检查,再对 CEUS 视频做定量,最后把通过的病例装进资料库。 +- **PE 峰值增强**:最亮时比增强前亮了多少。 +- **TTP 达峰时间**:最亮的时刻。 +- **WiR 最大洗入斜率**:变亮最快时每秒亮多少。 +- **RT 上升时间**:从开始变亮到最亮用了多久。 +- **WiAUC 洗入曲线下面积**:从开始变亮到最亮这一段曲线下的面积,反映这段时间进来的造影剂多少。 +- **WiPI 洗入灌注指数**:WiAUC 除以 RT,即变亮这一段的平均增强程度。 +- **WoR 最大洗出斜率**:变暗最快时每秒暗多少,是负数。 +- **FT 下降时间**:从最亮到退回增强前水平用了多久。 +- **WoAUC 洗出曲线下面积**:从最亮到退回增强前水平这一段的面积。 +- **WiWoAUC 洗入洗出曲线下面积**:上面两段面积加起来。 +- **mTTl 平均渡越时间**:从开始变亮到退回增强前水平这一段里,造影剂平均停留了多久,从开始变亮算起;l 表示只在这一段(local)里算。 -**第一段:读交付资料、逐例检查。** 程序读临床工作簿、超声报告工作簿、MR 报告工作簿和每例的病例目录,逐例做一串检查(编号能认出、三张表都有这一例、病例目录文件齐全、取值合规、三张表的诊断年龄性别一致、报告描述里没有结论段标题、没有亚型名、没有性别年龄等)。一例遇到第一项没通过的检查就整例跳过(见第九节)。关键图要能原样重新保存(检查 C14)。 +看这些数时请注意三点。一是数值在视频画面的亮度(灰度,0–255)上算,不是超声机内部的回声强度,单看一个数意义不大,主要看两组的对比。二是时间从视频第一帧算起,不是从推注算起。三是参数都从贴合出的平滑曲线上算:某一组曲线没贴合上,这一组整列空着,表的最后一行写了原因;个别格子空着,是视频录得不够长(如还没退回增强前水平就结束了),这一项算不出来,就空着,不去估。 -**第二段:CEUS 定量。** 每段 CEUS 视频依次做下面 9 步。第 1、2、4 步遇到视频不满足前提时,这一例整例失败、记下失败在哪一步和原因,接着处理下一例;第 7、8 步做不出来不算整例失败,只是相应结果空缺。 +## 六、几张表 -1. **解码与分屏、两半配准。** 读视频;超声机录造影时屏幕分左右两半,一半是造影画面,一半是普通灰阶画面,程序找出分界线,再估出两半画面之间的位置偏移。每段视频的偏移都经过我们人工目视核对,没通过核对的病例在这一步失败。视频读不出、找不到分界线、两半对不齐,也在这一步失败。 -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 专家会诊。跳过病例表里每例只记第一项没通过的检查:遇到它就跳过整例,后面的检查没有做过,所以表里没写的检查不代表没有问题。 +- **问题清单.csv**:一个问题一行:病例号、视频号(你们交付包里视频的编号,如 U001)、环节(资料核对、造影视频处理、TIC 曲线、关键帧、紫圈)和一句话说明;同一个原因引起的几件事合成一行。「资料核对」是资料本身的问题,你们改好重交就行;其余是程序处理视频和曲线时遇到的,想请你们看看那段视频本身有没有异常,告诉我们就好。 +- **病例总表.csv**:全部病例一例一行:进没进资料库、没进的原因、造影视频处理成功没有(没成功时写停在第几步)、7 个关键帧各在视频第几秒。 +- **标准答案/化名对照.csv**:资料库里代替病例号的化名与病例号的对照。 +- **标准答案/诊断.csv**:三张表的病理诊断编码(1 IPMN,2 SPN,3 MCN,4 SCN)。 +- **标准答案/跳过病例.csv**:内容和问题清单里没进资料库的那几条一样,是程序原文版本,一般不用看。 +- **标准答案/报告复核句.csv**:报告描述里含「考虑」「可能」「倾向」「提示」的句子。这些句子目前仍留在给 AI 专家的报告里,想请你们看看它们算不算带出了诊断倾向、该不该删掉。 diff --git a/tests/fake_ceus_run.py b/tests/fake_ceus_run.py index acebba7..28d7ed2 100644 --- a/tests/fake_ceus_run.py +++ b/tests/fake_ceus_run.py @@ -3,7 +3,8 @@ 数值全部编造,不调显卡。tic.png 和 overlay.mp4 只是带视频号的几个字节(核对包按字节拷贝,不打开)。 summary.json 故意带上 tic.json 不该有的字段(视频号、视频 SHA-256、分割缓存键、帧号、拟合参数、 -失败原因代码、推注时间、紫圈检出率),用来测 build 只按白名单挑字段。视频 SHA-256 和推注时间照 +四条曲线的拟合成败与 R²、失败原因代码、推注时间、紫圈检出率与是否可用、灌注指标的口径标签和辅助 +时刻、关键帧状态与未定位原因),用来测 build 只按白名单挑字段。视频 SHA-256 和推注时间照 `fake_delivery` 的假交付包(视频内容 `VIDEO_BYTES`、推注 5 秒)。关键帧 PNG 带文本块和 EXIF。 """ @@ -24,7 +25,8 @@ CEUS_YAML = REPO_ROOT / "config" / "ceus.yaml" UNLOCATED_REASON = "拟合曲线上没有 D4 时刻(编造的原因)" -def _perfusion(scale: float) -> dict[str, Any]: +def perfusion(scale: float) -> dict[str, Any]: + """一组灌注指标的 summary 段:11 个指标依次是 `scale` × 1, 2, …, 11。""" names = ("PE_au", "WiAUC_au_s", "RT_s", "mTTl_s", "TTP_s", "WiR_au_per_s", "WiPI_au") more = ("WoAUC_au_s", "WiWoAUC_au_s", "FT_s", "WoR_au_per_s") return { @@ -88,7 +90,8 @@ def summary(video_id: str, located: tuple[str, ...]) -> dict[str, Any]: "roi_pan": _fit(False, None), "background_circle": _fit(False, 0.2), }, - "perfusion_metrics": {"roi_all": _perfusion(1.0), "roi_perf": None}, + # 紫圈拟合没过(reference_valid 为 False),整组为 null;病灶去暗区拟合过了,有指标。 + "perfusion_metrics": {"roi_perf": perfusion(1.0), "roi_pan": None}, "anchor_frames": anchors, "available_dynamic_slots": [s for s in ("B0", "D1", "D2", "D3", "D4") if s in located], } diff --git a/tests/test_build_ceus.py b/tests/test_build_ceus.py index 84a9e5e..55dcdb2 100644 --- a/tests/test_build_ceus.py +++ b/tests/test_build_ceus.py @@ -1,8 +1,9 @@ """build 装入 CEUS 结果:C15 跳过、tic.json 只有白名单字段、关键帧去附加数据、运行目录不合规时停下。 -预期的 tic.json 键集合照任务书 02 第四阶段的文字手写:四条曲线各自拟合是否成功和拟合优度(R²); -病灶两条曲线的灌注指标(同旧 replay summary 的 perfusion_metrics 段);紫圈曲线是否可用; -7 个槽位各自是否定位到、定位到的视频时间、未定位的原因。 +tic.json 的预期内容照任务书 04 第二部分手写:紫圈(`roi_pan`)和病灶去暗区(`roi_perf`)两组各 +11 个灌注指标(拟合没过整组为 null,单项算不出为 null),7 个槽位定位到的视频时间(没取到为 +null),此外什么都没有。超声一路的文件是 report.txt、tic.json 和 keyframes/ 下的 key.png 加定位到 +的槽位。 """ import csv @@ -17,33 +18,13 @@ from PIL import Image from cadx.answers import read_aliases from cadx.build import BuildSummary, run_build from cadx.library import list_aliases, read_ultrasound, scan_for_leaks -from fake_ceus_run import UNLOCATED_REASON, summary, write_run +from fake_ceus_run import perfusion, summary, write_run from fake_delivery import REPO_ROOT, make_delivery, write_config NOW = datetime(2026, 10, 16, 11, 0, 0).astimezone() RUN_ID = "20261016-100000-abcdef0" LOCATED = ("D1", "D2", "F1") - -_PERFUSION_KEYS = { - "signal_domain", - "time_origin", - "area_curve", - "values", - "supporting_times_s", -} -_PERFUSION_VALUES = { - "PE_au", - "WiAUC_au_s", - "RT_s", - "mTTl_s", - "TTP_s", - "WiR_au_per_s", - "WiPI_au", - "WoAUC_au_s", - "WiWoAUC_au_s", - "FT_s", - "WoR_au_per_s", -} +ALL_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2") def _build(tmp_path: Path, count: int, run_dir: Path) -> BuildSummary: @@ -66,13 +47,6 @@ def _png_chunks(data: bytes) -> list[str]: return chunks -def _key_tree(node: Any) -> Any: - """嵌套字典的键结构:字典换成「键 → 子结构」,其余值换成 None。""" - if isinstance(node, dict): - return {key: _key_tree(value) for key, value in cast(dict[str, Any], node).items()} - return None - - def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None: run = write_run( tmp_path / "runs" / RUN_ID, @@ -95,39 +69,173 @@ def test_c15_skips_failed_and_missing_cases(tmp_path: Path) -> None: assert manifest["ceus_source"] == RUN_ID -def test_tic_json_has_exactly_the_listed_fields(tmp_path: Path) -> None: - result = _build( - tmp_path, - 1, - write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, []), - ) - (alias,) = list_aliases(result.library_dir) - tic = read_ultrasound(result.library_dir, alias).tic - perfusion = { - **dict.fromkeys(_PERFUSION_KEYS - {"values", "supporting_times_s"}), - "values": dict.fromkeys(_PERFUSION_VALUES), - "supporting_times_s": dict.fromkeys(("TI", "mTT", "TO")), - } - slot = {"located": None, "video_time_s": None, "reason": None} - curves = ("roi_all", "roi_perf", "roi_pan", "background_circle") - assert _key_tree(tic) == { - "fits": {name: {"success": None, "r2": None} for name in curves}, - "perfusion_metrics": {"roi_all": perfusion, "roi_perf": None}, - "pan_curve_usable": None, - "keyframes": {s: slot for s in ("B0", "D1", "D2", "D3", "D4", "F1", "F2")}, - } - assert tic is not None - assert tic["fits"]["roi_all"] == {"success": True, "r2": 0.93} - assert tic["fits"]["roi_pan"] == {"success": False, "r2": None} - assert tic["pan_curve_usable"] is False - assert tic["perfusion_metrics"]["roi_all"]["values"]["PE_au"] == 1.0 - # 假数据里 D1 是第 2 个槽位:视频时间 1 × 2.5 秒。 - assert tic["keyframes"]["D1"] == {"located": True, "video_time_s": 2.5, "reason": None} - assert tic["keyframes"]["D4"] == { - "located": False, - "video_time_s": None, - "reason": UNLOCATED_REASON, - } +# 四例各自定位到的槽位和 summary.json 的改法: +# - U001:紫圈、病灶去暗区两组都有(紫圈 = perfusion(10.0)),7 个槽位都取到; +# - U002:只有病灶去暗区一组(假运行的原样),其中 WoAUC、FT 两项算不出;只取到 D1、D2、F1; +# - U003:两组都 null(病灶两条曲线拟合都没过,紫圈不可用);只取到 F1、F2; +# - U004:只有紫圈一组,其中 RT 一项算不出;F2 没取到。 +_CASE_SLOTS: dict[str, tuple[str, ...]] = { + "U001": ALL_SLOTS, + "U002": LOCATED, + "U003": ("F1", "F2"), + "U004": ("B0", "D1", "D2", "D3", "D4", "F1"), +} + + +def _both_groups(content: dict[str, Any]) -> None: + content["reference_valid"] = True + content["fits"]["roi_pan"].update(success=True, failure_reason=None) + content["perfusion_metrics"]["roi_pan"] = perfusion(10.0) + + +def _perf_items_missing(content: dict[str, Any]) -> None: + values = content["perfusion_metrics"]["roi_perf"]["values"] + values["WoAUC_au_s"] = None + values["FT_s"] = None + + +def _no_groups(content: dict[str, Any]) -> None: + for curve in ("roi_all", "roi_perf"): + content["fits"][curve].update(success=False, failure_reason="credible_peak_not_found") + content["lesion_fit_ready"] = False + content["perfusion_metrics"]["roi_perf"] = None + + +def _pan_only(content: dict[str, Any]) -> None: + _both_groups(content) + content["fits"]["roi_perf"].update(success=False, failure_reason="parameter_on_boundary") + content["lesion_fit_ready"] = False + content["perfusion_metrics"]["roi_perf"] = None + content["perfusion_metrics"]["roi_pan"]["values"]["RT_s"] = None + + +_EDITS = {"U001": _both_groups, "U002": _perf_items_missing, "U003": _no_groups, "U004": _pan_only} + +_PAN = { + "PE_au": 10.0, + "WiAUC_au_s": 20.0, + "RT_s": 30.0, + "mTTl_s": 40.0, + "TTP_s": 50.0, + "WiR_au_per_s": 60.0, + "WiPI_au": 70.0, + "WoAUC_au_s": 80.0, + "WiWoAUC_au_s": 90.0, + "FT_s": 100.0, + "WoR_au_per_s": 110.0, +} +_PERF = { + "PE_au": 1.0, + "WiAUC_au_s": 2.0, + "RT_s": 3.0, + "mTTl_s": 4.0, + "TTP_s": 5.0, + "WiR_au_per_s": 6.0, + "WiPI_au": 7.0, + "WoAUC_au_s": 8.0, + "WiWoAUC_au_s": 9.0, + "FT_s": 10.0, + "WoR_au_per_s": 11.0, +} +# 假运行里定位到的槽位,视频时间 = 槽位序号 × 2.5 秒。 +_ALL_TIMES = {"B0": 0.0, "D1": 2.5, "D2": 5.0, "D3": 7.5, "D4": 10.0, "F1": 12.5, "F2": 15.0} + +EXPECTED_TIC = { + "U001": { + "perfusion_metrics": {"roi_pan": _PAN, "roi_perf": _PERF}, + "keyframe_times_s": _ALL_TIMES, + }, + "U002": { + "perfusion_metrics": { + "roi_pan": None, + "roi_perf": {**_PERF, "WoAUC_au_s": None, "FT_s": None}, + }, + "keyframe_times_s": { + "B0": None, + "D1": 2.5, + "D2": 5.0, + "D3": None, + "D4": None, + "F1": 12.5, + "F2": None, + }, + }, + "U003": { + "perfusion_metrics": {"roi_pan": None, "roi_perf": None}, + "keyframe_times_s": { + "B0": None, + "D1": None, + "D2": None, + "D3": None, + "D4": None, + "F1": 12.5, + "F2": 15.0, + }, + }, + "U004": { + "perfusion_metrics": {"roi_pan": {**_PAN, "RT_s": None}, "roi_perf": None}, + "keyframe_times_s": {**_ALL_TIMES, "F2": None}, + }, +} +_FORBIDDEN_TIC_KEYS = { + "fits", + "r2", + "success", + "roi_all", + "background_circle", + "reason", + "pan_curve_usable", +} + + +@pytest.fixture +def four_cases(tmp_path: Path) -> BuildSummary: + run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", _CASE_SLOTS, []) + for video_id, located in _CASE_SLOTS.items(): + content = summary(video_id, located) + _EDITS[video_id](content) + (run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8") + return _build(tmp_path, 4, run) + + +def _all_keys(node: object) -> set[str]: + if isinstance(node, dict): + items = cast(dict[str, Any], node) + return set(items) | {k for value in items.values() for k in _all_keys(value)} + return set() + + +def test_tic_json_is_exactly_the_agent_parameters(four_cases: BuildSummary) -> None: + by_video = {r.video_id: r.alias for r in read_aliases(four_cases.answers_dir)} + assert sorted(by_video) == sorted(EXPECTED_TIC) + for video_id, expected in EXPECTED_TIC.items(): + tic_path = four_cases.library_dir / "cases" / by_video[video_id] / "ultrasound" / "tic.json" + assert json.loads(tic_path.read_text(encoding="utf-8")) == expected, video_id + assert read_ultrasound(four_cases.library_dir, by_video[video_id]).tic == expected + + +def test_tic_json_has_no_internal_keys(four_cases: BuildSummary) -> None: + for alias in list_aliases(four_cases.library_dir): + tic_path = four_cases.library_dir / "cases" / alias / "ultrasound" / "tic.json" + tic = json.loads(tic_path.read_text(encoding="utf-8")) + assert _all_keys(tic) & _FORBIDDEN_TIC_KEYS == set() + + +def test_ultrasound_files_are_exactly_report_tic_and_keyframes( + tmp_path: Path, four_cases: BuildSummary +) -> None: + for row in read_aliases(four_cases.answers_dir): + folder = four_cases.library_dir / "cases" / row.alias / "ultrasound" + files = sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file()) + slots = [f"keyframes/{slot}.png" for slot in _CASE_SLOTS[row.video_id]] + assert files == sorted(["report.txt", "tic.json", "keyframes/key.png", *slots]) + source = tmp_path / "delivery" / "CEUS_agent" / row.case_no / f"U{row.case_no}_key.png" + with Image.open(source) as original, Image.open(folder / "keyframes" / "key.png") as copy: + assert copy.mode == original.mode and copy.size == original.size + assert copy.tobytes() == original.tobytes() + route = read_ultrasound(four_cases.library_dir, row.alias) + assert route.keyframes["key"] == folder / "keyframes" / "key.png" + assert sorted(route.keyframes) == sorted(["key", *_CASE_SLOTS[row.video_id]]) def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None: @@ -135,8 +243,9 @@ def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None: result = _build(tmp_path, 1, run) (alias,) = list_aliases(result.library_dir) route = read_ultrasound(result.library_dir, alias) - assert sorted(route.keyframes) == sorted(LOCATED) - for slot, copied in route.keyframes.items(): + assert sorted(route.keyframes) == sorted(["key", *LOCATED]) + for slot in LOCATED: + copied = route.keyframes[slot] assert ( copied == result.library_dir / "cases" / alias / "ultrasound/keyframes" / f"{slot}.png" ) @@ -149,18 +258,16 @@ def test_keyframes_metadata_dropped_and_pixels_kept(tmp_path: Path) -> None: assert _png_chunks(copied.read_bytes()) == ["IHDR", "IDAT", "IEND"] -def test_null_ceus_run_has_no_ceus_files(tmp_path: Path) -> None: +def test_null_ceus_run_has_only_key_image_in_keyframes(tmp_path: Path) -> None: root = make_delivery(2).write(tmp_path / "delivery") result = run_build(write_config(tmp_path, root, 1, None, None), REPO_ROOT, NOW) for alias in list_aliases(result.library_dir): route = read_ultrasound(result.library_dir, alias) - assert route.tic is None and route.keyframes == {} - assert sorted( - p.name for p in (result.library_dir / "cases" / alias / "ultrasound").iterdir() - ) == [ - "key.png", - "report.txt", - ] + folder = result.library_dir / "cases" / alias / "ultrasound" + assert route.tic is None + assert route.keyframes == {"key": folder / "keyframes" / "key.png"} + files = sorted(p.relative_to(folder).as_posix() for p in folder.rglob("*") if p.is_file()) + assert files == ["keyframes/key.png", "report.txt"] def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> None: @@ -173,22 +280,12 @@ def test_scan_finds_nothing_then_hits_video_id_in_tic_json(tmp_path: Path) -> No alias = list_aliases(result.library_dir)[0] tic_path = result.library_dir / "cases" / alias / "ultrasound" / "tic.json" tic = json.loads(tic_path.read_text(encoding="utf-8")) - tic["keyframes"]["D4"]["reason"] = "见 U002 第 3 帧" + tic["keyframe_times_s"]["D4"] = "见 U002 第 3 帧" tic_path.write_text(json.dumps(tic, ensure_ascii=False), encoding="utf-8") hits = scan_for_leaks(result.library_dir, forbidden, ("name",)) assert [(Path(h.file).name, h.kind, h.term) for h in hits] == [("tic.json", "字符串", "U002")] -def test_reason_with_video_id_stops_build(tmp_path: Path) -> None: - run = write_run(tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED}, []) - content = summary("U001", LOCATED) - content["anchor_frames"]["D4"]["reason"] = "U001 的 D4 不在视频里" - (run / "U001" / "summary.json").write_text(json.dumps(content), encoding="utf-8") - with pytest.raises(ValueError, match="视频号或路径"): - _build(tmp_path, 1, run) - assert not (tmp_path / "library").exists() - - def test_keyframe_that_cannot_be_reencoded_skips_case_as_c14(tmp_path: Path) -> None: run = write_run( tmp_path / "runs" / RUN_ID, tmp_path / "delivery", {"U001": LOCATED, "U002": LOCATED}, [] diff --git a/tests/test_build_outputs.py b/tests/test_build_outputs.py index ec7a790..f6e29a3 100644 --- a/tests/test_build_outputs.py +++ b/tests/test_build_outputs.py @@ -78,7 +78,7 @@ def test_visibility_sex_and_age_only_in_clinical_route(tmp_path: Path) -> None: assert files == [ "clinical/clinical.json", "mr/report.txt", - "ultrasound/key.png", + "ultrasound/keyframes/key.png", "ultrasound/report.txt", ] clinical = json.loads((case_dir / "clinical/clinical.json").read_text(encoding="utf-8")) @@ -195,7 +195,7 @@ def test_key_image_pixels_kept_and_text_chunk_dropped(tmp_path: Path) -> None: with Image.open(source) as original: assert original.info.get("PatientName") == "should-not-survive" original_mode, original_pixels = original.mode, original.tobytes() - copied = read_ultrasound(summary.library_dir, alias).key_image + copied = read_ultrasound(summary.library_dir, alias).keyframes["key"] with Image.open(copied) as image: assert image.mode == original_mode assert image.tobytes() == original_pixels @@ -282,7 +282,7 @@ def test_key_image_one_bit_kept_as_is(tmp_path: Path) -> None: library_root = tmp_path / "library" (library_dir,) = list(library_root.iterdir()) by_case = {r.case_no: r.alias for r in read_aliases(tmp_path / "answers" / library_dir.name)} - copied = read_ultrasound(library_dir, by_case["002"]).key_image + copied = read_ultrasound(library_dir, by_case["002"]).keyframes["key"] with Image.open(copied) as image: assert image.mode == "1" assert image.tobytes() == source.tobytes() diff --git a/tests/test_ceus_pipeline.py b/tests/test_ceus_pipeline.py index c354fde..d67eb39 100644 --- a/tests/test_ceus_pipeline.py +++ b/tests/test_ceus_pipeline.py @@ -13,6 +13,7 @@ 超过 40 秒的视频末尾,未定位。 """ +import dataclasses import json import math @@ -22,10 +23,17 @@ import pytest from cadx.ceus import ( ANCHOR_SLOTS, CeusConfig, + LognormalFitResult, + LognormalParameters, NoValidContourError, SegmentationInput, + TicResult, VideoFile, + compute_lognormal_perfusion_metrics, + compute_tic_for_video, crop_frame, + evaluate_lognormal, + fit_lognormal_curve, keyframe_images, locate_contrast_side, pack_tic_curves, @@ -156,6 +164,9 @@ def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfi json.dumps(summary, allow_nan=False) assert summary["anchor_frames"]["F1"]["frame_index"] == 150 assert summary["perfusion_metrics"]["roi_perf"] is not None + # 没有紫圈:紫圈那一组整组为 null;病灶整体不算指标。 + assert set(summary["perfusion_metrics"]) == {"roi_perf", "roi_pan"} + assert summary["perfusion_metrics"]["roi_pan"] is None markers = tic_plot_markers(result.anchors) assert [marker.label.split()[0] for marker in markers][:3] == ["D1", "D2", "D3"] @@ -179,3 +190,73 @@ def test_mask_shape_mismatch_is_rejected(prepared: SegmentationInput, config: Ce masks = _lesion_masks(prepared)[:-1] with pytest.raises(ValueError, match="分割掩膜"): quantify_case(prepared, masks, _INJECTION_OFFSET_S, config) + + +def _clean_fit(values: np.ndarray, times: np.ndarray, config: CeusConfig) -> LognormalFitResult: + """直接调拟合函数拟一条曲线(基线 40、噪声 0.2,前 20 帧当基线段)。""" + return fit_lognormal_curve( + times, + values, + baseline=40.0, + baseline_noise=0.2, + baseline_end_index=20, + duplicate_mask=np.zeros(times.size, dtype=bool), + sample_mask=None, + cfg=config.tic_analysis.fitting, + anchor_cfg=config.tic_analysis.anchor_frames, + ) + + +def test_pan_metrics_computed_and_lesion_gate_unchanged( + prepared: SegmentationInput, config: CeusConfig, monkeypatch: pytest.MonkeyPatch +) -> None: + """紫圈拟合过、病灶整体拟合没过:紫圈与病灶去暗区各算一组指标,病灶整体不算; + D1–D4 照「病灶两条曲线都过才定位」不定位。 + + 合成视频里没有紫圈,所以把第 6–7 步交出的拟合换掉:紫圈换成一条干净 Lognormal 曲线的拟合 + (峰与病灶不同),病灶整体换成一条平线的拟合(没有可信的峰,拟合不过)。 + """ + times = np.arange(FRAMES) / FPS + pan_truth = LognormalParameters( + area=600.0, mu=2.0, sigma=0.5, arrival_offset_s=3.0, baseline=40.0 + ) + pan_fit = _clean_fit(evaluate_lognormal(times, pan_truth), times, config) + flat_fit = _clean_fit(np.full(times.size, 40.0), times, config) + assert pan_fit.success is True and flat_fit.success is False + + def patched(*args: object, **kwargs: object) -> TicResult: + tic = compute_tic_for_video(*args, **kwargs) # pyright: ignore[reportArgumentType] + return dataclasses.replace(tic, fits={**tic.fits, "roi_pan": pan_fit, "roi_all": flat_fit}) + + monkeypatch.setattr("cadx.ceus._pipeline.compute_tic_for_video", patched) + result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config) + + assert result.tic.fits["roi_perf"].success is True + assert result.lesion_fit_ready is False + for slot in ("B0", "D1", "D2", "D3", "D4"): + assert result.anchors[slot].status != "ok" + assert result.anchors[slot].frame_index is None + assert set(result.perfusion) == {"roi_perf", "roi_pan"} + assert result.perfusion["roi_pan"] == compute_lognormal_perfusion_metrics(pan_fit) + + summary = quant_summary(result) + assert set(summary["perfusion_metrics"]) == {"roi_perf", "roi_pan"} + expected = compute_lognormal_perfusion_metrics(pan_fit) + assert expected is not None + # 11 项指标的键名与 LognormalPerfusionMetrics 字段的对应,照任务书 04 列的指标逐个手写。 + assert summary["perfusion_metrics"]["roi_pan"]["values"] == { + "PE_au": expected.pe_au, + "WiAUC_au_s": expected.wi_auc_au_s, + "RT_s": expected.rt_s, + "mTTl_s": expected.mttl_s, + "TTP_s": expected.ttp_s, + "WiR_au_per_s": expected.wir_au_per_s, + "WiPI_au": expected.wipi_au, + "WoAUC_au_s": expected.wo_auc_au_s, + "WiWoAUC_au_s": expected.wiwo_auc_au_s, + "FT_s": expected.ft_s, + "WoR_au_per_s": expected.wor_au_per_s, + } + # 紫圈的峰与病灶不同:两组指标不是同一组数。 + assert summary["perfusion_metrics"]["roi_pan"] != summary["perfusion_metrics"]["roi_perf"] + assert summary["lesion_fit_ready"] is False diff --git a/tests/test_ceus_quant.py b/tests/test_ceus_quant.py index 9bb445e..9b21e72 100644 --- a/tests/test_ceus_quant.py +++ b/tests/test_ceus_quant.py @@ -238,11 +238,14 @@ def test_build_reads_real_run_layout( route = read_ultrasound(result.library_dir, alias) summary = json.loads((run_dir / "U001" / "summary.json").read_text(encoding="utf-8")) located = {s for s, a in summary["anchor_frames"].items() if a["status"] == "ok"} - assert set(route.keyframes) == located + assert set(route.keyframes) == {"key", *located} assert route.tic is not None - assert set(route.tic) == {"fits", "perfusion_metrics", "pan_curve_usable", "keyframes"} - assert route.tic["keyframes"]["F1"]["video_time_s"] == pytest.approx(15.0, abs=0.1) - assert route.tic["keyframes"]["F2"]["located"] is False + assert set(route.tic) == {"perfusion_metrics", "keyframe_times_s"} + # 合成视频里没有紫圈:紫圈一组为 null,病灶去暗区一组有 11 个指标。 + assert route.tic["perfusion_metrics"]["roi_pan"] is None + assert len(route.tic["perfusion_metrics"]["roi_perf"]) == 11 + assert route.tic["keyframe_times_s"]["F1"] == pytest.approx(15.0, abs=0.1) + assert route.tic["keyframe_times_s"]["F2"] is None # 医生核对包也读这个真实布局:TIC 曲线图和回放视频按字节拷进病例目录。 pack_config = tmp_path / "review_pack.yaml" diff --git a/tests/test_review_pack.py b/tests/test_review_pack.py index 58ac784..f41a3ab 100644 --- a/tests/test_review_pack.py +++ b/tests/test_review_pack.py @@ -1,15 +1,20 @@ -"""医生核对包:目录结构、CSV 规格、问题清单的每种来源、逐字节拷贝、TIC结果、没装 CEUS 的构建、PDF。 +"""医生核对包:目录结构、CSV 规格、TIC参数、问题清单的医生写法、程序原文快照、逐字节拷贝、PDF。 -预期值照任务书 03 写:目录和文件名、列名、问题清单的「环节」与「问题」原文、关键帧文件名的秒数 -(`video_time_s` 保留一位小数)都按任务书的文字手写,不从实现里推。 +预期值照任务书 03、04 写:目录和文件名、列名、问题清单的「环节」与「问题」、TIC参数.csv 的中文名、 +单位和小数位都按任务书的文字手写,不从实现里推。原因对照表的逐条预期在 `test_review_reasons.py`。 -假数据(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);紫圈可用。 +假数据(8 例): +- P001:进库;病灶去暗区曲线拟合失败(parameter_on_boundary),B0、D1–D4 因此未取到;F2 落在视频 + 之后;F1 取到;紫圈不可用。TIC参数两组都空。 +- P002:CEUS 第 2 步失败(两半都没有绿框,C15 跳过)。P003:CEUS 运行里没有结果目录(C15 跳过)。 +- P004:进库;7 个槽位都取到,D2、F1 的时间改成 5.04、12.46 秒;拟合都成功;紫圈可用;两组指标都有, + 病灶一组观测不够的 4 项为 null。 +- P005:MR 表诊断、临床表年龄与另两张表不同(C10 跳过);CEUS 运行里却有它完整的结果目录。 +- P006:进库;病灶整体曲线拟合失败(credible_peak_not_found),B0、D1–D4 未取到;紫圈可用但拟合失败 + (fit_quality_failed)。TIC参数只有病灶一组。 +- P007:MR 表性别与超声表不同(C10 跳过);CEUS 第 1 步失败(两半配准不一致)。 +- P008:进库;拟合都成功;B0 基线是估计的、D2 和 D3 的候选帧被占、D4 落在视频之后、 + F2 附近只有重复帧。 """ import csv @@ -30,13 +35,27 @@ 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_ceus_run import perfusion, 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") +DYNAMIC = ("B0", "D1", "D2", "D3", "D4") +CONS = ",程序没法处理这段视频" +SEX_NOTE = "(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)" +LESION = ";B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,所以这 5 个关键帧没有取" +BOUNDARY = ( + "试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上," + "有一项卡在了程序允许范围的边上" +) +FIT_FAILED = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门" +F2_AFTER = ( + "推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),落在视频有效帧范围 [0.00, 40.20] 秒之外" +) +STEP2 = "左右两半都没有找到绿框,选不出提示帧" +STEP1 = "两半配准不一致:互相关估计 (dx, dy) = (3, 4),模板匹配估计 (9, 4),相差 6 像素,超过门槛 4" SUMMARY_HEADER = [ "病例号", @@ -49,55 +68,132 @@ SUMMARY_HEADER = [ *ALL_SLOTS, ] PROBLEM_HEADER = ["病例号", "视频号", "环节", "问题"] +TIC_HEADER = ["指标", "单位", "胰腺实质(紫圈)", "病灶(去暗区)"] +METRIC_NAMES = [ + ["峰值增强 PE", "灰度"], + ["达峰时间 TTP", "秒"], + ["最大洗入斜率 WiR", "灰度/秒"], + ["上升时间 RT", "秒"], + ["洗入曲线下面积 WiAUC", "灰度·秒"], + ["洗入灌注指数 WiPI", "灰度"], + ["最大洗出斜率 WoR", "灰度/秒"], + ["下降时间 FT", "秒"], + ["洗出曲线下面积 WoAUC", "灰度·秒"], + ["洗入洗出曲线下面积 WiWoAUC", "灰度·秒"], + ["平均渡越时间 mTTl", "秒"], +] +# fake_ceus_run.perfusion(1.0) 的 11 个值按 TIC参数.csv 的行序、按小数位写出: +# PE 1、WiAUC 2、RT 3、mTTl 4、TTP 5、WiR 6、WiPI 7、WoAUC 8、WiWoAUC 9、FT 10、WoR −11。 +SCALE1 = ["1.00", "5.0", "6.00", "3.0", "2.00", "7.00", "-11.00", "10.0", "8.00", "9.00", "4.0"] +BLANK = [""] * 11 ANSWER_HEADERS = { "化名对照.csv": ["化名", "病例号", "视频号"], "诊断.csv": ["化名", "病例号", "临床表诊断编码", "超声表诊断编码", "MR表诊断编码", "诊断名"], "跳过病例.csv": ["病例", "检查编号", "检查说明", "文件", "列", "涉及值"], "报告复核句.csv": ["化名", "路", "命中的词", "所在句子"], } -CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键图.png"} -CEUS_FILES = {"关键帧", "TIC曲线.png", "TIC结果.csv", "回放视频.mp4"} +CASE_FILES = {"临床信息.csv", "超声报告.pdf", "MR报告.pdf", "关键帧"} +CEUS_FILES = {"TIC曲线.png", "TIC参数.csv", "回放视频.mp4"} +# 任务书 04 验收第 3 条:问题清单里不许出现的东西。 +FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}") def _rewrite_summary(run: Path, video_id: str, located: tuple[str, ...], edit: Any) -> None: content = summary(video_id, located) edit(content) + # 最大洗出斜率 WoR 在真实运行里是负数,假数据也照此写。 + for group in content["perfusion_metrics"].values(): + if group is not None and group["values"]["WoR_au_per_s"] is not None: + group["values"]["WoR_au_per_s"] = -abs(group["values"]["WoR_au_per_s"]) (run / video_id / "summary.json").write_text(json.dumps(content), encoding="utf-8") -def _fail_fit(curve: str, code: str) -> Any: - def edit(content: dict[str, Any]) -> None: - content["fits"][curve].update(success=False, failure_reason=code) +def _unlocated(content: dict[str, Any], reasons: dict[str, str]) -> None: + for slot, reason in reasons.items(): + content["anchor_frames"][slot]["reason"] = reason - return edit + +def _fail(content: dict[str, Any], curve: str, code: str) -> None: + content["fits"][curve].update(success=False, failure_reason=code) + if curve in content["perfusion_metrics"]: + content["perfusion_metrics"][curve] = None + + +def _pan_ok(content: dict[str, Any]) -> None: + content["reference_valid"] = True + content["fits"]["roi_pan"].update(success=True, failure_reason=None) + content["perfusion_metrics"]["roi_pan"] = perfusion(1.0) + + +def _p001(content: dict[str, Any]) -> None: + _fail(content, "roi_perf", "parameter_on_boundary") + content["fits"]["roi_pan"]["failure_reason"] = "insufficient_valid_samples" + _unlocated(content, {**dict.fromkeys(DYNAMIC, FIT_FAILED), "F2": F2_AFTER}) def _p004(content: dict[str, Any]) -> None: - content["reference_valid"] = True + _pan_ok(content) content["anchor_frames"]["D2"]["frame_time_video_s"] = 5.04 content["anchor_frames"]["F1"]["frame_time_video_s"] = 12.46 + values = content["perfusion_metrics"]["roi_perf"]["values"] + values.update( + PE_au=21.456, + TTP_s=13.04, + WiR_au_per_s=3.2, + RT_s=6.26, + WiAUC_au_s=80, + WiPI_au=12.777, + WoR_au_per_s=-1.234, + FT_s=None, + WoAUC_au_s=None, + WiWoAUC_au_s=None, + mTTl_s=None, + ) def _p006(content: dict[str, Any]) -> None: - _fail_fit("roi_all", "credible_peak_not_found")(content) + _fail(content, "roi_all", "credible_peak_not_found") content["reference_valid"] = True + content["fits"]["roi_pan"]["failure_reason"] = "fit_quality_failed" + _unlocated(content, dict.fromkeys(DYNAMIC, FIT_FAILED)) + + +def _p008(content: dict[str, Any]) -> None: + _pan_ok(content) + taken = "{} 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序" + _unlocated( + content, + { + "B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义", + "D2": taken.format("D2"), + "D3": taken.format("D3"), + "D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外", + "F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧", + }, + ) + + +LOCATED: dict[str, tuple[str, ...]] = { + "U001": ("F1",), + "U004": ALL_SLOTS, + "U005": ALL_SLOTS, + "U006": ("F1", "F2"), + "U008": ("D1", "F1"), +} def _build_with_ceus(root: Path) -> tuple[BuildSummary, Path]: - delivery = make_delivery(6) + delivery = make_delivery(8) delivery.case(5).mr[DIAGNOSIS_COLUMN] = 2 + delivery.case(5).clinical["Age"] = 28 + delivery.case(7).mr["性别"] = "男" 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, "两半都没有绿框")], + root / "runs" / RUN_ID, delivery_dir, LOCATED, [("U002", 2, STEP2), ("U007", 1, STEP1)] ) - _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) + for video_id, edit in (("U001", _p001), ("U004", _p004), ("U006", _p006), ("U008", _p008)): + _rewrite_summary(run, video_id, LOCATED[video_id], edit) + _rewrite_summary(run, "U005", ALL_SLOTS, _pan_ok) return run_build(write_config(root, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW), run @@ -160,28 +256,32 @@ def test_directory_layout(packed: Packed) -> None: "病例", } assert {p.name for p in (pack / "标准答案").iterdir()} == set(ANSWER_HEADERS) - assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006"} - for case in ("P001", "P004", "P006"): + assert {p.name for p in (pack / "病例").iterdir()} == {"P001", "P004", "P006", "P008"} + for case in ("P001", "P004", "P006", "P008"): assert {p.name for p in (pack / "病例" / case).iterdir()} == CASE_FILES | CEUS_FILES - assert {p.name for p in (pack / "病例" / "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", + assert not [p for p in pack.rglob("*") if p.name in ("TIC结果.csv", "关键图.png")] + keyframes = { + "P001": {"医生截图.png", "F1_第12.5秒.png"}, + "P004": { + "医生截图.png", + "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", + }, + "P006": {"医生截图.png", "F1_第12.5秒.png", "F2_第15.0秒.png"}, + "P008": {"医生截图.png", "D1_第2.5秒.png", "F1_第12.5秒.png"}, } + for case, names in keyframes.items(): + assert {p.name for p in (pack / "病例" / case / "关键帧").iterdir()} == names def test_every_csv_has_bom_and_no_placeholder_for_missing(packed: Packed) -> None: csvs = sorted(packed.pack.rglob("*.csv")) - assert len(csvs) == 2 + 4 + 3 * 2 + assert len(csvs) == 2 + 4 + 4 * 2 for path in csvs: for row in _csv(path): assert not {"null", "None", "NaN", "nan"} & set(row), path @@ -200,25 +300,11 @@ def test_case_summary(packed: Packed) -> None: rows = _csv(packed.pack / "病例总表.csv")[1:] blank = [""] * 7 a = packed.alias + no = "未取到" assert rows == [ - [ - "P001", - "U001", - a["001"], - "是", - "", - "是", - "", - "未取到", - "2.5", - "5.0", - "未取到", - "未取到", - "12.5", - "未取到", - ], - ["P002", "U002", "", "否", "C15 CEUS 定量结果齐全", "否", "2", *blank], - ["P003", "U003", "", "否", "C15 CEUS 定量结果齐全", "", "", *blank], + ["P001", "U001", a["001"], "是", "", "是", "", no, no, no, no, no, "12.5", no], + ["P002", "U002", "", "否", f"视频里找不到画好的绿框{CONS}", "否", "2", *blank], + ["P003", "U003", "", "否", "造影视频没有处理结果", "", "", *blank], [ "P004", "U004", @@ -235,45 +321,152 @@ def test_case_summary(packed: Packed) -> None: "12.46", "15.0", ], - ["P005", "U005", "", "否", "C10 跨表一致(诊断、年龄、性别)", "是", "", *blank], [ - "P006", - "U006", - a["006"], + "P005", + "U005", + "", + "否", + "三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;" + "三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁", "是", "", - "是", - "", - "0.0", - "2.5", - "5.0", - "7.5", - "10.0", - "12.5", - "15.0", + *blank, ], + ["P006", "U006", a["006"], "是", "", "是", "", no, no, no, no, no, "12.5", "15.0"], + [ + "P007", + "U007", + "", + "否", + f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE}", + "否", + "1", + *blank, + ], + ["P008", "U008", a["008"], "是", "", "是", "", no, "2.5", no, no, no, "12.5", no], ] -def test_problem_list_has_every_source(packed: Packed) -> None: - rows = _csv(packed.pack / "问题清单.csv")[1:] +EXPECTED_PROBLEMS = [ + [ + "P001", + "U001", + "TIC 曲线", + f"病灶去暗区那条曲线没能拟合({BOUNDARY}){LESION},TIC 参数里病灶一组也空着", + ], + [ + "P001", + "U001", + "关键帧", + "F2 没有取:换算到视频里的时刻不在录到的范围内" + "(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频只录到第 40.2 秒)", + ], + [ + "P001", + "U001", + "紫圈", + "视频里大部分时间认不出紫圈,紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着", + ], + ["P002", "U002", "造影视频处理", f"视频里找不到画好的绿框{CONS},这一例没进资料库"], + ["P003", "U003", "造影视频处理", "造影视频没有处理结果,这一例没进资料库"], + [ + "P005", + "U005", + "资料核对", + "三张表的病理诊断编码对不上:临床表写 1,超声表写 1,MR 表写 2;" + "三张表的年龄对不上:临床表 28 岁,超声表 35 岁,MR 表 35 岁,这一例没进资料库", + ], + [ + "P006", + "U006", + "TIC 曲线", + f"病灶整体那条曲线没能拟合(曲线太平,没有明显的峰){LESION}", + ], + [ + "P006", + "U006", + "TIC 曲线", + "紫圈那条曲线没能拟合(曲线起伏杂乱,平滑曲线和它差得太多),TIC 参数里胰腺实质一组空着", + ], + [ + "P007", + "U007", + "资料核对", + f"超声表和 MR 表的性别对不上:超声表 女,MR 表 男{SEX_NOTE},这一例没进资料库", + ], + ["P007", "U007", "造影视频处理", f"视频左右两屏的位置对不齐{CONS}"], + [ + "P008", + "U008", + "关键帧", + "B0 没有取:视频一开始病灶就已经在变亮,没录到增强之前的那一段", + ], + [ + "P008", + "U008", + "关键帧", + "D2、D3 没有取:这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧" + "(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)", + ], + [ + "P008", + "U008", + "关键帧", + "D4 没有取:算出的时刻不在视频能用的画面范围里" + "(D4 在视频第 41.5 秒,能用的画面是第 0.0–40.2 秒)", + ], + ["P008", "U008", "关键帧", "F2 没有取:这个时刻前后没有能用的画面(都是重复帧)"], +] + + +def test_problem_list_in_plain_words(packed: Packed) -> None: + assert _csv(packed.pack / "问题清单.csv")[1:] == EXPECTED_PROBLEMS + counts = {"资料核对": 2, "造影视频处理": 3, "TIC 曲线": 3, "关键帧": 5, "紫圈": 1} + assert packed.result.problems_by_stage == dict(sorted(counts.items())) + + +def test_problem_list_has_no_program_words(packed: Packed) -> None: + """任务书 04 验收第 3 条:问题清单全文不含检查编号、字段名、下划线、括号、band、运行号。""" + text = (packed.pack / "问题清单.csv").read_text(encoding="utf-8-sig") + assert FORBIDDEN.findall(text) == [] + + +def test_raw_problem_list_in_snapshot(packed: Packed) -> None: + """程序原文照任务书 03 的写法留在快照里,不进医生的目录。""" + assert not (packed.pack / "问题清单_原文.csv").exists() + rows = _csv(packed.result.run_dir / "snapshot" / "问题清单_原文.csv") skipped = {row[0]: row for row in _csv(packed.pack / "标准答案" / "跳过病例.csv")[1:]} - unlocated = [ - ["P001", "U001", "关键帧", f"{slot} 未取到:{UNLOCATED_REASON}"] - for slot in ("B0", "D3", "D4", "F2") - ] + fit_failed = [f"{slot} 未取到:{FIT_FAILED}" for slot in DYNAMIC] + p008 = { + "B0": "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义", + "D2": "D2 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序", + "D3": "D3 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序", + "D4": "D4 时刻 41.50 秒落在视频有效帧范围 [0.00, 40.20] 秒 之外", + "F2": "视频第 55.00 秒 ±2 秒内没有不重复的帧", + } + + def c10(no: str) -> list[str]: + row = skipped[no] + return [ + f"P{no}", + f"U{no}", + "装配资料库", + f"C10 跨表一致(诊断、年龄、性别);文件:{row[3]};列:{row[4]};涉及值:{row[5]}", + ] + assert rows == [ + PROBLEM_HEADER, ["P001", "U001", "TIC 拟合", "病灶去暗区曲线未完成拟合(parameter_on_boundary)"], - *unlocated, + *[["P001", "U001", "关键帧", text] for text in fit_failed], + ["P001", "U001", "关键帧", f"F2 未取到:{F2_AFTER}"], ["P001", "U001", "紫圈", "紫圈曲线不可用"], [ "P002", "U002", "装配资料库", - f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;" - '涉及值:"第 2 步失败:两半都没有绿框"', + f'C15 CEUS 定量结果齐全;文件:{RUN_ID}/failed.csv;涉及值:"第 2 步失败:{STEP2}"', ], - ["P002", "U002", "CEUS 第 2 步", "第 2 步失败:两半都没有绿框"], + ["P002", "U002", "CEUS 第 2 步", f"第 2 步失败:{STEP2}"], [ "P003", "U003", @@ -281,30 +474,75 @@ def test_problem_list_has_every_source(packed: Packed) -> None: f"C15 CEUS 定量结果齐全;文件:{RUN_ID}/U003;" '涉及值:"CEUS 运行目录里没有这一例的结果目录"', ], - [ - "P005", - "U005", - "装配资料库", - f"C10 跨表一致(诊断、年龄、性别);文件:{skipped['005'][3]};" - f"列:{skipped['005'][4]};涉及值:{skipped['005'][5]}", - ], + c10("005"), ["P006", "U006", "TIC 拟合", "病灶整体曲线未完成拟合(credible_peak_not_found)"], + *[["P006", "U006", "关键帧", text] for text in fit_failed], + c10("007"), + ["P007", "U007", "CEUS 第 1 步", f"第 1 步失败:{STEP1}"], + *[["P008", "U008", "关键帧", f"{slot} 未取到:{text}"] for slot, text in p008.items()], ] - counts = {"装配资料库": 3, "CEUS 第 2 步": 1, "TIC 拟合": 2, "关键帧": 4, "紫圈": 1} - assert packed.result.problems_by_stage == dict(sorted(counts.items())) + + +def test_tic_params(packed: Packed) -> None: + def table(case: str) -> list[list[str]]: + return _csv(packed.pack / "病例" / case / "TIC参数.csv") + + def rows(pan: list[str], perf: list[str]) -> list[list[str]]: + return [[*m, a, b] for m, a, b in zip(METRIC_NAMES, pan, perf, strict=True)] + + p004_perf = ["21.46", "13.0", "3.20", "6.3", "80.00", "12.78", "-1.23", "", "", "", ""] + assert table("P004") == [TIC_HEADER, *rows(SCALE1, p004_perf)] + assert table("P008") == [TIC_HEADER, *rows(SCALE1, SCALE1)] + assert table("P006") == [ + TIC_HEADER, + *rows(BLANK, SCALE1), + ["没算出的原因", "", "曲线起伏杂乱,平滑曲线和它差得太多,没能拟合", ""], + ] + assert table("P001") == [ + TIC_HEADER, + *rows(BLANK, BLANK), + [ + "没算出的原因", + "", + "视频里大部分时间认不出紫圈", + f"{BOUNDARY},没能拟合", + ], + ] + + +def test_tic_params_match_library(packed: Packed) -> None: + """TIC参数.csv 的每一格都是资料库 tic.json 里那个数按小数位写出(null 为空)。""" + keys = ["PE_au", "TTP_s", "WiR_au_per_s", "RT_s", "WiAUC_au_s", "WiPI_au"] + keys += ["WoR_au_per_s", "FT_s", "WoAUC_au_s", "WiWoAUC_au_s", "mTTl_s"] + for case_no in ("001", "004", "006", "008"): + tic = read_ultrasound(packed.build.library_dir, packed.alias[case_no]).tic + assert tic is not None + rows = _csv(packed.pack / "病例" / f"P{case_no}" / "TIC参数.csv")[1:12] + for row, key in zip(rows, keys, strict=True): + for cell, group in zip(row[2:], ("roi_pan", "roi_perf"), strict=True): + values = tic["perfusion_metrics"][group] + value = None if values is None else values[key] + digits = 1 if key in ("TTP_s", "RT_s", "FT_s", "mTTl_s") else 2 + assert cell == ("" if value is None else f"{round(value, digits):.{digits}f}") def test_copies_are_byte_identical(packed: Packed) -> None: 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() + key = (case_dir / "关键帧" / "医生截图.png").read_bytes() + assert key == route.keyframes["key"].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(): + copied = { + p.name.split("_")[0]: p + for p in (case_dir / "关键帧").iterdir() + if p.name != "医生截图.png" + } + slots = {slot: path for slot, path in route.keyframes.items() if slot != "key"} + assert sorted(copied) == sorted(slots) + for slot, source in slots.items(): assert copied[slot].read_bytes() == source.read_bytes() - seconds = route.tic["keyframes"][slot]["video_time_s"] + seconds = route.tic["keyframe_times_s"][slot] assert copied[slot].name == f"{slot}_第{round(seconds, 1)}秒.png" run_case = packed.run / video_id assert (case_dir / "TIC曲线.png").read_bytes() == (run_case / "tic.png").read_bytes() @@ -325,45 +563,21 @@ def _tool_output(tool: str, *args: str) -> str: return subprocess.run([path, *args], check=True, capture_output=True, text=True).stdout # noqa: S603 -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 + assert len(pdfs) == packed.result.pdfs == 1 + 4 * 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 + pages = re.search(r"^Pages:\s+([0-9]+)$", _tool_output("pdfinfo", str(readme)), re.M) + assert pages is not None and int(pages.group(1)) <= 4 text = _tool_output("pdftotext", str(readme), "-") - assert packed.build.build_id in text and RUN_ID in text and "parameter_on_boundary" in text + assert packed.build.build_id in text and RUN_ID in text + # 说明里不写检查编号、原因码、字段名。 + for word in ("C15", "parameter_on_boundary", "perfusion_metrics", "贵方", "MedSAM"): + assert word not in text report = _tool_output("pdftotext", str(packed.pack / "病例" / "P001" / "超声报告.pdf"), "-") assert report.startswith("病例号 P001 超声描述") @@ -384,6 +598,17 @@ def test_existing_run_dir_stops(packed: Packed) -> None: run_review_pack(packed.config, REPO_ROOT, PACK_NOW) +def test_unknown_reason_stops(tmp_path: Path) -> None: + """关键帧未取到的原因不在对照表里(假运行的默认原因是编造的):报错停下,不出包。""" + delivery_dir = make_delivery(1).write(tmp_path / "delivery") + run = write_run(tmp_path / "runs" / RUN_ID, delivery_dir, {"U001": ("D1",)}, []) + build = run_build(write_config(tmp_path, delivery_dir, 1, None, run), REPO_ROOT, BUILD_NOW) + config = _pack_config(tmp_path, build.library_dir, build.answers_dir) + with pytest.raises(ValueError, match="不在对照表里"): + run_review_pack(config, REPO_ROOT, PACK_NOW) + assert not [p for p in (tmp_path / "review_pack").iterdir() if not p.name.endswith(".partial")] + + def test_build_without_ceus(tmp_path: Path) -> None: 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) @@ -391,7 +616,9 @@ def test_build_without_ceus(tmp_path: Path) -> None: _pack_config(tmp_path, build.library_dir, build.answers_dir), REPO_ROOT, PACK_NOW ) for case in ("P001", "P002"): - assert {p.name for p in (result.pack_dir / "病例" / case).iterdir()} == CASE_FILES + case_dir = result.pack_dir / "病例" / case + assert {p.name for p in case_dir.iterdir()} == CASE_FILES + assert {p.name for p in (case_dir / "关键帧").iterdir()} == {"医生截图.png"} alias = {r.case_no: r.alias for r in read_aliases(build.answers_dir)} rows = _csv(result.pack_dir / "病例总表.csv")[1:] assert [row[:5] for row in rows] == [ diff --git a/tests/test_review_reasons.py b/tests/test_review_reasons.py new file mode 100644 index 0000000..cf83e82 --- /dev/null +++ b/tests/test_review_reasons.py @@ -0,0 +1,595 @@ +"""原因对照表:每种程序原因 → 给医生的那句话,逐条手写预期(任务书 04「原因的写法」)。 + +程序原因按源码里的写法造(`_anchor_frames.py`、`_tic_fitting.py`、`ceus/` 各步的异常、 +`ceus_quant.py` 的目视核对、delivery 的 C01–C13、build 的 C14、C15),数值是编的。 +""" + +import ast +import json +import re +from pathlib import Path +from typing import Any + +import pytest + +from cadx.answers import SkippedRow +from cadx.review._problems import ( + UnknownReasonError, + case_label, + ceus_failure, + check_plain, + curve_problems, + group_reason, + skip_problem, +) +from fake_ceus_run import summary + +FORBIDDEN = re.compile(r"C[0-9]|roi_|_|\{|\[|band|[0-9]{8}-[0-9]{6}") +BROKEN = "视频文件本身有问题(帧率、帧数或时间信息不对),程序读不了" +NO_SPLIT = "视频里找不到左右两屏的分界线" +MISALIGNED = "视频左右两屏的位置对不齐" + + +@pytest.mark.parametrize( + ("step", "reason", "expected"), + [ + (1, "配准目视核对未通过", f"{MISALIGNED}(我们人工逐例看过,这一例没对上)"), + (1, "cv2 打不开视频:/x/U001_roi2.mp4", "视频文件打不开"), + (1, "探测到非法 fps=0.0:/x/U001.mp4", BROKEN), + (1, "探测到非法元数据 frame_count=0 width=1 height=1:/x/U001.mp4", BROKEN), + (1, "视频包含非法显示时间戳 -1.0:/x/U001.mp4", BROKEN), + (1, "视频显示时间戳不严格递增:第 3 帧 0.1,前一帧 0.2:/x/U001.mp4", BROKEN), + (1, "时间戳帧数与容器记录的帧数不一致:3 / 4:/x/U001.mp4", BROKEN), + (1, "解码帧数 3 与容器记录的帧数 4 不一致:/x/U001.mp4", BROKEN), + (1, "帧尺寸不一致:首帧 (4, 6, 3),第 2 帧 (4, 8, 3)。", "视频前后画面大小不一致"), + (1, "帧序列为空,无法求跨帧平均灰度。", "视频里一帧画面都没有"), + (1, "帧序列为空,无法找分屏线。", "视频里一帧画面都没有"), + ( + 1, + "中部 band 内未找到稳定暗缝(谷值 40.2 未显著低于 中位 51.0),疑为无缝或全黑画面。", + NO_SPLIT, + ), + (1, "非法 search_band:要求 0<=lo None: + assert ceus_failure(step, reason) == f"{expected},程序没法处理这段视频" + + +REPO = Path(__file__).resolve().parents[1] +# ceus 各步一例失败时抛的 CeusCaseError 子类 → 第几步(各类的 *_STEP 常量)。 +_CASE_ERRORS = { + "VideoDecodeError": 1, + "SplitDetectionError": 1, + "RegistrationError": 1, + "ContrastSideError": 2, + "NoValidContourError": 4, +} + + +def _raised_messages() -> list[tuple[str, int, str]]: + """源码里每处 raise 一例失败的地方:(位置,第几步,消息,格式化部分代成 1)。""" + found: list[tuple[str, int, str]] = [] + files = [ + *sorted((REPO / "src" / "cadx" / "ceus").glob("*.py")), + REPO / "scripts" / "ceus_quant.py", + ] + for path in files: + for node in ast.walk(ast.parse(path.read_text(encoding="utf-8"))): + if not (isinstance(node, ast.Raise) and isinstance(node.exc, ast.Call)): + continue + func = node.exc.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "") + args = node.exc.args + if name in _CASE_ERRORS: + step, message = _CASE_ERRORS[name], args[0] + elif name == "CeusCaseError": + first = args[0] + assert isinstance(first, ast.Constant) and isinstance(first.value, int) + step, message = first.value, args[1] + else: + continue + if isinstance(message, ast.Constant): + text = str(message.value) + else: + assert isinstance(message, ast.JoinedStr), f"{path}:{node.lineno}" + text = "".join( + str(v.value) if isinstance(v, ast.Constant) else "1" for v in message.values + ) + found.append((f"{path.name}:{node.lineno}", step, text)) + return found + + +def test_every_raised_ceus_failure_is_in_the_table() -> None: + """ceus 和 ceus_quant 里每一处 raise 一例失败的消息,对照表都认得(新加一处就会在这里报出)。""" + messages = _raised_messages() + assert len(messages) >= 20 + for where, step, text in messages: + assert not FORBIDDEN.search(ceus_failure(step, text)), where + + +@pytest.mark.parametrize( + ("step", "reason"), + [(3, "分割模型报错"), (2, "配准目视核对未通过"), (1, "编造的原因")], +) +def test_unknown_ceus_failure_stops(step: int, reason: str) -> None: + with pytest.raises(UnknownReasonError): + ceus_failure(step, reason) + + +FIT_CASES = { + "insufficient_valid_samples": "能用的画面太少(很多帧认不出这块区域,或是重复帧),连不成曲线", + "candidate_grid_unavailable": "能用的画面时间点太少,连不成曲线", + "credible_peak_not_found": "曲线太平,没有明显的峰", + "candidate_arrival_not_found": "峰之前找不到亮度明确、持续升起来的时刻", + "insufficient_bolus_samples": "造影剂第一次流过的那一段能用的画面太少", + "long_gap_in_wash_in": "变亮的那一段中间缺了一大段画面", + "arrival_bounds_invalid": "峰离视频开头太近,或造影剂第一次流过的那一段太短", + "optimizer_failed": "曲线形状不规则,平滑曲线怎么也贴不上", + "parameter_on_boundary": ( + "试着贴出来的曲线在峰高、峰出现的时间、曲线宽窄、开始变亮的时刻或增强前水平上," + "有一项卡在了程序允许范围的边上" + ), + "model_peak_on_boundary": "试着贴出来的曲线,峰落在可用那一段的最开头或最末尾,峰的位置不可信", + "fit_quality_failed": "曲线起伏杂乱,平滑曲线和它差得太多", +} + + +@pytest.mark.parametrize(("code", "phrase"), FIT_CASES.items()) +def test_fit_reason_codes(code: str, phrase: str) -> None: + content = summary("U001", ()) + content["reference_valid"] = True + content["fits"]["roi_pan"].update(success=False, failure_reason=code) + assert group_reason(content, "roi_pan") == f"{phrase},没能拟合" + content["fits"]["roi_perf"].update(success=False, failure_reason=code) + assert group_reason(content, "roi_perf") == f"{phrase},没能拟合" + + +def test_every_fit_reject_code_is_in_the_table() -> None: + source = (REPO / "src" / "cadx" / "ceus" / "_tic_fitting.py").read_text(encoding="utf-8") + codes = set(re.findall(r'reject\("([a-z_]+)"\)', source)) + assert codes == set(FIT_CASES) + + +def test_group_reason_pan_unusable_and_unknown_code() -> None: + content = summary("U001", ()) + assert group_reason(content, "roi_pan") == "视频里大部分时间认不出紫圈" + content["fits"]["roi_perf"].update(success=False, failure_reason="new_code") + with pytest.raises(UnknownReasonError): + group_reason(content, "roi_perf") + + +BOUNDARY = FIT_CASES["parameter_on_boundary"] +LESION = ";B0、D1–D4 要病灶整体和病灶去暗区两条曲线都拟合上才取,所以这 5 个关键帧没有取" +FIT_FAILED = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门" + + +def _curves(located: tuple[str, ...], edit: dict[str, Any]) -> list[tuple[str, str]]: + content = summary("U001", located) + content["reference_valid"] = True + content["fits"]["roi_pan"].update(success=True, failure_reason=None) + for slot, reason in edit.items(): + content["anchor_frames"][slot]["reason"] = reason + return curve_problems(content) + + +ALL = ("B0", "D1", "D2", "D3", "D4", "F1", "F2") + + +@pytest.mark.parametrize( + ("slot", "reason", "expected"), + [ + ( + "B0", + "B0 锚点 3.70 秒的容差窗口 [3.20, 4.20] 秒 内," + "观测到的基线段里、到达时刻之前没有可用帧", + "B0 没有取:开始增强之前的那一小段里没有能用的画面", + ), + ( + "B0", + "拟合曲线升不过到达门槛,没有到达时刻", + "B0 没有取:曲线上升得太少,定不出造影剂到达的时刻", + ), + ( + "D1", + "拟合曲线上没有 D1 时刻(升不过到达门槛)", + "D1 没有取:曲线上升得太少,定不出造影剂到达的时刻", + ), + ( + "B0", + "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义", + "B0 没有取:视频一开始病灶就已经在变亮,没录到增强之前的那一段", + ), + ( + "D4", + "峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4", + "D4 没有取:峰值之后录得不够长,找不到变暗最快的时刻", + ), + ( + "D3", + "D3 时刻 -0.40 秒落在视频有效帧范围 [0.04, 40.20] 秒 之外", + "D3 没有取:算出的时刻不在视频能用的画面范围里" + "(D3 在视频第 -0.4 秒,能用的画面是第 0.0–40.2 秒)", + ), + ( + "D2", + "D2 容差窗口 [4.70, 5.30] 秒 内的候选帧都被时间更早的槽位占用,无法保持先后顺序", + "D2 没有取:这个时刻附近能用的画面都不晚于前一个关键帧取的那一帧" + "(D1–D4 要按时间先后各取一帧,后一个必须比前一个晚)", + ), + ( + "D2", + "D2 容差窗口 [4.70, 5.30] 秒 内没有可用帧(拟合区间内、不重复)", + "D2 没有取:这个时刻附近没有能用的画面" + "(附近都是重复帧,或已过了造影剂第一次流过的那一段)", + ), + ( + "F1", + "推注后第 20 秒在视频第 -5.00 秒(推注时间 X=25 秒)," + "落在视频有效帧范围 [0.00, 40.20] 秒之外", + "F1 没有取:换算到视频里的时刻不在录到的范围内" + "(F1 是推注后第 20 秒,应在视频第 -5.0 秒,那时还没开始录)", + ), + ( + "F2", + "推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒)," + "落在视频有效帧范围 [0.00, 40.20] 秒之外", + "F2 没有取:换算到视频里的时刻不在录到的范围内" + "(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频只录到第 40.2 秒)", + ), + ( + "F2", + "推注后第 60 秒在视频第 55.00 秒(推注时间 X=5 秒),落在没有不重复的帧之外", + "F2 没有取:换算到视频里的时刻不在录到的范围内" + "(F2 是推注后第 60 秒,应在视频第 55.0 秒,视频里没有能用的画面)", + ), + ( + "F2", + "视频第 55.00 秒 ±2 秒内没有不重复的帧", + "F2 没有取:这个时刻前后没有能用的画面(都是重复帧)", + ), + ( + "F1", + "没有推注时间,推注后时刻换算不到视频时间", + "F1 没有取:没有推注时间,换算不出视频里的时刻", + ), + ], +) +def test_keyframe_reasons(slot: str, reason: str, expected: str) -> None: + located = tuple(s for s in ALL if s != slot) + assert _curves(located, {slot: reason}) == [("关键帧", expected)] + + +def test_same_reason_merges_into_one_row() -> None: + reason = "视频第 {} 秒 ±2 秒内没有不重复的帧" + rows = _curves(("B0", "D1", "D2", "D3", "D4"), {"F1": reason.format(1), "F2": reason.format(2)}) + assert rows == [("关键帧", "F1、F2 没有取:这个时刻前后没有能用的画面(都是重复帧)")] + + +@pytest.mark.parametrize( + ("failed", "expected"), + [ + ( + {"roi_all": "parameter_on_boundary"}, + f"病灶整体那条曲线没能拟合({BOUNDARY}){LESION}", + ), + ( + {"roi_all": "parameter_on_boundary", "roi_perf": "parameter_on_boundary"}, + f"病灶整体、病灶去暗区两条曲线都没能拟合(都是{BOUNDARY}){LESION}" + ",TIC 参数里病灶一组也空着", + ), + ( + {"roi_all": "credible_peak_not_found", "roi_perf": "fit_quality_failed"}, + "病灶整体、病灶去暗区两条曲线都没能拟合(病灶整体:曲线太平,没有明显的峰;" + f"病灶去暗区:曲线起伏杂乱,平滑曲线和它差得太多){LESION}" + ",TIC 参数里病灶一组也空着", + ), + ], +) +def test_lesion_fit_failure_is_one_row(failed: dict[str, str], expected: str) -> None: + content = summary("U001", ("F1", "F2")) + content["reference_valid"] = True + content["fits"]["roi_pan"].update(success=True, failure_reason=None) + for curve, code in failed.items(): + content["fits"][curve].update(success=False, failure_reason=code) + for slot in ("B0", "D1", "D2", "D3", "D4"): + content["anchor_frames"][slot]["reason"] = FIT_FAILED + assert curve_problems(content) == [("TIC 曲线", expected)] + + +def test_roi_perf_fit_reason_counts_as_fit_failure() -> None: + content = summary("U001", ("F1", "F2")) + content["reference_valid"] = True + content["fits"]["roi_pan"].update(success=True, failure_reason=None) + content["fits"]["roi_perf"].update(success=False, failure_reason="optimizer_failed") + for slot in ("B0", "D1", "D2", "D3", "D4"): + content["anchor_frames"][slot]["reason"] = ( + "roi_perf 曲线的 Lognormal 拟合没过质量门(optimizer_failed)" + ) + assert [stage for stage, _ in curve_problems(content)] == ["TIC 曲线"] + + +def test_fit_failure_without_its_keyframes_stops() -> None: + content = summary("U001", ALL) + content["fits"]["roi_all"].update(success=False, failure_reason="optimizer_failed") + with pytest.raises(ValueError, match="对不上"): + curve_problems(content) + + +def test_pan_rows() -> None: + content = summary("U001", ALL) + assert curve_problems(content) == [ + ("紫圈", "视频里大部分时间认不出紫圈,紫圈那条曲线不能用,TIC 参数里胰腺实质一组空着") + ] + content["reference_valid"] = True + content["fits"]["roi_pan"]["failure_reason"] = "long_gap_in_wash_in" + assert curve_problems(content) == [ + ( + "TIC 曲线", + "紫圈那条曲线没能拟合(变亮的那一段中间缺了一大段画面),TIC 参数里胰腺实质一组空着", + ) + ] + + +def test_unknown_keyframe_reason_stops() -> None: + with pytest.raises(UnknownReasonError): + _curves(("B0",), {s: "编造的原因" for s in ALL if s != "B0"}) + + +CLINICAL = "Clinical_agent/临床工作簿_40例.xlsx" +ULTRASOUND = "CEUS_agent/超声报告文本_40例.xlsx" +MR = "MR_agent/MR报告文本_40例.xlsx" +DIAGNOSIS = "病理类型(1 IPMN,2 SPN,3 MCN,4 SCN)" + + +def _row(case: str, check: str, files: tuple[str, ...], value: object) -> SkippedRow: + return SkippedRow( + case=case, + check=check, + description="(检查说明不用)", + files=files, + columns=(), + value_json=json.dumps(value, ensure_ascii=False), + ) + + +NOT_IN = ",这一例没进资料库" + + +@pytest.mark.parametrize( + ("row", "expected"), + [ + ( + _row(f"{CLINICAL} 第 5 行", "C01", (CLINICAL,), {"原始写法": "P1", "行号": 5}), + "临床表第 5 行的病例编号认不出(「P1」)", + ), + ( + _row("病例目录 1000", "C01", ("CEUS_agent/1000",), {"目录名": "1000"}), + "病例文件夹名「1000」认不出是哪个病例", + ), + ( + _row("003", "C02", (MR,), {"行号": [4, 9]}), + "MR 表里这个病例号出现了 2 次(第 4、9 行)", + ), + ( + _row( + "003", + "C03", + (ULTRASOUND, "CEUS_agent/003/"), + {"缺在": [ULTRASOUND, "CEUS_agent/003/"]}, + ), + "在超声表、病例文件夹里找不到这一例", + ), + ( + _row("003", "C04", ("CEUS_agent/003",), {"文件": ["U003_key.png", "U003.mp4"]}), + "病例文件夹里应当恰好有截图、造影视频、单例超声报告表三个文件,实际有 2 个", + ), + ( + _row("003", "C05", (ULTRASOUND,), {"CEUS视频ID": "U030", "病例号": "003"}), + "超声表里这一例的 CEUS视频ID 写的是「U030」,和病例号对不上", + ), + ( + _row("003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"工作表": ["A", "B"]}), + "单例超声报告表的工作表不对(应只有一张)", + ), + ( + _row( + "003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"表头": "与超声汇总表不一致"} + ), + "单例超声报告表的表头和超声汇总表不一样", + ), + ( + _row("003", "C06", ("CEUS_agent/003/U003_report.xlsx",), {"数据行数": 2}), + "单例超声报告表应只有一行数据,实际有 2 行", + ), + ( + _row( + "003", + "C06", + ("CEUS_agent/003/U003_report.xlsx", ULTRASOUND), + { + "年龄": {"单例报告表": "40岁", "超声汇总表": "41岁"}, + "超声描述": {"单例报告表": "甲" * 31, "超声汇总表": "乙"}, + }, + ), + "单例超声报告表和超声汇总表对不上:" + "年龄(单例表 「40岁」,汇总表 「41岁」);超声描述(内容不一样)", + ), + ( + _row("003", "C07", (CLINICAL,), {"Sex": 3, "CA199_Level": None}), + "临床表这几项的取值不合规:性别(3)、CA19-9 是否异常(空着)", + ), + ( + _row("003", "C08", (ULTRASOUND,), {"年龄": "四十", "推注开始时间s": -1}), + "超声表这几项的取值不合规:年龄(「四十」)、推注开始时间(-1)", + ), + ( + _row("003", "C09", (MR,), {DIAGNOSIS: 5, "MR描述": None}), + "MR 表这几项的取值不合规:病理诊断(5)、MR描述(空着)", + ), + ( + _row( + "003", + "C10", + (CLINICAL, ULTRASOUND, MR), + {"年龄": {"临床表": 28, "超声表": "63岁", "MR 表": "63岁"}}, + ), + "三张表的年龄对不上:临床表 28 岁,超声表 63 岁,MR 表 63 岁", + ), + ( + _row( + "003", + "C10", + (CLINICAL, ULTRASOUND, MR), + {"性别": {"超声表": "男", "MR 表": "男", "临床表(解码后)": "女"}}, + ), + "三张表的性别对不上:超声表 男,MR 表 男,临床表 女", + ), + ( + _row( + "003", + "C10", + (CLINICAL, ULTRASOUND, MR), + {"性别": {"超声表": "女", "MR 表": "男"}}, + ), + "超声表和 MR 表的性别对不上:超声表 女,MR 表 男" + "(临床表的性别是数字编码,还没有编码对照表,这次没有拿来比)", + ), + ( + _row("003", "C08", (ULTRASOUND,), {"年龄": "datetime.datetime(2026, 1, 1, 0, 0)"}), + "超声表这几项的取值不合规:年龄(这一格是日期或时间格式)", + ), + ( + _row("003", "C07", (CLINICAL,), {"Symptom_Fever": True, "Hx_Alcohol": False}), + "临床表这几项的取值不合规:发热(这一格填的是 TRUE)、饮酒史(这一格填的是 FALSE)", + ), + ( + _row( + "003", + "C06", + ("CEUS_agent/003/U003_report.xlsx", ULTRASOUND), + {"推注开始时间s": {"单例报告表": "datetime.time(0, 0, 5)", "超声汇总表": 5}}, + ), + "单例超声报告表和超声汇总表对不上:" + "推注开始时间(单例表 这一格是日期或时间格式,汇总表 5)", + ), + ( + _row( + "003", + "C11", + (ULTRASOUND,), + {"超声描述": [{"关键词": "超声提示", "句子": "超声提示:囊性灶"}]}, + ), + "超声报告的描述里有像结论段标题的写法「超声提示:」(所在句子:超声提示:囊性灶)", + ), + ( + _row("003", "C12", (MR,), {"MR描述": ["SCN", "浆液性囊"]}), + "MR报告的描述里出现了亚型名「SCN」、「浆液性囊」", + ), + ( + _row("003", "C13", (ULTRASOUND, MR), {"超声描述": ["女"], "MR描述": ["45岁"]}), + "超声报告的描述里出现了性别或年龄「女」;MR报告的描述里出现了性别或年龄「45岁」", + ), + ( + _row("003", "C14", ("CEUS_agent/003/U003_key.png",), "不是 PNG"), + "医生截图不是 PNG 格式", + ), + ( + _row("003", "C14", ("CEUS_agent/003/U003_key.png",), "带 tRNS 透明色,重编码会丢透明"), + "医生截图带透明色,另存会丢掉透明信息", + ), + ( + _row( + "003", + "C14", + ("20261017-080000-abcdef0/U003/keyframes/D2.png",), + "位深 16、颜色类型 2 不能逐像素保真重编码", + ), + "程序从视频里取的 D2 关键帧的颜色格式特殊,程序没法一点不差地另存", + ), + ], +) +def test_skip_reasons(row: SkippedRow, expected: str) -> None: + assert skip_problem(row, {}) == ("资料核对", expected, expected + NOT_IN) + + +def test_c15_merges_with_failed_csv() -> None: + failed = _row( + "002", "C15", ("RUN/failed.csv",), "第 2 步失败:左右两半都没有找到绿框,选不出提示帧" + ) + reason = "视频里找不到画好的绿框,程序没法处理这段视频" + steps = {"002": (2, "左右两半都没有找到绿框,选不出提示帧")} + assert skip_problem(failed, steps) == ("造影视频处理", reason, reason + NOT_IN) + with pytest.raises(ValueError, match="对不上"): + skip_problem(failed, {"002": (1, "两半配准不一致:")}) + missing = _row("003", "C15", ("RUN/U003",), "CEUS 运行目录里没有这一例的结果目录") + assert skip_problem(missing, {}) == ( + "造影视频处理", + "造影视频没有处理结果", + "造影视频没有处理结果" + NOT_IN, + ) + + +@pytest.mark.parametrize( + "row", + [ + _row("003", "C99", (), {}), + _row("003", "C07", (CLINICAL,), {"NewColumn": 1}), + _row("003", "C14", ("CEUS_agent/003/U003_key.png",), "编造的原因"), + _row("003", "C15", ("RUN/U003",), "编造的原因"), + _row("003", "C10", (CLINICAL,), {"身高": {"临床表": 1}}), + _row("003", "C03", ("x/y.xlsx",), {"缺在": ["x/y.xlsx"]}), + ], +) +def test_unknown_skip_reason_stops(row: SkippedRow) -> None: + with pytest.raises(UnknownReasonError): + skip_problem(row, {}) + + +def test_case_labels() -> None: + assert case_label("007") == "P007" + assert case_label(f"{CLINICAL} 第 5 行") == "临床表第 5 行" + assert case_label("病例目录 1000") == "病例文件夹「1000」" + + +def test_program_words_stop() -> None: + for text in ("C15 失败", "roi_pan", "a_b", "{", "[x]", "band", "20261017-080000"): + assert FORBIDDEN.search(text) is not None + with pytest.raises(ValueError, match="程序用语"): + check_plain(text) + assert check_plain("视频里找不到画好的绿框") == "视频里找不到画好的绿框"