Files
CADx-4/research-wiki/guides/CEUS定量.md
T
iomgaa 2753ea7721 feat(build): 资料库装入全量 CEUS 结果;补 CEUS 定量使用说明
- config/build.yaml 的 ceus_run 指向全量运行 runs/ceus/20261016-090325-1de88a5-dirty;
  test_committed_config_is_valid 随之改为断言指向 runs/ceus/ 下跑完的运行目录。
- config/ceus.yaml 配准门槛注释注明全量运行复算的分布相同。
- 新增 research-wiki/guides/CEUS定量.md(已过一轮硕士生阅读);装配两个库.md 补上 CEUS 运行与
  交付包的三条核对、finished.json;结构.md 行数表按当前代码更新。
- WORK_LOG 记任务书 02 完成情况。

来源:任务书 02 第四阶段。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 09:49:53 -04:00

212 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CEUS 定量
读者:要跑一次 CEUS 定量、或者要看它的产物和失败清单的人。这一步对交付包里检查全部通过的病例(编号、文件、取值、跨表一致等检查,即「装配两个库」里的 C01–C13,见 `装配两个库.md` 第四节;每例恰好一段视频),逐例读超声造影视频,圈出病灶、画出亮度曲线、拟合、挑关键帧、算灌注指标,结果写进 `runs/ceus/<运行号>/`,再由「装配两个库」装进给超声专家看的那部分资料(「两个库」是给 AI 专家看的资料库和只给评价用的标准答案库;资料库按临床、超声、MR 三路分开,每个专家只看自己那一路)。它在整条流水线里的位置见 `../explanation/结构.md` 第二部分,9 个步骤和关键帧的定义见第四部分,各函数见第八部分;为什么这样设计见 `../任务书/02-CEUS定量.md`。
## 先认识几个词
- **CEUS**(超声造影):往静脉推注(快速注射)造影剂后连续录下的超声视频。超声机录像时屏幕左右各一半:一半是**造影画面**(造影剂到了就变亮),另一半是普通**灰阶画面**(常规黑白 B 超,显示组织结构,不随造影剂变亮)。哪一半是造影画面,各例不一定相同,程序按哪一半有绿框来认。两半中间的分界叫**分屏线**。
- **扇形区**:每一半画面里真正有超声图像的扇形区域,四周是黑边、文字和刻度。
- **绿框**:医生在造影画面上画的病灶框,烧录在视频里,是 CEUS 定量的输入(它在最终轮廓里占多大分量见 `../待定问题/01-医生画的绿框能否作为输入.md`)。
- **紫圈**:医生用紫色轮廓圈的一块正常胰腺,作对照用(个别病例圈的不是胰腺或没有圈,见 `../待定问题/08`)。程序算出的曲线里叫 `roi_pan`。
- **ROI**(感兴趣区):程序最终圈出的病灶区域。**暗区**:病灶里装囊液、不进造影剂、一直发黑的部分。
- **配准**:估出左半画面局部坐标到右半画面局部坐标的平移 (dx, dy)(方向固定是左到右,与哪一半是造影画面无关),让两半里同一个解剖位置对上。程序在造影画面上找绿框,挪到灰阶那一半交给分割模型,分割完再把轮廓挪回造影那一半采亮度,挪的就是这个平移。在灰阶那一半分割,是因为造影画面里病灶边界随造影剂进出时明时暗,灰阶画面上边界稳定(沿用旧代码,任务书 02「先认识几个词」)。配准错了不会报错,只会让亮度采在病灶旁边,所以全量运行之前要做一轮**目视核对**(第二节)。
- **提示帧**:绿框位置或形状明显变化的那几帧,第 2 步挑出,交给分割模型告诉它「病灶在这里」。
- **MedSAM2**:医学图像分割模型,给它几帧提示帧上的病灶框,它把病灶轮廓传播到整段视频的每一帧。要用显卡,是最慢的一步(第 3 步)。怎么装见 `开发环境.md`。
- **TIC**(时间-强度曲线):ROI 平均亮度随时间的变化,造影剂进来变亮、洗出变暗。程序用 Lognormal(对数正态)形状的光滑曲线去拟合它,拟合要过一道**质量门**(拟合优度等门槛,见 `ceus.yaml` 的 `tic_analysis.fitting`)才算成功。病灶有两条曲线(整体、去掉暗区),两条都过了质量门才算**病灶拟合成功**,D1–D4 和灌注指标都要它。
- **灌注指标**:从拟合曲线算出的 11 个数(峰值强度、达峰时间、洗入速率等),口径仿照商用造影定量软件 VueBOX(`结构.md` 第四部分)。
- **关键帧与槽位**:按 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` 第四部分的表)。
- **运行号**:格式同构建号,`YYYYMMDD-HHMMSS-<git 短哈希>`,工作区有未提交改动时末尾加 `-dirty`(见 `装配两个库.md` 第三节「构建号」)。要写进论文的运行应在干净的工作区上跑。
## 一、整体顺序
1. 改 `config/ceus.yaml`(一般不用改;字段的定义和出处写在文件注释里)。
2. `ceus_align_review.py render` 出配准核对图(不用显卡)。
3. 派两个子 Agent 各自判读,写 `verdicts_a.csv`、`verdicts_b.csv` 到这次出图的运行目录。
4. `ceus_align_review.py score` 计分,得到 `review.csv`。
5. 把 `ceus.yaml` 的 `paths.align_review` 改成这份 `review.csv` 的路径。
6. 在 tmux 里跑 `ceus_quant.py`,得到 `runs/ceus/<运行号>/`。
7. 把 `config/build.yaml` 的 `ceus_run` 指向这个运行目录,重新构建两个库(`装配两个库.md`)。
全部命令在仓库根目录下敲。两个脚本都只接收配置文件路径(`score` 只接收出图的运行目录),不能在命令行单独改某个参数(`结构.md` 第五部分「配置」)。终端只打印计数,不打印视频号,可以贴进汇报。
## 二、配准的目视核对
为什么要做、为什么用子 Agent 而不是人、为什么混阴性对照,见任务书 02「配准的目视核对」。负责人 2026-10-16 同意由子 Agent 判读、负责人不看。
### 1. 出图
```bash
uv run python scripts/ceus_align_review.py render --config config/ceus.yaml
```
只做第 1 步。对第 1 步成功的每例出两张图,都只截两半扇形区的范围:
- `<编号>_checker.png`(棋盘拼图):左半和按配准平移过来的右半像棋盘一样交替拼接。对齐时,血管、器官轮廓跨过格子交界处是连续的;没对齐时在交界处错开。
- `<编号>_edges.png`(边缘叠加图):左半的边缘画红色,右半平移过来的边缘画绿色,重合处红加绿成黄色。对齐时大部分是黄色;没对齐时红线、绿线成对平行错开。医生画的绿框、紫圈只出现在其中一半,它们的边缘不参与比对。
另随机挑几例,把配准故意错开一段距离,出同样的两张图当**阴性对照**(例数、错开距离区间、随机数种子在 `ceus.yaml` 的 `align_check` 段)。全部图按打乱的三位编号命名,编号和「哪一例、是不是对照、用的 (dx, dy)、视频 SHA-256」的对照写在 `key.csv`。成功例数少于对照例数时脚本报错。
第 1 步就失败的病例不出图,记在这次出图目录的 `failed.csv`(列同第五节);它们在全量运行里同样会在第 1 步失败。
### 2. 判读
派两个互不知情的全新子 Agent,各自看完 `images/` 下的全部图,写一份判读表。prompt 要点:
- 只给 `images/` 目录的路径,让它只读这个目录;不给、不提 `key.csv`,**不提有阴性对照,也不提比例**(提了它就会去猜哪几张是对照)。
- 说明两种图怎么看(上一小节的两条),同一编号的两张图合起来判一次。
- 每个编号只判三档:`对齐`、`没对齐`、`看不清`,外加一句依据(例如「血管在棋盘交界处错开」)。
- 只写编号、判定和依据,不抄图上的任何文字:帧上烧录了拼音姓名、检查号等(`AGENTS.md` 硬性约束 3)。
- 写进运行目录的 `verdicts_a.csv`(第一个)或 `verdicts_b.csv`(第二个),用 Python 的 `csv` 模块写(依据里有逗号也不会错列),UTF-8 **不带 BOM**(计分脚本逐字比对表头,BOM 会让表头对不上而报错),表头恰好是 `编号,判定,依据`,编号写成和文件名一样的三位数字(如 `001`),每个编号恰好一行。
- 本机 `grep`、`find` 用 `/usr/bin/` 下的版本;要用浏览器只能无头模式。
### 3. 计分
```bash
uv run python scripts/ceus_align_review.py score --run-dir runs/ceus_align/<运行号>
```
- 判读表的表头、编号集合、判定取值不合规就报错,改判读表再跑。
- 任一张阴性对照被任一人判成「对齐」或「看不清」,这次核对**作废**:不写 `review.csv`,退出码 1。作废后不放宽标准、不换说法让子 Agent 重判同一批图(任务书 02),在汇报里写明,下一步由负责人定。
- 对照全部判出来了,写 `review.csv`:每个真实病例一行,列是 `video_sha256, dx, dy, passed, verdict_a, verdict_b`。两人都判「对齐」才 `passed` 为 true,其余(含有人判「看不清」)为 false。终端打印通过、不通过、有人判看不清、两人不一致各几例。
- `review.csv` 已存在时报错,不覆盖。
### 4. 接进配置
把 `config/ceus.yaml` 里 `paths.align_review` 改成 `runs/ceus_align/<运行号>/review.csv`(文件里原来是一个占位路径,不改的话全量运行一开始就因找不到文件停下)。
### 什么时候要重做核对
全量运行时,`ceus_quant` 按视频的 SHA-256 在 `review.csv` 里查每例,并核对这次算出的 (dx, dy) 和表里记的相同。下面几种情况会让它整次停下,这时要重新出图、判读、计分:
- 报「配准目视核对表里没有这段视频」:交付包换了视频,或指错了 `review.csv`。
- 报「配准结果 … 与目视核对表里的 … 不同」:改了影响第 1 步的参数(`ceus.yaml` 的 `views`、`registration`、`prompt_source`、`pan_source` 段;后两段是绿框、紫圈的颜色范围,第 2 步找绿框用它们,第 1 步配准时也用它们把绿框紫圈像素先修补掉)或配准代码,核对的已不是这次跑的东西。
## 三、全量运行
要跑一阵子,放进 tmux,日志写到文件:
```bash
mkdir -p work/logs
tmux new -s ceus 'uv run python scripts/ceus_quant.py --config config/ceus.yaml 2>&1 | tee work/logs/ceus_quant-$(date +%Y%m%d-%H%M%S).log'
```
- 跑的是交付包里检查全部通过的病例(C01–C13)。C14 查的是医生截的那张超声关键图(不是程序挑的关键帧)能否保真重编码,它在构建时才查,被它跳过的病例这里照跑,构建时不用就是了。
- 每例打印一行 `[第几例/总数] 完成` 或 `失败在第 N 步`。跑完打印:运行目录、病例总数、成功数、按步分的失败数、关键帧按槽位分的定位数、分割缓存命中数、耗时、显存峰值。
- 不花钱(不调模型 API),要本机显卡(`ceus.yaml` 的 `segment.device`)。分割结果有缓存,第 3 步的输入和参数不变时,再跑一次不再调显卡,只重算 CPU 上的第 4–9 步。
- 只落在一例上的问题(第五节的表)记进 `failed.csv`,接着跑下一例;其余错误整次停下,留下 `.partial` 目录(第四节)。
## 四、产物在哪
### 配准核对 `runs/ceus_align/<运行号>/`
```
images/<编号>_checker.png、<编号>_edges.png
key.csv 编号 → 视频号、是否对照、所用 (dx, dy)、视频 SHA-256(不给判读者)
failed.csv 第 1 步就失败的病例
snapshot/ snapshot.json(git 状态、开始时间等)、ceus.yaml 原文、finished.json(结束时间)
verdicts_a.csv、verdicts_b.csv 判读(第二节第 2 步写进来)
review.csv 计分结果(第二节第 3 步写出,作废时没有)
```
### 全量运行 `runs/ceus/<运行号>/`
```
failed.csv
snapshot/
snapshot.json git 状态、uv.lock 和各配置文件的 SHA-256、开始时间(开跑时就写)
ceus.yaml 这次所用配置的原文(开跑时复制,这次运行读的就是这份副本)
align_review.csv 这次所用的配准核对表原文(同上)
segment.json 权重文件 SHA-256、MedSAM2 提交号、设备
finished.json 结束时间、显存峰值、分割缓存命中数(跑完才写;没有它说明没跑完)
<视频号>/ 每个成功的病例一个目录,失败的没有
summary.json 视频 SHA-256、推注时间、配准结果、四条曲线的拟合、灌注指标、紫圈检出率、7 个槽位的定位
trajectory.npz 逐帧轮廓(两半的 ROI、暗区、背景圆)
tic_curves.npz 逐帧的曲线数值
tic.png 曲线图(第七节)
overlay.mp4 带轮廓的回放视频(第六节)
keyframes/<槽位>.png 定位到的关键帧整幅原始画面(左右两半都在),未定位的槽位没有文件
```
`summary.json` 里与人工检查最相关的几处:`registration`(第 2 步定出的哪一半是造影画面,第 1 步两种配准方法即互相关、模板匹配各自的估计和差值);`fits.<曲线>.success`、`failure_reason`(拟合过没过质量门、为什么);`pan_detected_fraction`(多少比例的帧认出了紫圈);`anchor_frames.<槽位>.status`、`reason`(第七节)。四条曲线是 `roi_all`(病灶整体)、`roi_perf`(病灶去掉暗区)、`roi_pan`(紫圈)、`background_circle`(程序在病灶外自动挑的一小块背景圆)。
### 分割缓存 `work/segment_cache/<键>/masks.npz`
第 3 步的逐帧病灶掩码。键是「视频 SHA-256 + 第 1–3 步的参数 + 权重 SHA-256 + MedSAM2 提交号」的哈希,键相同就直接读缓存。`work/` 整个可以删掉,代价是下次重新调显卡。
### `.partial` 是什么
三处都是先写进 `<名字>.partial/`,写完再改名:
- `runs/ceus/<运行号>.partial/`、`runs/ceus_align/<运行号>.partial/`:那次运行中途停下了。不会被自动删,留着查原因;查完删不删先问负责人(运行产物只追加,`AGENTS.md`「两档标准」)。构建拒绝 `.partial` 结尾的 `ceus_run`。
- `work/segment_cache/<键>.partial/`:那一例分割写到一半时停下了。它会让下次跑到这一例时报错,**直接删掉**它再跑(`work/` 可以删掉重算)。
目标目录已存在时报错、不覆盖;运行号带到秒,正常不会重名。
## 五、failed.csv 怎么看
三列:`video_id`(视频号)、`step`(失败在第几步)、`reason`(原因,原文是代码里的报错消息)。UTF-8 不带 BOM,用 Excel 直接打开中文可能乱码,用编辑器或导入时选 UTF-8。只有终端打印的计数可以贴进汇报;`video_id` 是病例标识,不进文档、提交说明和 Linear。
| 第几步 | `reason` 里的关键词 | 意思和常见原因 |
| --- | --- | --- |
| 1 | `cv2 打不开视频`、`非法 fps`、`非法元数据`、`时间戳`、`解码帧数 … 与容器记录的帧数 … 不一致` | 视频解码失败:文件损坏、编码不常见,或容器里记的帧数、时间戳不可信。用播放器打开看能不能放完 |
| 1 | `未找到稳定暗缝`、`落在搜索 band … 外`、`宽度退化`、`左右宽度比` | 分屏线找不到:画面中部没有一条明显暗缝(例如不是左右分屏录的、单画面录像),或找到的线把画面切得一边太窄。搜索范围在 `ceus.yaml` 的 `views` 段 |
| 1 | `找不到扇形区` | 某一半画面没有随时间变化的区域(例如这一半是静止的) |
| 1 | `可用的模板块只有 … 块` | 左半扇形太小或太暗,模板匹配凑不够块 |
| 1 | `两半配准不一致` | 互相关和模板匹配两种方法估出的平移相差超过 `registration.max_disagreement_px`,不知道该信哪个 |
| 1 | `配准目视核对未通过` | 配准算出来了,但核对时两个子 Agent 没有都判「对齐」(含有人判「看不清」) |
| 2 | `左右两半都找到绿框` | 两半各自都选出了提示帧,定不出哪一半是造影画面;可能灰阶那一半也有颜色接近绿框的东西 |
| 2 | `左右两半都没有找到绿框` | 视频里没有绿框,或颜色、面积没达到 `prompt_source` 段的门槛 |
| 4 | `未产出任何有效 ROI 轮廓` | 分割在每一帧都没出像样的掩码 |
几点说明:
- 病灶拟合没过质量门**不算失败**:这一例照常有结果目录,D1–D4 记为未定位,灌注指标写 null,原因写在 `summary.json`;F1、F2 照常按推注时间取。个别槽位未定位也不算失败。
- 不进 `failed.csv`、而是整次停下的:配准核对表对不上(第二节末)、MedSAM2 报错(如显存不够、权重找不到)、重新解码时读不到关键帧、配置写错。报错信息在终端和日志里,留下 `.partial`。
- 失败的病例构建时按 C15 整例跳过(`装配两个库.md` 第四节);要不要改成「超声不可评价、仍参与会诊」等 `../待定问题/03-病例排除标准.md` 定。
## 六、overlay.mp4 怎么看
整段视频的回放,左右两半都在,程序画的线叠在原始画面上(医生烧录的绿框、紫圈也照常可见):
| 颜色 | 画在哪一半 | 是什么 |
| --- | --- | --- |
| 黄色轮廓 | 造影画面 | 病灶 ROI,**亮度就在这里面采**(TIC 来自它) |
| 绿色轮廓 | 灰阶画面 | 分割那一半的 ROI(MedSAM2 在这一半上分割) |
| 橙红色轮廓 | 造影画面 | 暗区(`roi_perf` 采样时去掉的部分) |
| 青色圆 | 造影画面 | 背景圆(`background_circle`) |
看什么:
- **黄色轮廓是否始终落在病灶上**:从开头到造影剂洗出,轮廓贴着病灶边界走,探头移动时跟着病灶动,不跳到旁边的组织或血管上。前后两三秒的轻微漂移可以接受,整段偏在一边就是有问题。
- **黄、绿两个轮廓圈的是不是同一块解剖结构**:两半里病灶的形状、位置应对得上。对不上说明配准错了,即使目视核对通过了也要报告。
- **橙红色暗区是否在病灶内部、而且确实一直发黑**:暗区盖住了明显变亮的部分,`roi_perf` 的曲线就不可信。
- **青色圆是否在病灶外的组织里**,不落在黑边、文字或病灶上。
## 七、tic.png 怎么看
横轴是视频时间(秒),纵轴是灰度强度。
- **曲线**:蓝色 `roi_all`(病灶整体)、橙色 `roi_perf`(病灶去暗区)、紫色 `roi_pan`(紫圈)。细的带点线是逐帧原始数值,粗实线是过了质量门的 Lognormal 拟合。病灶曲线拟合没过就不画粗线;紫圈拟合没过时画成虚线并标 `QC rejected`,便于复核。背景圆的曲线不画。
- **竖虚线**:D1(红)、D2(绿)、D3(紫)、D4(蓝),标签形如 `D1 model`(model 指取自拟合模型),画在从 `roi_perf` 拟合曲线算出的时刻上,病灶拟合没成功时不画;算不出时刻的不画。标签后带 `[状态]` 的,表示时刻算出来了,但这个槽位没定位到帧(状态含义见本节末)。B0、F1、F2 不画在图上,看 `summary.json`。
看什么:
- 粗线是否贴着原始点走,尤其是上升段和峰。贴不住而又过了质量门,记下来报告。
- D1 是否在曲线刚开始抬头处,D2 在上升最陡处,D3 在峰顶,D4 在下降最陡处。
- 没有粗线、也没有竖线:病灶拟合没过质量门,原因看 `summary.json` 的 `fits.roi_all.failure_reason`、`fits.roi_perf.failure_reason`。
- 曲线在开头就已经很高(没有变亮之前的平台),说明录像开始时造影剂已经到了。这时 `summary.json` 的 `early_truncation_flag` 为 true(`roi_all`、`roi_perf`、`roi_pan` 中有一条凑不出足够长的增强前基线,即造影剂到达前那段平稳的亮度),B0 往往未定位。
`summary.json` 里每个槽位的 `status`:`ok`(定位到了);`anchor_undefined`(这个时刻本身算不出来,例如病灶拟合没过质量门、曲线升不过到达门槛即判定「造影剂到了」的亮度门槛(`anchor_frames.arrival`)、开头没有稳定基线时的 B0);`not_covered`(时刻落在视频有效帧之外,例如推注后 20 秒早于视频开头,或峰后录得不够长、D4 落在录像结束之后);`no_frame_in_window`(时刻在视频内,但容差窗口里没有可用帧);`no_ordered_frame`(窗口里有帧,但都被时间更早的槽位占用了,再取会打乱先后顺序)。`reason` 一栏写具体原因。容差窗口在 `ceus.yaml` 的 `tic_analysis.anchor_frames` 段。
## 八、装进资料库
把 `config/build.yaml` 的 `ceus_run` 改成 `runs/ceus/<运行号>`,再按 `装配两个库.md` 第二节构建并跑泄露扫描。构建只认跑完的运行目录(不是 `.partial`,有 `failed.csv` 和 `snapshot/snapshot.json`),并核对它确实跑在这份交付包上:快照 `ceus.yaml` 的 `paths.delivery_dir` 与 `build.yaml` 的是同一路径,每个成功病例 `summary.json` 里的视频 SHA-256 和推注时间与交付包一致,任一条不符整次停下。旧代码跑出、`summary.json` 里没有 `video_sha256` 的运行目录不能用,要重跑;资料库里装哪些字段、哪些图,见 `结构.md` 第四部分和 `装配两个库.md` 第三节。
## 九、注意
- `runs/`、`work/`、`data/` 都不进 git。`key.csv`、`failed.csv`、各病例目录名里有视频号(视频号里带病例号),帧上烧录了姓名拼音和检查号;文档、提交说明、Linear 和汇报里只写计数、运行号和文件路径的结构,不写视频号。
- 把图发给子 Agent 之外的外部模型服务,先问负责人(`AGENTS.md` 硬性约束 3)。
- 要写进论文的数字,追到具体的 `runs/ceus/<运行号>/`(`AGENTS.md` 硬性约束 5)。