feat(ceus,segment): 任务书 02 第二阶段——分割和后半段算法

- ceus 迁入 polygons、dark_region、reference_region、tic_fitting、tic、anchor_frames、
  perfusion_metrics、trajectory、tic_curves、tic_plot、overlay,旧测试照搬(改 import 与配置读取)。
- 对外两段接口:prepare_segmentation(第 1–2 步)与 quantify_case(第 4–9 步)。
- 第 3 条:删掉 D1 取医生标注时刻的模式,D1 只从拟合曲线算。
- 第 4 条:F1/F2 取视频第 20−X、60−X 秒(X 为视频第 0 秒时已推注的秒数),落在视频外记未定位。
- 绿框融合权重、紫圈颜色阈值照搬;关键帧容差窗口的出处写进 ceus.yaml 注释。
- segment:同一进程调用 MedSAM2,设置写进 ceus.yaml 的 segment 段,全部必填;gpu 测试真跑通过。
- 依赖加 scipy、matplotlib、medsam2(可编辑安装,不编译 CUDA 扩展);uv 只解析 linux x86_64。
- 导入契约:只有 segment 引用 torch、sam2;segment 不引用其他 cadx 模块。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 05:07:02 -04:00
parent 9a916c65a5
commit 150eb5f6ae
35 changed files with 10655 additions and 505 deletions
+39 -13
View File
@@ -1,7 +1,8 @@
# CEUS 定量的配置,给 scripts/ceus_quant.py 用。
#
# 分三段:paths(位置)、segment(分割设置,任务书 02 第二阶段加)、ceus(算法参数)。
# ceus 段的字段定义和校验在 src/cadx/ceus/_config.py;paths 段和最外层由 scripts/ceus_quant.py 定义。
# ceus 段的字段定义和校验在 src/cadx/ceus/_config.py,segment 段在 src/cadx/segment/_config.py;
# paths 段和最外层由 scripts/ceus_quant.py 定义。路径都相对仓库根目录。
# 每个字段必填,可以没有的值写 null。
#
# ceus 段的数值逐个照抄旧项目 config/ceus_roi_default.yaml,没有改动(待定问题/02:参数何时冻结)。
@@ -17,6 +18,14 @@ paths:
runs_root: runs/ceus # 每次运行一个子目录,只追加不删
work_root: work # 可删掉重算的中间文件(分割缓存)
segment: # 分割设置,字段定义在 src/cadx/segment/_config.py。出自旧项目 debugs/medsam2_local_track.py
medsam2_dir: reference/MedSAM2 # MedSAM2 代码,提交号从它的 .git 读。旧脚本是远程 /home/wangwenhan/MedSAM2
checkpoint: models/MedSAM2_latest.pt # 权重。旧脚本是 MedSAM2/checkpoints/MedSAM2_latest.pt
sam2_config: configs/sam2.1_hiera_t512.yaml # 模型配置的 Hydra 名(相对 sam2 包)。照旧脚本 DEFAULT_MEDSAM2_CFG
device: cuda # 照旧脚本 DEFAULT_DEVICE
offload_video_to_cpu: false # 照旧脚本:t1_local_debug.py 调它时没开这个开关
offload_state_to_cpu: false # 同上
ceus:
config_version: "4" # 参数口径版本号,由人改。出自旧项目:Lognormal-v2 参数口径(design/0044)
@@ -65,6 +74,12 @@ ceus:
threshold_offset: 0 # 出自旧项目 findings §3.8
min_area: 300 # findings §2A:非代码默认 64。出自旧项目 findings §3.8
min_area_ratio: 0.015 # findings §2A:非代码默认 0.0。出自旧项目 findings §3.8
# 下面四项旧 schema 里没有,旧 dark_region.py 用 getattr(..., 默认值) 读,这里照旧值写出来。
# 旧项目工程侧定的,无出处
max_regions: null # 每帧最多保留几块暗区,null 不限
max_contours_per_region: null # 每块暗区最多取几条外轮廓,null 不限
preferred_area_ratio_range: null # 暗区占 ROI 面积的偏好区间 [lo, hi],null 时面积分偏好占一半
prompt_exclude_dilate: 3 # 剔绿框色前对绿色像素的膨胀核边长(与 tic_analysis 那个是两个独立的数)
preserve_holes: true # 出自旧项目 findings §3.8
fill_holes: false # 出自旧项目 findings §3.8
morphology_kernel: 5 # 出自旧项目 findings §3.8
@@ -129,9 +144,11 @@ ceus:
estimated_baseline_lookback_s: 10.0 # 允许 t0 位于视频起点前,但截图不外推。出自旧项目 design/0044
maximum_perf_area_step_fraction: 0.15 # 相邻帧去暗区面积比例最大变化。出自旧项目 design/0044
parameter_boundary_fraction: 0.001 # 形状参数贴边判据占允许区间的比例。出自旧项目 design/0044
anchor_frames: # 7 张关键帧定位。出自旧项目 design/0040 决策二
anchor_frames: # 7 张关键帧定位(第 8 步)。出自旧项目 design/0040 决策二
# 下面这批数旧项目注明镜像自 Linear《临床口径契约》(2026-08-25 版,同步日期 2026-09-01),
# 动任何一个之前先去那边核对。各容差窗口的出处在第二阶段迁移关键帧模块时逐个再核对。
# 动任何一个之前先去那边核对。容差窗口的出处任务书 02 第二阶段逐个核对过(旧 yaml 注释、
# 旧 research-wiki/explanation/tic-quantification-encoding.md 第 8、9 节、
# 旧 research-wiki/reference/glossary.md「锚点帧」两张表、旧 design/0030 的「已定的事」表)。
arrival: # 到达判据,门槛取两个候选的高者。出自旧项目:《临床口径契约》5.2
baseline_sd_multiplier: 3.0 # 候选一:基线抖动 ×3.0。出自旧项目:《临床口径契约》5.2
relative_peak_threshold: 0.10 # 候选二:峰高 ×0.10。出自旧项目:《临床口径契约》5.2
@@ -139,17 +156,26 @@ ceus:
# 主峰突出度门槛:基线抖动 ×3。与到达判据那个 3.0 是两个独立的数,改一个不连带改另一个。
# 出自旧项目:《临床口径契约》第六节
peak_prominence_baseline_sd_multiplier: 3.0
baseline_frame_backoff_s: 0.3 # B0 锚点 = 到达时刻再往前 0.3 秒。出自旧项目:《临床口径契约》5.11
d1_window_after_s: 0.5 # D1 窗 [锚点, 锚点+0.5]。出自旧项目:《临床口径契约》第 12 题
d2_window_s: 0.3 # D2 窗 ±0.3 秒。出自旧项目:《临床口径契约》第 12 题
d3_window_s: 0.5 # D3 窗 ±0.5 秒。出自旧项目:《临床口径契约》第 12 题
# D4 窗 ±0.5 秒:契约空着、第 12 题委托工程侧。旧项目工程侧定,理由是 D4 与 D2/D3 同属曲线
# 形状锚点,洗出段比洗入段平缓、导数极值定位误差更大,取 D2/D3 中较宽的一档。无外部出处
baseline_frame_backoff_s: 0.3 # B0 锚点 = 到达时刻再往前 0.3 秒。出自旧项目:《临床口径契约》5.11(临床方原规范 §13.2)
# D1 窗 [锚点, 锚点+0.5 秒](不许取到达之前)。出自旧项目:《临床口径契约》第 12 题四个容差之一,
# 对应临床方原规范 §13.3
d1_window_after_s: 0.5
d2_window_s: 0.3 # D2 窗 ±0.3 秒。出自旧项目:《临床口径契约》第 12 题,临床方原规范 §13.4
d3_window_s: 0.5 # D3 窗 ±0.5 秒。出自旧项目:《临床口径契约》第 12 题,临床方原规范 §13.5
# D4 窗 ±0.5 秒:临床方原规范 §13.6 只给约束(晚于主峰、不许用最后一帧)不给窗口,契约第 12 题
# 委托工程侧。旧项目工程侧定,理由是 D4 与 D2/D3 同属曲线形状锚点,洗出段比洗入段平缓、导数
# 极值定位误差更大,取 D2/D3 中较宽的一档。无外部出处
d4_window_s: 0.5
b0_window_s: 0.5 # B0 窗 ±0.5 秒:契约空着,旧项目工程侧取与 D4 同档,无外部出处
# B0 窗 ±0.5 秒:临床口径只说「视频开始到到达前 0.3 秒之间挑帧」,没给窗口。旧项目工程侧取与 D4
# 同档,无外部出处
b0_window_s: 0.5
late_phase:
preferred_time_s: 60.0 # F2 锚点:推注后 60 秒。出自旧项目:《临床口径契约》
tolerance_s: 2.0 # F2 容差 ±2 秒。出自旧项目:《临床口径契约》第 12 题
# F2 锚点:推注后 60 秒。出自旧项目:《临床口径契约》,临床方 2026-08-16 定(旧 glossary 记 HAI-75)
preferred_time_s: 60.0
# F2 容差 ±2 秒(只属于 F2)。出自旧项目:《临床口径契约》第 12 题四个容差之四,临床方原规范
# §10.1、§13.7
tolerance_s: 2.0
# F1 锚点:推注后 20 秒。出自旧项目:《临床口径契约》(08-16 口头定)。
# F1 没有容差键:旧项目工程侧暂定「无上限,取最近的一帧」,无出处
# F1 没有容差键:旧项目工程侧暂定「无上限,在视频有效帧范围内取最近的一帧」(旧
# tic-quantification-encoding.md 第 8 节),临床方没给容差,无外部出处
f1_anchor_s: 20.0
+29 -4
View File
@@ -4,12 +4,15 @@ version = "0.0.0"
description = "胰腺囊性肿瘤多模态会诊诊断的研究代码"
requires-python = ">=3.12"
dependencies = [
"matplotlib>=3.11.2",
"medsam2",
"numpy>=2.5.4",
"opencv-python-headless>=5.0.0.93",
"openpyxl>=3.1.5",
"pillow>=12.3.0",
"pydantic>=2.14.1",
"pyyaml>=6.0.3",
"scipy>=1.18.1",
]
[dependency-groups]
@@ -23,6 +26,14 @@ dev = [
"types-openpyxl>=3.1.5.20260827",
]
[tool.uv]
# 只在本机和远程服务器(都是 Linux x86_64)上跑;只为这一种平台解析依赖,
# 免得 torch 的各平台 CUDA 轮子都要下载一遍。
environments = ["sys_platform == 'linux' and platform_machine == 'x86_64'"]
[tool.uv.sources]
medsam2 = { path = "reference/MedSAM2", editable = true }
[build-system]
requires = ["hatchling>=1.26"]
build-backend = "hatchling.build"
@@ -50,7 +61,8 @@ pythonVersion = "3.12"
typeCheckingMode = "strict"
venvPath = "."
venv = ".venv"
extraPaths = ["tests"]
# medsam2 以 setuptools 的导入钩子方式可编辑安装,pyright 跟不过去,直接指给它源码目录。
extraPaths = ["tests", "reference/MedSAM2"]
[tool.pytest.ini_options]
testpaths = ["tests"]
@@ -69,12 +81,25 @@ root_packages = ["cadx"]
include_external_packages = true
[[tool.importlinter.contracts]]
# design 0001:torch 与 MedSAM2 只许出现在分割模块(模块落地时在这里放行 cadx.segment)。
name = "torch 与 sam2 不进 cadx"
# design 0001:torch 与 MedSAM2 只许出现在分割模块。import-linter 不能写「除 segment 外的 cadx」
# (只有 ignore_imports 能开例外),所以逐个列出其余模块;新模块落地时加进 source_modules。
name = "cadx 里只有 segment 引用 torch、sam2"
type = "forbidden"
source_modules = ["cadx"]
source_modules = [
"cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build", "cadx.ceus",
]
forbidden_modules = ["torch", "sam2"]
[[tool.importlinter.contracts]]
# 结构.md 第三部分依赖表:segment 不引用其他 cadx 模块(它不认识分屏和绿框)。
name = "segment 不引用其他 cadx 模块"
type = "forbidden"
source_modules = ["cadx.segment"]
forbidden_modules = [
"cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build",
"cadx.ceus", "cadx.llm", "cadx.ledger", "cadx.consult", "cadx.evaluate", "cadx.verdicts",
]
[[tool.importlinter.contracts]]
# 结构.md 第三部分依赖表:外部层与格式层的模块不依赖其他 cadx 模块。
name = "delivery、library、answers、runs 互不依赖"
+23 -11
View File
@@ -172,7 +172,7 @@ library/<构建号>/
| MR 报告「所见」段 | 看不见 | 看不见 | 看得见 |
| 病理诊断、报告「印象 / 结论」段、医生标注工作簿、真实病例号、Excel 行序、姓名 | 看不见 | 看不见 | 看不见 |
性别、年龄只给临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。
性别、年龄在资料库里只放临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路的第一轮判断必须只凭影像证据,先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。这张表管的是资料库里每一路放什么。会诊时,影像两路在第一轮判断之后,需要时可以向临床专家提问,临床专家的回答可以含性别、年龄;这属于会诊流程,细节由会诊任务书定(负责人 2026-10-16)。
病例用化名,目录和清单按化名排序:交付文件名里带病例号(如 `U001_roi2.mp4`),行序可能和诊断有关;化名是按种子随机抽的 4 位小写字母加数字,按它排序后的顺序与交付顺序无关。图片写入时去掉文件元数据。构建完不再修改;要改就做一次新构建。
@@ -263,9 +263,10 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
文件格式:数组用 npz(读取时 `allow_pickle=False`,pickle 读取时会执行任意代码),每例的标量和摘要用 json,跨病例的表用 csv,图用 png。缺失值写 null,不写 NaN 或 0:0 是合法的测量值,NaN 在 json 里不合法。
导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 7 条),没有的保护不写:
导入边界用 import-linter 检查,写进 `make check`。契约在 `pyproject.toml`,检查范围是 `src/cadx`,**不含 `scripts/` 和 `tests/`**。下面只列契约实际强制的(共 8 条),没有的保护不写:
- `cadx` 不引用 `torch`、`sam2`。
- `cadx` 里只有 segment 引用 `torch`、`sam2`(契约逐个列出其余已落地的模块,新模块落地时加进去)。
- segment 不引用其他 `cadx` 模块。
- delivery、library、answers、runs 互不引用。
- delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。
- ceus 不引用其他 `cadx` 模块(含 segment:分割结果由 `scripts/ceus_quant.py` 传进来)。
@@ -273,7 +274,7 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
- 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。
- 只有 library 引用 `PIL`(写资料库的图片时去掉元数据)。
第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 segment 引用 `torch`、`sam2`(落地时把上面第一条改成放行 segment);只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。
第三部分依赖表的其余部分和下面几条外部库独占,随模块落地逐条写成契约(import-linter 只能检查已经存在的模块):只有 llm 引用 `polygateway`;只有 ledger 引用 `sqlite3`。
契约管不到的,不要当成机器守住了:
@@ -292,7 +293,7 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
| 模块 | 预期行数 | 实际行数 | 旧项目对应部分的行数 |
| --- | --- | --- | --- |
| ceus | 6000–7500(基本照搬旧算法,去掉只为产品层服务的部分) | — | `ceus_roi/` 除 `service.py` 7330,`video_decoder.py` 135 |
| segment | 150–300 | — | `medsam2_local_track.py` 240 |
| segment | 150–300 | 220 | `medsam2_local_track.py` 240 |
| delivery | 400–700 | 733 | `clinical.py` 266、`modalities.py` 522、`workbook.py` 99、`delivery.py` 134,共 1021,其中入库与数据库部分不迁 |
| library | 150–300 | 300 | `input_packages.py` 104 |
| answers | 100–200 | 201 | — |
@@ -365,15 +366,26 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
- `make_build_id(now, git)`:构建号 `YYYYMMDD-HHMMSS-<git 短哈希>`,工作区不干净时末尾加 `-dirty`。
- `write_snapshot(snapshot_dir, build_id, git, started_at, finished_at, uv_lock, configs)`:新建快照目录,复制配置原文并写 `snapshot.json`(构建号、git 状态、Python 版本、`uv.lock` 哈希、起止时间、各配置的哈希)。
### ceus(`src/cadx/ceus/`,目前只有第 1、2 步和坐标换算)
### ceus(`src/cadx/ceus/`)
对外两段接口,中间由 `scripts/ceus_quant.py` 调 segment 做第 3 步:
- `prepare_segmentation(video_path, cfg)`:第 1–2 步,交出 `SegmentationInput`;找不到可信分屏线抛 `SplitDetectionError`(第 1 步),两半都有或都没有绿框抛 `ContrastSideError`(第 2 步)。
- `SegmentationInput`:视频的 fps、帧数、宽高、逐帧时间戳,分屏线,`contrast`(`ContrastSide`:哪一半是造影画面、造影视图和分割那一半视图 `target_view`、提示帧),`target_frames`(分割那一半的逐帧 BGR 图像),`prompt_masks`(提示帧帧号 → 绿框挪到 `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)、紫圈检出率。
- `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`。
- `CeusConfig` 及各段的配置类:`config/ceus.yaml` 里 `ceus` 段的字段定义(pydantic,字段必填、不许多写、读入后不可改);`paths` 段由 `scripts/ceus_quant.py` 定义。
- `CeusCaseError`:只落在这一例上的失败,带失败的步号和原因;`SplitDetectionError`、`ContrastSideError`、`NoValidContourError` 是它的子类。脚本据此把这一例记进 `failed.csv`、接着跑下一例。
- `VideoFile(path)`:按路径用 cv2 读视频:`probe()` 给 fps、帧数、宽高;`iter_frames()` 每次从第 0 帧逐帧读;`frame_times_s()` 给逐帧显示时间戳。
- `split_views(frames, views_cfg)`:第 1 步,在均匀抽的帧上找分屏线,交出分屏线和左右两半的 `ViewRect`;找不到可信的分屏线抛 `SplitDetectionError`。
- `locate_contrast_side(frames, left_view, right_view, prompt_cfg, keyframes_cfg)`:第 2 步,两半各自找绿框、选提示帧,恰好一半有就定为造影画面,交出 `ContrastSide`(哪一半、造影视图、另一半视图、提示帧);两半都有或都没有抛 `ContrastSideError`。
- `view_offset`、`map_source_mask_to_target`、`target_polygons_to_source`:两半之间的坐标换算,平移量是两个视图左上角之差。
- `CeusCaseError`:只落在这一例上的失败,带失败的步号和原因;`SplitDetectionError`、`ContrastSideError` 是它的子类。脚本据此把这一例记进 `failed.csv`、接着跑下一例。
- 其余导出(`ViewRect` 与几何函数、`extract_color_prompt`、`select_prompt_frames` 等)是各步的组成部分,供第二阶段的模块和测试用。
- 其余导出(几何函数、各步的算法函数与结果类型,如 `split_views`、`locate_contrast_side`、`masks_to_smoothed_polygons`、`extract_dark_region_polygons`、`select_reference_region`、`compute_tic_for_video`、`fit_lognormal_curve`、`locate_anchor_frames`、`compute_lognormal_perfusion_metrics`、`render_overlay`)是两段接口的组成部分,供测试用。
### segment(`src/cadx/segment/`)
- `segment_video(frames, prompt_masks, cfg)`:把提示帧的病灶掩码用 MedSAM2 传播到每一帧;`frames` 是分割那一半的逐帧 BGR 图像,`prompt_masks` 是提示帧帧号 → 同尺寸布尔掩码,交出 `(帧数, 高, 宽)` 布尔数组(即 `quantify_case` 的 `target_masks`)。
- `SegmentConfig`:`config/ceus.yaml` 里 `segment` 段的字段定义(MedSAM2 代码目录、权重、模型配置名、设备、两个 offload 开关;pydantic,字段必填、不许多写、读入后不可改)。
- `medsam2_commit(medsam2_dir)`:读 MedSAM2 代码目录 `.git` 的 HEAD 提交号,给快照和分割缓存键用。
### build(`src/cadx/build/`)
+50 -1
View File
@@ -32,6 +32,55 @@ uv run pre-commit install # 每次提交前跑 gitleaks、本地材料拦截
|---|---|
| `data/` | 远程 `wangwenhan@202.38.247.58:CADx-4-CAD/data/` 下的 `交付包_20260908_40例`、`1.2-TEST`,用 rsync 同步 |
| `reference/CADx-4-CAD/` | 旧项目:先 rsync 工作区(排除 `data/`),再 `git fetch` 远程 `CADx-4` 仓库的全部分支恢复历史 |
| `reference/MedSAM2/` | MedSAM2 代码(连同 `.git`),见下一节 |
| `models/` | MedSAM2 权重 `MedSAM2_latest.pt`,见下一节 |
远程服务器走 `202.38.247.58`,不走 Tailscale 地址 `100.83.164.94`:同一台机器,后者慢几百倍。
| `models/` | MedSAM2 权重,CEUS 迁移的任务书落地后补充来源 |
## 五、MedSAM2(CEUS 分割用)
MedSAM2 是医学图像分割模型(GitHub `bowang-lab/MedSAM2`),CEUS 定量的第 3 步用它把几帧的病灶提示传播到整段视频。它以可编辑方式装进项目环境,代码放在 `reference/MedSAM2/`,`src/cadx/segment` 在同一进程里 `import sam2` 调用。位置写在 `config/ceus.yaml` 的 `segment` 段。
**1. 拷代码和权重**(两种情况都要做;代码和权重都不进 git):
```bash
# 代码:连同 .git 一起拷(提交号从这个 .git 读),不要 checkpoints/ 和 data/,约 25MB
rsync -a --exclude checkpoints/ --exclude data/ \
wangwenhan@202.38.247.58:/home/wangwenhan/MedSAM2/ reference/MedSAM2/
# 权重:只要这一个文件,约 156MB
mkdir -p models
rsync -a wangwenhan@202.38.247.58:/home/wangwenhan/MedSAM2/checkpoints/MedSAM2_latest.pt models/
```
远程的代码是 `bowang-lab/MedSAM2` 的 332f30d 提交,只改了 `download.sh`,不影响代码。
**2. 装进环境**,分两种情况:
- **仓库里已有 `uv.lock` 且已含 `medsam2`**(`pyproject.toml` 的依赖里有 `medsam2`,平常就是这种):拷完代码后 `make sync`(即 `uv sync`)就会按锁文件装上 torch 和 MedSAM2。
- **第一次把 MedSAM2 加进项目**(`pyproject.toml` 里还没有 `medsam2`):
```bash
SAM2_BUILD_CUDA=0 uv add --editable reference/MedSAM2
```
`SAM2_BUILD_CUDA=0` 表示不编译 MedSAM2 的 CUDA 扩展(旧项目也没用它)。命令会改 `pyproject.toml` 和 `uv.lock`。
`uv sync` 时同样要设 `SAM2_BUILD_CUDA=0`(`SAM2_BUILD_CUDA=0 uv sync`),否则重新构建 `medsam2` 时会尝试编译 CUDA 扩展。
**代理太慢时**:torch 的轮子有几个 GB,走代理可能很慢。可以临时绕开代理直连国内镜像:
```bash
env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY \
UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \
SAM2_BUILD_CUDA=0 uv sync # 第一次加进项目时把 uv sync 换成 uv add --editable reference/MedSAM2
```
镜像地址只在这一条命令里临时用,不写进 `pyproject.toml`;`uv add` 会把镜像地址写进 `uv.lock` 里各个包的来源,装完要把这些地址换回 `https://pypi.org/simple`(以及对应的 `files.pythonhosted.org` 下载地址),再跑一次 `uv lock --check` 确认锁文件和 `pyproject.toml` 一致。
**3. 确认 torch 看得到显卡**:
```bash
uv run python -c "import torch; print(torch.__version__, torch.cuda.is_available(), torch.cuda.get_device_name(0))"
```
应打印 torch 版本、`True` 和显卡名。最后跑 `uv run pytest -m gpu -v`:在一段合成小视频上真跑一次 MedSAM2。运行时会出现几条警告(注意力内核回退;`sam2/_C.so` 里的连通域后处理报 `inputs must be a CUDA tensor` 后被跳过),MedSAM2 自己说明这不影响主要结果,测试仍应 `passed`。
+142 -3
View File
@@ -1,10 +1,23 @@
"""ceus:CEUS 定量的全部算法和参数,纯计算,不引用其他 cadx 模块。
目前有第 1、2 步(视频解码、分屏、找绿框、选提示帧、判断造影画面在哪一半)和两半之间的坐标换算;
第 3 步以后随任务书 02 第二阶段加入。
某一例不满足算法前提时抛 `CeusCaseError`(带失败的步号和原因)。
对外两段接口:`prepare_segmentation` 做第 1–2 步(解码、分屏、定造影那一半、选提示帧),交出分割的
输入;`quantify_case` 拿分割结果做第 4–9 步(轮廓、暗区、背景圆、TIC、拟合、关键帧、灌注指标)。
第 3 步分割由 segment 做,ceus 不引用它。某一例不满足算法前提时抛 `CeusCaseError`
(带失败的步号和原因)。
写产物用:`pack_trajectory`、`pack_tic_curves`、`render_tic_png` + `tic_plot_markers`、
`render_case_overlay`、`keyframe_images`、`quant_summary`。
"""
from cadx.ceus._anchor_frames import (
ANCHOR_SLOTS,
STATUS_ANCHOR_UNDEFINED,
STATUS_NO_FRAME_IN_WINDOW,
STATUS_NO_ORDERED_FRAME,
STATUS_NOT_COVERED,
STATUS_OK,
AnchorFrameLocation,
locate_anchor_frames,
)
from cadx.ceus._config import (
AnchorArrivalConfig,
AnchorFramesConfig,
@@ -25,6 +38,15 @@ from cadx.ceus._config import (
ViewsConfig,
)
from cadx.ceus._contrast_side import ContrastSide, ContrastSideError, locate_contrast_side
from cadx.ceus._dark_region import (
apply_dark_morphology,
dark_candidate_mask,
extract_dark_region_masks,
extract_dark_region_polygons,
fill_binary_holes,
score_dark_candidate,
split_dark_components,
)
from cadx.ceus._errors import CeusCaseError
from cadx.ceus._geometry import (
ViewRect,
@@ -49,7 +71,33 @@ from cadx.ceus._green_prompt import (
prompt_color_mask,
)
from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source, view_offset
from cadx.ceus._overlay import OverlayConfig, OverlayLayers, draw_polygon_overlay, render_overlay
from cadx.ceus._perfusion_metrics import (
LognormalPerfusionMetrics,
compute_lognormal_perfusion_metrics,
)
from cadx.ceus._pipeline import (
LESION_CURVES,
QuantResult,
SegmentationInput,
keyframe_images,
prepare_segmentation,
quant_summary,
quantify_case,
render_case_overlay,
tic_plot_markers,
)
from cadx.ceus._polygons import (
NoValidContourError,
align_polygon_to_previous,
apply_prompt_frame_position_constraints,
fuse_prompt_prior_with_medsam,
largest_contour,
mask_center,
masks_to_smoothed_polygons,
)
from cadx.ceus._prompt_frames import PromptFrame, select_prompt_frames
from cadx.ceus._reference_region import select_reference_region
from cadx.ceus._split_screen import (
SplitDetectionError,
detect_split_x,
@@ -57,12 +105,54 @@ from cadx.ceus._split_screen import (
split_views,
view_rect,
)
from cadx.ceus._tic import (
CURVE_NAMES,
BaselineResult,
TicResult,
compute_tic_for_video,
detect_baseline,
mean_or_nan,
)
from cadx.ceus._tic_curves import (
CURVES_SCHEMA_VERSION,
TicCurves,
TicCurveSeries,
from_tic_result,
pack_tic_curves,
unpack_tic_curves,
)
from cadx.ceus._tic_fitting import (
LognormalFitResult,
LognormalParameters,
evaluate_lognormal,
evaluate_lognormal_derivative,
fit_lognormal_curve,
)
from cadx.ceus._tic_plot import TicPlotMarker, render_tic_png
from cadx.ceus._trajectory import (
POLYGON_NUM_POINTS,
Trajectory,
pack_trajectory,
unpack_trajectory,
)
from cadx.ceus._video import VideoFile
__all__ = [
"ANCHOR_SLOTS",
"CURVES_SCHEMA_VERSION",
"CURVE_NAMES",
"LESION_CURVES",
"POLYGON_NUM_POINTS",
"STATUS_ANCHOR_UNDEFINED",
"STATUS_NOT_COVERED",
"STATUS_NO_FRAME_IN_WINDOW",
"STATUS_NO_ORDERED_FRAME",
"STATUS_OK",
"AnchorArrivalConfig",
"AnchorFrameLocation",
"AnchorFramesConfig",
"AnchorLatePhaseConfig",
"BaselineResult",
"CeusCaseError",
"CeusConfig",
"ColorPrompt",
@@ -72,42 +162,91 @@ __all__ = [
"InteriorDarkRegionConfig",
"KeyframePositionConstraint",
"KeyframesConfig",
"LognormalFitResult",
"LognormalParameters",
"LognormalPerfusionMetrics",
"NoValidContourError",
"OverlayConfig",
"OverlayLayers",
"PanSourceConfig",
"PromptFrame",
"PromptObservation",
"PromptSourceConfig",
"QuantResult",
"ReferenceRegionConfig",
"RoiShapeConfig",
"SegmentationInput",
"SmoothingConfig",
"SplitDetectionError",
"TicAnalysisConfig",
"TicBaselineConfig",
"TicCurveSeries",
"TicCurves",
"TicFittingConfig",
"TicPlotMarker",
"TicResult",
"Trajectory",
"VideoFile",
"ViewRect",
"ViewsConfig",
"align_polygon_to_previous",
"apply_dark_morphology",
"apply_prompt_frame_position_constraints",
"collect_prompt_observations",
"compute_lognormal_perfusion_metrics",
"compute_tic_for_video",
"crop_frame",
"dark_candidate_mask",
"detect_baseline",
"detect_split_x",
"draw_polygon_overlay",
"evaluate_lognormal",
"evaluate_lognormal_derivative",
"extract_color_prompt",
"extract_dark_region_masks",
"extract_dark_region_polygons",
"fill_binary_holes",
"fit_lognormal_curve",
"from_tic_result",
"fuse_prompt_prior_with_medsam",
"keyframe_images",
"largest_contour",
"local_rect_within",
"locate_anchor_frames",
"locate_contrast_side",
"map_source_mask_to_target",
"mask_center",
"mask_iou",
"masks_to_smoothed_polygons",
"mean_or_nan",
"odd_window",
"optional_positive_int",
"pack_tic_curves",
"pack_trajectory",
"polygon_area",
"polygon_center",
"polygon_to_mask",
"polygons_to_mask",
"prepare_segmentation",
"prompt_color_mask",
"quant_summary",
"quantify_case",
"rect_from_values",
"render_case_overlay",
"render_overlay",
"render_tic_png",
"resample_closed_contour",
"sample_frames_for_split",
"score_dark_candidate",
"select_prompt_frames",
"select_reference_region",
"split_dark_components",
"split_views",
"target_polygons_to_source",
"tic_plot_markers",
"triangular_smooth",
"unpack_tic_curves",
"unpack_trajectory",
"view_offset",
"view_rect",
]
+419
View File
@@ -0,0 +1,419 @@
"""第 8 步:把关键帧时刻映射到真实、有效且不重复的视频帧,共 7 个槽位。
- B0、D1、D2、D3、D4:从 `roi_perf`(病灶去暗区)曲线的 Lognormal 拟合上取。B0 是到达时刻再往前
退 `baseline_frame_backoff_s` 秒,只在观测到的基线段里、到达之前取帧;D1 是拟合曲线上的到达
时刻(design 0002 决定 6:不用医生标注的造影剂到达时间);D2、D3、D4 是洗入拐点、峰值、
洗出拐点。病灶两条曲线(`roi_all`、`roi_perf`)的拟合没有都过质量门时,这 5 个槽位全部未定位。
D1–D4 在各自的容差窗口里找帧,并保持时间先后(不会出现 D2 的帧早于 D1 的帧)。
- F1、F2:推注后第 `f1_anchor_s`、`late_phase.preferred_time_s` 秒。`injection_offset_s`(下称 X)
是超声报告表的「推注开始时间s」,含义是视频第 0 秒时已经推注了 X 秒(design 0002 决定 5),
所以推注后第 T 秒在视频第 T − X 秒。落在视频有效帧范围之外(早于第一帧或晚于最后一帧),
这一槽位记为未定位,不拿别的帧补。
每个槽位交出帧号、视频时间、状态和未定位的原因;个别槽位未定位不算这一例失败。
照搬旧项目 `anchor_frames.py`,两处改动(任务书 02「四处要改的地方」第 3、4 条):删掉 D1 取医生
标注时刻的 `excel_video` 模式;推注时间的符号按上面的含义(旧 `excel_video` 模式按「推注发生在
视频第 X 秒」算成了 T + X)。
"""
import math
from dataclasses import dataclass
import numpy as np
from cadx.ceus._config import AnchorFramesConfig
from cadx.ceus._geometry import AnyArray
from cadx.ceus._tic_fitting import LognormalFitResult
ANCHOR_SLOTS = ("B0", "D1", "D2", "D3", "D4", "F1", "F2")
STATUS_OK = "ok"
STATUS_ANCHOR_UNDEFINED = "anchor_undefined"
STATUS_NO_FRAME_IN_WINDOW = "no_frame_in_window"
STATUS_NO_ORDERED_FRAME = "no_ordered_frame"
STATUS_NOT_COVERED = "not_covered"
@dataclass(frozen=True)
class AnchorFrameLocation:
"""一个关键帧槽位的定位结果。
- `frame_index`、`frame_time_video_s`:定位到的帧号和它的视频时间,未定位为 None;
- `time_s`:这一帧在推注时间轴上的时刻(视频时间 + X),没有推注时间或未定位为 None;
- `status`:`ok` 或未定位的类别(`anchor_undefined`、`not_covered`、`no_frame_in_window`、
`no_ordered_frame`);`reason`:未定位的原因(中文),定位到时为 None;
- `anchor_time_video_s`:要找的那个时刻(视频时间),没有时为 None;
- `time_error_s`:帧时间减锚点时刻;`source_curve`:时刻来自哪条曲线或推注时钟。
"""
slot: str
frame_index: int | None
time_s: float | None
status: str
reason: str | None
anchor_time_video_s: float | None
frame_time_video_s: float | None
time_error_s: float | None
source_curve: str | None
def _finite_offset(injection_offset_s: float | None) -> float | None:
"""推注时间 X:None 表示没有;有就必须是有限非负秒数,否则报错。"""
if injection_offset_s is None:
return None
parsed = float(injection_offset_s)
if not math.isfinite(parsed) or parsed < 0.0:
raise ValueError(f"injection_offset_s 必须是有限非负秒数,收到 {injection_offset_s}")
return parsed
def _validate_axis(frame_indices: AnyArray, time_video_s: AnyArray) -> tuple[AnyArray, AnyArray]:
"""帧号与视频时间必须等长、非空、严格递增。"""
indices = np.asarray(frame_indices, dtype=np.int32)
times = np.asarray(time_video_s, dtype=np.float64)
if indices.ndim != 1 or times.shape != indices.shape or indices.size == 0:
raise ValueError("frame_indices 与 time_video_s 必须是等长非空一维数组")
if np.any(np.diff(indices) <= 0):
raise ValueError("frame_indices 必须严格递增")
if not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0):
raise ValueError("time_video_s 必须有限且严格递增")
return indices, times
def _nearest_frame(
anchor_time_s: float,
window_before_s: float,
window_after_s: float,
frame_indices: AnyArray,
time_video_s: AnyArray,
eligible_mask: AnyArray,
) -> int | None:
"""窗口 `[锚点 − before, 锚点 + after]` 内离锚点最近的可用帧;距离相同取更早的。"""
eligible = (
np.asarray(eligible_mask, dtype=bool)
& (time_video_s >= anchor_time_s - window_before_s)
& (time_video_s <= anchor_time_s + window_after_s)
)
positions = np.flatnonzero(eligible)
if positions.size == 0:
return None
distances = np.abs(time_video_s[positions] - anchor_time_s)
order = np.lexsort((time_video_s[positions], distances))
return int(frame_indices[positions[int(order[0])]])
type _State = tuple[int, float, tuple[int | None, ...]]
def _ordered_anchor_frames(
anchor_specs: tuple[tuple[str, float, float, float], ...],
frame_indices: AnyArray,
time_video_s: AnyArray,
eligible_mask: AnyArray,
) -> dict[str, int]:
"""给 D1–D4 选帧:先保留最多的槽位,再让严格递增映射的总时间误差最小(动态规划)。"""
eligible = np.asarray(eligible_mask, dtype=bool)
states: dict[int, _State] = {-1: (0, 0.0, ())}
for _slot, anchor_time_s, before_s, after_s in anchor_specs:
positions = np.flatnonzero(
eligible
& (time_video_s >= anchor_time_s - before_s)
& (time_video_s <= anchor_time_s + after_s)
)
next_states: dict[int, _State] = {}
for previous, (count, cost, path) in states.items():
skipped: _State = (count, cost, (*path, None))
incumbent = next_states.get(previous)
if incumbent is None or _state_key(skipped) < _state_key(incumbent):
next_states[previous] = skipped
for position_value in positions.tolist():
position = int(position_value)
if position <= previous:
continue
assigned: _State = (
count + 1,
cost + abs(float(time_video_s[position]) - anchor_time_s),
(*path, position),
)
incumbent = next_states.get(position)
if incumbent is None or _state_key(assigned) < _state_key(incumbent):
next_states[position] = assigned
states = next_states
_count, _cost, best_path = min(states.values(), key=_state_key)
return {
spec[0]: int(frame_indices[position])
for spec, position in zip(anchor_specs, best_path, strict=True)
if position is not None
}
def _state_key(state: _State) -> tuple[int, float, tuple[int, ...]]:
"""动态规划的比较键:槽位多者优先,其次总误差小者,再按路径定序。"""
count, cost, path = state
sortable_path = tuple(-1 if position is None else position for position in path)
return -count, cost, sortable_path
def _video_time_for_frame(
frame_index: int | None,
frame_indices: AnyArray,
time_video_s: AnyArray,
) -> float | None:
"""帧号对应的视频时间;帧号必须在时间轴里恰好出现一次。"""
if frame_index is None:
return None
positions = np.flatnonzero(frame_indices == frame_index)
if positions.size != 1:
raise ValueError(f"帧号 {frame_index} 在时间轴中不是唯一值")
return float(time_video_s[int(positions[0])])
def _fit_eligible(fit: LognormalFitResult) -> AnyArray:
"""拟合用到的有效且不重复的样本。"""
return fit.valid_sample_mask & ~fit.duplicate_sample_mask
def _span_text(start_s: float, end_s: float) -> str:
return f"[{start_s:.2f}, {end_s:.2f}] 秒"
def locate_anchor_frames(
*,
perf_fit: LognormalFitResult,
perf_baseline_end_index: int,
lesion_fit_ready: bool,
frame_indices: AnyArray,
time_video_s: AnyArray,
injection_offset_s: float | None,
cfg: AnchorFramesConfig,
) -> dict[str, AnchorFrameLocation]:
"""定位 7 个槽位,按 `ANCHOR_SLOTS` 的顺序交出。
`lesion_fit_ready` 是病灶两条曲线的拟合是否都过了质量门;`injection_offset_s` 是推注时间 X
(秒,视频第 0 秒时已推注的时长),None 时 F1、F2 未定位。
"""
indices, times = _validate_axis(frame_indices, time_video_s)
if perf_fit.valid_sample_mask.shape != times.shape:
raise ValueError("perf_fit 的样本掩膜长度与时间轴不一致")
injection_axis_offset_s = _finite_offset(injection_offset_s)
arrival_time = perf_fit.d1_time_s
locations: dict[str, AnchorFrameLocation] = {}
def record(
slot: str,
frame_index: int | None,
status: str,
reason: str | None,
*,
anchor_time_video_s: float | None,
source_curve: str | None,
) -> None:
frame_time_video_s = _video_time_for_frame(frame_index, indices, times)
locations[slot] = AnchorFrameLocation(
slot=slot,
frame_index=frame_index,
time_s=(
None
if frame_time_video_s is None or injection_axis_offset_s is None
else frame_time_video_s + injection_axis_offset_s
),
status=status,
reason=None if status == STATUS_OK else reason,
anchor_time_video_s=anchor_time_video_s,
frame_time_video_s=frame_time_video_s,
time_error_s=(
None
if frame_time_video_s is None or anchor_time_video_s is None
else frame_time_video_s - anchor_time_video_s
),
source_curve=source_curve,
)
model_ready = lesion_fit_ready and perf_fit.success
if not lesion_fit_ready:
fit_failure = "病灶两条曲线(roi_all、roi_perf)的 Lognormal 拟合没有都过质量门"
elif not perf_fit.success:
fit_failure = f"roi_perf 曲线的 Lognormal 拟合没过质量门({perf_fit.failure_reason})"
else:
fit_failure = ""
perf_eligible = _fit_eligible(perf_fit)
b0_ready = arrival_time is not None and perf_fit.baseline_source == "observed" and model_ready
if b0_ready and arrival_time is not None:
b0_time = float(arrival_time) - cfg.baseline_frame_backoff_s
frame = _nearest_frame(
b0_time,
cfg.b0_window_s,
cfg.b0_window_s,
indices,
times,
perf_eligible
& (np.arange(times.size) < perf_baseline_end_index)
& (times < float(arrival_time)),
)
record(
"B0",
frame,
STATUS_OK if frame is not None else STATUS_NO_FRAME_IN_WINDOW,
f"B0 锚点 {b0_time:.2f} 秒的容差窗口 "
f"{_span_text(b0_time - cfg.b0_window_s, b0_time + cfg.b0_window_s)} 内,"
"观测到的基线段里、到达时刻之前没有可用帧",
anchor_time_video_s=b0_time,
source_curve="roi_perf",
)
else:
if not model_ready:
b0_reason = fit_failure
elif arrival_time is None:
b0_reason = "拟合曲线升不过到达门槛,没有到达时刻"
else:
b0_reason = "视频开头没有观测到稳定的增强前基线(基线是估计的),B0 不定义"
record(
"B0",
None,
STATUS_ANCHOR_UNDEFINED,
b0_reason,
anchor_time_video_s=None,
source_curve="roi_perf",
)
raw_specs = (
("D1", arrival_time, 0.0, cfg.d1_window_after_s),
("D2", perf_fit.d2_time_s, cfg.d2_window_s, cfg.d2_window_s),
("D3", perf_fit.d3_time_s, cfg.d3_window_s, cfg.d3_window_s),
("D4", perf_fit.d4_time_s, cfg.d4_window_s, cfg.d4_window_s),
)
eligible_times = times[perf_eligible]
video_start_s = float(eligible_times[0]) if eligible_times.size else math.inf
video_end_s = float(eligible_times[-1]) if eligible_times.size else -math.inf
covered_specs = tuple(
(slot, float(anchor_time), before, after)
for slot, anchor_time, before, after in raw_specs
if model_ready
and anchor_time is not None
and video_start_s <= float(anchor_time) <= video_end_s
)
dynamic_eligible = perf_eligible
if model_ready:
dynamic_eligible &= times < perf_fit.fit_end_s
ordered = _ordered_anchor_frames(covered_specs, indices, times, dynamic_eligible)
for slot, anchor_time, before, after in raw_specs:
if not model_ready:
record(
slot,
None,
STATUS_ANCHOR_UNDEFINED,
fit_failure,
anchor_time_video_s=anchor_time,
source_curve="roi_perf",
)
elif anchor_time is None:
record(
slot,
None,
STATUS_NOT_COVERED if slot == "D4" else STATUS_ANCHOR_UNDEFINED,
"峰后观测不够长,或洗出拐点落在观测范围之外,拟合曲线上没有 D4"
if slot == "D4"
else f"拟合曲线上没有 {slot} 时刻(升不过到达门槛)",
anchor_time_video_s=None,
source_curve="roi_perf",
)
elif not video_start_s <= float(anchor_time) <= video_end_s:
record(
slot,
None,
STATUS_NOT_COVERED,
f"{slot} 时刻 {float(anchor_time):.2f} 秒落在视频有效帧范围 "
f"{_span_text(video_start_s, video_end_s)} 之外",
anchor_time_video_s=float(anchor_time),
source_curve="roi_perf",
)
elif slot in ordered:
record(
slot,
ordered[slot],
STATUS_OK,
None,
anchor_time_video_s=float(anchor_time),
source_curve="roi_perf",
)
else:
window = _span_text(float(anchor_time) - before, float(anchor_time) + after)
has_candidate = bool(
np.any(
dynamic_eligible
& (times >= float(anchor_time) - before)
& (times <= float(anchor_time) + after)
)
)
record(
slot,
None,
STATUS_NO_ORDERED_FRAME if has_candidate else STATUS_NO_FRAME_IN_WINDOW,
f"{slot} 容差窗口 {window} 内的候选帧都被时间更早的槽位占用,无法保持先后顺序"
if has_candidate
else f"{slot} 容差窗口 {window} 内没有可用帧(拟合区间内、不重复)",
anchor_time_video_s=float(anchor_time),
source_curve="roi_perf",
)
globally_eligible = ~perf_fit.duplicate_sample_mask
for slot, injection_time_s, tolerance_s in (
("F1", cfg.f1_anchor_s, None),
("F2", cfg.late_phase.preferred_time_s, cfg.late_phase.tolerance_s),
):
if injection_axis_offset_s is None:
record(
slot,
None,
STATUS_NOT_COVERED,
"没有推注时间,推注后时刻换算不到视频时间",
anchor_time_video_s=None,
source_curve="injection_clock",
)
continue
video_time_s = injection_time_s - injection_axis_offset_s
fixed_eligible_times = times[globally_eligible]
if (
fixed_eligible_times.size == 0
or video_time_s < float(fixed_eligible_times[0])
or video_time_s > float(fixed_eligible_times[-1])
):
span = (
"没有不重复的帧"
if fixed_eligible_times.size == 0
else f"视频有效帧范围 "
f"{_span_text(float(fixed_eligible_times[0]), float(fixed_eligible_times[-1]))}"
)
record(
slot,
None,
STATUS_NOT_COVERED,
f"推注后第 {injection_time_s:g} 秒在视频第 {video_time_s:.2f} 秒"
f"(推注时间 X={injection_axis_offset_s:g} 秒),落在{span}之外",
anchor_time_video_s=video_time_s,
source_curve="injection_clock",
)
continue
window = float("inf") if tolerance_s is None else tolerance_s
frame = _nearest_frame(
video_time_s,
window,
window,
indices,
times,
globally_eligible,
)
record(
slot,
frame,
STATUS_OK if frame is not None else STATUS_NO_FRAME_IN_WINDOW,
f"视频第 {video_time_s:.2f} 秒 ±{window:g} 秒内没有不重复的帧",
anchor_time_video_s=video_time_s,
source_curve="injection_clock",
)
return {slot: locations[slot] for slot in ANCHOR_SLOTS}
+23
View File
@@ -211,6 +211,10 @@ class InteriorDarkRegionConfig(BaseModel):
threshold_offset: int
min_area: int
min_area_ratio: float
max_regions: int | None
max_contours_per_region: int | None
preferred_area_ratio_range: list[float] | None
prompt_exclude_dilate: int
preserve_holes: bool
fill_holes: bool
morphology_kernel: int
@@ -235,6 +239,25 @@ class InteriorDarkRegionConfig(BaseModel):
raise ValueError(f"interior_dark_region.adaptive_block_size 必须 ≥3,收到 {validated}")
return validated
@field_validator("max_regions", "max_contours_per_region")
@classmethod
def _optional_count_positive(cls, value: int | None) -> int | None:
"""截断个数为 null(不限)或正整数;0 会把暗区全部截掉而不报错。"""
if value is not None and value < 1:
raise ValueError(f"interior_dark_region 的截断个数必须为 null 或 ≥1,收到 {value}")
return value
@field_validator("preferred_area_ratio_range")
@classmethod
def _area_range_pair(cls, value: list[float] | None) -> list[float] | None:
"""偏好面积比区间为 null 或 [lo, hi],0 ≤ lo ≤ hi ≤ 1。"""
if value is not None and not (len(value) == 2 and 0.0 <= value[0] <= value[1] <= 1.0):
raise ValueError(
"interior_dark_region.preferred_area_ratio_range 必须为 null 或 "
f"0≤lo≤hi≤1 的 [lo, hi],收到 {value}"
)
return value
@field_validator("percentile")
@classmethod
def _percentile_within_bounds(cls, value: float) -> float:
+670
View File
@@ -0,0 +1,670 @@
"""第 5 步:在病灶 ROI 内找暗区(不进造影剂、一直发黑的囊液部分)。
哪种阈值法在当前帧最可信事先定不了,所以 `threshold_method="auto_ensemble"` 时对
`candidate_methods` 里的每种阈值法各出一份暗块候选,用统一评分(明暗对比 + 面积分
− 碎片惩罚 + 与上一帧的时间一致性)择优。
- `dark_candidate_mask`:按方法在 ROI 灰度内出候选暗掩膜(otsu / percentile / absolute /
adaptive / kmeans / clahe_otsu);`threshold_offset` 只对前三种生效;末尾过形态学;
- `apply_dark_morphology`:闭、开运算各一次(`morphology_kernel` 方核,≤1 跳过);
`fill_holes` 且不 `preserve_holes` 时填洞;最后与采样掩膜取交;
- `split_dark_components`:连通域拆分,面积门槛 `max(min_area, ROI 面积 × min_area_ratio)`;
`preserve_holes` 时把洞挖回;按面积降序,`max_regions` 截断;
- `score_dark_candidate`:评分(三个常量 64.0 / 4.0 / 2.0);
- `extract_dark_region_masks`:单帧——剔绿框色 → 各候选法择优 → 拆分;
- `extract_dark_region_polygons`:逐帧编排,`enabled=False` 时每帧空列表(仍保留帧对齐);
否则各暗块取显著轮廓、等弧长重采样到 `contour_points` 点。
输入是造影那一半画面(`analysis_view: source`)的逐帧 BGR 与 ROI 多边形,输出同一坐标系的暗区
多边形。照搬旧项目 `dark_region.py`,去掉产品层;旧代码用 `getattr(..., 默认值)` 读的四个旧 schema
里没有的键(`max_regions`、`max_contours_per_region`、`preferred_area_ratio_range`、
`prompt_exclude_dilate`)改成配置字段、值照旧;剔绿用的 HSV 阈值只取 `prompt_source`,
旧代码在没传时回退的写死阈值删掉。
"""
from collections.abc import Iterable, Sequence
from typing import Any, Protocol, cast
import cv2
import numpy as np
from cadx.ceus._geometry import (
AnyArray,
Mask,
mask_iou,
polygon_to_mask,
resample_closed_contour,
)
# 评分公式的三个常量(照参考组件 P:1296 / 1308 / 1310)。
_CONTRAST_NORMALIZER = 64.0 # 明暗对比归一化除数
_AREA_SLOPE = 4.0 # preferred_area_ratio_range 之外面积分的线性衰减斜率
_AREA_SCORE_GAIN = 2.0 # 没有 preferred_area_ratio_range 时面积分的系数
_EMPTY_SCORE = -1e9 # 空候选的哨兵分:择优时永远不会被选中
class _DarkRegionConfigLike(Protocol):
"""暗区配置(`InteriorDarkRegionConfig` 符合)。"""
@property
def enabled(self) -> bool: ...
@property
def exclude_prompt_color(self) -> bool: ...
@property
def prompt_exclude_dilate(self) -> int: ...
@property
def threshold_method(self) -> str: ...
@property
def candidate_methods(self) -> Sequence[str]: ...
@property
def threshold_offset(self) -> int: ...
@property
def min_area(self) -> int: ...
@property
def min_area_ratio(self) -> float: ...
@property
def max_regions(self) -> int | None: ...
@property
def max_contours_per_region(self) -> int | None: ...
@property
def preferred_area_ratio_range(self) -> list[float] | None: ...
@property
def preserve_holes(self) -> bool: ...
@property
def fill_holes(self) -> bool: ...
@property
def morphology_kernel(self) -> int: ...
@property
def fragment_penalty_weight(self) -> float: ...
@property
def temporal_consistency_weight(self) -> float: ...
@property
def contour_points(self) -> int: ...
@property
def percentile(self) -> float: ...
@property
def threshold(self) -> int: ...
@property
def adaptive_block_size(self) -> int: ...
@property
def adaptive_c(self) -> float: ...
@property
def kmeans_max_iter(self) -> int: ...
@property
def kmeans_attempts(self) -> int: ...
@property
def clahe_clip_limit(self) -> float: ...
@property
def clahe_tile_grid_size(self) -> int: ...
class _PromptHsvConfigLike(Protocol):
"""剔绿框色用的 HSV 阈值(`PromptSourceConfig` 符合)。"""
@property
def hsv_lower(self) -> list[int]: ...
@property
def hsv_upper(self) -> list[int]: ...
def fill_binary_holes(mask: AnyArray) -> AnyArray:
"""对二值 mask 填充内部空洞(参考 P:1112-1119)。
参数:
mask: ``(h, w)`` 的二值数组(bool 或非零即真)。
返回:
填洞后的 ``dtype=bool`` mask(外接背景之外的所有像素均置 True)。
关键实现:
从角点 ``(0, 0)`` 泛洪标记"可从外部到达的背景",取反即得"被前景包围的洞",
与原前景按位或合并。要求 ``(0, 0)`` 像素为背景(暗区不会触达画面角点,成立)。
"""
filled = np.asarray(mask, dtype=np.uint8) * np.uint8(255)
flood = filled.copy()
height, width = flood.shape
flood_mask = np.zeros((height + 2, width + 2), dtype=np.uint8)
cv2.floodFill(flood, flood_mask, (0, 0), 255)
holes = np.asarray(cv2.bitwise_not(flood), dtype=np.uint8)
return (filled | holes) > 0
def apply_dark_morphology(
dark: AnyArray,
sample_mask: AnyArray,
dark_cfg: _DarkRegionConfigLike,
) -> AnyArray:
"""对候选暗 mask 做形态学闭 / 开运算并约束回 sample_mask(参考 P:1122-1136)。
参数:
dark: 候选暗 mask(bool)。
sample_mask: 分析采样掩膜(ROI 内、已剔绿的有效区);结果与其取交。
dark_cfg: 暗区配置(取 ``morphology_kernel`` / ``fill_holes`` / ``preserve_holes``)。
返回:
形态学处理后、且**仅落在 sample_mask 内**的 ``dtype=bool`` mask。
关键实现:
方核大小 ``> 1`` 时先 CLOSE(补小缺口)再 OPEN(去毛刺)各一次;
仅当 ``fill_holes=True 且 preserve_holes=False`` 才填洞(保洞优先于填洞);
末尾与 sample_mask 取交,杜绝形态学膨胀外溢 ROI。
"""
kernel_size = int(dark_cfg.morphology_kernel)
if kernel_size > 1:
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
dark_u8 = dark.astype(np.uint8) * 255
dark_u8 = cv2.morphologyEx(dark_u8, cv2.MORPH_CLOSE, kernel, iterations=1)
dark_u8 = cv2.morphologyEx(dark_u8, cv2.MORPH_OPEN, kernel, iterations=1)
dark = dark_u8 > 0
if bool(dark_cfg.fill_holes) and not bool(dark_cfg.preserve_holes):
dark = fill_binary_holes(dark)
return dark & sample_mask
def _otsu_threshold(values: AnyArray) -> float:
"""对一维灰度样本求 Otsu 阈值(参考 P:1150-1155 / 1206-1211)。"""
threshold, _ = cv2.threshold(values.reshape(-1, 1), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
return float(threshold)
def _candidate_otsu(gray: AnyArray, roi_values: AnyArray) -> AnyArray:
"""Otsu 法候选:ROI 灰度 Otsu 阈值,``gray <= 阈值`` 为暗(P:1149-1156)。"""
return gray <= _otsu_threshold(roi_values)
def _candidate_percentile(
gray: AnyArray, roi_values: AnyArray, dark_cfg: _DarkRegionConfigLike
) -> AnyArray:
"""分位法候选:ROI 灰度的 ``percentile`` 分位作阈值(P:1157-1159)。"""
threshold = float(np.percentile(roi_values, float(dark_cfg.percentile)))
return gray <= threshold
def _candidate_absolute(gray: AnyArray, dark_cfg: _DarkRegionConfigLike) -> AnyArray:
"""绝对阈法候选:固定灰度阈 ``threshold``(P:1160-1162)。"""
return gray <= float(dark_cfg.threshold)
def _candidate_adaptive(gray: AnyArray, dark_cfg: _DarkRegionConfigLike) -> AnyArray:
"""自适应阈法候选:高斯邻域自适应 + ``THRESH_BINARY_INV``(P:1163-1176)。
block_size 强制奇数且 ``>= 3``(偶数加 1);inverse 二值化使暗部为前景。
"""
block_size = int(dark_cfg.adaptive_block_size)
if block_size % 2 == 0:
block_size += 1
block_size = max(3, block_size)
adaptive = cv2.adaptiveThreshold(
gray,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,
block_size,
float(dark_cfg.adaptive_c),
)
return adaptive > 0
def _candidate_kmeans(
sample_mask: AnyArray, roi_values: AnyArray, dark_cfg: _DarkRegionConfigLike
) -> AnyArray:
"""K-means 二聚类候选:ROI 灰度聚成两簇,低均值簇为暗(P:1177-1196)。
EPS=0.5 硬编码(与参考一致);样本不足 2 个时返回全 False。结果仅写回 sample_mask 内。
"""
values = roi_values.reshape(-1, 1).astype(np.float32)
if len(values) < 2:
return np.zeros_like(sample_mask, dtype=bool)
criteria = (
cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
int(dark_cfg.kmeans_max_iter),
0.5,
)
# cv2 的类型存根不接受 bestLabels=None(运行时接受,表示不给初始标签)。
kmeans = cast(Any, cv2.kmeans)
_, labels, centers = cast(
tuple[float, AnyArray, AnyArray],
kmeans(
values,
2,
None,
criteria,
int(dark_cfg.kmeans_attempts),
cv2.KMEANS_PP_CENTERS,
),
)
dark_label = int(np.argmin(centers.reshape(-1)))
dark = np.zeros_like(sample_mask, dtype=bool)
dark[sample_mask] = labels.reshape(-1) == dark_label
return dark
def _candidate_clahe_otsu(
gray: AnyArray, sample_mask: AnyArray, dark_cfg: _DarkRegionConfigLike
) -> AnyArray:
"""CLAHE 增强后 Otsu 候选:先局部直方图均衡再在 ROI 内 Otsu(P:1197-1212)。"""
clahe = cv2.createCLAHE(
clipLimit=float(dark_cfg.clahe_clip_limit),
tileGridSize=(
int(dark_cfg.clahe_tile_grid_size),
int(dark_cfg.clahe_tile_grid_size),
),
)
enhanced = clahe.apply(gray)
threshold = _otsu_threshold(enhanced[sample_mask])
return enhanced <= threshold
def _apply_threshold_offset(
dark: AnyArray,
gray: AnyArray,
sample_mask: AnyArray,
method: str,
dark_cfg: _DarkRegionConfigLike,
) -> AnyArray:
"""对 otsu / percentile / absolute 三法施加 ``threshold_offset``(参考 P:1216-1233)。
其余方法(adaptive / kmeans / clahe_otsu)不支持偏移,原样返回。偏移为 0 时也原样返回。
重新以"基准阈值 + 偏移"二值化(不是在已二值化结果上平移),与参考严格等价。
"""
threshold_offset = float(dark_cfg.threshold_offset)
if threshold_offset == 0 or method not in {"otsu", "percentile", "absolute"}:
return dark
base_values = gray[sample_mask]
if method == "otsu":
return gray <= (_otsu_threshold(base_values) + threshold_offset)
if method == "percentile":
base = float(np.percentile(base_values, float(dark_cfg.percentile)))
return gray <= (base + threshold_offset)
# absolute
return gray <= (float(dark_cfg.threshold) + threshold_offset)
def dark_candidate_mask(
gray: AnyArray,
sample_mask: AnyArray,
method: str,
dark_cfg: _DarkRegionConfigLike,
) -> AnyArray:
"""按指定阈值法在 ROI 灰度内出候选暗 mask(参考 P:1139-1235)。
参数:
gray: ``(h, w)`` 单通道 uint8 灰度图(整帧)。
sample_mask: 分析采样掩膜(ROI 内、已剔绿);阈值统计只在其内取灰度。
method: 阈值法名(otsu / percentile / absolute / adaptive / kmeans / clahe_otsu)。
dark_cfg: 暗区配置(提供各法子参与 ``threshold_offset``)。
返回:
过形态学并约束回 sample_mask 后的 ``dtype=bool`` 暗 mask;
sample_mask 为空时返回全 False。
关键实现:
各法分派到独立 helper(控复杂度);除 adaptive/clahe 在全图取阈外,统计量
均取自 ``gray[sample_mask]``。``threshold_offset`` 仅对 otsu/percentile/absolute
生效(见 ``_apply_threshold_offset``)。末尾 ``(dark & sample_mask)`` 再过
``apply_dark_morphology``(其内会再次与 sample_mask 取交)。未知方法直接报错(不兜底)。
"""
roi_values = gray[sample_mask]
if len(roi_values) == 0:
return np.zeros_like(sample_mask, dtype=bool)
if method == "otsu":
dark = _candidate_otsu(gray, roi_values)
elif method == "percentile":
dark = _candidate_percentile(gray, roi_values, dark_cfg)
elif method == "absolute":
dark = _candidate_absolute(gray, dark_cfg)
elif method == "adaptive":
dark = _candidate_adaptive(gray, dark_cfg)
elif method == "kmeans":
dark = _candidate_kmeans(sample_mask, roi_values, dark_cfg)
elif method == "clahe_otsu":
dark = _candidate_clahe_otsu(gray, sample_mask, dark_cfg)
else:
raise RuntimeError(f"不支持的 interior_dark_region.threshold_method: {method}")
dark = _apply_threshold_offset(dark, gray, sample_mask, method, dark_cfg)
return apply_dark_morphology(dark & sample_mask, sample_mask, dark_cfg)
def split_dark_components(
dark: AnyArray,
roi_mask: AnyArray,
dark_cfg: _DarkRegionConfigLike,
) -> list[AnyArray]:
"""把候选暗 mask 拆成达标连通域列表(参考 P:1238-1274)。
参数:
dark: 候选暗 mask(bool)。
roi_mask: ROI 掩膜(**未剔绿的原始 ROI**,用于面积阈与组件裁剪)。
dark_cfg: 暗区配置(``min_area`` / ``min_area_ratio`` / ``preserve_holes`` /
``max_regions``)。
返回:
按面积降序排列的连通域 ``dtype=bool`` mask 列表;
每个面积须 ``>= max(min_area, ROI面积×min_area_ratio)``;``max_regions`` 截断。
关键实现:
组件阈 ``max(min_area, roi_mask.sum()×min_area_ratio)``(``min_area_ratio>0`` 才生效)。
``findContours(RETR_CCOMP)`` 取外 / 内两层;仅遍历外轮廓(``hierarchy[3]<0``),
实心填充后——若 ``preserve_holes``——再把其子轮廓(洞)挖回 0,最后与 roi_mask 取交。
``max_regions`` 为 None 时不限。
"""
min_area = float(dark_cfg.min_area)
min_area_ratio = float(dark_cfg.min_area_ratio)
if min_area_ratio > 0:
min_area = max(min_area, float(roi_mask.sum()) * min_area_ratio)
contours, hierarchy = cv2.findContours(
dark.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_SIMPLE
)
# 没有轮廓时 cv2 交回的 hierarchy 是 None。
if not contours:
return []
hierarchy = np.asarray(hierarchy)[0]
preserve_holes = bool(dark_cfg.preserve_holes)
components: list[tuple[int, AnyArray]] = []
for idx, _contour in enumerate(contours):
if hierarchy[idx][3] >= 0:
continue
out = np.zeros_like(dark, dtype=np.uint8)
cv2.drawContours(out, contours, idx, 255, thickness=-1)
if preserve_holes:
child = int(hierarchy[idx][2])
while child >= 0:
cv2.drawContours(out, contours, child, 0, thickness=-1)
child = int(hierarchy[child][0])
component = (out > 0) & roi_mask
area = int(component.sum())
if area >= min_area:
components.append((area, component))
components.sort(key=lambda item: item[0], reverse=True)
max_regions = dark_cfg.max_regions
selected = components if max_regions is None else components[:max_regions]
return [component for _, component in selected]
def _area_score(area_ratio: float, dark_cfg: _DarkRegionConfigLike) -> float:
"""面积分项(参考 P:1298-1310,含斜率 4.0 / 系数 2.0 两个硬编码常量)。
无 ``preferred_area_ratio_range`` 时 ``min(r, 1−r) × 2.0``(偏好"占一半"的暗块);
给定区间则命中得 1.0,越界按 ``max(0, 1 − 与最近边距×4.0)`` 线性衰减。
"""
preferred_range = dark_cfg.preferred_area_ratio_range
if preferred_range is None:
return min(area_ratio, 1.0 - area_ratio) * _AREA_SCORE_GAIN
preferred_low = float(preferred_range[0])
preferred_high = float(preferred_range[1])
if preferred_low <= area_ratio <= preferred_high:
return 1.0
nearest = preferred_low if area_ratio < preferred_low else preferred_high
return max(0.0, 1.0 - abs(area_ratio - nearest) * _AREA_SLOPE)
def score_dark_candidate(
gray: AnyArray,
sample_mask: AnyArray,
component_masks: list[AnyArray],
previous_mask: AnyArray | None,
dark_cfg: _DarkRegionConfigLike,
) -> float:
"""对一份候选暗块集打分,用于 auto_ensemble 择优(参考 P:1277-1319)。
参数:
gray: 整帧灰度图。
sample_mask: 分析采样掩膜(ROI 内、已剔绿)。
component_masks: 候选暗块 mask 列表(``split_dark_components`` 的输出)。
previous_mask: 上一帧选中暗部并集(无则 None),用于时间一致性。
dark_cfg: 暗区配置(``fragment_penalty_weight`` / ``temporal_consistency_weight``
/ 可选 ``preferred_area_ratio_range``)。
返回:
``对比分 + 面积分 + 时间一致性×权重 − 碎片惩罚``;空候选 / 暗或亮像素为空
时返回哨兵 ``-1e9``(择优时永不胜出)。
关键实现(四项逐行核对参考):
- 对比分:``max(0, (median(亮) − median(暗)) / 64.0)``(除数 64.0 硬编码)。
- 面积分:见 ``_area_score``(斜率 4.0 / 系数 2.0)。
- 碎片惩罚:``fragment_penalty_weight × max(0, 块数 − 1)``(块数 ≤1 不罚)。
- 时间一致性:仅当 ``previous_mask`` 同形且非空,取 ``IoU(union, previous_mask)``
再乘 ``temporal_consistency_weight``。
"""
if not component_masks:
return _EMPTY_SCORE
union = np.zeros_like(sample_mask, dtype=bool)
for component in component_masks:
union |= component
dark_values = gray[union & sample_mask]
bright_values = gray[(~union) & sample_mask]
if len(dark_values) == 0 or len(bright_values) == 0:
return _EMPTY_SCORE
contrast_score = max(
0.0,
(float(np.median(bright_values)) - float(np.median(dark_values))) / _CONTRAST_NORMALIZER,
)
area_ratio = float(union.sum()) / max(1.0, float(sample_mask.sum()))
area_score = _area_score(area_ratio, dark_cfg)
fragment_penalty = float(dark_cfg.fragment_penalty_weight) * max(0, len(component_masks) - 1)
temporal_score = 0.0
if previous_mask is not None and previous_mask.shape == union.shape and previous_mask.any():
temporal_score = mask_iou(union, previous_mask)
temporal_weight = float(dark_cfg.temporal_consistency_weight)
return contrast_score + area_score + temporal_score * temporal_weight - fragment_penalty
def _prompt_exclusion_mask(
target_bgr: AnyArray,
dark_cfg: _DarkRegionConfigLike,
prompt_cfg: _PromptHsvConfigLike,
) -> Mask:
"""要从分析中剔除的绿框色像素(参考 P:1334-1345)。
HSV 阈值取 `prompt_cfg`(与找绿框共用同一组阈值);`prompt_exclude_dilate` 大于 1 时按方核
膨胀一次。
"""
lower = np.asarray(prompt_cfg.hsv_lower, dtype=np.uint8)
upper = np.asarray(prompt_cfg.hsv_upper, dtype=np.uint8)
hsv = cv2.cvtColor(target_bgr, cv2.COLOR_BGR2HSV)
prompt_pixels: Mask = np.asarray(cv2.inRange(hsv, lower, upper)) > 0
kernel_size = int(dark_cfg.prompt_exclude_dilate)
if kernel_size > 1:
kernel = np.ones((kernel_size, kernel_size), dtype=np.uint8)
prompt_pixels = cv2.dilate(prompt_pixels.astype(np.uint8) * 255, kernel, iterations=1) > 0
return prompt_pixels
def extract_dark_region_masks(
target_bgr: AnyArray,
roi_polygon: AnyArray,
dark_cfg: _DarkRegionConfigLike,
previous_mask: AnyArray | None,
prompt_cfg: _PromptHsvConfigLike,
) -> tuple[list[AnyArray], AnyArray | None]:
"""单帧暗区提取:剔绿 → 候选法(auto_ensemble 择优)→ 拆分(参考 P:1322-1373)。
参数:
target_bgr: 分析坐标系下的整帧 BGR uint8 图(``analysis_view='source'`` 即左视图局部)。
roi_polygon: 该帧 ROI 多边形 ``(N, 2)``(分析坐标系)。
dark_cfg: 暗区配置。
previous_mask: 上一帧选中暗部并集(供时间一致性评分),首帧传 None。
prompt_cfg: 剔绿用 HSV 阈值(``exclude_prompt_color=True`` 时使用)。
返回:
``(选中暗块 mask 列表, 暗块并集 mask)``;无有效 ROI / 无暗块时返回 ``([], None)``。
关键实现:
``sample_mask`` 由 ROI mask 剔除绿框像素得到(评分统计在其内),但
``split_dark_components`` 的面积阈与裁剪仍用**原始 roi_mask**(与参考 P:1363 一致)。
``threshold_method='auto_ensemble'`` 时遍历 ``candidate_methods`` 各出候选 → 拆分 →
``score_dark_candidate`` 取最高分者;否则单法。返回的并集供下一帧 previous_mask 用。
"""
roi_mask = polygon_to_mask(roi_polygon, target_bgr.shape[0], target_bgr.shape[1])
if not roi_mask.any():
return [], None
gray = cv2.cvtColor(target_bgr, cv2.COLOR_BGR2GRAY)
sample_mask = roi_mask.copy()
if bool(dark_cfg.exclude_prompt_color):
sample_mask &= ~_prompt_exclusion_mask(target_bgr, dark_cfg, prompt_cfg)
if len(gray[sample_mask]) == 0:
return [], None
method = dark_cfg.threshold_method
candidate_methods = list(dark_cfg.candidate_methods) if method == "auto_ensemble" else [method]
components, union = _select_best_candidate(
gray, sample_mask, roi_mask, candidate_methods, previous_mask, dark_cfg
)
if not components:
return [], None
return components, union
def _select_best_candidate(
gray: AnyArray,
sample_mask: AnyArray,
roi_mask: AnyArray,
candidate_methods: list[str],
previous_mask: AnyArray | None,
dark_cfg: _DarkRegionConfigLike,
) -> tuple[list[AnyArray], AnyArray | None]:
"""对每个候选阈值法出暗块 + 评分,取最高分者(参考 P:1360-1373)。
参数:
gray: 整帧灰度图。
sample_mask: 分析采样掩膜(ROI 内、已剔绿)。
roi_mask: 原始 ROI 掩膜(供 ``split_dark_components`` 面积阈与裁剪)。
candidate_methods: 待评估的阈值法名列表(auto_ensemble 即全部,否则单元素)。
previous_mask: 上一帧选中暗部并集(时间一致性评分用)。
dark_cfg: 暗区配置。
返回:
``(最优候选的暗块列表, 其并集 mask)``;无任何候选(列表空)时并集为 None。
关键实现:
逐候选 ``dark_candidate_mask → split_dark_components → score_dark_candidate``,
``score`` 严格大于现任最优才更换(首个候选无条件入选),并随即缓存其并集,
避免对落选候选白算并集。空候选的评分哨兵 ``-1e9`` 保证其永不胜出。
"""
best: tuple[float, list[AnyArray], AnyArray] | None = None
for candidate_method in candidate_methods:
dark = dark_candidate_mask(gray, sample_mask, candidate_method, dark_cfg)
components = split_dark_components(dark, roi_mask, dark_cfg)
score = score_dark_candidate(gray, sample_mask, components, previous_mask, dark_cfg)
if best is None or score > best[0]:
union = np.zeros_like(sample_mask, dtype=bool)
for component in components:
union |= component
best = (score, components, union)
if best is None:
return [], None
return best[1], best[2]
def extract_dark_region_polygons(
analysis_frames: Iterable[AnyArray],
roi_polygons: Sequence[AnyArray],
dark_cfg: _DarkRegionConfigLike,
prompt_cfg: _PromptHsvConfigLike,
) -> list[list[AnyArray]]:
"""逐帧暗区多边形提取编排(参考 P:1376-1466,按本项目可测性裁剪视图映射)。
参数:
analysis_frames: 分析坐标系下的逐帧整帧 BGR uint8 可迭代对象(``analysis_view='source'``
即造影那一半的局部坐标;调用方已解码并切好)。**逐帧消费、不留**,
所以可以直接喂一个解码器的迭代器。
roi_polygons: 与帧序列等长的逐帧 ROI 多边形(同一坐标系)。
dark_cfg: 暗区配置。
prompt_cfg: 剔绿用 HSV 阈值(透传给 ``extract_dark_region_masks``)。
返回:
与帧序列**等长**的逐帧暗部多边形列表(外层帧对齐,内层变长 0~N 个):
每个多边形为 ``(contour_points, 2)`` float32(等弧长重采样)。
``enabled=False`` 时每帧返回空列表 ``[]``(**仍保留帧对齐**,N 帧进 N 帧出)。
关键实现:
逐帧 ``extract_dark_region_masks`` 取选中暗块,其并集作为下一帧时间一致性的
``previous_mask`` 串起来;对每个暗块用 ``significant_contours``(轮廓面积阈
``min_area×0.1``,与参考 P:1437 一致)取显著外轮廓,再
``resample_closed_contour`` 重采样到 ``contour_points`` 点,重采样退化(None)则跳过。
``max_contours_per_region`` 为 None 时不限。
校验帧数与多边形数一致,越界即配置缺陷直接报错(不静默对齐)——流式之后这条校验
由 ``zip(..., strict=True)`` 执行,因为帧数要遍历完才知道。
"""
if not bool(dark_cfg.enabled):
# 关掉也要保持帧对齐(N 帧进 N 帧出),所以仍旧把帧走一遍,按帧数产空列表。
return [[] for _, _ in zip(analysis_frames, roi_polygons, strict=True)]
num_points = int(dark_cfg.contour_points)
contour_area_floor = float(dark_cfg.min_area) * 0.1
max_contours = dark_cfg.max_contours_per_region
dark_polygons_by_frame: list[list[AnyArray]] = []
previous_mask: AnyArray | None = None
for frame, polygon in zip(analysis_frames, roi_polygons, strict=True):
dark_masks, selected_union = extract_dark_region_masks(
frame, polygon, dark_cfg, previous_mask, prompt_cfg
)
previous_mask = selected_union
frame_polygons: list[AnyArray] = []
for dark_mask in dark_masks:
for contour in _significant_contours(dark_mask, contour_area_floor, max_contours):
poly = resample_closed_contour(contour, num_points)
if poly is not None:
frame_polygons.append(poly)
dark_polygons_by_frame.append(frame_polygons)
return dark_polygons_by_frame
def _significant_contours(
mask: AnyArray,
min_area: float,
max_regions: int | None,
) -> list[AnyArray]:
"""取 mask 的显著外轮廓(面积降序,``min_area`` 过滤,可截断;参考 P:665-690)。
参数:
mask: 单个暗块的 ``dtype=bool`` mask。
min_area: 轮廓面积下限(``cv2.contourArea``)。
max_regions: 保留的外轮廓数上限(None 不限)。
返回:
显著轮廓点列表(每个 ``(M, 2)`` float32,OpenCV ``CHAIN_APPROX_NONE`` 原始稠密点)。
含外轮廓与其下的子轮廓(洞)——外轮廓阈 ``min_area``、子轮廓阈 ``min_area×0.1``。
关键实现(严格对照参考 ``significant_contours`` P:665-690):
``RETR_CCOMP`` 取外 / 内两层;外轮廓(``hierarchy[3]<0``)面积 ``>= min_area``,
按面积降序、``max_regions`` 截断;对每个入选外轮廓,沿 ``hierarchy`` 的
first_child(``[idx][2]``)+ next_sibling(``[child][0]``)遍历其洞,面积
``>= min_area×0.1`` 的子轮廓一并追加。``split_dark_components`` 已按
``preserve_holes`` 决定 mask 是否含洞,故洞是否出现由 mask 自然决定。
"""
contours, hierarchy = cv2.findContours(
mask.astype(np.uint8), cv2.RETR_CCOMP, cv2.CHAIN_APPROX_NONE
)
# 没有轮廓时 cv2 交回的 hierarchy 是 None。
if not contours:
return []
hierarchy = np.asarray(hierarchy)[0]
external = [
(cv2.contourArea(contour), idx, contour)
for idx, contour in enumerate(contours)
if hierarchy[idx][3] < 0 and cv2.contourArea(contour) >= min_area
]
external.sort(key=lambda item: item[0], reverse=True)
selected_external = external if max_regions is None else external[:max_regions]
selected: list[AnyArray] = []
for _, idx, contour in selected_external:
selected.append(np.asarray(contour).reshape(-1, 2).astype(np.float32))
child = int(hierarchy[idx][2])
while child >= 0:
child_contour = np.asarray(contours[child])
if cv2.contourArea(child_contour) >= min_area * 0.1:
selected.append(child_contour.reshape(-1, 2).astype(np.float32))
child = int(hierarchy[child][0])
return selected
+322
View File
@@ -0,0 +1,322 @@
"""overlay.mp4:把病灶轮廓、暗区、背景圆叠回整幅画面逐帧渲染,供人目视检查。
各视图局部坐标的多边形加上所属视图的左上角偏移,画到整帧坐标上。颜色(BGR)和线宽照参考组件:
造影那一半(source)的 ROI 黄 (0,255,255)、分割那一半(target)的 ROI 绿 (0,255,0)、暗区橙红
(255,80,20);背景圆画青 (255,255,0) 而不照参考组件画紫,理由见 `_BACKGROUND_CIRCLE_CYAN`。
ROI 半透明填充 alpha=0.18;线条是黑色外描边加彩色内线。
`render_overlay` 接收已解码的逐帧整帧图像(可以是迭代器)和逐帧图层,返回 mp4 字节:
`cv2.VideoWriter` 不能直写内存,所以写临时文件、读回、在 finally 里删掉。fps 由调用方给,非正报错,
不回退 25。
照搬旧项目 `overlay.py`,去掉产品层;`OverlayConfig`、`OverlayLayers` 的字段默认值去掉,调用方全部
显式给出。
"""
import tempfile
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import cv2
import numpy as np
from cadx.ceus._geometry import AnyArray, ViewRect
# 各层颜色(BGR,照旧项目 findings §3.10 / 参考组件 P:1469-1544)。
_OUTLINE_BLACK = (0, 0, 0)
_SOURCE_ROI_YELLOW = (0, 255, 255)
_TARGET_ROI_GREEN = (0, 255, 0)
_DARK_ORANGE_RED = (255, 80, 20)
_BACKGROUND_CIRCLE_CYAN = (255, 255, 0)
"""自动挑的那个背景圆画成青色。**不能是紫**——紫色是医生画在同一画面上的 ``ROI_pan``
轮廓(邻近正常胰腺实质,也就是病变旁边那块没病的胰腺腺体组织)的颜色,而这个圆是程序
自己找的一小块干净背景,两者是不同的东西(旧项目 design/0030 决策四)。参考实现
把它画成品红 (255,0,255),而紫色轮廓已经在算了(``tic.py`` 的 ``CURVE_NAMES`` 四条里就有
``roi_pan``),两圈都是紫的话叠加在视频上人眼分不出哪个是参照区、哪个是医生画的——
而**看 overlay 是第一步唯一的人工验收手段**,分不出就等于没验。
青色和绿框、紫色轮廓在色相上都隔得开。"""
# ROI 半透明填充透明度(P:1500 硬编码 0.18)。
_FILL_ALPHA = 0.18
@dataclass(frozen=True)
class OverlayConfig:
"""各画层开关与填充:画不画造影那一半的 ROI(黄)、分割那一半的 ROI(绿)、分割那一半的暗区、
造影那一半的暗区、背景圆(青),以及要不要对分割那一半的 ROI 做 alpha=0.18 半透明填充。"""
draw_source_roi: bool
draw_target_polygon: bool
draw_dark_region: bool
draw_source_dark_region: bool
draw_reference_region: bool
fill_polygon: bool
@dataclass(frozen=True)
class OverlayLayers:
"""一帧要画的各层几何(多边形都是各自视图的局部坐标)。
- `target_polygon`、`target_rect`:分割那一半的 ROI 和它在整帧中的矩形;
- `dark_polygons`:分割那一半的暗区多边形(可为空列表);
- `source_roi_polygon`、`source_rect`:造影那一半的 ROI 和矩形(不画时可为 None);
- `source_dark_polygons`:造影那一半的暗区多边形(可为空列表);
- `reference_region`:背景圆元数据(`enabled`、`center_xy`、`radius_px`,圆心是造影那一半的
局部坐标),没有时为 None。
"""
target_polygon: AnyArray
target_rect: ViewRect
dark_polygons: list[AnyArray]
source_roi_polygon: AnyArray | None
source_rect: ViewRect | None
source_dark_polygons: list[AnyArray]
reference_region: dict[str, Any] | None
def _to_global_points(polygon: AnyArray, rect: ViewRect) -> AnyArray:
"""把视图局部坐标多边形抬到整帧坐标,并量化为 cv2 折线点 ``(M, 1, 2)`` int32。
参数:
polygon: 形如 ``(N, 2)`` 的局部坐标顶点。
rect: 该多边形所属视图在整帧中的矩形(提供左上角偏移)。
返回:
``cv2.polylines`` / ``cv2.fillPoly`` 直接可用的 ``(N, 1, 2)`` int32 点阵。
关键实现:
加 ``rect.x1`` / ``rect.y1`` 偏移后四舍五入取整(参考 P:1487-1489),先 copy
避免就地污染入参多边形。
"""
global_poly = polygon.copy().astype(np.float64)
global_poly[:, 0] += rect.x1
global_poly[:, 1] += rect.y1
return np.round(global_poly).astype(np.int32).reshape(-1, 1, 2)
def _draw_outlined_polygon(
frame: AnyArray,
points: AnyArray,
color: tuple[int, int, int],
outline_thickness: int,
color_thickness: int,
) -> None:
"""就地画"黑色外描边 + 彩色内线"的闭合多边形(参考 P:1490-1491 等的双描边手法)。
参数:
frame: 待绘制的整帧(就地修改)。
points: ``(N, 1, 2)`` int32 折线点阵(整帧坐标)。
color: 彩色内线 BGR。
outline_thickness: 黑色外描边线宽(> 彩色线宽,作描边效果)。
color_thickness: 彩色内线线宽。
"""
cv2.polylines(
frame,
[points],
isClosed=True,
color=_OUTLINE_BLACK,
thickness=outline_thickness,
)
cv2.polylines(frame, [points], isClosed=True, color=color, thickness=color_thickness)
def _fill_target_polygon(frame: AnyArray, points: AnyArray) -> AnyArray:
"""对 target ROI 内部做 alpha=0.18 绿色半透明填充(参考 P:1497-1500)。
参数:
frame: 当前帧。
points: target ROI 整帧坐标折线点阵。
返回:
混合后的新帧(``addWeighted(fill, 0.18, frame, 0.82, 0)``)。
关键实现:
在帧副本上实心填充绿色后与原帧按 0.18 / 0.82 加权混合,得到半透明效果。
"""
fill = frame.copy()
cv2.fillPoly(fill, [points], _TARGET_ROI_GREEN)
return cv2.addWeighted(fill, _FILL_ALPHA, frame, 1.0 - _FILL_ALPHA, 0)
def _draw_source_roi_layer(frame: AnyArray, layers: OverlayLayers) -> None:
"""画左 / source ROI 轮廓(黄,线宽 5→2,参考 P:1481-1491)。需 source_rect。"""
if layers.source_roi_polygon is None or layers.source_rect is None:
return
points = _to_global_points(layers.source_roi_polygon, layers.source_rect)
_draw_outlined_polygon(frame, points, _SOURCE_ROI_YELLOW, 5, 2)
def _draw_dark_layer(
frame: AnyArray,
polygons: Sequence[AnyArray],
rect: ViewRect,
outline_thickness: int,
color_thickness: int,
) -> None:
"""画一组暗区轮廓(橙红,参考 P:1504-1526);逐多边形抬到整帧再双描边。
参数:
frame: 当前帧(就地修改)。
polygons: 暗区局部坐标多边形列表。
rect: 这些暗区所属视图矩形(target 或 source)。
outline_thickness: 黑色外描边线宽(target 6 / source 5,参考 P:1510/1525)。
color_thickness: 橙红内线线宽(target 3 / source 2,参考 P:1511/1526)。
"""
for polygon in polygons:
points = _to_global_points(polygon, rect)
_draw_outlined_polygon(frame, points, _DARK_ORANGE_RED, outline_thickness, color_thickness)
def _draw_reference_layer(frame: AnyArray, layers: OverlayLayers) -> None:
"""画 source 视图那个自动挑的背景圆(青,参考 P:1528-1543 画的是紫)。
关键实现:
仅当 ``reference_region.enabled`` 且 ``source_rect`` / ``center_xy`` /
``radius_px`` 齐备时绘制;圆心由 source 局部坐标加 ``source_rect`` 偏移抬到整帧。
黑色外描边线宽 6、青色内线线宽 3(参考 P:1542-1543)。
"""
region = layers.reference_region
if region is None or layers.source_rect is None or not region.get("enabled", False):
return
center = region.get("center_xy")
radius = region.get("radius_px")
if center is None or radius is None:
return
center_global = (
round(float(center[0]) + layers.source_rect.x1),
round(float(center[1]) + layers.source_rect.y1),
)
radius_int = round(float(radius))
cv2.circle(frame, center_global, radius_int, color=_OUTLINE_BLACK, thickness=6)
cv2.circle(frame, center_global, radius_int, color=_BACKGROUND_CIRCLE_CYAN, thickness=3)
def draw_polygon_overlay(
frame: AnyArray,
layers: OverlayLayers,
cfg: OverlayConfig,
) -> AnyArray:
"""在整帧坐标系上叠加 ROI / 暗区 / reference 各层,返回画好的新帧(不改入参)。
参数:
frame: 整帧 BGR 图像 ``(H, W, 3)``。
layers: 单帧各层几何(局部坐标多边形 + 视图矩形)。
cfg: 各画层开关与填充配置。
返回:
叠加后的整帧副本(尺寸 / dtype 与入参一致)。
关键实现:
先 copy 帧避免污染入参;按参考 P:1469-1544 的层序绘制——source ROI(黄)→
target ROI 填充 + 轮廓(绿)→ target 暗区(橙红)→ source 暗区(橙红)→
背景圆(青)。各层的局部→整帧坐标变换在 ``_to_global_points`` 内统一完成。
填充层(``fill_polygon``)须先于轮廓绘制,否则填充会盖住轮廓。
"""
out = frame.copy()
# Phase 1: source ROI 轮廓(黄)。
if cfg.draw_source_roi:
_draw_source_roi_layer(out, layers)
# Phase 2: target ROI——先半透明填充,再画绿色轮廓(主产物)。
target_points = _to_global_points(layers.target_polygon, layers.target_rect)
if cfg.fill_polygon:
out = _fill_target_polygon(out, target_points)
if cfg.draw_target_polygon:
_draw_outlined_polygon(out, target_points, _TARGET_ROI_GREEN, 7, 3)
# Phase 3: target 暗区轮廓(橙红)。
if cfg.draw_dark_region and layers.dark_polygons:
_draw_dark_layer(out, layers.dark_polygons, layers.target_rect, 6, 3)
# Phase 4: source 暗区轮廓(橙红)。
if (
cfg.draw_source_dark_region
and layers.source_dark_polygons
and layers.source_rect is not None
):
_draw_dark_layer(out, layers.source_dark_polygons, layers.source_rect, 5, 2)
# Phase 5: 背景圆(青)。
if cfg.draw_reference_region:
_draw_reference_layer(out, layers)
return out
def render_overlay(
frames: Iterable[AnyArray],
per_frame_layers: Sequence[OverlayLayers],
fps: float,
cfg: OverlayConfig,
*,
frame_size: tuple[int, int],
) -> bytes:
"""逐帧渲染 overlay 并编码为 mp4 字节(fourcc mp4v、原帧尺寸、调用方 fps)。
参数:
frames: 已解码的逐帧整帧 BGR 图像,非空且尺寸一致。**逐帧消费、不留**,
所以可以直接喂一个解码器的迭代器。
per_frame_layers: 与 ``frames`` 一一对应的逐帧图层;长度须等于帧数。
fps: 视频帧率,**必须 > 0**(调用方已探测);非正直接报错,不静默回退 25.0。
cfg: 各画层开关与填充配置。
frame_size: 输出视频的 ``(width, height)``。**由调用方给,不再从首帧读**——
编码器要在写第一帧之前就建好,而流式之后「先看一眼首帧再决定尺寸」意味着
要么把首帧留住、要么把迭代器消费掉一帧。调用方本来就从探测拿到了这个尺寸。
返回:
mp4 视频字节(mp4v 编码),由调用方写文件。
异常:
ValueError: ``frames`` 为空、``frames`` 与 ``per_frame_layers`` 长度不一致
(由 ``zip`` 的 ``strict`` 执行——流式之后帧数要遍历完才知道)、
``fps`` 非正,或 ``frame_size`` 非正。
RuntimeError: ``cv2.VideoWriter`` 无法打开(编码器不可用 / 尺寸非法)。
关键实现:
``cv2.VideoWriter`` 不能直写内存,故写到临时 ``.mp4`` 文件、读回字节、``finally``
``unlink`` 删除(仿 ``roi_inference/frames.py`` 临时文件纪律,确保异常路径也清理);
本模块不落本地、不碰对象存储。逐帧 ``draw_polygon_overlay`` 保持帧尺寸不变,
故无需逐帧重校尺寸。
"""
# Phase 1: 防御校验(不静默兜底,缺陷直接报错)。
if float(fps) <= 0.0:
raise ValueError(f"fps 必须为正(调用方应已探测),得到 {fps}。")
width, height = frame_size
if width <= 0 or height <= 0:
raise ValueError(f"frame_size 必须为正,得到 {frame_size}。")
if not per_frame_layers:
raise ValueError("per_frame_layers 不能为空:一帧都不写产不出视频。")
# Phase 2: 写临时 mp4,结束(含异常)后必删。
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
temp_path = Path(handle.name)
try:
writer = cv2.VideoWriter(
str(temp_path),
cv2.VideoWriter.fourcc(*"mp4v"),
float(fps),
(width, height),
)
if not writer.isOpened():
raise RuntimeError(f"cv2.VideoWriter 无法打开(尺寸 {width}x{height}、fps {fps})。")
try:
# strict:帧数要遍历完才知道,长度不一致在这里当场抛,不静默按较短的截断。
for index, (frame, layers) in enumerate(zip(frames, per_frame_layers, strict=True)):
if index == 0 and frame.shape[:2] != (height, width):
# **尺寸不符的帧会被 VideoWriter 静默丢掉**:实测写 6 帧读回 3 帧,
# 只在 stderr 留一行警告,不抛异常也不改返回值。结果是 overlay 视频
# 短一截甚至是空的,而它照样有字节、照样被写成文件。
# 尺寸以前由首帧决定所以结构上不可能不符,现在由调用方给,这条得自己守。
raise ValueError(
f"帧尺寸 {frame.shape[:2]} 与 frame_size {(width, height)} 不符;"
"尺寸不符的帧会被编码器静默丢掉"
)
writer.write(draw_polygon_overlay(frame, layers, cfg))
finally:
writer.release()
return temp_path.read_bytes()
finally:
temp_path.unlink(missing_ok=True)
+151
View File
@@ -0,0 +1,151 @@
"""第 9 步:从过了质量门的 Lognormal 拟合曲线算 11 个灌注指标(口径仿照商用造影定量软件 VueBOX)。
指标在 MP4 显示灰度域上算(不是线性化的回声功率),时间以视频开头为零点,面积用「拟合曲线减
基线」。依赖观测范围的指标(如洗出段面积)在观测不够时为 None,不外推。
- PE:峰值增强(峰值减基线);TTP:达峰时间(D3);WiR:最大洗入斜率(D2 处导数);
- TI:洗入切线与基线的交点;RT:上升时间 TTP − TI;WiAUC:TI 到 TTP 的面积;WiPI:WiAUC / RT;
- WoR:最大洗出斜率(D4 处导数);TO:洗出切线与基线的交点;FT:下降时间 TO − TTP;
- WoAUC:TTP 到 TO 的面积;WiWoAUC:两段面积之和;mTT:TI 到 TO 之间的平均渡越时刻;mTTl:mTT − TI。
照搬旧项目 `perfusion_metrics.py`,去掉产品层。
"""
import math
from dataclasses import dataclass
import numpy as np
from cadx.ceus._tic_fitting import (
LognormalFitResult,
LognormalParameters,
evaluate_lognormal,
evaluate_lognormal_derivative,
)
@dataclass(frozen=True)
class LognormalPerfusionMetrics:
"""11 项指标及三个支撑时刻(TI、mTT、TO),单位见字段名后缀;观测不够的为 None。"""
pe_au: float
wi_auc_au_s: float | None
rt_s: float | None
mttl_s: float | None
ttp_s: float
wir_au_per_s: float
wipi_au: float | None
wo_auc_au_s: float | None
wiwo_auc_au_s: float | None
ft_s: float | None
wor_au_per_s: float | None
ti_s: float | None
mtt_s: float | None
to_s: float | None
def _normal_cdf(value: float) -> float:
return 0.5 * (1.0 + math.erf(value / math.sqrt(2.0)))
def _elapsed_cdf(time_s: float, parameters: LognormalParameters) -> float:
elapsed_s = time_s - parameters.arrival_offset_s
if elapsed_s <= 0.0:
return 0.0
return _normal_cdf((math.log(elapsed_s) - parameters.mu) / parameters.sigma)
def _elapsed_first_moment_cdf(time_s: float, parameters: LognormalParameters) -> float:
elapsed_s = time_s - parameters.arrival_offset_s
if elapsed_s <= 0.0:
return 0.0
standardized = (math.log(elapsed_s) - parameters.mu - parameters.sigma**2) / parameters.sigma
return math.exp(parameters.mu + 0.5 * parameters.sigma**2) * _normal_cdf(standardized)
def _pulse_auc(start_s: float, end_s: float, parameters: LognormalParameters) -> float:
if end_s <= start_s:
raise ValueError("Lognormal 面积终点必须晚于起点")
return parameters.area * (_elapsed_cdf(end_s, parameters) - _elapsed_cdf(start_s, parameters))
def _pulse_first_moment(start_s: float, end_s: float, parameters: LognormalParameters) -> float:
probability = _elapsed_cdf(end_s, parameters) - _elapsed_cdf(start_s, parameters)
elapsed_moment = _elapsed_first_moment_cdf(end_s, parameters) - _elapsed_first_moment_cdf(
start_s, parameters
)
return parameters.area * (parameters.arrival_offset_s * probability + elapsed_moment)
def _observed_time(value: float, lower: float, upper: float) -> float | None:
return value if lower <= value <= upper else None
def compute_lognormal_perfusion_metrics(
fit: LognormalFitResult,
) -> LognormalPerfusionMetrics | None:
"""拟合没过质量门时返回 None;过了就算 11 项指标,观测范围不够的那几项为 None。"""
if not fit.success:
return None
parameters = fit.parameters
if parameters is None or fit.d2_time_s is None or fit.d3_time_s is None:
raise ValueError("成功 Lognormal 拟合缺少参数或洗入/峰值时刻")
d2_time_s = float(fit.d2_time_s)
ttp_s = float(fit.d3_time_s)
peak_value = float(evaluate_lognormal(np.asarray([ttp_s]), parameters)[0])
pe_au = peak_value - parameters.baseline
wir_au_per_s = evaluate_lognormal_derivative(d2_time_s, parameters)
if not math.isfinite(pe_au) or pe_au <= 0.0:
raise ValueError("成功 Lognormal 拟合的峰值增强必须为有限正数")
if not math.isfinite(wir_au_per_s) or wir_au_per_s <= 0.0:
raise ValueError("成功 Lognormal 拟合的最大洗入斜率必须为有限正数")
d2_value = float(evaluate_lognormal(np.asarray([d2_time_s]), parameters)[0])
ti_candidate_s = d2_time_s - (d2_value - parameters.baseline) / wir_au_per_s
ti_s = _observed_time(ti_candidate_s, fit.fit_start_s, d2_time_s)
rt_s = None if ti_s is None else ttp_s - ti_s
wi_auc_au_s = None if ti_s is None else _pulse_auc(ti_s, ttp_s, parameters)
wipi_au = None if wi_auc_au_s is None or rt_s is None else wi_auc_au_s / rt_s
wor_au_per_s: float | None = None
to_s: float | None = None
ft_s: float | None = None
wo_auc_au_s: float | None = None
if fit.d4_time_s is not None:
d4_time_s = float(fit.d4_time_s)
wor_au_per_s = evaluate_lognormal_derivative(d4_time_s, parameters)
if not math.isfinite(wor_au_per_s) or wor_au_per_s >= 0.0:
raise ValueError("成功 Lognormal 拟合的最大洗出斜率必须为有限负数")
d4_value = float(evaluate_lognormal(np.asarray([d4_time_s]), parameters)[0])
to_candidate_s = d4_time_s - (d4_value - parameters.baseline) / wor_au_per_s
to_s = _observed_time(to_candidate_s, d4_time_s, fit.fit_end_s)
if to_s is not None:
ft_s = to_s - ttp_s
wo_auc_au_s = _pulse_auc(ttp_s, to_s, parameters)
wiwo_auc_au_s = (
None if wi_auc_au_s is None or wo_auc_au_s is None else wi_auc_au_s + wo_auc_au_s
)
mtt_s: float | None = None
mttl_s: float | None = None
if ti_s is not None and to_s is not None and wiwo_auc_au_s is not None:
mtt_s = _pulse_first_moment(ti_s, to_s, parameters) / wiwo_auc_au_s
mttl_s = mtt_s - ti_s
return LognormalPerfusionMetrics(
pe_au=pe_au,
wi_auc_au_s=wi_auc_au_s,
rt_s=rt_s,
mttl_s=mttl_s,
ttp_s=ttp_s,
wir_au_per_s=wir_au_per_s,
wipi_au=wipi_au,
wo_auc_au_s=wo_auc_au_s,
wiwo_auc_au_s=wiwo_auc_au_s,
ft_s=ft_s,
wor_au_per_s=wor_au_per_s,
ti_s=ti_s,
mtt_s=mtt_s,
to_s=to_s,
)
+480
View File
@@ -0,0 +1,480 @@
"""ceus 对外的两段接口,中间由 `scripts/ceus_quant.py` 调 segment 做第 3 步分割。
- `prepare_segmentation`(第 1–2 步):读视频、找分屏线、判断造影画面在哪一半、选提示帧,交出
要交给分割的那一半(灰阶那一半,`target_view`)的逐帧图像和提示帧掩膜(`target_view` 局部坐标)。
- `quantify_case`(第 4–9 步):拿分割得到的逐帧掩膜(`target_view` 局部坐标)整理成平滑轮廓并与
绿框融合、挪回造影那一半,找暗区、选背景圆、逐帧采 TIC、拟合、定位 7 个关键帧、算灌注指标。
ceus 不引用 segment(`结构.md` 第三部分),所以分割夹在两段中间。编排顺序照旧 debug 脚本
`t1_local_debug.py`(第 1–6 步)和 `tic_lognormal_replay.py`(第 6–9 步):旧 replay 读回轨迹后
重新从视频采了一遍 TIC,两遍输入相同,这里只采一遍。
另有给第三阶段脚本写产物用的几个函数:`tic_plot_markers`(tic.png 上 D1–D4 的竖线)、
`render_case_overlay`(overlay.mp4 的字节)、`keyframe_images`(定位到的关键帧整幅原图)、
`quant_summary`(summary.json 里拟合、指标、关键帧那几段)。
"""
import math
from collections.abc import Iterator
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
from cadx.ceus._anchor_frames import (
ANCHOR_SLOTS,
STATUS_OK,
AnchorFrameLocation,
locate_anchor_frames,
)
from cadx.ceus._config import CeusConfig
from cadx.ceus._contrast_side import ContrastSide, locate_contrast_side
from cadx.ceus._dark_region import extract_dark_region_polygons
from cadx.ceus._geometry import AnyArray, Frame, Mask, crop_frame
from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source
from cadx.ceus._overlay import OverlayConfig, OverlayLayers, render_overlay
from cadx.ceus._perfusion_metrics import (
LognormalPerfusionMetrics,
compute_lognormal_perfusion_metrics,
)
from cadx.ceus._polygons import (
apply_prompt_frame_position_constraints,
fuse_prompt_prior_with_medsam,
masks_to_smoothed_polygons,
)
from cadx.ceus._reference_region import select_reference_region
from cadx.ceus._split_screen import split_views
from cadx.ceus._tic import CURVE_NAMES, TicResult, compute_tic_for_video
from cadx.ceus._tic_curves import TicCurves, from_tic_result
from cadx.ceus._tic_fitting import LognormalFitResult, evaluate_lognormal
from cadx.ceus._tic_plot import TicPlotMarker
from cadx.ceus._trajectory import Trajectory
from cadx.ceus._video import VideoFile
# 灌注指标只对病灶两条曲线算(同旧 replay 的 `_LESION_CURVES`)。
LESION_CURVES = ("roi_all", "roi_perf")
# tic.png 上 D1–D4 竖线的颜色(BGR,同旧 replay)。
_ANCHOR_COLORS = {
"D1": (30, 30, 220),
"D2": (40, 150, 40),
"D3": (180, 60, 160),
"D4": (180, 130, 30),
}
# overlay.mp4 的画层(同旧 debug 脚本 `render_overlay_bytes`):两半都画 ROI,暗区只画在造影那一半。
_OVERLAY_CONFIG = OverlayConfig(
draw_source_roi=True,
draw_target_polygon=True,
draw_dark_region=False,
draw_source_dark_region=True,
draw_reference_region=True,
fill_polygon=False,
)
@dataclass(frozen=True)
class SegmentationInput:
"""第一段(第 1–2 步)的结果。
- `video_path`、`fps`、`frame_count`、`frame_size`(宽, 高)、`frame_times_s`:视频本身;
- `split_x`:分屏线的列;`contrast`:哪一半是造影画面、两个视图矩形(整帧坐标)、提示帧
(造影那一半局部坐标的绿框掩膜);
- `target_frames`:要交给分割的那一半(`contrast.target_view`,灰阶画面)的逐帧 BGR 图像,
与帧号一一对应;
- `prompt_masks`:提示帧帧号 → 这一帧绿框挪到 `target_view` 局部坐标后的布尔掩膜;
- `first_contrast_frame`:造影那一半的第一帧(选背景圆用)。
"""
video_path: Path
fps: float
frame_count: int
frame_size: tuple[int, int]
frame_times_s: AnyArray
split_x: int
contrast: ContrastSide
target_frames: list[Frame]
prompt_masks: dict[int, Mask]
first_contrast_frame: Frame
@dataclass(frozen=True)
class QuantResult:
"""第二段(第 4–9 步)的结果。
- `trajectory`:逐帧轮廓、暗区、背景圆和几何(写 trajectory.npz 用 `pack_trajectory`);
- `tic`:逐帧采样和四条曲线的基线、拟合;`curves`:同一份数据的 npz 载体(`pack_tic_curves`);
- `injection_offset_s`:推注时间 X(秒,视频第 0 秒时已推注的时长);
- `lesion_fit_ready`:病灶两条曲线的拟合是否都过了质量门;
- `anchors`:7 个关键帧槽位的定位(按 `ANCHOR_SLOTS` 顺序);
- `perfusion`:`roi_all`、`roi_perf` 各自的灌注指标,拟合没过质量门为 None;
- `pan_detected_fraction`:紫圈检出率(非重复帧里认出紫圈的帧占比)。
"""
trajectory: Trajectory
tic: TicResult
curves: TicCurves
injection_offset_s: float
lesion_fit_ready: bool
anchors: dict[str, AnchorFrameLocation]
perfusion: dict[str, LognormalPerfusionMetrics | None]
pan_detected_fraction: float
def prepare_segmentation(video_path: Path, cfg: CeusConfig) -> SegmentationInput:
"""第 1–2 步:解码、分屏、找绿框定造影那一半、选提示帧,交出分割的输入。
找不到可信分屏线抛 `SplitDetectionError`(第 1 步),两半都有或都没有绿框抛 `ContrastSideError`
(第 2 步),两者都是 `CeusCaseError`。
"""
video = VideoFile(video_path)
fps, frame_count, width, height = video.probe()
frame_times_s = video.frame_times_s()
full_frames = [frame for _, frame in video.iter_frames()]
if len(full_frames) != frame_count:
raise RuntimeError(
f"解码帧数 {len(full_frames)} 与容器记录的帧数 {frame_count} 不一致:{video_path}"
)
split_x, left_view, right_view = split_views(full_frames, cfg.views)
contrast = locate_contrast_side(
full_frames, left_view, right_view, cfg.prompt_source, cfg.keyframes
)
target_frames = [crop_frame(frame, contrast.target_view).copy() for frame in full_frames]
first_contrast_frame = crop_frame(full_frames[0], contrast.contrast_view).copy()
prompt_masks = {
prompt.frame_index: map_source_mask_to_target(
prompt.mask, contrast.contrast_view, contrast.target_view
)
for prompt in contrast.prompt_frames
}
return SegmentationInput(
video_path=video_path,
fps=fps,
frame_count=frame_count,
frame_size=(width, height),
frame_times_s=frame_times_s,
split_x=split_x,
contrast=contrast,
target_frames=target_frames,
prompt_masks=prompt_masks,
first_contrast_frame=first_contrast_frame,
)
def _contrast_frames(prepared: SegmentationInput) -> Iterator[Frame]:
"""重新解码,逐帧交出造影那一半(不把整段留在内存里)。"""
for _, frame in VideoFile(prepared.video_path).iter_frames():
yield crop_frame(frame, prepared.contrast.contrast_view)
def _validate_masks(prepared: SegmentationInput, target_masks: AnyArray) -> None:
"""分割结果必须是 `(帧数, target 高, target 宽)` 的布尔数组。"""
view = prepared.contrast.target_view
expected = (prepared.frame_count, view.height, view.width)
if target_masks.dtype != np.bool_ or target_masks.shape != expected:
raise ValueError(
f"分割掩膜应为形状 {expected} 的布尔数组,"
f"收到 {target_masks.shape} {target_masks.dtype}"
)
def quantify_case(
prepared: SegmentationInput,
target_masks: AnyArray,
injection_offset_s: float,
cfg: CeusConfig,
) -> QuantResult:
"""第 4–9 步:轮廓、暗区、背景圆、TIC、拟合、关键帧、灌注指标。
`target_masks` 是分割在 `target_view` 局部坐标上的逐帧掩膜 `(N, H, W)` 布尔数组;
`injection_offset_s` 是超声报告表的「推注开始时间s」X。分割掩膜一帧有效轮廓都没有时抛
`NoValidContourError`(第 4 步,`CeusCaseError`)。拟合没过质量门不算失败:D1–D4 未定位、
灌注指标为 None。
"""
_validate_masks(prepared, target_masks)
contrast = prepared.contrast
frame_count = prepared.frame_count
prompt_frame_dicts: list[dict[str, Any]] = [
{"frame_index": prompt.frame_index, "mask": prompt.mask}
for prompt in contrast.prompt_frames
]
# 第 4 步:掩膜 → 平滑多边形 → 位置约束 → 与绿框融合 → 挪回造影那一半。
masks_by_frame = {index: target_masks[index] for index in range(frame_count)}
target_polygons = masks_to_smoothed_polygons(masks_by_frame, frame_count, cfg.smoothing)
target_polygons = apply_prompt_frame_position_constraints(
target_polygons,
prompt_frame_dicts,
contrast.contrast_view,
contrast.target_view,
cfg.constraints.keyframe_position,
)
target_polygons = fuse_prompt_prior_with_medsam(
target_polygons,
prompt_frame_dicts,
contrast.contrast_view,
contrast.target_view,
cfg.roi_shape,
cfg.smoothing,
)
source_polygons = target_polygons_to_source(
target_polygons, contrast.contrast_view, contrast.target_view
)
# 第 5 步:暗区(在造影那一半上找)。
dark_polygons = extract_dark_region_polygons(
_contrast_frames(prepared),
source_polygons,
cfg.interior_dark_region,
cfg.prompt_source,
)
# 第 6 步:背景圆、四条 TIC;第 7 步拟合在 compute_tic_for_video 里。
source_polygon_array = np.asarray(source_polygons, dtype=np.float32)
reference_mask, reference_meta = select_reference_region(
prepared.first_contrast_frame,
source_polygon_array,
cfg.prompt_source,
cfg.pan_source,
cfg.tic_analysis,
)
tic = compute_tic_for_video(
_contrast_frames(prepared),
source_polygons,
dark_polygons,
reference_mask,
prepared.fps,
cfg.prompt_source,
cfg.pan_source,
cfg.tic_analysis,
prepared.frame_times_s,
)
# 第 8 步:关键帧。
lesion_fit_ready = all(tic.fits[name].success for name in LESION_CURVES)
frame_indices = np.arange(frame_count, dtype=np.int32)
anchors = locate_anchor_frames(
perf_fit=tic.fits["roi_perf"],
perf_baseline_end_index=tic.baselines["roi_perf"].frame_end,
lesion_fit_ready=lesion_fit_ready,
frame_indices=frame_indices,
time_video_s=prepared.frame_times_s,
injection_offset_s=injection_offset_s,
cfg=cfg.tic_analysis.anchor_frames,
)
# 第 9 步:灌注指标。
perfusion = {
name: compute_lognormal_perfusion_metrics(tic.fits[name]) for name in LESION_CURVES
}
trajectory = Trajectory(
frame_indices=frame_indices,
fps=float(prepared.fps),
target_polygons=np.asarray(target_polygons, dtype=np.float32),
source_polygons=source_polygon_array,
source_view_rect=contrast.contrast_view,
target_view_rect=contrast.target_view,
source_effective_rect=contrast.contrast_view,
target_effective_rect=contrast.target_view,
source_polygon_coordinate_space="source_view_local",
target_polygon_coordinate_space="target_effective_view_local",
reference_mask=reference_mask.astype(np.uint8),
reference_region=reference_meta,
keyframe_indices=np.asarray(
[prompt.frame_index for prompt in contrast.prompt_frames], dtype=np.int32
),
dark_polygons=dark_polygons,
calibration={
"split_x": prepared.split_x,
"source": "auto_detected",
"contrast_side": contrast.side,
},
)
curves = from_tic_result(
tic,
fps=prepared.fps,
injection_offset_s=injection_offset_s,
config_version=cfg.config_version,
)
return QuantResult(
trajectory=trajectory,
tic=tic,
curves=curves,
injection_offset_s=injection_offset_s,
lesion_fit_ready=lesion_fit_ready,
anchors=anchors,
perfusion=perfusion,
pan_detected_fraction=tic.pan_detected_fraction,
)
def tic_plot_markers(anchors: dict[str, AnchorFrameLocation]) -> tuple[TicPlotMarker, ...]:
"""tic.png 上 D1–D4 的竖线:有锚点时刻的画在锚点时刻,没定位到帧的在标签后注明状态。"""
markers: list[TicPlotMarker] = []
for slot in ("D1", "D2", "D3", "D4"):
anchor = anchors[slot]
if anchor.anchor_time_video_s is None:
continue
status = "" if anchor.status == STATUS_OK else f" [{anchor.status}]"
markers.append(
TicPlotMarker(f"{slot} model{status}", anchor.anchor_time_video_s, _ANCHOR_COLORS[slot])
)
return tuple(markers)
def render_case_overlay(prepared: SegmentationInput, result: QuantResult) -> bytes:
"""overlay.mp4 的字节:重新解码整段视频,两半都画 ROI,造影那一半画暗区和背景圆。"""
trajectory = result.trajectory
layers = [
OverlayLayers(
target_polygon=trajectory.target_polygons[index],
target_rect=trajectory.target_view_rect,
dark_polygons=[],
source_roi_polygon=trajectory.source_polygons[index],
source_rect=trajectory.source_view_rect,
source_dark_polygons=list(trajectory.dark_polygons[index]),
reference_region=trajectory.reference_region,
)
for index in range(prepared.frame_count)
]
frames = (frame for _, frame in VideoFile(prepared.video_path).iter_frames())
return render_overlay(
frames, layers, prepared.fps, _OVERLAY_CONFIG, frame_size=prepared.frame_size
)
def keyframe_images(
prepared: SegmentationInput, anchors: dict[str, AnchorFrameLocation]
) -> dict[str, Frame]:
"""定位到的关键帧的整幅原始画面(左右两半都在,不裁剪),槽位 → BGR 图像;未定位的槽位没有。"""
wanted: dict[int, list[str]] = {}
for slot, anchor in anchors.items():
if anchor.frame_index is not None:
wanted.setdefault(anchor.frame_index, []).append(slot)
images: dict[str, Frame] = {}
for index, frame in VideoFile(prepared.video_path).iter_frames():
for slot in wanted.get(index, []):
images[slot] = frame.copy()
missing = sorted(slot for slots in wanted.values() for slot in slots if slot not in images)
if missing:
raise RuntimeError(f"重新解码时没读到关键帧 {missing}:{prepared.video_path}")
return {slot: images[slot] for slot in ANCHOR_SLOTS if slot in images}
def _finite_or_none(value: float | None) -> float | None:
"""非有限数(NaN、inf)在 JSON 里写 null。"""
if value is None or not math.isfinite(float(value)):
return None
return float(value)
def _fit_summary(fit: LognormalFitResult) -> dict[str, Any]:
"""一条曲线的拟合摘要(同旧 replay 的 `_fit_summary`)。"""
parameters = fit.parameters
anchor_times = {
"D2": _finite_or_none(fit.d2_time_s),
"D3": _finite_or_none(fit.d3_time_s),
"D4": _finite_or_none(fit.d4_time_s),
}
anchor_values = {
slot: None
if parameters is None or time_s is None
else float(evaluate_lognormal(np.asarray([time_s]), parameters)[0])
for slot, time_s in anchor_times.items()
}
return {
"success": fit.success,
"failure_reason": fit.failure_reason or None,
"curve_model": fit.curve_model,
"baseline_source": fit.baseline_source,
"valid_samples": int(fit.valid_sample_mask.sum()),
"duplicate_samples": int(fit.duplicate_sample_mask.sum()),
"parameters": None
if parameters is None
else {
"area": parameters.area,
"mu": parameters.mu,
"sigma": parameters.sigma,
"arrival_offset_s": parameters.arrival_offset_s,
"baseline": parameters.baseline,
},
"quality": {
"r2": _finite_or_none(fit.r2),
"nrmse": _finite_or_none(fit.nrmse),
"residual_noise_ratio": _finite_or_none(fit.residual_noise_ratio),
"multi_peak_flag": fit.multi_peak_flag,
},
"model_arrival_time_video_s": _finite_or_none(fit.d1_time_s),
"model_landmark_times_video_s": anchor_times,
"model_landmark_intensities": anchor_values,
}
def _perfusion_summary(metrics: LognormalPerfusionMetrics | None) -> dict[str, Any] | None:
"""灌注指标摘要(同旧 replay 的 `_perfusion_metrics_summary`),拟合没过质量门为 None。"""
if metrics is None:
return None
return {
"signal_domain": "db_display",
"time_origin": "video_start",
"area_curve": "fitted_minus_baseline",
"values": {
"PE_au": metrics.pe_au,
"WiAUC_au_s": metrics.wi_auc_au_s,
"RT_s": metrics.rt_s,
"mTTl_s": metrics.mttl_s,
"TTP_s": metrics.ttp_s,
"WiR_au_per_s": metrics.wir_au_per_s,
"WiPI_au": metrics.wipi_au,
"WoAUC_au_s": metrics.wo_auc_au_s,
"WiWoAUC_au_s": metrics.wiwo_auc_au_s,
"FT_s": metrics.ft_s,
"WoR_au_per_s": metrics.wor_au_per_s,
},
"supporting_times_s": {
"TI": metrics.ti_s,
"mTT": metrics.mtt_s,
"TO": metrics.to_s,
},
}
def _anchor_summary(anchor: AnchorFrameLocation) -> dict[str, Any]:
"""一个关键帧槽位的定位:帧号、视频时间、推注后时刻、状态、未定位原因。"""
return {
"frame_index": anchor.frame_index,
"frame_time_video_s": anchor.frame_time_video_s,
"time_since_injection_s": anchor.time_s,
"status": anchor.status,
"reason": anchor.reason,
"anchor_time_video_s": anchor.anchor_time_video_s,
"time_error_s": anchor.time_error_s,
"source_curve": anchor.source_curve,
}
def quant_summary(result: QuantResult) -> dict[str, Any]:
"""一例的定量摘要(可直接写成 JSON):拟合、灌注指标、紫圈检出率、7 个关键帧的定位。
不含视频号、路径等病例标识,由脚本按需另加。
"""
tic = result.tic
dynamic_slots = ("B0", "D1", "D2", "D3", "D4")
return {
"injection_offset_s": result.injection_offset_s,
"reference_valid": tic.reference_valid,
"pan_detected_fraction": result.pan_detected_fraction,
"early_truncation_flag": tic.early_truncation_flag,
"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
},
"anchor_frames": {slot: _anchor_summary(item) for slot, item in result.anchors.items()},
"available_dynamic_slots": [
slot for slot in dynamic_slots if result.anchors[slot].status == STATUS_OK
],
}
+357
View File
@@ -0,0 +1,357 @@
"""第 4 步:逐帧分割掩膜 → 平滑多边形 → 位置约束 → 与绿框融合。
分割在灰度那一半(target_view)上做,交来的是这一半局部坐标的逐帧二值掩膜。这里把它们变成
顶点数统一的平滑多边形轨迹,再用提示帧上的绿框(挪到 target_view 后)施加两类约束:
- `masks_to_smoothed_polygons`:逐帧取最大外轮廓 → 等弧长重采样到 `contour_points` 个顶点 →
与上一帧对齐起点和朝向 → 空掩膜帧由相邻帧沿时间线性插值补齐(N 帧进 N 帧出,不剔除帧)→
沿时间轴三角平滑;
- `apply_prompt_frame_position_constraints`:提示帧绿框质心之间线性插值出逐帧中心轨迹,把每帧
多边形按 `strength` 整体平移过去(不改形状和面积);
- `fuse_prompt_prior_with_medsam`:`roi_shape.source` 是 `prompt_prior` 时,由提示帧绿框推出逐帧
先验多边形,逐顶点混合 `多边形 = 分割 × (1 − s) + 先验 × s`(提示帧用 `keyframe_shape_strength`,
其余帧用 `propagation_shape_strength`),再把面积收紧到先验面积的 `[min, max]` 倍
(绿框在最终轮廓里占多少,见 `待定问题/01`)。
照搬旧项目 `polygons.py`,去掉产品层;坐标换算改用两个视图矩形左上角之差(`_mapping.py`),
不再需要旧的有效区矩形和映射锚点。
"""
from typing import Any, Protocol
import cv2
import numpy as np
from cadx.ceus._errors import CeusCaseError
from cadx.ceus._geometry import (
AnyArray,
Points,
ViewRect,
polygon_area,
polygon_center,
resample_closed_contour,
triangular_smooth,
)
from cadx.ceus._mapping import map_source_mask_to_target
class _SmoothingConfigLike(Protocol):
"""平滑配置(`SmoothingConfig` 符合)。"""
@property
def contour_points(self) -> int: ...
@property
def temporal_window(self) -> int: ...
class _RoiShapeConfigLike(Protocol):
"""绿框融合配置(`RoiShapeConfig` 符合)。"""
@property
def source(self) -> str: ...
@property
def keyframe_shape_strength(self) -> float: ...
@property
def propagation_shape_strength(self) -> float: ...
@property
def min_area_ratio_to_prompt(self) -> float: ...
@property
def max_area_ratio_to_prompt(self) -> float: ...
class _KeyframePositionConstraintLike(Protocol):
"""提示帧位置约束配置(`KeyframePositionConstraint` 符合)。"""
@property
def enabled(self) -> bool: ...
@property
def mode(self) -> str: ...
@property
def strength(self) -> float: ...
_SUPPORTED_CONSTRAINT_MODE = "center_interpolation"
POLYGON_STEP = 4
class NoValidContourError(CeusCaseError, RuntimeError):
"""这一例的掩膜序列(分割结果或挪过来的绿框)一帧有效轮廓都没有,第 4 步做不下去。
同时是 `RuntimeError`:旧测试按 `RuntimeError` 断言,语义不变。
"""
def __init__(self, reason: str) -> None:
super().__init__(POLYGON_STEP, reason)
def largest_contour(mask: AnyArray) -> Points | None:
"""二值掩膜面积最大的外轮廓顶点 `(M, 2)` float32;没有轮廓返回 None。
取全部轮廓点(`CHAIN_APPROX_NONE`),方便后面等弧长重采样。
"""
contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
if not contours:
return None
best = max(contours, key=cv2.contourArea)
return np.asarray(best).reshape(-1, 2).astype(np.float32)
def mask_center(mask: AnyArray) -> Points:
"""二值掩膜前景像素的质心 `[cx, cy]`;掩膜全空时返回 (0, 0)。"""
ys, xs = np.where(mask)
if len(xs) == 0:
return np.array([0.0, 0.0], dtype=np.float32)
return np.array([float(xs.mean()), float(ys.mean())], dtype=np.float32)
def align_polygon_to_previous(poly: AnyArray, previous: AnyArray | None) -> AnyArray:
"""把多边形的起点和朝向对齐到上一帧,让逐顶点的时间序列连续。
枚举正序、逆序两种朝向 × 全部循环移位,取与上一帧逐顶点平方差最小者;不对齐的话,时间
平滑会把不同位置的顶点混在一起。`previous` 为 None(首帧)时原样返回。
"""
if previous is None:
return poly
candidates = [poly, poly[::-1].copy()]
best: tuple[float, AnyArray] | None = None
for candidate in candidates:
for shift in range(len(candidate)):
shifted = np.roll(candidate, shift, axis=0)
score = float(((shifted - previous) ** 2).sum())
if best is None or score < best[0]:
best = (score, shifted)
if best is None:
raise ValueError("多边形没有顶点,无法对齐。")
return best[1]
def _interpolate_polygons(polygons: list[AnyArray | None], num_points: int) -> AnyArray:
"""含 None 的多边形序列沿帧轴线性插值,补齐成 `(N, num_points, 2)`。
一帧有效轮廓都没有时无从插值,报 `RuntimeError`。
"""
valid_indices = np.asarray(
[idx for idx, poly in enumerate(polygons) if poly is not None], dtype=np.int32
)
if len(valid_indices) == 0:
raise NoValidContourError("逐帧 mask 未产出任何有效 ROI 轮廓,无从时间插值。")
valid_values = np.asarray(
[poly.reshape(-1) for poly in polygons if poly is not None], dtype=np.float32
)
frame_indices = np.arange(len(polygons), dtype=np.float32)
interpolated = np.zeros((len(polygons), num_points * 2), dtype=np.float32)
for col in range(num_points * 2):
interpolated[:, col] = np.interp(
frame_indices, valid_indices.astype(np.float32), valid_values[:, col]
)
return interpolated.reshape(len(polygons), num_points, 2)
def _interpolate_centers(centers_by_frame: dict[int, AnyArray], frame_count: int) -> AnyArray:
"""稀疏的提示帧中心沿帧轴线性插值成逐帧中心轨迹 `(N, 2)`,两端保持端值。"""
frame_ids = np.asarray(sorted(centers_by_frame), dtype=np.float32)
centers = np.asarray(
[centers_by_frame[int(frame_id)] for frame_id in frame_ids.tolist()], dtype=np.float32
)
all_frames = np.arange(frame_count, dtype=np.float32)
interpolated = np.zeros((frame_count, 2), dtype=np.float32)
for col in range(2):
interpolated[:, col] = np.interp(all_frames, frame_ids, centers[:, col])
return interpolated
def _clamp_polygon_to_view(polygon: AnyArray, target_rect: ViewRect) -> AnyArray:
"""顶点裁到视图边界 `[0, w-1] × [0, h-1]`(返回副本)。"""
out = polygon.copy()
out[:, 0] = np.clip(out[:, 0], 0, target_rect.width - 1)
out[:, 1] = np.clip(out[:, 1], 0, target_rect.height - 1)
return out
def _scale_polygon_about_center(polygon: AnyArray, center: AnyArray, scale: float) -> AnyArray:
"""绕给定中心等比缩放多边形。"""
return center[None, :] + (polygon - center[None, :]) * float(scale)
def masks_to_smoothed_polygons(
masks_by_frame: dict[int, AnyArray],
frame_count: int,
smoothing_cfg: _SmoothingConfigLike,
) -> list[AnyArray]:
"""逐帧掩膜 → 顶点统一、沿时间平滑的多边形序列,长度 `frame_count`。
缺帧或空掩膜视为退化帧:保留位置,由时间插值补齐。一帧有效轮廓都没有时报 `NoValidContourError`。
"""
num_points = int(smoothing_cfg.contour_points)
temporal_window = int(smoothing_cfg.temporal_window)
aligned: list[AnyArray | None] = []
previous: AnyArray | None = None
for frame_idx in range(frame_count):
mask = masks_by_frame.get(frame_idx)
poly: AnyArray | None = None
if mask is not None:
poly = resample_closed_contour(largest_contour(mask), num_points)
if poly is not None:
poly = align_polygon_to_previous(poly, previous)
previous = poly
aligned.append(poly)
interpolated = _interpolate_polygons(aligned, num_points)
smoothed = triangular_smooth(interpolated, temporal_window)
return [smoothed[idx] for idx in range(frame_count)]
def _prompt_frame_target_centers(
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
target_view_rect: ViewRect,
) -> dict[int, AnyArray]:
"""各提示帧的绿框掩膜挪到 target_view 后的质心,`帧号 → (cx, cy)`。"""
centers: dict[int, AnyArray] = {}
for prompt_frame in prompt_frames:
target_mask = map_source_mask_to_target(
prompt_frame["mask"], source_view_rect, target_view_rect
)
centers[int(prompt_frame["frame_index"])] = mask_center(target_mask)
return centers
def apply_prompt_frame_position_constraints(
polygons: list[AnyArray],
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
target_view_rect: ViewRect,
constraint_cfg: _KeyframePositionConstraintLike,
) -> list[AnyArray]:
"""按提示帧中心插值轨迹整体平移逐帧多边形(不改形状和面积),再裁到 target_view 内。
`prompt_frames` 每项含 `frame_index` 和造影那一半局部坐标的绿框 `mask`。每帧位移
= (插值中心 − 当前质心) × strength,strength 截到 [0, 1]。`enabled=False` 时原对象返回;
`mode` 不是 `center_interpolation` 报 `RuntimeError`。
"""
if not constraint_cfg.enabled:
return polygons
if constraint_cfg.mode != _SUPPORTED_CONSTRAINT_MODE:
raise RuntimeError(
f"constraints.keyframe_position.mode 仅支持 '{_SUPPORTED_CONSTRAINT_MODE}',"
f"得到 '{constraint_cfg.mode}'。"
)
strength = float(np.clip(constraint_cfg.strength, 0.0, 1.0))
anchor_centers = _prompt_frame_target_centers(prompt_frames, source_view_rect, target_view_rect)
center_track = _interpolate_centers(anchor_centers, len(polygons))
corrected: list[AnyArray] = []
for frame_idx, polygon in enumerate(polygons):
shift = (center_track[frame_idx] - polygon_center(polygon)) * strength
corrected.append(_clamp_polygon_to_view(polygon + shift[None, :], target_view_rect))
return corrected
def _prompt_prior_polygons(
prompt_frames: list[dict[str, Any]],
frame_count: int,
source_view_rect: ViewRect,
target_view_rect: ViewRect,
smoothing_cfg: _SmoothingConfigLike,
) -> list[AnyArray]:
"""由提示帧绿框推出逐帧先验多边形(target_view 局部坐标)。
每个提示帧的绿框挪到 target_view,取最大外轮廓重采样、对齐上一帧,放在它的帧号上;
其余帧沿时间插值、平滑;最后提示帧那几帧用原始绿框多边形覆盖(不被平滑改动)。
"""
num_points = int(smoothing_cfg.contour_points)
temporal_window = int(smoothing_cfg.temporal_window)
sparse: list[AnyArray | None] = [None] * frame_count
raw_key_polygons: dict[int, AnyArray] = {}
previous: AnyArray | None = None
for prompt_frame in prompt_frames:
target_mask = map_source_mask_to_target(
prompt_frame["mask"], source_view_rect, target_view_rect
)
resampled = resample_closed_contour(largest_contour(target_mask), num_points)
if resampled is None:
continue
poly = align_polygon_to_previous(resampled, previous)
previous = poly
frame_idx = int(prompt_frame["frame_index"])
sparse[frame_idx] = poly
raw_key_polygons[frame_idx] = poly
interpolated = _interpolate_polygons(sparse, num_points)
if temporal_window > 1:
interpolated = triangular_smooth(interpolated, temporal_window)
for frame_idx, polygon in raw_key_polygons.items():
interpolated[frame_idx] = polygon
return [interpolated[idx] for idx in range(frame_count)]
def _tighten_area_to_prior_band(
polygon: AnyArray,
prior_poly: AnyArray,
min_area_ratio: float,
max_area_ratio: float,
) -> AnyArray:
"""把多边形面积收紧到先验面积的 `[min, max]` 倍之内(绕先验质心按 sqrt(目标/当前) 缩放)。
先验面积或当前面积不超过 1 像素时不收紧(避免对退化面积做伪缩放);已在带内原样返回。
"""
prior_area = polygon_area(prior_poly)
current_area = polygon_area(polygon)
if prior_area <= 1.0 or current_area <= 1.0:
return polygon
min_area = prior_area * min_area_ratio
max_area = prior_area * max_area_ratio
target_area = float(np.clip(current_area, min_area, max_area))
if np.isclose(target_area, current_area):
return polygon
scale = float(np.sqrt(target_area / current_area))
return _scale_polygon_about_center(polygon, polygon_center(prior_poly), scale)
def fuse_prompt_prior_with_medsam(
medsam_polygons: list[AnyArray],
prompt_frames: list[dict[str, Any]],
source_view_rect: ViewRect,
target_view_rect: ViewRect,
shape_cfg: _RoiShapeConfigLike,
smoothing_cfg: _SmoothingConfigLike,
) -> list[AnyArray]:
"""分割多边形与绿框先验逐顶点融合并收紧面积,裁到 target_view 内。
`shape_cfg.source` 不是 `prompt_prior` 时原对象返回(不融合)。每帧先把分割多边形对齐到先验的
起点和朝向,再按权重 s(截到 [0, 1])混合。
"""
if shape_cfg.source != "prompt_prior":
return medsam_polygons
prior_polygons = _prompt_prior_polygons(
prompt_frames,
len(medsam_polygons),
source_view_rect,
target_view_rect,
smoothing_cfg,
)
prompt_frame_indices = {int(prompt_frame["frame_index"]) for prompt_frame in prompt_frames}
prompt_frame_strength = float(shape_cfg.keyframe_shape_strength)
propagation_strength = float(shape_cfg.propagation_shape_strength)
min_area_ratio = float(shape_cfg.min_area_ratio_to_prompt)
max_area_ratio = float(shape_cfg.max_area_ratio_to_prompt)
fused: list[AnyArray] = []
for frame_idx, (medsam_poly, prior_poly) in enumerate(
zip(medsam_polygons, prior_polygons, strict=True)
):
raw_strength = (
prompt_frame_strength if frame_idx in prompt_frame_indices else propagation_strength
)
strength = float(np.clip(raw_strength, 0.0, 1.0))
aligned_medsam = align_polygon_to_previous(medsam_poly, prior_poly)
polygon = aligned_medsam * (1.0 - strength) + prior_poly * strength
polygon = _tighten_area_to_prior_band(polygon, prior_poly, min_area_ratio, max_area_ratio)
fused.append(_clamp_polygon_to_view(polygon, target_view_rect))
return fused
+408
View File
@@ -0,0 +1,408 @@
"""第 6 步之前:在造影那一半的第一帧上选背景圆(`background_circle` 曲线的采样区)。
背景圆是 ROI 之外一小块干净背景,用来看整机增益漂移:病灶有了 ROI 曲线还不够,还要一块同帧
的背景作对照,才能区分「造影剂真增强」和「全局亮度漂移」。它不是紫圈(`roi_pan`):紫圈是医生
画的正常胰腺对照,由 `_tic.py` 逐帧直接读颜色;背景圆是程序自动挑的,不喂任何口径字段。
`select_reference_region` 按模式分派:
- `enabled=False`:空掩膜 + `{"enabled": False}`;
- `manual`:以配置 `center_xy`(局部像素坐标)和 `radius_px` 直接画圆;
- `auto_near_roi`:从 ROI 质心沿 8 个偏好方向(左下优先)× 若干搜索半径放候选圆心,避开 ROI
膨胀 `min_distance_px` 的禁区,候选圆内「有效背景」(非黑、不是绿框色或紫圈色)占比须 ≥
`min_valid_fraction`,按「有效占比 − 离 ROI 远近惩罚」择优;找不到时返回空掩膜和失败原因,
背景曲线保持 NaN。
搜索半径倍率和偏好方向是本模块的常量,不在配置里。照搬旧项目 `reference_region.py`,去掉产品层。
"""
from typing import Any, Protocol, cast
import cv2
import numpy as np
from cadx.ceus._config import TicAnalysisConfig
from cadx.ceus._geometry import AnyArray, polygon_to_mask
from cadx.ceus._green_prompt import prompt_color_mask
# auto 模式搜索半径(None 时)相对 ROI 等效尺度的倍率(参考 P:1058)。
_SEARCH_RADIUS_FACTORS = (0.65, 0.85, 1.05, 1.25, 1.5)
# 8 个偏好方向(左下优先,参考 P:1062):左下 / 左 / 下 / 右下 / 右 / 左上 / 上 / 右上。
_PREFERRED_DIRECTIONS = (
(-1.0, 1.0),
(-1.0, 0.0),
(0.0, 1.0),
(1.0, 1.0),
(1.0, 0.0),
(-1.0, -1.0),
(0.0, -1.0),
(1.0, -1.0),
)
# 评分中"离 ROI 距离"惩罚系数(参考 P:1091,硬编码不可改)。
_DISTANCE_PENALTY_WEIGHT = 0.05
class _ReferenceRegionConfigLike(Protocol):
"""背景圆配置(`ReferenceRegionConfig` 符合)。"""
@property
def enabled(self) -> bool: ...
@property
def mode(self) -> str: ...
@property
def radius_px(self) -> int: ...
@property
def min_distance_px(self) -> int: ...
@property
def nonblack_threshold(self) -> int: ...
@property
def min_valid_fraction(self) -> float: ...
@property
def center_xy(self) -> list[float] | None: ...
class _PromptHsvConfigLike(Protocol):
"""一组颜色的 HSV 阈值(`PromptSourceConfig`、`PanSourceConfig` 都符合)。"""
@property
def hsv_lower(self) -> list[int]: ...
@property
def hsv_upper(self) -> list[int]: ...
def _circle_mask(shape: tuple[int, int], center_xy: AnyArray, radius: float) -> AnyArray:
"""以 ``center_xy`` 为圆心、``radius`` 为半径栅格化实心圆 mask(参考 P:975-979)。
参数:
shape: 目标 mask 形状 ``(height, width)``。
center_xy: 圆心 ``[cx, cy]`` 像素坐标(可为浮点)。
radius: 圆半径(像素)。
返回:
``dtype=bool`` 的 ``(height, width)`` mask,圆内(含边界,``<= r²``)为 ``True``。
关键实现:
用 ``np.ogrid`` 广播算每像素到圆心的平方距离,与 ``radius²`` 比较——闭区间
(``<=``)与参考一致,避免边界像素被漏。
"""
height, width = shape
yy, xx = np.ogrid[:height, :width]
cx, cy = float(center_xy[0]), float(center_xy[1])
return (xx - cx) ** 2 + (yy - cy) ** 2 <= float(radius) ** 2
def _polygon_union_mask(polygons: AnyArray, height: int, width: int) -> AnyArray:
"""逐帧 ROI 多边形并集 mask(参考 P:982-986)。
参数:
polygons: 逐帧 ROI 多边形序列(每帧 ``(N, 2)``)。
height: mask 高度。
width: mask 宽度。
返回:
所有帧 ROI 多边形按位或的 ``dtype=bool`` 并集 mask——禁区取并集而非单帧,
保证参考圆在整段视频内都不与任何帧的 ROI 重叠。
"""
union = np.zeros((height, width), dtype=bool)
for polygon in polygons:
union |= polygon_to_mask(polygon, height, width)
return union
def _forbidden_roi_mask(roi_union: AnyArray, min_distance_px: int) -> AnyArray:
"""把 ROI 并集按 ``min_distance_px`` 膨胀为参考圆禁区(参考 P:1040-1045)。
参数:
roi_union: ROI 并集 mask(bool)。
min_distance_px: 参考圆与 ROI 的最小间距(像素);``<= 0`` 时不膨胀。
返回:
膨胀后的 ``dtype=bool`` 禁区 mask——参考圆不得触及其内任何像素。
关键实现:
膨胀核边长 ``2·min_distance_px + 1``(方核),与参考一致;``<= 0`` 直接返回原并集
(禁区即 ROI 本身)。
"""
if min_distance_px <= 0:
return roi_union
kernel = np.ones((min_distance_px * 2 + 1, min_distance_px * 2 + 1), dtype=np.uint8)
return cv2.dilate(roi_union.astype(np.uint8) * 255, kernel, iterations=1) > 0
def _search_radii(roi_scale: float) -> list[float]:
"""求 auto 模式的候选搜索半径列表(参考 P:1056-1058)。
参数:
roi_scale: ROI 等效尺度(bbox 长 / 宽较大者,下限 1.0)。
返回:
候选圆心到 ROI 质心的距离列表:``roi_scale × _SEARCH_RADIUS_FACTORS``。
关键实现:
倍率不从配置读——``ReferenceRegionConfig`` 里没有这一项,而它是 ``extra="forbid"``,
往 yaml 里写 ``search_radii_px`` 会在加载时被拒。要做成可配就得先进 schema。
"""
return [roi_scale * factor for factor in _SEARCH_RADIUS_FACTORS]
def _preferred_directions() -> AnyArray:
"""auto 模式的偏好方向数组(参考 P:1059-1065)。
返回:
``(8, 2)`` float32 方向数组(左下优先排序)——评分相同则靠前方向先入选。
关键实现:
方向表不从配置读,同 ``_search_radii``:``preferred_directions`` 不在 schema 里,
写进 yaml 会被 ``extra="forbid"`` 拒掉。
"""
return np.asarray(_PREFERRED_DIRECTIONS, dtype=np.float32)
def _candidate_centers(
roi_center: AnyArray,
search_radii: list[float],
directions: AnyArray,
) -> list[AnyArray]:
"""生成 auto 模式的候选圆心列表(参考 P:1068-1073 的外两层循环)。
参数:
roi_center: ROI 质心 ``[cx, cy]``(float32)。
search_radii: 候选距离列表。
directions: ``(K, 2)`` 偏好方向数组。
返回:
候选圆心 ``[cx, cy]`` 列表——半径 × 方向(单位化)双重笛卡尔积;零向量跳过。
外层遍历距离、内层遍历方向,保持与参考一致的先小半径、左下优先的枚举顺序。
"""
centers: list[AnyArray] = []
for distance in search_radii:
for row in range(directions.shape[0]):
direction = directions[row]
norm = float(cast(float, np.linalg.norm(direction)))
if norm <= 1e-6:
continue
centers.append(roi_center + direction / norm * float(distance))
return centers
def _circle_within_bounds(center: AnyArray, radius: float, height: int, width: int) -> bool:
"""判断以 ``center`` 为心、``radius`` 为半径的圆是否完整落在画面内(参考 P:1074-1079)。"""
return not (
center[0] - radius < 0
or center[1] - radius < 0
or center[0] + radius >= width
or center[1] + radius >= height
)
def _valid_fraction(mask: AnyArray, valid_background: AnyArray) -> float:
"""候选圆内"有效背景"像素占比(参考 P:1085)。
参数:
mask: 候选圆 mask(bool)。
valid_background: 有效背景 mask(非黑、非医生画的线、非禁区,bool)。
返回:
``(圆 ∩ 有效背景) / 圆面积``;圆面积为 0 时返回 0.0(不会被选中)。
"""
area = int(mask.sum())
if area == 0:
return 0.0
return float((mask & valid_background).sum()) / float(area)
def _score_candidate(
center: AnyArray,
valid_fraction: float,
first_polygon: AnyArray,
roi_scale: float,
) -> float:
"""候选圆评分:有效占比越高越好,离 ROI 越远略减分(参考 P:1088-1091)。
参数:
center: 候选圆心 ``[cx, cy]``。
valid_fraction: 该候选圆内有效背景占比。
first_polygon: 第一帧 ROI 多边形(``(N, 2)``),用于算到 ROI 的最近点距离。
roi_scale: ROI 等效尺度(归一化距离用,下限 1.0)。
返回:
``valid_fraction − 到ROI最近距离 / roi_scale × 0.05``——倾向"贴近 ROI 的干净背景"
(同帧、就近,光照 / 探头条件最一致),但有效占比仍是主导项。
"""
distance_to_roi = float(np.sqrt(((center[None, :] - first_polygon) ** 2).sum(axis=1)).min())
return valid_fraction - distance_to_roi / max(roi_scale, 1.0) * _DISTANCE_PENALTY_WEIGHT
def _roi_scale(first_polygon: AnyArray) -> float:
"""ROI 等效尺度:bbox 长 / 宽中较大者,下限 1.0(参考 P:1053-1055)。"""
bbox_min = first_polygon.min(axis=0)
bbox_max = first_polygon.max(axis=0)
return max(float(bbox_max[0] - bbox_min[0]), float(bbox_max[1] - bbox_min[1]), 1.0)
def _manual_reference(
shape: tuple[int, int], ref_cfg: _ReferenceRegionConfigLike, radius: float
) -> tuple[AnyArray, dict[str, Any]]:
"""manual 模式:以配置 ``center_xy`` + ``radius`` 直接出圆 + metadata(参考 P:1021-1033)。
参数:
shape: 第一帧 ``(height, width)``。
ref_cfg: 参考区配置(取 ``center_xy``)。
radius: 圆半径(像素)。
返回:
``(圆 mask, metadata dict)``;metadata 含 enabled/mode/shape/center_xy/radius_px/area_px。
关键实现:
``center_xy`` 形状必须为 ``(2,)``,否则视为配置缺陷直接报错(不静默兜底)。schema
的 ``model_validator`` 已强制 manual 模式 center_xy 非空,这里再校验维度做纵深防御。
"""
center = np.asarray(ref_cfg.center_xy, dtype=np.float32)
if center.shape != (2,):
raise RuntimeError("tic_analysis.reference_region.center_xy 必须是 [x, y]。")
mask = _circle_mask(shape, center, radius)
return mask, {
"enabled": True,
"mode": "manual",
"shape": "circle",
"center_xy": [float(center[0]), float(center[1])],
"radius_px": radius,
"area_px": int(mask.sum()),
}
def _auto_reference(
first_source_bgr: AnyArray,
source_polygons: AnyArray,
ref_cfg: _ReferenceRegionConfigLike,
prompt_cfg: _PromptHsvConfigLike,
pan_cfg: _PromptHsvConfigLike,
tic_cfg: TicAnalysisConfig,
radius: float,
) -> tuple[AnyArray, dict[str, Any]]:
"""auto_near_roi 模式:ROI 外搜索一块有效背景圆择优(参考 P:1038-1109)。
参数:
first_source_bgr: 第一帧 source 视图 BGR uint8 ``(h, w, 3)``(已是 source 局部坐标)。
source_polygons: 逐帧 ROI 多边形序列(禁区取并集,避开整段 ROI)。
ref_cfg: 参考区配置。
prompt_cfg: 剔绿 HSV 阈值(透传给 ``prompt_color_mask``)。
pan_cfg: 剔紫 HSV 阈值(同一路传法,见「关键实现」为什么紫色也要排)。
tic_cfg: TIC 配置(剔绿开关 / 膨胀核,透传给 ``prompt_color_mask``)。
radius: 圆半径(像素)。
返回:
``(选中圆 mask, metadata dict)``;metadata 额外含 ``valid_fraction_first_frame``。
关键实现:
有效背景 = 非黑(灰度 > ``nonblack_threshold``)且不是医生画上去的线(绿框色与
紫色轮廓色都排掉)且非禁区。逐候选圆需满足:完整在画面内、不触禁区、有效占比
≥ ``min_valid_fraction``,再按 ``_score_candidate`` 取最高分。无合规候选返回空掩膜和
``failure_reason=no_valid_candidate``;背景曲线据此保持 NaN,不阻断病灶拟合。
**紫色和绿色一起排。** 紫色轮廓是医生画的邻近正常胰腺实质参照区,TIC 采样时把
这些像素从掩膜里扣掉(``tic._sample_frame_row`` 的排除色 = 绿 ∪ 紫)。这里若只
排绿,挑中的背景圆可能压在那圈紫色上,``valid_background_circle_px`` 被悄悄削掉
一块,而没有任何一处会报出来——紫色的灰度不低,``nonblack_threshold`` 也拦不住它。
"""
height, width = first_source_bgr.shape[:2]
roi_union = _polygon_union_mask(source_polygons, height, width)
forbidden_roi = _forbidden_roi_mask(roi_union, int(ref_cfg.min_distance_px))
ink_mask = prompt_color_mask(first_source_bgr, prompt_cfg, tic_cfg) | prompt_color_mask(
first_source_bgr, pan_cfg, tic_cfg
)
gray = cv2.cvtColor(first_source_bgr, cv2.COLOR_BGR2GRAY)
nonblack = gray > int(ref_cfg.nonblack_threshold)
valid_background = nonblack & ~ink_mask & ~forbidden_roi
first_polygon = source_polygons[0]
roi_center = first_polygon.mean(axis=0).astype(np.float32)
roi_scale = _roi_scale(first_polygon)
search_radii = _search_radii(roi_scale)
directions = _preferred_directions()
min_valid_fraction = float(ref_cfg.min_valid_fraction)
best: tuple[float, AnyArray, AnyArray, float] | None = None
for candidate_center in _candidate_centers(roi_center, search_radii, directions):
if not _circle_within_bounds(candidate_center, radius, height, width):
continue
mask = _circle_mask((height, width), candidate_center, radius)
if int(mask.sum()) == 0 or np.any(mask & forbidden_roi):
continue
valid_fraction = _valid_fraction(mask, valid_background)
if valid_fraction < min_valid_fraction:
continue
score = _score_candidate(candidate_center, valid_fraction, first_polygon, roi_scale)
if best is None or score > best[0]:
best = (score, candidate_center, mask, valid_fraction)
if best is None:
return np.zeros((height, width), dtype=bool), {
"enabled": True,
"mode": "auto_near_roi",
"shape": "circle",
"available": False,
"failure_reason": "no_valid_candidate",
}
_, selected_center, selected_mask, selected_fraction = best
return selected_mask, {
"enabled": True,
"mode": "auto_near_roi",
"shape": "circle",
"center_xy": [float(selected_center[0]), float(selected_center[1])],
"radius_px": radius,
"area_px": int(selected_mask.sum()),
"valid_fraction_first_frame": float(selected_fraction),
}
def select_reference_region(
first_source_bgr: AnyArray,
source_polygons: AnyArray,
prompt_cfg: _PromptHsvConfigLike,
pan_cfg: _PromptHsvConfigLike,
tic_cfg: TicAnalysisConfig,
) -> tuple[AnyArray, dict[str, Any]]:
"""在第一帧 source 视图上选定 TIC 参考区圆,出 mask + 元数据(参考 P:1007-1109)。
参数:
first_source_bgr: 第一帧 source 视图 BGR uint8 ``(h, w, 3)``(已是 source 局部坐标)。
source_polygons: 逐帧 ROI 多边形序列 ``(T, N, 2)``(参考区只看第一帧定位,禁区取整段并集)。
prompt_cfg: 绿框 HSV 阈值配置(剔绿用,透传 ``prompt_color_mask``)。
pan_cfg: 紫色轮廓 HSV 阈值配置(剔紫用,同一路传法)。
tic_cfg: TIC 分析配置;从中取 ``reference_region`` 子配置与剔绿开关 / 膨胀核。
返回:
``(reference mask, metadata dict)``——
mask:``dtype=bool`` 的 ``(h, w)`` 圆 mask(TIC 据此采样 ``background_circle`` 曲线);
metadata:写进 trajectory.npz 的 ``reference_region_json``。各模式的键集:
- enabled=False → ``{"enabled": False}``(空 mask);
- manual → ``{enabled, mode, shape, center_xy, radius_px, area_px}``;
- auto_near_roi → 额外含 ``valid_fraction_first_frame``。
``shape`` 恒为 ``"circle"``(出自旧项目 findings §2B:输出死键,不从配置读)。
异常:
RuntimeError: ``mode`` 非法,或 manual 的 center_xy 维度非法。
关键实现:
三模式分派——关闭直接空返;manual 直接画圆;auto 走 ``_auto_reference`` 搜索择优。
参考区配置经 ``tic_cfg.reference_region`` 取出,不在本模块写默认值。
"""
ref_cfg = tic_cfg.reference_region
if not bool(ref_cfg.enabled):
empty = np.zeros(first_source_bgr.shape[:2], dtype=bool)
return empty, {"enabled": False}
radius = float(ref_cfg.radius_px)
mode = ref_cfg.mode
if mode == "manual":
return _manual_reference(first_source_bgr.shape[:2], ref_cfg, radius)
if mode != "auto_near_roi":
raise RuntimeError(f"不支持的 tic_analysis.reference_region.mode: {mode}")
return _auto_reference(
first_source_bgr, source_polygons, ref_cfg, prompt_cfg, pan_cfg, tic_cfg, radius
)
+438
View File
@@ -0,0 +1,438 @@
"""第 6 步:TIC 四条曲线的逐帧采样、增强前基线,以及(调第 7 步)Lognormal 拟合。
TIC(时间-强度曲线)是病灶亮度随时间的变化。在造影那一半画面(局部坐标)上逐帧采四条
灰度均值曲线:
- `roi_all`:病灶 ROI 内(剔除绿框色与紫圈色后)的灰度均值;
- `roi_perf`:在 `roi_all` 基础上再剔除暗区后的灰度均值(perf 指灌注,见 `_tic_curves.py`);
- `roi_pan`:医生画的紫圈内部(一块正常胰腺,作对照);
- `background_circle`:程序自动挑的一小块背景圆,只用来看整机增益漂移,不是 `roi_pan`。
掩膜构成:
exclude = 绿框色 ∪ 紫圈色
valid_roi_all = roi_mask & ~exclude
valid_roi_perf = valid_roi_all & ~dark_union
valid_pan = 紫圈填充区 & ~exclude
valid_background = background_circle_mask & ~exclude
四条原始曲线采好后,每条各自识别增强前基线(`detect_baseline`),再交给 `_tic_fitting` 拟合。
曲线最前面不插「时间 0、值 0」的合成点:那个点不是量出来的,会在它与首个真实帧之间造出
一段最陡的假上升,把「变亮最快」的时刻钉在视频开头。
照搬旧项目 `tic.py`,去掉产品层;另加 `pan_detected_fraction`(紫圈检出率,任务书 02 要报)。
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from typing import Any
import cv2
import numpy as np
from cadx.ceus._config import (
PanSourceConfig,
PromptSourceConfig,
TicAnalysisConfig,
TicBaselineConfig,
)
from cadx.ceus._geometry import AnyArray, Frame, Mask, polygon_to_mask, polygons_to_mask
from cadx.ceus._green_prompt import extract_color_prompt, prompt_color_mask
from cadx.ceus._tic_fitting import LognormalFitResult, fit_lognormal_curve
__all__ = [
"CURVE_NAMES",
"BaselineResult",
"TicResult",
"compute_tic_for_video",
"detect_baseline",
"mean_or_nan",
]
# 四条曲线的名字;逐帧行键按 `{name}_raw` / `{name}_processed` 等派生。
CURVE_NAMES = ("roi_all", "roi_perf", "roi_pan", "background_circle")
# 临床口径认得的三条。背景圆不在里面:它不喂任何口径字段,基线被截短的质控位也不由它置位。
_SPEC_CURVE_NAMES = ("roi_all", "roi_perf", "roi_pan")
@dataclass(frozen=True)
class BaselineResult:
"""一条曲线的增强前基线区间(还没被造影剂点亮的那一段)。
`mean`、`sd` 是区间内的稳健中心和噪声(中位数、1.4826×MAD),`valid=False` 时为 None;
`frame_start` 含、`frame_end` 不含,无效时都是 -1。无效时用 None 而不用 0.0:0.0 能一路
算下去把下游悄悄算错,None 会在第一个用它的地方报错。
"""
mean: float | None
sd: float | None
frame_start: int
frame_end: int
valid: bool
@dataclass(frozen=True)
class TicResult:
"""TIC 采样与拟合的结果。
- `rows`:逐帧一行(只有真实帧),键见 `compute_tic_for_video`;
- `parameters`:定量参数,这里恒为 None(灌注指标由 `_perfusion_metrics` 从拟合算);
- `baselines`、`fits`:曲线名 → 基线 / 拟合,键集恒为 `CURVE_NAMES`;
- `early_truncation_flag`:口径三条曲线里有一条采到过值、却凑不出足够长的基线;
- `reference_valid`:紫圈检出率达标,`roi_pan` 可用;为 False 时 `roi_pan` 整条 NaN;
- `pan_detected_fraction`:非重复帧里真正采到紫圈像素的帧占比(紫圈检出率)。
"""
rows: list[dict[str, Any]]
parameters: dict[str, Any] | None
baselines: dict[str, BaselineResult]
fits: dict[str, LognormalFitResult]
early_truncation_flag: bool
reference_valid: bool
pan_detected_fraction: float
def mean_or_nan(gray: AnyArray, mask: AnyArray) -> float:
"""掩膜内灰度均值;掩膜全空时返回 NaN(这一帧这条曲线没采到,不当作亮度 0)。"""
values = gray[mask]
if len(values) == 0:
return float("nan")
return float(values.mean())
_INVALID_BASELINE = BaselineResult(mean=None, sd=None, frame_start=-1, frame_end=-1, valid=False)
def _robust_center_noise(values: AnyArray) -> tuple[float, float]:
"""中位数与 1.4826×MAD(正态下等于标准差的稳健估计)。"""
center = float(np.median(values))
noise = 1.4826 * float(np.median(np.abs(values - center)))
return center, noise
def _window_is_stable(window: AnyArray, sd_multiplier: float) -> bool | None:
"""候选窗对半分,后半中位数有没有明显高过前半。
稳定返回 True、已在上涨返回 False;任一半没有有效采样、判不出来时返回 None。
"""
half = len(window) // 2
first = window[:half]
second = window[half:]
first = first[np.isfinite(first)]
second = second[np.isfinite(second)]
if first.size == 0 or second.size == 0:
return None
first_center, first_noise = _robust_center_noise(first)
second_center = float(np.median(second))
first_differences = np.diff(first)
if first_differences.size:
difference_center = float(np.median(first_differences))
difference_noise = 1.4826 * float(np.median(np.abs(first_differences - difference_center)))
else:
difference_noise = first_noise
return bool(
second_center - first_center
<= sd_multiplier * max(difference_noise, float(np.finfo(np.float64).eps))
)
def detect_baseline(
values: AnyArray,
time_s: AnyArray,
cfg: TicBaselineConfig,
sample_mask: AnyArray | None,
) -> BaselineResult:
"""在曲线开头按真实时间找增强前基线。
候选窗从视频开头覆盖 `preferred_duration_s` 秒;对半分后后半中位数比前半高出超过
`stability_sd_multiplier × 前半差分噪声`,就从右侧逐个样本缩窗;有效样本覆盖的时长不足
`minimum_duration_s` 时基线无效。NaN 帧和 `sample_mask` 为 False 的帧不算基线样本
(`sample_mask` 为 None 表示都算)。
这一步只出基线均值和噪声,不出到达时刻和峰值:到达门槛含峰高,要先找峰,所以到达时刻
在拟合那一步回头定。
"""
series = np.asarray(values, dtype=np.float64)
times = np.asarray(time_s, dtype=np.float64)
mask = (
np.ones(series.shape, dtype=bool)
if sample_mask is None
else np.asarray(sample_mask, dtype=bool)
)
if series.ndim != 1 or times.shape != series.shape or mask.shape != series.shape:
raise ValueError("values、time_s 与 sample_mask 必须是等长一维数组。")
if series.size == 0 or not np.isfinite(times).all() or np.any(np.diff(times) <= 0):
raise ValueError("time_s 必须非空、有限且严格递增。")
window_end_s = float(times[0]) + cfg.preferred_duration_s
end = int(np.searchsorted(times, window_end_s, side="left"))
end = min(max(end, 1), int(series.size))
cadence = float(np.median(np.diff(times))) if series.size > 1 else 0.0
while end > 0:
window_mask = np.isfinite(series[:end]) & mask[:end]
finite_indices = np.flatnonzero(window_mask)
if finite_indices.size < 2:
return _INVALID_BASELINE
observed_duration = float(times[finite_indices[-1]] - times[finite_indices[0]]) + cadence
if observed_duration + 1e-9 < cfg.minimum_duration_s:
return _INVALID_BASELINE
window = np.where(window_mask, series[:end], np.nan)
stable = _window_is_stable(window, cfg.stability_sd_multiplier)
if stable is None:
return _INVALID_BASELINE
if stable:
finite = series[:end][window_mask]
center, noise = _robust_center_noise(finite)
return BaselineResult(
mean=center,
sd=noise,
frame_start=0,
frame_end=end,
valid=True,
)
end -= 1
return _INVALID_BASELINE
def _sample_frame_row(
frame_index: int,
time_s: float,
source_bgr: Frame,
roi_polygon: AnyArray,
frame_dark_polygons: Sequence[AnyArray],
background_circle_mask: Mask,
prompt_cfg: PromptSourceConfig,
pan_cfg: PanSourceConfig,
tic_cfg: TicAnalysisConfig,
) -> tuple[dict[str, Any], AnyArray]:
"""一帧的四掩膜采样,返回这一帧的原始行和判断相邻重复帧用的 ROI 灰度签名。
紫圈的线本身是医生画的,不是组织,所以排除色是「绿 ∪ 紫」。`roi_pan` 取紫圈实心填充
再减排除色(轮廓线本身不算参照区内部),这一帧检不到紫圈时 `pan_area_px=0`、
`roi_pan_raw` 为 NaN;紫圈不走分割、不做传播,画面上每帧都有,直接读。
`pan_area_px` 是紫圈填充面积(未扣排除色),`valid_pan_px` 是扣完后真正参与统计的像素数;
检出率数后者。暗区多边形一次奇偶填充成掩膜(洞不计回暗区),再收口到 `valid_roi_all`;
收口后暗区面积为 0 时暗块计数也记 0。
"""
height, width = source_bgr.shape[:2]
gray = np.asarray(cv2.cvtColor(source_bgr, cv2.COLOR_BGR2GRAY))
exclude_mask = prompt_color_mask(source_bgr, prompt_cfg, tic_cfg) | prompt_color_mask(
source_bgr, pan_cfg, tic_cfg
)
roi_mask = polygon_to_mask(roi_polygon, height, width)
valid_roi_all_mask = roi_mask & ~exclude_mask
valid_background_mask = background_circle_mask & ~exclude_mask
dark_union = polygons_to_mask(frame_dark_polygons, height, width)
dark_union &= valid_roi_all_mask
valid_roi_perf_mask = valid_roi_all_mask & ~dark_union
pan_prompt = extract_color_prompt(source_bgr, pan_cfg)
if pan_prompt is None:
pan_mask = np.zeros((height, width), dtype=bool)
pan_area_px = 0
else:
pan_mask = pan_prompt.mask & ~exclude_mask
pan_area_px = int(pan_prompt.mask.sum())
dark_area_px = int(dark_union.sum())
dark_component_count = len(frame_dark_polygons) if dark_area_px > 0 else 0
x_coord, y_coord, box_width, box_height = cv2.boundingRect(
np.asarray(roi_polygon, dtype=np.float32)
)
roi_gray = gray[y_coord : y_coord + box_height, x_coord : x_coord + box_width]
roi_crop_mask = roi_mask[y_coord : y_coord + box_height, x_coord : x_coord + box_width]
signature_gray = np.asarray(cv2.resize(roi_gray, (32, 32), interpolation=cv2.INTER_AREA))
signature_mask = np.asarray(
cv2.resize(roi_crop_mask.astype(np.uint8), (32, 32), interpolation=cv2.INTER_NEAREST)
).astype(bool)
signature = np.where(signature_mask, signature_gray, 0).astype(np.float32)
row: dict[str, Any] = {
"frame_index": frame_index,
"time_sec": float(time_s),
"roi_all_raw": mean_or_nan(gray, valid_roi_all_mask),
"roi_perf_raw": mean_or_nan(gray, valid_roi_perf_mask),
"roi_pan_raw": mean_or_nan(gray, pan_mask),
"background_circle_raw": mean_or_nan(gray, valid_background_mask),
"roi_area_px": int(roi_mask.sum()),
"dark_area_px": dark_area_px,
"pan_area_px": pan_area_px,
"background_circle_area_px": int(background_circle_mask.sum()),
"valid_roi_all_px": int(valid_roi_all_mask.sum()),
"valid_roi_perf_px": int(valid_roi_perf_mask.sum()),
"valid_pan_px": int(pan_mask.sum()),
"valid_background_circle_px": int(valid_background_mask.sum()),
"dark_component_count": dark_component_count,
}
return row, signature
def _attach_baselines_and_fits(
rows: list[dict[str, Any]], tic_cfg: TicAnalysisConfig
) -> tuple[dict[str, BaselineResult], dict[str, LognormalFitResult], bool]:
"""逐条曲线识别基线并拟合,把候选、拟合、正式曲线原地写回逐帧行。
每条曲线各算各的基线窗:胰腺比病灶早增强,共用一个窗会把 `roi_pan` 的窗拖进已在上涨的区间。
`roi_perf` 只用「去暗区面积占比与上一帧相比变化不超过 `maximum_perf_area_step_fraction`」
的帧(`perf_mask_stable`),暗区掩膜跳变的帧不进基线和拟合。
`early_truncation_flag` 只看口径三条:某条采到过实测值、却凑不出足够长的基线就置位;
整条 NaN 的曲线不算(那是没检出,由 `reference_valid` 管);背景圆不参与。
"""
baselines: dict[str, BaselineResult] = {}
fits: dict[str, LognormalFitResult] = {}
early_truncation_flag = False
times = np.asarray([row["time_sec"] for row in rows], dtype=np.float64)
duplicates = np.asarray([row["duplicate_frame"] for row in rows], dtype=bool)
roi_areas = np.asarray([row["roi_area_px"] for row in rows], dtype=np.float64)
perf_areas = np.asarray([row["valid_roi_perf_px"] for row in rows], dtype=np.float64)
perf_fractions = np.divide(
perf_areas,
roi_areas,
out=np.zeros_like(perf_areas),
where=roi_areas > 0.0,
)
perf_mask_stable = roi_areas > 0.0
if len(rows) > 1:
stable_steps = (
np.abs(np.diff(perf_fractions)) <= tic_cfg.fitting.maximum_perf_area_step_fraction
)
perf_mask_stable[1:] &= stable_steps
for index, row in enumerate(rows):
row["perf_mask_stable"] = bool(perf_mask_stable[index])
for name in CURVE_NAMES:
raw = np.asarray([row[f"{name}_raw"] for row in rows], dtype=np.float64)
curve_mask = perf_mask_stable if name == "roi_perf" else np.ones(len(rows), dtype=bool)
baseline = detect_baseline(raw, times, tic_cfg.baseline, ~duplicates & curve_mask)
baselines[name] = baseline
if name in _SPEC_CURVE_NAMES and not baseline.valid and bool(np.isfinite(raw).any()):
early_truncation_flag = True
fit = fit_lognormal_curve(
times,
raw,
baseline=baseline.mean,
baseline_noise=baseline.sd,
baseline_end_index=baseline.frame_end,
duplicate_mask=duplicates,
sample_mask=curve_mask,
cfg=tic_cfg.fitting,
anchor_cfg=tic_cfg.anchor_frames,
)
fits[name] = fit
for idx, row in enumerate(rows):
row[f"{name}_candidate"] = float(fit.candidate[idx])
row[f"{name}_fitted"] = float(fit.fitted[idx])
row[f"{name}_processed"] = float(fit.processed[idx])
return baselines, fits, early_truncation_flag
def compute_tic_for_video(
source_frames: Iterable[Frame],
roi_polygons: Sequence[AnyArray],
dark_polygons_per_frame: Sequence[Sequence[AnyArray]],
reference_mask: AnyArray,
fps: float,
prompt_cfg: PromptSourceConfig,
pan_cfg: PanSourceConfig,
tic_cfg: TicAnalysisConfig,
frame_times_s: Sequence[float] | AnyArray | None,
) -> TicResult:
"""逐帧采四掩膜灰度 → 标记重复帧 → 基线与 Lognormal 拟合,交出 `TicResult`。
- `source_frames`:造影那一半的逐帧 BGR(局部坐标),逐帧消费、不留,可以直接喂解码迭代器;
- `roi_polygons`、`dark_polygons_per_frame`:与帧等长的逐帧 ROI 多边形和暗区多边形列表;
- `reference_mask`:背景圆掩膜(第一帧定位、整段沿用);
- `frame_times_s`:逐帧显示时间戳(秒);为 None 时按 `fps` 构造等间隔时间轴(只给合成测试用)。
`rows` 每行键:`frame_index`、`time_sec`、`duplicate_frame`、`perf_mask_stable`,四条曲线各自的
`_raw`、`_candidate`、`_fitted`、`_processed`,以及各掩膜面积和有效像素数、暗块计数。
紫圈检出率 = 非重复帧里 `valid_pan_px > 0` 的帧占比(数的是真正参与统计的像素,不是填充面积:
一整块实心紫色会让填充面积过门槛而扣完排除色一个像素不剩)。达到
`pan_cfg.min_detected_frame_fraction` 才算紫圈可用;不达标时 `roi_pan` 整条记 NaN、基线无效,
不拿背景圆去填(两者不是同一种东西)。
"""
if len(roi_polygons) != len(dark_polygons_per_frame):
raise ValueError(
"roi_polygons 与 dark_polygons_per_frame 长度必须一致,得到 "
f"{len(roi_polygons)} / {len(dark_polygons_per_frame)}。"
)
if float(fps) <= 0.0:
raise ValueError(f"fps 必须为正,得到 {fps}。")
if tic_cfg.brightness_metric != "grayscale_mean":
raise ValueError(
f"仅支持 brightness_metric=grayscale_mean,得到 {tic_cfg.brightness_metric}。"
)
background_circle_mask = reference_mask.astype(bool)
if frame_times_s is None:
times = np.arange(len(roi_polygons), dtype=np.float64) / float(fps)
else:
times = np.asarray(frame_times_s, dtype=np.float64)
if times.shape != (len(roi_polygons),):
raise ValueError(
"frame_times_s 必须与 roi_polygons 等长,得到 "
f"{times.shape} / {len(roi_polygons)}。"
)
if not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0):
raise ValueError("frame_times_s 必须有限且严格递增。")
# 帧是流式的,长度要遍历完才知道:zip(strict=True) 保证三个序列等长,多了少了当场报错。
rows: list[dict[str, Any]] = []
previous_unique_signature: AnyArray | None = None
for frame_index, (frame, roi_polygon, dark_polygons) in enumerate(
zip(source_frames, roi_polygons, dark_polygons_per_frame, strict=True)
):
if frame_index == 0 and background_circle_mask.shape != frame.shape[:2]:
raise ValueError(
f"reference_mask 形状 {background_circle_mask.shape} 与 source 帧 "
f"{frame.shape[:2]} 不符。"
)
row, signature = _sample_frame_row(
frame_index,
float(times[frame_index]),
frame,
roi_polygon,
dark_polygons,
background_circle_mask,
prompt_cfg,
pan_cfg,
tic_cfg,
)
duplicate = False
if tic_cfg.fitting.detect_duplicate_frames and previous_unique_signature is not None:
difference = float(np.mean(np.abs(signature - previous_unique_signature)))
duplicate = difference <= tic_cfg.fitting.duplicate_mean_absdiff_threshold
row["duplicate_frame"] = duplicate
rows.append(row)
if not duplicate:
previous_unique_signature = signature
if not rows:
raise ValueError("source_frames 不能为空。")
unique_rows = [row for row in rows if not row["duplicate_frame"]]
detected_frames = sum(1 for row in unique_rows if row["valid_pan_px"] > 0)
pan_detected_fraction = detected_frames / len(unique_rows) if unique_rows else 0.0
reference_valid = bool(unique_rows) and (
pan_detected_fraction >= pan_cfg.min_detected_frame_fraction
)
if not reference_valid:
for row in rows:
row["roi_pan_raw"] = float("nan")
baselines, fits, early_truncation_flag = _attach_baselines_and_fits(rows, tic_cfg)
return TicResult(
rows=rows,
parameters=None,
baselines=baselines,
fits=fits,
early_truncation_flag=early_truncation_flag,
reference_valid=reference_valid,
pan_detected_fraction=pan_detected_fraction,
)
+436
View File
@@ -0,0 +1,436 @@
"""tic_curves.npz 的打包和解包:四条曲线连同基线、拟合、面积、有效像素数(格式同旧项目)。
曲线数值单独存一份,问「第 37 秒那条曲线是多少」不必重跑整段视频。
- 解包用 `np.load(..., allow_pickle=False)`,坏字节直接报错;
- 打包前校验各逐帧数组长度一致;
- 字符串存 0 维 str 数组,不写 object 数组;
- NaN 原样写入:它是「这一帧没采到」的编码,本模块不用 `np.nan_to_num`,填 0 会把
「没采到」变成「亮度是 0」而不报错。
照搬旧项目 `tic_curves.py`,去掉产品层。
"""
import io
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
from cadx.ceus._geometry import AnyArray
from cadx.ceus._tic import CURVE_NAMES, TicResult
# 这个文件自己的布局版本。键集、dtype、形状任一处变了就要换它。
CURVES_SCHEMA_VERSION = "3"
# 逐帧整数列的键名(面积 / 有效像素数 / 暗块计数),行键与工件键同名。
_COUNT_KEYS = (
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
)
@dataclass(frozen=True)
class TicCurveSeries:
"""一条曲线的原始样本、候选曲线、Lognormal 结果与拟合质量。
``fitted`` 保留优化器得到但可能未过质量门的曲线,便于复核失败;``processed`` 只在
质量门通过时等于 ``fitted``,失败时整条为 NaN。这样候选或失败拟合不会冒充正式曲线。
"""
raw: AnyArray
candidate: AnyArray
fitted: AnyArray
processed: AnyArray
valid_sample_mask: AnyArray
duplicate_sample_mask: AnyArray
baseline_mean: float
baseline_sd: float
baseline_frames: tuple[int, int]
baseline_valid: bool
fit_success: bool
fit_failure_reason: str
curve_model: str
smoothing_method: str
baseline_source: str
fit_area: float
fit_mu: float
fit_sigma: float
fit_arrival_offset_s: float
fit_baseline: float
fit_r2: float
fit_nrmse: float
residual_noise_ratio: float
fit_interval_s: tuple[float, float]
multi_peak_flag: bool
anchor_times_s: tuple[float, float, float, float]
@dataclass(frozen=True)
class TicCurves:
"""TIC 曲线工件的内存表示(npz pack/unpack 的双向载体)。
属性:
frame_indices: ``(T,)`` int32,逐帧下标。``T`` 是真实帧数,**不含任何合成点**。
time_video_s: ``(T,)`` float32,来自 MP4 presentation timestamp。
injection_offset_s: 推注偏移(秒);取不到时为 ``float("nan")``,**不是 0**
——填 0 等于断言「视频和推注同时开始」,而那是我们没有的信息。
time_origin: ``"injection"`` 或 ``"video_start"``,说明绝对时间轴以谁为零点。
fps: 视频帧率。
curves_schema_version: 这个文件自己的布局版本。
curves: 曲线名 → ``TicCurveSeries``,键集恒为 ``tic.CURVE_NAMES``。
roi_area_px / dark_area_px / pan_area_px / background_circle_area_px:
``(T,)`` int32,逐帧各掩膜栅格面积。
valid_roi_all_px / valid_roi_perf_px / valid_pan_px /
valid_background_circle_px: ``(T,)`` int32,逐帧真正参与统计的像素数。
dark_component_count: ``(T,)`` int32,逐帧暗块个数。
reference_valid: 紫色轮廓检出率达标,``roi_pan`` 那条可用。
early_truncation_flag: 有曲线没能凑出足够长的基线区间。
**``roi_perf`` 这个名字断言了「暗区 = 无灌注区」这个等号。** 「灌注」指血液连同
造影剂真正流进一块组织,没有灌注的地方(囊液、坏死、出血)不会被点亮;``_perf``
后缀在规范里指「去掉无灌注区之后剩下的可灌注组织」。而我们量到的是**暗区**——
病变轮廓内部灰度明显偏低的那一块,它不必然等于无灌注区,**伪影**(超声成像自身
产生的假象,比如前方有气体或钙化挡住声波、后方整片变暗的声影)也会让有灌注的组织
显得暗。**这个等号是工科方在 2026-08-12 会上提出的,临床方带着「肯定会有被伪影干扰」
这个保留意见暂时认下**(旧项目会议转录 475 与 477 行;旧 design/0030 决策七据此写成)。
2026-08-16 项目负责人拍板先按这个等号做、暂不为伪影做任何区分,将来它被推翻时,要回头
重算的正是所有带 ``_perf`` 后缀的量。
**三样东西这里不存**,各有各的理由:
``S(t) = raw − B`` 不存。``B`` 已经在文件里了,再存一遍就是同一个数放两份,
迟早有一份被改、另一份没改。
``t_injection = time_video_s + injection_offset_s`` 不存。同上,两个加数都在。
无灌注比例不存。临床方原规范 §6 给了公式 ``(A_all − A_perf) / A_all``,但没给它的时间下标
——取哪个时点还挂在临床方名下。逐帧的分子分母(``roi_area_px`` 与 ``dark_area_px``)
都在文件里,时点定下来之后是一次派生,不必重跑视频。
"""
frame_indices: AnyArray
time_video_s: AnyArray
injection_offset_s: float
time_origin: str
fps: float
config_version: str
algorithm_version: str
curves_schema_version: str
curves: dict[str, TicCurveSeries]
roi_area_px: AnyArray
dark_area_px: AnyArray
pan_area_px: AnyArray
background_circle_area_px: AnyArray
valid_roi_all_px: AnyArray
valid_roi_perf_px: AnyArray
valid_pan_px: AnyArray
valid_background_circle_px: AnyArray
dark_component_count: AnyArray
perf_mask_stable: AnyArray
reference_valid: bool
early_truncation_flag: bool
def _nan_if_none(value: float | None) -> float:
"""缺失的时刻在 npz 里记 NaN(float 数组没有 null)。"""
return float("nan") if value is None else float(value)
def from_tic_result(
result: TicResult,
fps: float,
injection_offset_s: float | None,
config_version: str,
) -> TicCurves:
"""把 ``TicResult`` 转成 ``TicCurves``,调用方不必知道行键名。
参数:
result: ``tic.compute_tic_for_video`` 的产物。
fps: 视频帧率(>0)。
injection_offset_s: 推注偏移(秒);源资料里没有就传 ``None``。
config_version: 生成本工件的科研配置版本。
返回:
``TicCurves``。
异常:
ValueError: ``result.rows`` 为空,或 ``fps`` 非正。
``time_origin`` 由 ``injection_offset_s`` 派生,不单独作为入参:两者只有一种自洽的
搭配(有偏移才谈得上以推注为零点),拆成两个参数只是多一个能填错的地方。
"""
if float(fps) <= 0.0:
raise ValueError(f"fps 必须为正,得到 {fps}。")
rows = result.rows
if not rows:
raise ValueError("TicResult.rows 不能为空。")
if not config_version:
raise ValueError("config_version 不能为空。")
algorithm_versions = {result.fits[name].algorithm_version for name in CURVE_NAMES}
if len(algorithm_versions) != 1:
raise ValueError(f"四条曲线的 algorithm_version 不一致:{algorithm_versions}")
curves: dict[str, TicCurveSeries] = {}
for name in CURVE_NAMES:
baseline = result.baselines[name]
fit = result.fits[name]
parameters = fit.parameters
curves[name] = TicCurveSeries(
raw=np.asarray([row[f"{name}_raw"] for row in rows], dtype=np.float32),
candidate=np.asarray([row[f"{name}_candidate"] for row in rows], dtype=np.float32),
fitted=np.asarray([row[f"{name}_fitted"] for row in rows], dtype=np.float32),
processed=np.asarray([row[f"{name}_processed"] for row in rows], dtype=np.float32),
valid_sample_mask=fit.valid_sample_mask.astype(bool),
duplicate_sample_mask=fit.duplicate_sample_mask.astype(bool),
baseline_mean=float("nan") if baseline.mean is None else baseline.mean,
baseline_sd=float("nan") if baseline.sd is None else baseline.sd,
baseline_frames=(baseline.frame_start, baseline.frame_end),
baseline_valid=baseline.valid,
fit_success=fit.success,
fit_failure_reason=fit.failure_reason,
curve_model=fit.curve_model,
smoothing_method=fit.smoothing_method,
baseline_source=fit.baseline_source,
fit_area=float("nan") if parameters is None else parameters.area,
fit_mu=float("nan") if parameters is None else parameters.mu,
fit_sigma=float("nan") if parameters is None else parameters.sigma,
fit_arrival_offset_s=(
float("nan") if parameters is None else parameters.arrival_offset_s
),
fit_baseline=float("nan") if parameters is None else parameters.baseline,
fit_r2=fit.r2,
fit_nrmse=fit.nrmse,
residual_noise_ratio=fit.residual_noise_ratio,
fit_interval_s=(fit.fit_start_s, fit.fit_end_s),
multi_peak_flag=fit.multi_peak_flag,
anchor_times_s=(
_nan_if_none(fit.d1_time_s),
_nan_if_none(fit.d2_time_s),
_nan_if_none(fit.d3_time_s),
_nan_if_none(fit.d4_time_s),
),
)
counts = {key: np.asarray([row[key] for row in rows], dtype=np.int32) for key in _COUNT_KEYS}
frame_indices = np.asarray([row["frame_index"] for row in rows], dtype=np.int32)
return TicCurves(
frame_indices=frame_indices,
time_video_s=np.asarray([row["time_sec"] for row in rows], dtype=np.float32),
injection_offset_s=(
float("nan") if injection_offset_s is None else float(injection_offset_s)
),
time_origin="video_start" if injection_offset_s is None else "injection",
fps=float(fps),
config_version=config_version,
algorithm_version=algorithm_versions.pop(),
curves_schema_version=CURVES_SCHEMA_VERSION,
curves=curves,
perf_mask_stable=np.asarray([row["perf_mask_stable"] for row in rows], dtype=bool),
reference_valid=result.reference_valid,
early_truncation_flag=result.early_truncation_flag,
**counts,
)
def _validate_lengths(curves: TicCurves) -> int:
"""校验逐帧列长度一致,返回帧数 T;任何不一致直接 raise。"""
if curves.frame_indices.ndim != 1:
raise ValueError(f"frame_indices 必须一维,得到 {curves.frame_indices.shape}。")
num_frames = int(curves.frame_indices.shape[0])
curve_names = set(curves.curves)
expected_names = set(CURVE_NAMES)
if curve_names != expected_names:
raise ValueError(f"曲线键集必须为 {sorted(expected_names)},得到 {sorted(curve_names)}。")
if curves.curves_schema_version != CURVES_SCHEMA_VERSION:
raise ValueError(
f"只支持 curves schema {CURVES_SCHEMA_VERSION},得到 {curves.curves_schema_version}。"
)
if not curves.config_version or not curves.algorithm_version:
raise ValueError("config_version 与 algorithm_version 不能为空。")
if num_frames == 0 or np.any(np.diff(curves.frame_indices) <= 0):
raise ValueError("frame_indices 必须非空且严格递增。")
if not np.isfinite(curves.time_video_s).all() or np.any(np.diff(curves.time_video_s) <= 0.0):
raise ValueError("time_video_s 必须有限且严格递增。")
named: list[tuple[str, AnyArray]] = [("time_video_s", curves.time_video_s)]
named += [(key, getattr(curves, key)) for key in _COUNT_KEYS]
named.append(("perf_mask_stable", curves.perf_mask_stable))
for name in CURVE_NAMES:
series = curves.curves[name]
named.append((f"{name}_raw", series.raw))
named.append((f"{name}_candidate", series.candidate))
named.append((f"{name}_fitted", series.fitted))
named.append((f"{name}_processed", series.processed))
named.append((f"{name}_valid_sample_mask", series.valid_sample_mask))
named.append((f"{name}_duplicate_sample_mask", series.duplicate_sample_mask))
for key, array in named:
if array.shape != (num_frames,):
raise ValueError(f"{key} 必须形如 ({num_frames},),得到 {array.shape}。")
return num_frames
def pack_tic_curves(curves: TicCurves) -> bytes:
"""把 ``TicCurves`` 校验并打包为压缩 npz 字节(产物工件)。
参数:
curves: 待打包的曲线载体。
返回:
压缩 npz 的原始字节。
异常:
ValueError: 逐帧列长度不一致,或曲线名缺失。
关键实现:
先 ``_validate_lengths`` 钉死长度(不一致即 raise,不静默对齐);字符串键存为
0 维 str 数组,标量存 ``(1,)`` 数组,布尔存 ``int8``,全程不写入任何 object
数组(禁 pickle)。NaN 原样写入 float32——它是「这一帧没采到」的编码,
任何形式的填零都会把这个事实抹掉。
"""
_validate_lengths(curves)
payload: dict[str, AnyArray] = {
"frame_indices": curves.frame_indices.astype(np.int32),
"time_video_s": curves.time_video_s.astype(np.float32),
"injection_offset_s": np.asarray([curves.injection_offset_s], dtype=np.float32),
"time_origin": np.array(curves.time_origin),
"fps": np.asarray([curves.fps], dtype=np.float32),
"config_version": np.array(curves.config_version),
"algorithm_version": np.array(curves.algorithm_version),
"curves_schema_version": np.array(curves.curves_schema_version),
"reference_valid": np.asarray([int(curves.reference_valid)], dtype=np.int8),
"early_truncation_flag": np.asarray([int(curves.early_truncation_flag)], dtype=np.int8),
"perf_mask_stable": curves.perf_mask_stable.astype(np.int8),
}
for key in _COUNT_KEYS:
payload[key] = np.asarray(getattr(curves, key), dtype=np.int32)
for name in CURVE_NAMES:
series = curves.curves[name]
payload[f"{name}_raw"] = series.raw.astype(np.float32)
payload[f"{name}_candidate"] = series.candidate.astype(np.float32)
payload[f"{name}_fitted"] = series.fitted.astype(np.float32)
payload[f"{name}_processed"] = series.processed.astype(np.float32)
payload[f"{name}_valid_sample_mask"] = series.valid_sample_mask.astype(np.int8)
payload[f"{name}_duplicate_sample_mask"] = series.duplicate_sample_mask.astype(np.int8)
payload[f"{name}_baseline_mean"] = np.asarray([series.baseline_mean], dtype=np.float32)
payload[f"{name}_baseline_sd"] = np.asarray([series.baseline_sd], dtype=np.float32)
payload[f"{name}_baseline_frames"] = np.asarray(series.baseline_frames, dtype=np.int32)
payload[f"{name}_baseline_valid"] = np.asarray([int(series.baseline_valid)], dtype=np.int8)
payload[f"{name}_fit_success"] = np.asarray([int(series.fit_success)], dtype=np.int8)
payload[f"{name}_fit_failure_reason"] = np.array(series.fit_failure_reason)
payload[f"{name}_curve_model"] = np.array(series.curve_model)
payload[f"{name}_smoothing_method"] = np.array(series.smoothing_method)
payload[f"{name}_baseline_source"] = np.array(series.baseline_source)
payload[f"{name}_fit_parameters"] = np.asarray(
[
series.fit_area,
series.fit_mu,
series.fit_sigma,
series.fit_arrival_offset_s,
series.fit_baseline,
],
dtype=np.float64,
)
payload[f"{name}_fit_quality"] = np.asarray(
[series.fit_r2, series.fit_nrmse, series.residual_noise_ratio],
dtype=np.float64,
)
payload[f"{name}_fit_interval_s"] = np.asarray(series.fit_interval_s, dtype=np.float64)
payload[f"{name}_multi_peak_flag"] = np.asarray(
[int(series.multi_peak_flag)], dtype=np.int8
)
payload[f"{name}_anchor_times_s"] = np.asarray(series.anchor_times_s, dtype=np.float64)
buffer = io.BytesIO()
# 存根把 **payload 也当成可能填进 allow_pickle 等关键字参数,这里只有数组键。
cast(Any, np.savez_compressed)(buffer, **payload)
return buffer.getvalue()
def unpack_tic_curves(raw: bytes) -> TicCurves:
"""把 npz 字节解包还原为 ``TicCurves``(禁 pickle,坏字节 raise)。
参数:
raw: ``pack_tic_curves`` 产出的 npz 原始字节。
返回:
还原后的 ``TicCurves``,dtype 与 pack 时一致。
异常:
ValueError / OSError / KeyError: 非法 npz、含 object pickle 成员、或缺键。
关键实现:
``np.load(..., allow_pickle=False)`` 硬禁 pickle——任何含 object pickle 的恶意
字节都会在访问该成员时抛错而非被反序列化执行。NaN 原样取回,不做任何替换。
"""
with np.load(io.BytesIO(raw), allow_pickle=False) as archive:
schema_version = str(archive["curves_schema_version"].item())
if schema_version != CURVES_SCHEMA_VERSION:
raise ValueError(
f"只支持 curves schema {CURVES_SCHEMA_VERSION},得到 {schema_version}。"
)
curves: dict[str, TicCurveSeries] = {}
for name in CURVE_NAMES:
frames = archive[f"{name}_baseline_frames"].astype(np.int32)
fit_parameters = archive[f"{name}_fit_parameters"].astype(np.float64)
fit_quality = archive[f"{name}_fit_quality"].astype(np.float64)
fit_interval = archive[f"{name}_fit_interval_s"].astype(np.float64)
anchor_times = archive[f"{name}_anchor_times_s"].astype(np.float64)
curves[name] = TicCurveSeries(
raw=archive[f"{name}_raw"].astype(np.float32),
candidate=archive[f"{name}_candidate"].astype(np.float32),
fitted=archive[f"{name}_fitted"].astype(np.float32),
processed=archive[f"{name}_processed"].astype(np.float32),
valid_sample_mask=archive[f"{name}_valid_sample_mask"].astype(bool),
duplicate_sample_mask=archive[f"{name}_duplicate_sample_mask"].astype(bool),
baseline_mean=float(archive[f"{name}_baseline_mean"][0]),
baseline_sd=float(archive[f"{name}_baseline_sd"][0]),
baseline_frames=(int(frames[0]), int(frames[1])),
baseline_valid=bool(archive[f"{name}_baseline_valid"][0]),
fit_success=bool(archive[f"{name}_fit_success"][0]),
fit_failure_reason=str(archive[f"{name}_fit_failure_reason"].item()),
curve_model=str(archive[f"{name}_curve_model"].item()),
smoothing_method=str(archive[f"{name}_smoothing_method"].item()),
baseline_source=str(archive[f"{name}_baseline_source"].item()),
fit_area=float(fit_parameters[0]),
fit_mu=float(fit_parameters[1]),
fit_sigma=float(fit_parameters[2]),
fit_arrival_offset_s=float(fit_parameters[3]),
fit_baseline=float(fit_parameters[4]),
fit_r2=float(fit_quality[0]),
fit_nrmse=float(fit_quality[1]),
residual_noise_ratio=float(fit_quality[2]),
fit_interval_s=(float(fit_interval[0]), float(fit_interval[1])),
multi_peak_flag=bool(archive[f"{name}_multi_peak_flag"][0]),
anchor_times_s=(
float(anchor_times[0]),
float(anchor_times[1]),
float(anchor_times[2]),
float(anchor_times[3]),
),
)
counts = {key: archive[key].astype(np.int32) for key in _COUNT_KEYS}
return TicCurves(
frame_indices=archive["frame_indices"].astype(np.int32),
time_video_s=archive["time_video_s"].astype(np.float32),
injection_offset_s=float(archive["injection_offset_s"][0]),
time_origin=str(archive["time_origin"].item()),
fps=float(archive["fps"][0]),
config_version=str(archive["config_version"].item()),
algorithm_version=str(archive["algorithm_version"].item()),
curves_schema_version=schema_version,
curves=curves,
perf_mask_stable=archive["perf_mask_stable"].astype(bool),
reference_valid=bool(archive["reference_valid"][0]),
early_truncation_flag=bool(archive["early_truncation_flag"][0]),
**counts,
)
+928
View File
@@ -0,0 +1,928 @@
"""第 7 步:TIC 的约束 Lognormal 拟合,以及拟合曲线上的连续时刻 D1–D4。
正式模型直接拟合清洗后的原始灰度样本。二阶 Savitzky-Golay 平滑只用来找候选峰、圈出第一遍
团注(造影剂第一次流过)的时间段和给优化器初值;质量门不通过时,不把候选曲线顶替成正式的
`processed` 曲线(`processed` 整条为 NaN),免得一条没拟合好的曲线冒充正式结果。
照搬旧项目 `tic_fitting.py`,去掉产品层;旧 `_empty_result` 与 `fit_lognormal_curve` 的参数
默认值改成调用处显式传入。
"""
import itertools
import math
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
from scipy.optimize import ( # pyright: ignore[reportMissingTypeStubs]
brentq, # pyright: ignore[reportUnknownVariableType]
least_squares, # pyright: ignore[reportUnknownVariableType]
)
from scipy.signal import ( # pyright: ignore[reportMissingTypeStubs]
find_peaks, # pyright: ignore[reportUnknownVariableType]
savgol_filter, # pyright: ignore[reportUnknownVariableType]
)
from cadx.ceus._config import AnchorFramesConfig, TicFittingConfig
from cadx.ceus._geometry import AnyArray
@dataclass(frozen=True)
class LognormalParameters:
"""Lognormal 团注曲线的五个参数:面积、对数均值、对数标准差、到达偏移(秒)、基线。"""
area: float
mu: float
sigma: float
arrival_offset_s: float
baseline: float
@dataclass(frozen=True)
class LognormalFitResult:
"""一条曲线的拟合结果。
`candidate` 是候选平滑曲线(只作检测用);`fitted` 是优化器给出的曲线(可能没过质量门);
`processed` 只在 `success` 时等于 `fitted`,否则整条 NaN。`d1_time_s`–`d4_time_s` 是拟合
曲线上的到达、洗入拐点、峰值、洗出拐点时刻(视频时间,秒),失败时为 None。
"""
success: bool
failure_reason: str
algorithm_version: str
curve_model: str
smoothing_method: str
baseline_source: str
candidate: AnyArray
fitted: AnyArray
processed: AnyArray
valid_sample_mask: AnyArray
duplicate_sample_mask: AnyArray
parameters: LognormalParameters | None
r2: float
nrmse: float
residual_noise_ratio: float
fit_start_s: float
fit_end_s: float
multi_peak_flag: bool
d1_time_s: float | None
d2_time_s: float | None
d3_time_s: float | None
d4_time_s: float | None
def evaluate_lognormal(time_s: AnyArray | float, parameters: LognormalParameters) -> AnyArray:
"""带固定基线与到达偏移的 Lognormal 团注曲线在给定时刻的值。"""
times = np.asarray(time_s, dtype=np.float64)
values = np.full(times.shape, parameters.baseline, dtype=np.float64)
shifted = times - parameters.arrival_offset_s
positive = shifted > 0.0
if not positive.any():
return values
x_values = shifted[positive]
exponent = -((np.log(x_values) - parameters.mu) ** 2 / (2.0 * parameters.sigma**2))
values[positive] += (
parameters.area
/ (x_values * parameters.sigma * math.sqrt(2.0 * math.pi))
* np.exp(exponent)
)
return values
def _empty_result(
size: int,
cfg: TicFittingConfig,
reason: str,
*,
candidate: AnyArray | None,
fitted: AnyArray | None,
valid_mask: AnyArray | None,
duplicate_mask: AnyArray | None,
parameters: LognormalParameters | None,
r2: float,
nrmse: float,
residual_noise_ratio: float,
fit_start_s: float,
fit_end_s: float,
multi_peak_flag: bool,
baseline_source: str,
) -> LognormalFitResult:
"""拟合被拒时的结果:`success=False`、`processed` 全 NaN、四个时刻为 None。"""
nan_curve = np.full(size, np.nan, dtype=np.float64)
return LognormalFitResult(
success=False,
failure_reason=reason,
algorithm_version=cfg.algorithm_version,
curve_model="lognormal",
smoothing_method="",
baseline_source=baseline_source,
candidate=nan_curve.copy() if candidate is None else candidate,
fitted=nan_curve.copy() if fitted is None else fitted,
processed=nan_curve.copy(),
valid_sample_mask=(np.zeros(size, dtype=bool) if valid_mask is None else valid_mask),
duplicate_sample_mask=(
np.zeros(size, dtype=bool) if duplicate_mask is None else duplicate_mask
),
parameters=parameters,
r2=r2,
nrmse=nrmse,
residual_noise_ratio=residual_noise_ratio,
fit_start_s=fit_start_s,
fit_end_s=fit_end_s,
multi_peak_flag=multi_peak_flag,
d1_time_s=None,
d2_time_s=None,
d3_time_s=None,
d4_time_s=None,
)
def _finite_runs(mask: AnyArray) -> list[tuple[int, int]]:
"""布尔序列里连续为真的段,每段 `[start, stop)`。"""
runs: list[tuple[int, int]] = []
start: int | None = None
for index, value in enumerate(mask):
if value and start is None:
start = index
elif not value and start is not None:
runs.append((start, index))
start = None
if start is not None:
runs.append((start, len(mask)))
return runs
def _odd_window(sample_count: int, desired: int, polynomial_order: int) -> int:
"""Savitzky-Golay 窗口:奇数,不超过样本数,不小于多项式阶数 + 2。"""
maximum = sample_count if sample_count % 2 == 1 else sample_count - 1
minimum = polynomial_order + 2
if minimum % 2 == 0:
minimum += 1
window = desired if desired % 2 == 1 else desired + 1
return min(maximum, max(minimum, window))
def _savgol(segment: AnyArray, window: int, polyorder: int) -> AnyArray:
"""二阶 Savitzky-Golay 平滑(`mode="interp"`)。"""
return cast(
AnyArray,
savgol_filter(segment, window_length=window, polyorder=polyorder, mode="interp"),
)
def _candidate_curve(
times: AnyArray,
values: AnyArray,
valid_mask: AnyArray,
cfg: TicFittingConfig,
) -> tuple[AnyArray, AnyArray, AnyArray, float]:
"""把有效样本插值到等间隔网格,短窗与长窗两次平滑按 1:3 混合,得到候选曲线。
相邻有效样本间隔超过 `max_candidate_gap_s` 的地方不插值(记 NaN),分段各自平滑。
返回(采样时刻上的候选值、网格、网格上的候选值、网格步长)。
"""
valid_times = times[valid_mask]
intervals = np.diff(valid_times)
positive_intervals = intervals[intervals > 0.0]
if positive_intervals.size == 0:
raise ValueError("有效时间样本不足以建立候选网格")
grid_step = float(np.median(positive_intervals))
grid = np.arange(valid_times[0], valid_times[-1] + grid_step * 0.5, grid_step, dtype=np.float64)
interpolated = np.interp(grid, valid_times, values[valid_mask])
for left, right in itertools.pairwise(valid_times):
if right - left - grid_step > cfg.max_candidate_gap_s:
interpolated[(grid > left) & (grid < right)] = np.nan
smoothed = np.full(grid.shape, np.nan, dtype=np.float64)
finite = np.isfinite(interpolated)
short_window = max(1, round(cfg.candidate_window_s / grid_step))
long_window = max(1, round(cfg.candidate_long_window_s / grid_step))
for start, stop in _finite_runs(finite):
segment = interpolated[start:stop]
if segment.size <= cfg.candidate_polyorder + 1:
continue
short = _odd_window(int(segment.size), short_window, cfg.candidate_polyorder)
long = _odd_window(int(segment.size), long_window, cfg.candidate_polyorder)
if short <= cfg.candidate_polyorder or long <= cfg.candidate_polyorder:
continue
short_values = _savgol(segment, short, cfg.candidate_polyorder)
long_values = _savgol(segment, long, cfg.candidate_polyorder)
smoothed[start:stop] = 0.25 * short_values + 0.75 * long_values
at_samples = np.full(times.shape, np.nan, dtype=np.float64)
for start, stop in _finite_runs(np.isfinite(smoothed)):
in_run = (times >= grid[start]) & (times <= grid[stop - 1])
at_samples[in_run] = np.interp(times[in_run], grid[start:stop], smoothed[start:stop])
return at_samples, grid, smoothed, grid_step
def _candidate_peaks(
grid: AnyArray,
candidate: AnyArray,
baseline: float,
baseline_noise: float,
baseline_end_s: float,
grid_step: float,
cfg: TicFittingConfig,
anchor_cfg: AnchorFramesConfig,
) -> list[int]:
"""基线段之后的候选峰(网格下标):高度与突出度都须过「峰高比例」与「基线抖动倍数」的高者。"""
enhancement = candidate - baseline
eligible = np.isfinite(enhancement) & (grid >= baseline_end_s)
if not eligible.any():
return []
amplitude = float(np.nanmax(enhancement[eligible]))
if not math.isfinite(amplitude) or amplitude <= 0.0:
return []
minimum_height = max(
cfg.minimum_relative_peak_height * amplitude,
anchor_cfg.peak_prominence_baseline_sd_multiplier * baseline_noise,
)
distance = max(1, math.ceil(cfg.minimum_peak_distance_s / grid_step))
peaks: list[int] = []
for start, stop in _finite_runs(eligible):
segment = enhancement[start:stop]
if segment.size < 3:
continue
found = cast(
tuple[AnyArray, object],
find_peaks(
segment, height=minimum_height, prominence=minimum_height, distance=distance
),
)
local = found[0]
peaks.extend(int(value) + start for value in local.tolist())
return sorted(peaks)
def _estimate_baseline(
values: AnyArray,
valid_mask: AnyArray,
cfg: TicFittingConfig,
) -> tuple[float, float]:
"""视频开头没有稳定基线时,用低分位数估一个有界的基线和噪声。"""
finite = values[valid_mask]
baseline = float(np.percentile(finite, cfg.estimated_baseline_percentile))
low_limit = float(np.percentile(finite, min(cfg.estimated_baseline_percentile * 2.0, 45.0)))
low_values = finite[finite <= low_limit]
center = float(np.median(low_values)) if low_values.size else baseline
noise = 1.4826 * float(np.median(np.abs(low_values - center)))
if not math.isfinite(noise) or noise <= 0.0:
differences = np.diff(finite)
difference_center = float(np.median(differences)) if differences.size else 0.0
noise = 1.4826 * float(np.median(np.abs(differences - difference_center)))
amplitude = max(float(np.percentile(finite, 95.0) - baseline), 1e-3)
return baseline, max(noise, amplitude * 0.01, 1e-3)
def _candidate_detection_noise(
grid: AnyArray,
candidate: AnyArray,
baseline_end_s: float,
baseline_noise: float,
) -> float:
"""检测用的噪声:基线段候选曲线一阶差分的稳健离散度,不超过基线噪声。"""
baseline_values = candidate[np.isfinite(candidate) & (grid <= baseline_end_s)]
differences = np.diff(baseline_values)
if differences.size < 2:
return baseline_noise
center = float(np.median(differences))
difference_noise = 1.4826 * float(np.median(np.abs(differences - center)))
return min(baseline_noise, max(difference_noise / math.sqrt(2.0), 1e-3))
def _select_primary_peak(
peaks: list[int],
grid: AnyArray,
candidate: AnyArray,
baseline: float,
) -> int | None:
"""主峰:按「升降完整度 × 半高宽」打分,够强(≥ 最强者 1/4)的里取最早的一个。"""
if not peaks:
return None
finite_runs = _finite_runs(np.isfinite(candidate))
scored: list[tuple[float, float, int]] = []
for peak in peaks:
containing_run = next(
((start, stop) for start, stop in finite_runs if start < peak < stop - 1),
None,
)
if containing_run is None:
continue
first, stop = containing_run
last = stop - 1
peak_height = float(candidate[peak] - baseline)
left = candidate[first : peak + 1]
right = candidate[peak : last + 1]
if peak_height <= 0.0:
continue
rise = float(candidate[peak] - np.min(left))
decline = float(candidate[peak] - np.min(right))
completeness = min(rise, decline)
half_prominence = float(candidate[peak] - 0.5 * completeness)
width_left = peak
while width_left > first and candidate[width_left - 1] >= half_prominence:
width_left -= 1
width_right = peak
while width_right < last and candidate[width_right + 1] >= half_prominence:
width_right += 1
width = float(grid[width_right] - grid[width_left])
if width <= 0.0:
continue
scored.append((completeness * max(width, 1e-6), peak_height, peak))
if not scored:
return None
complete = [item for item in scored if item[0] > 0.0 and item[1] > 0.0]
if complete:
strongest = max(item[0] for item in complete)
credible = [item for item in complete if item[0] >= strongest * 0.25]
if credible:
return min(credible, key=lambda item: item[2])[2]
return max(scored, key=lambda item: (item[0], item[1], -item[2]))[2]
def _persistent_arrival(
grid: AnyArray,
enhancement: AnyArray,
peak_index: int,
threshold: float,
persistence_s: float,
) -> float | None:
"""主峰之前第一次越过门槛并持续 `persistence_s` 秒的时刻;没有返回 None。"""
above = enhancement >= threshold
run_start: int | None = None
for index in range(peak_index + 1):
if not np.isfinite(enhancement[index]) or not above[index]:
run_start = None
continue
if run_start is None:
run_start = index
if grid[index] - grid[run_start] >= persistence_s:
return float(grid[run_start])
return None
def _first_bolus_end(
grid: AnyArray,
candidate: AnyArray,
baseline: float,
peaks: list[int],
primary_peak: int,
cfg: TicFittingConfig,
) -> tuple[float, bool]:
"""第一遍团注到哪里结束:只把相隔足够远、谷值足够深的后峰视为第二遍团注。"""
first_peak = primary_peak
first_height = float(candidate[first_peak] - baseline)
for second_peak in (peak for peak in peaks if peak > first_peak):
if grid[second_peak] - grid[first_peak] < cfg.multi_peak_minimum_separation_s:
continue
second_height = float(candidate[second_peak] - baseline)
if second_height < cfg.multi_peak_minimum_relative_height * first_height:
continue
valley_slice = candidate[first_peak : second_peak + 1]
if not np.isfinite(valley_slice).all() or valley_slice.size < 3:
continue
valley_offset = int(np.argmin(valley_slice))
valley_height = max(float(valley_slice[valley_offset] - baseline), 0.0)
reference_height = max(min(first_height, second_height), float(np.finfo(float).eps))
if valley_height / reference_height <= cfg.multi_peak_maximum_valley_fraction:
return float(grid[first_peak + valley_offset]), True
return float(grid[-1]), False
def _unpack_optimizer_parameters(packed: AnyArray) -> LognormalParameters:
"""优化器的参数化(对数峰高、峰时、对数 σ、到达偏移、基线)→ Lognormal 五参数。"""
peak_height = float(math.exp(float(packed[0])))
peak_time_s = float(packed[1])
sigma = float(math.exp(float(packed[2])))
arrival_offset_s = float(packed[3])
baseline = float(packed[4])
mode_x = peak_time_s - arrival_offset_s
mu = math.log(mode_x) + sigma**2
area = peak_height * mode_x * sigma * math.sqrt(2.0 * math.pi) * math.exp(0.5 * sigma**2)
return LognormalParameters(
area=area,
mu=mu,
sigma=sigma,
arrival_offset_s=arrival_offset_s,
baseline=baseline,
)
def _initial_guesses(
peak_time_s: float,
peak_height: float,
t0_lower: float,
t0_upper: float,
baseline: float,
sigma_min: float,
sigma_max: float,
) -> list[AnyArray]:
"""多组初值:到达偏移取区间两端与中点,σ 取四档,峰时取候选峰 ±0.25 秒。"""
guesses: list[AnyArray] = []
t0_values = np.unique(
np.clip(
np.asarray([t0_lower, (t0_lower + t0_upper) / 2.0, t0_upper], dtype=np.float64),
t0_lower,
t0_upper,
)
)
peak_time_values = np.asarray(
[peak_time_s - 0.25, peak_time_s, peak_time_s + 0.25], dtype=np.float64
)
sigma_values = np.clip(
np.asarray([0.25, 0.5, 0.9, 1.3], dtype=np.float64),
sigma_min,
sigma_max,
)
for t0_value in t0_values.tolist():
for sigma in np.unique(sigma_values).tolist():
for peak_time_value in peak_time_values.tolist():
if peak_time_value <= t0_value:
continue
guesses.append(
np.asarray(
[
math.log(max(peak_height, 1e-8)),
peak_time_value,
math.log(float(sigma)),
t0_value,
baseline,
],
dtype=np.float64,
)
)
return guesses
def _optimizer_bounds(
values: AnyArray,
baseline: float,
baseline_noise: float,
peak_height: float,
t0_lower: float,
t0_upper: float,
peak_lower: float,
peak_upper: float,
baseline_observed: bool,
cfg: TicFittingConfig,
) -> tuple[AnyArray, AnyArray, float]:
"""优化器参数的上下界和基线惩罚的尺度;观测到基线时基线只许在其附近小幅浮动。"""
amplitude = max(float(np.max(values) - baseline), peak_height, 1e-3)
height_upper = max(amplitude * 10.0, 1.0)
baseline_span = max(baseline_noise * 3.0, amplitude * 0.05, 1e-3)
if baseline_observed:
baseline_lower = baseline - baseline_span
baseline_upper = baseline + baseline_span
baseline_scale = baseline_span
else:
baseline_lower = max(0.0, float(np.percentile(values, 1.0)) - amplitude * 0.25)
baseline_upper = min(float(np.percentile(values, 45.0)), baseline + amplitude * 0.35)
baseline_upper = max(baseline_upper, baseline_lower + 1e-3)
baseline_scale = max(amplitude * 0.2, baseline_noise, 1e-3)
lower = np.asarray(
[
math.log(1e-8),
peak_lower,
math.log(cfg.sigma_min),
t0_lower,
baseline_lower,
],
dtype=np.float64,
)
upper = np.asarray(
[
math.log(height_upper),
peak_upper,
math.log(cfg.sigma_max),
t0_upper,
baseline_upper,
],
dtype=np.float64,
)
return lower, upper, baseline_scale
def _fit_parameters(
times: AnyArray,
values: AnyArray,
baseline: float,
baseline_noise: float,
peak_time_s: float,
peak_height: float,
t0_lower: float,
t0_upper: float,
peak_lower: float,
peak_upper: float,
baseline_observed: bool,
cfg: TicFittingConfig,
) -> LognormalParameters | None:
"""从每组初值各跑一次有界稳健最小二乘,取平方误差最小的那组;全部失败返回 None。"""
lower, upper, baseline_scale = _optimizer_bounds(
values,
baseline,
baseline_noise,
peak_height,
t0_lower,
t0_upper,
peak_lower,
peak_upper,
baseline_observed,
cfg,
)
amplitude = max(float(np.max(values) - baseline), peak_height, 1e-3)
residual_scale = max(baseline_noise, amplitude * 0.05, 1e-3)
def residuals(packed: AnyArray) -> AnyArray:
if packed[1] <= packed[3]:
return np.full(values.size + 1, 1e6, dtype=np.float64)
parameters = _unpack_optimizer_parameters(packed)
data = (evaluate_lognormal(times, parameters) - values) / residual_scale
penalty = np.asarray([(parameters.baseline - baseline) / baseline_scale])
return np.concatenate((data, penalty))
best: tuple[float, LognormalParameters] | None = None
for guess in _initial_guesses(
peak_time_s,
peak_height,
t0_lower,
t0_upper,
baseline,
cfg.sigma_min,
cfg.sigma_max,
):
clipped = np.clip(guess, lower + 1e-9, upper - 1e-9)
result = cast(
Any,
least_squares(
residuals,
clipped,
bounds=(lower, upper),
loss=cfg.robust_loss,
f_scale=1.0,
max_nfev=cfg.max_nfev,
),
)
solution = np.asarray(cast(AnyArray, result.x), dtype=np.float64)
if not bool(result.success) or not np.isfinite(solution).all():
continue
if solution[1] <= solution[3]:
continue
parameters = _unpack_optimizer_parameters(solution)
squared_error = float(np.sum((evaluate_lognormal(times, parameters) - values) ** 2))
if best is None or squared_error < best[0]:
best = (squared_error, parameters)
return None if best is None else best[1]
def _parameters_on_boundary(
parameters: LognormalParameters,
lower: AnyArray,
upper: AnyArray,
fraction: float,
) -> bool:
"""拟合参数(换回优化器参数化后)是否贴着上下界(区间宽度的 `fraction` 以内)。"""
peak_time_s = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2)
peak_height = float(
evaluate_lognormal(np.asarray([peak_time_s]), parameters)[0] - parameters.baseline
)
packed = np.asarray(
[
math.log(max(peak_height, 1e-8)),
peak_time_s,
math.log(parameters.sigma),
parameters.arrival_offset_s,
parameters.baseline,
],
dtype=np.float64,
)
margin = (upper - lower) * fraction
return bool(np.any(packed <= lower + margin) or np.any(packed >= upper - margin))
def _quality_metrics(
observed: AnyArray,
fitted: AnyArray,
baseline_noise: float,
) -> tuple[float, float, float]:
"""拟合优度:R²、按观测幅度归一的 RMSE、RMSE 与基线噪声之比。"""
residual = observed - fitted
residual_sum = float(np.sum(residual**2))
centered_sum = float(np.sum((observed - float(np.mean(observed))) ** 2))
r2 = float("nan") if centered_sum <= 0.0 else 1.0 - residual_sum / centered_sum
rmse = math.sqrt(residual_sum / max(int(observed.size), 1))
amplitude = max(float(np.max(observed) - np.min(observed)), 1e-6)
nrmse = rmse / amplitude
residual_noise_ratio = rmse / max(baseline_noise, 1e-6)
return r2, nrmse, residual_noise_ratio
def evaluate_lognormal_derivative(time_s: float, parameters: LognormalParameters) -> float:
"""Lognormal 曲线在某一时刻的一阶导数(到达偏移之前为 0)。"""
shifted = time_s - parameters.arrival_offset_s
if shifted <= 0.0:
return 0.0
value = float(evaluate_lognormal(np.asarray([time_s]), parameters)[0])
pulse = value - parameters.baseline
return (
pulse
* (parameters.mu - parameters.sigma**2 - math.log(shifted))
/ (parameters.sigma**2 * shifted)
)
def _arrival_root(
parameters: LognormalParameters, threshold: float, left: float, right: float
) -> float:
"""拟合曲线在 `[left, right]` 内升过「基线 + 门槛」的时刻(Brent 求根)。"""
def above_threshold(value: float) -> float:
return float(
evaluate_lognormal(np.asarray([value]), parameters)[0] - parameters.baseline - threshold
)
return float(cast(float, brentq(above_threshold, left, right)))
def _continuous_anchors(
parameters: LognormalParameters,
baseline_noise: float,
observed_end_s: float,
anchor_cfg: AnchorFramesConfig,
boundary_margin_s: float,
) -> tuple[float | None, float | None, float, float | None]:
"""拟合曲线上的四个连续时刻:到达 D1、洗入拐点 D2、峰值 D3、洗出拐点 D4。
到达门槛取「基线抖动 × 倍数」与「峰高 × 比例」的高者(`anchor_frames.arrival`)。D4 只在
峰后还有足够观测(`boundary_margin_s`)、且拐点落在观测范围内、导数为负时给出。
"""
peak_time = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2)
peak_value = float(evaluate_lognormal(np.asarray([peak_time]), parameters)[0])
peak_height = peak_value - parameters.baseline
threshold = max(
anchor_cfg.arrival.baseline_sd_multiplier * baseline_noise,
anchor_cfg.arrival.relative_peak_threshold * peak_height,
)
left = parameters.arrival_offset_s + 1e-9
arrival: float | None = None
if threshold > 0.0 and peak_height >= threshold:
arrival = _arrival_root(parameters, threshold, left, peak_time)
sigma_squared = parameters.sigma**2
extrema_discriminant = math.sqrt(sigma_squared**2 + 4.0 * sigma_squared)
wash_in_factor = (sigma_squared + extrema_discriminant) / 2.0
wash_out_factor = (sigma_squared - extrema_discriminant) / 2.0
wash_in_candidate = parameters.arrival_offset_s + math.exp(
parameters.mu - sigma_squared - wash_in_factor
)
wash_in_start = arrival if arrival is not None else left
d2_time = max(wash_in_start, wash_in_candidate)
d4_time: float | None = None
if observed_end_s - peak_time >= boundary_margin_s:
candidate_d4 = parameters.arrival_offset_s + math.exp(
parameters.mu - sigma_squared - wash_out_factor
)
if (
candidate_d4 < observed_end_s - boundary_margin_s
and evaluate_lognormal_derivative(candidate_d4, parameters) < 0.0
):
d4_time = candidate_d4
return arrival, d2_time, peak_time, d4_time
def fit_lognormal_curve(
time_s: AnyArray,
raw_values: AnyArray,
*,
baseline: float | None,
baseline_noise: float | None,
baseline_end_index: int,
duplicate_mask: AnyArray,
sample_mask: AnyArray | None,
cfg: TicFittingConfig,
anchor_cfg: AnchorFramesConfig,
) -> LognormalFitResult:
"""拟合一条原始灰度 TIC,并给出连续的 D1–D4 时刻。
`baseline`/`baseline_noise` 为 None 或 `baseline_end_index <= 0` 表示视频开头没有观测到
稳定基线,改用低分位数估计(`baseline_source="estimated"`)。`sample_mask` 为 None 表示
全部样本可用。任何一道门不过都返回 `success=False`,原因写在 `failure_reason`。
"""
times = np.asarray(time_s, dtype=np.float64)
values = np.asarray(raw_values, dtype=np.float64)
duplicates = np.asarray(duplicate_mask, dtype=bool)
if times.ndim != 1 or values.shape != times.shape or duplicates.shape != times.shape:
raise ValueError("time_s、raw_values 与 duplicate_mask 必须是等长一维数组")
if times.size == 0 or not np.isfinite(times).all() or np.any(np.diff(times) <= 0.0):
raise ValueError("time_s 必须是非空、有限且严格递增的一维数组")
eligibility = (
np.ones(times.shape, dtype=bool)
if sample_mask is None
else np.asarray(sample_mask, dtype=bool)
)
if eligibility.shape != times.shape:
raise ValueError("sample_mask 必须与 time_s 等长")
valid_mask = np.isfinite(values) & ~duplicates & eligibility
candidate_at_samples: AnyArray | None = None
fitted: AnyArray | None = None
parameters: LognormalParameters | None = None
r2 = nrmse = residual_noise_ratio = float("nan")
fit_start_s = fit_end_s = float("nan")
multi_peak = False
baseline_source = "unavailable"
def reject(reason: str) -> LognormalFitResult:
return _empty_result(
len(times),
cfg,
reason,
candidate=candidate_at_samples,
fitted=fitted,
valid_mask=valid_mask,
duplicate_mask=duplicates,
parameters=parameters,
r2=r2,
nrmse=nrmse,
residual_noise_ratio=residual_noise_ratio,
fit_start_s=fit_start_s,
fit_end_s=fit_end_s,
multi_peak_flag=multi_peak,
baseline_source=baseline_source,
)
if int(valid_mask.sum()) < cfg.minimum_valid_samples:
return reject("insufficient_valid_samples")
# 有效样本时刻全部相同时(理论上严格递增已排除)建不出候选网格,记为拟合失败。
try:
candidate_at_samples, grid, candidate_grid, grid_step = _candidate_curve(
times, values, valid_mask, cfg
)
except ValueError:
return reject("candidate_grid_unavailable")
baseline_observed = (
baseline is not None and baseline_noise is not None and baseline_end_index > 0
)
if baseline is not None and baseline_noise is not None and baseline_end_index > 0:
fit_baseline = float(baseline)
fit_baseline_noise = max(float(baseline_noise), 1e-3)
baseline_end_s = float(times[min(baseline_end_index - 1, len(times) - 1)])
baseline_source = "observed"
else:
fit_baseline, fit_baseline_noise = _estimate_baseline(values, valid_mask, cfg)
baseline_end_s = float(times[valid_mask][0])
baseline_source = "estimated"
detection_noise = _candidate_detection_noise(
grid, candidate_grid, baseline_end_s, fit_baseline_noise
)
peaks = _candidate_peaks(
grid,
candidate_grid,
fit_baseline,
detection_noise,
baseline_end_s,
grid_step,
cfg,
anchor_cfg,
)
first_peak = _select_primary_peak(peaks, grid, candidate_grid, fit_baseline)
if first_peak is None:
return reject("credible_peak_not_found")
first_peak_height = float(candidate_grid[first_peak] - fit_baseline)
arrival_threshold = max(
anchor_cfg.arrival.baseline_sd_multiplier * detection_noise,
anchor_cfg.arrival.relative_peak_threshold * first_peak_height,
)
if (
_persistent_arrival(
grid,
candidate_grid - fit_baseline,
first_peak,
arrival_threshold,
anchor_cfg.arrival.persistence_s,
)
is None
):
return reject("candidate_arrival_not_found")
fit_end_s, multi_peak = _first_bolus_end(
grid, candidate_grid, fit_baseline, peaks, first_peak, cfg
)
fit_mask = valid_mask & (times <= fit_end_s)
fit_times = times[fit_mask]
fit_values = values[fit_mask]
if fit_times.size < cfg.minimum_valid_samples:
return reject("insufficient_bolus_samples")
fit_start_s = float(fit_times[0])
long_gaps = np.diff(fit_times)
pre_peak = fit_times[:-1] < float(grid[first_peak])
if np.any(long_gaps[pre_peak] - grid_step > cfg.max_candidate_gap_s):
return reject("long_gap_in_wash_in")
observed_start_s = fit_start_s
peak_lower = max(
observed_start_s + cfg.boundary_margin_s,
float(grid[first_peak]) - cfg.candidate_long_window_s,
)
peak_upper = min(
fit_end_s - cfg.boundary_margin_s,
float(grid[first_peak]) + cfg.candidate_long_window_s,
)
t0_lower = (
observed_start_s
if baseline_observed
else observed_start_s - cfg.estimated_baseline_lookback_s
)
t0_upper = peak_lower - max(grid_step * 0.25, 1e-4)
if t0_upper <= t0_lower or peak_upper <= peak_lower:
return reject("arrival_bounds_invalid")
parameters = _fit_parameters(
fit_times,
fit_values,
fit_baseline,
fit_baseline_noise,
float(grid[first_peak]),
first_peak_height,
t0_lower,
t0_upper,
peak_lower,
peak_upper,
baseline_observed,
cfg,
)
if parameters is None:
return reject("optimizer_failed")
fitted = evaluate_lognormal(times, parameters)
fit_predictions = evaluate_lognormal(fit_times, parameters)
r2, nrmse, residual_noise_ratio = _quality_metrics(
fit_values, fit_predictions, fit_baseline_noise
)
peak_time_s = parameters.arrival_offset_s + math.exp(parameters.mu - parameters.sigma**2)
lower, upper, _ = _optimizer_bounds(
fit_values,
fit_baseline,
fit_baseline_noise,
first_peak_height,
t0_lower,
t0_upper,
peak_lower,
peak_upper,
baseline_observed,
cfg,
)
if _parameters_on_boundary(parameters, lower, upper, cfg.parameter_boundary_fraction):
return reject("parameter_on_boundary")
if (
not observed_start_s + cfg.boundary_margin_s
< peak_time_s
< fit_end_s - cfg.boundary_margin_s
):
return reject("model_peak_on_boundary")
residual_supported = (
math.isfinite(residual_noise_ratio)
and residual_noise_ratio <= cfg.maximum_residual_noise_ratio
)
variance_supported = math.isfinite(r2) and r2 >= cfg.minimum_r2
if nrmse > cfg.maximum_nrmse or not (variance_supported or residual_supported):
return reject("fit_quality_failed")
d1_time, d2_time, d3_time, d4_time = _continuous_anchors(
parameters,
fit_baseline_noise,
fit_end_s,
anchor_cfg,
cfg.boundary_margin_s,
)
return LognormalFitResult(
success=True,
failure_reason="",
algorithm_version=cfg.algorithm_version,
curve_model="lognormal",
smoothing_method="",
baseline_source=baseline_source,
candidate=candidate_at_samples,
fitted=fitted,
processed=fitted.copy(),
valid_sample_mask=valid_mask,
duplicate_sample_mask=duplicates,
parameters=parameters,
r2=r2,
nrmse=nrmse,
residual_noise_ratio=residual_noise_ratio,
fit_start_s=observed_start_s,
fit_end_s=fit_end_s,
multi_peak_flag=multi_peak,
d1_time_s=d1_time,
d2_time_s=d2_time,
d3_time_s=d3_time,
d4_time_s=d4_time,
)
+181
View File
@@ -0,0 +1,181 @@
# matplotlib 的类型存根把绘图方法的 **kwargs 标成 Unknown,这一个文件里关掉这条规则。
# pyright: reportUnknownMemberType=false
"""tic.png:TIC 原始样本、过了质量门的 Lognormal 拟合曲线和关键帧时刻竖线。
紫圈曲线拟合没过质量门时仍画出拟合(虚线、标 QC rejected),便于复核;病灶曲线拟合没过就不画。
照搬旧项目 `tic_plot.py`,去掉产品层。
"""
from collections.abc import Sequence
from dataclasses import dataclass
from io import BytesIO
import numpy as np
from matplotlib.axes import Axes
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure
from cadx.ceus._geometry import AnyArray
from cadx.ceus._tic import TicResult
_FIGURE_SIZE = (16.0, 8.0)
_FIGURE_DPI = 100
_CURVE_STYLES = {
"roi_all": ("roi_all", "#2563B8"),
"roi_perf": ("roi_perf (dark removed)", "#D46700"),
"roi_pan": ("roi_pan reference", "#7C3AED"),
}
@dataclass(frozen=True)
class TicPlotMarker:
"""复核时间标记;颜色沿用 OpenCV 的 BGR 顺序。"""
label: str
time_s: float
color: tuple[int, int, int]
def render_tic_png(tic: TicResult, *, markers: Sequence[TicPlotMarker]) -> bytes:
"""把病灶与紫圈(正常胰腺对照)的 TIC 画成 PNG 字节;`markers` 是要画的时刻竖线。"""
times, raw_series, processed_series = _curve_arrays(tic)
figure = Figure(
figsize=_FIGURE_SIZE,
dpi=_FIGURE_DPI,
facecolor="white",
layout="constrained",
)
canvas = FigureCanvasAgg(figure)
axes = figure.subplots()
_style_axes(axes)
plotted_values: list[AnyArray] = []
for name, (label, color) in _CURVE_STYLES.items():
values = raw_series[name]
if np.isfinite(values).any():
axes.plot(
times,
values,
color=color,
alpha=0.24,
linewidth=1.2,
marker=".",
markersize=2.2,
label=f"{label} raw",
)
plotted_values.append(values)
for name, (label, color) in _CURVE_STYLES.items():
fit = tic.fits[name]
if fit.success:
values = processed_series[name]
fit_label = f"{label} Lognormal fit"
linestyle = "-"
elif name == "roi_pan":
values = np.asarray(fit.fitted, dtype=np.float64)
fit_label = f"{label} Lognormal fit [QC rejected]"
linestyle = "--"
else:
continue
if np.isfinite(values).any():
axes.plot(
times,
values,
color=color,
linewidth=3.0,
linestyle=linestyle,
label=fit_label,
)
plotted_values.append(values)
_draw_markers(axes, markers, times)
_set_limits(axes, times, plotted_values)
if not plotted_values:
axes.text(
0.5,
0.5,
"No plottable TIC data",
ha="center",
va="center",
transform=axes.transAxes,
color="#555555",
fontsize=14,
)
if axes.lines:
axes.legend(loc="lower center", ncol=3, framealpha=0.92, fontsize=11)
with BytesIO() as output:
canvas.print_png(output)
return output.getvalue()
def _curve_arrays(
tic: TicResult,
) -> tuple[AnyArray, dict[str, AnyArray], dict[str, AnyArray]]:
times = np.asarray([row["time_sec"] for row in tic.rows], dtype=np.float64)
raw_series = {
name: np.asarray([row[f"{name}_raw"] for row in tic.rows], dtype=np.float64)
for name in _CURVE_STYLES
}
processed_series = {
name: np.asarray([row[f"{name}_processed"] for row in tic.rows], dtype=np.float64)
for name in _CURVE_STYLES
}
return times, raw_series, processed_series
def _style_axes(axes: Axes) -> None:
axes.set_title("TIC: Lognormal fit", fontsize=20, pad=12)
axes.set_xlabel("Time (s)", fontsize=15)
axes.set_ylabel("Grayscale intensity", fontsize=15)
axes.tick_params(axis="both", labelsize=12)
axes.grid(True, color="#B8B8B8", linewidth=0.8, alpha=0.32)
axes.set_axisbelow(True)
def _draw_markers(
axes: Axes,
markers: Sequence[TicPlotMarker],
times: AnyArray,
) -> None:
finite_times = times[np.isfinite(times)]
if not finite_times.size:
return
time_min = float(finite_times.min())
time_max = float(finite_times.max())
for marker in markers:
if not np.isfinite(marker.time_s) or not time_min <= marker.time_s <= time_max:
continue
axes.axvline(
marker.time_s,
color=tuple(channel / 255.0 for channel in reversed(marker.color)),
linestyle="--",
linewidth=1.5,
alpha=0.9,
label=marker.label,
)
def _set_limits(
axes: Axes,
times: AnyArray,
series: Sequence[AnyArray],
) -> None:
finite_times = times[np.isfinite(times)]
if finite_times.size:
time_min = float(finite_times.min())
time_max = float(finite_times.max())
time_padding = max((time_max - time_min) * 0.025, 0.1)
axes.set_xlim(time_min - time_padding, time_max + time_padding)
finite_values = [values[np.isfinite(values)] for values in series]
finite_values = [values for values in finite_values if values.size]
if not finite_values:
return
value_min = min(float(values.min()) for values in finite_values)
value_max = max(float(values.max()) for values in finite_values)
value_span = max(value_max - min(0.0, value_min), 1.0)
axes.set_ylim(
min(0.0, value_min) - 0.05 * value_span,
value_max + 0.08 * value_span,
)
+245
View File
@@ -0,0 +1,245 @@
"""trajectory.npz 的打包和解包:逐帧 ROI 多边形、暗区、背景圆和几何元数据(格式同旧项目)。
键集对齐参考组件 `write_roi_trajectory_npz`,另有 `keyframe_indices`(提示帧帧号)、
`dark_polygons`、`calibration_json` 三键。
- 解包用 `np.load(..., allow_pickle=False)`:变长和字符串数据一律存 JSON,不存 object 数组,
读取时不会执行任意代码;
- 坏字节(不是合法 npz)解包直接报错;
- 打包前校验形状(多边形 `(N, 64, 2)`、`frame_indices` 长 N、`reference_mask` 二维等),不一致报错。
`source_effective_rect` / `target_effective_rect` 两键保留是为了格式同旧项目:旧 debug 路径不裁
黑边,它们恒等于对应的视图矩形。照搬旧项目 `trajectory.py`,去掉产品层。
"""
import io
import json
from dataclasses import dataclass
from typing import Any, cast
import numpy as np
from cadx.ceus._geometry import AnyArray, ViewRect
# 重采样多边形的固定顶点数(与 geometry.resample_closed_contour 的下游约定一致)。
POLYGON_NUM_POINTS = 64
@dataclass
class Trajectory:
"""逐帧 ROI 轨迹工件的内存表示(npz pack/unpack 的双向载体)。
属性:
frame_indices: ``(N,)`` int32,逐帧索引(与多边形帧数一致)。
fps: 视频帧率(浮点标量)。npz 存档内 fps 存为 float32(schema 保证,见
``pack_trajectory``);in-memory ``Trajectory.fps`` 有意规范化为 Python
float(与领域实体 ``CeusRoiTracking.fps`` 的 float 一致,便于消费)。
target_polygons: ``(N, 64, 2)`` float32,目标视图逐帧 ROI 多边形。
source_polygons: ``(N, 64, 2)`` float32,源视图逐帧 ROI 多边形。
source_view_rect / target_view_rect: 源 / 目标视图整屏裁剪矩形。
source_effective_rect / target_effective_rect: 源 / 目标有效成像区矩形。
source_polygon_coordinate_space / target_polygon_coordinate_space: 多边形所处
坐标系名(如 ``source_view_local`` / ``target_effective_view_local``)。
reference_mask: ``(H, W)`` uint8,参考区二值掩膜。
reference_region: 参考区元信息字典(pack 时 JSON 序列化)。
keyframe_indices: ``(K,)`` int32,被选作提示的关键帧索引(变长一维)。
dark_polygons: ``list[list[AnyArray]]``,逐帧 0~N 个暗区多边形(变长嵌套,
pack 时整体 JSON 序列化为单个字符串,规避 npz 变长 / pickle)。
calibration: 标定溯源字典(标定来源 / hash / 是否 CLI 临时覆盖 / 检测 split_x)。
"""
frame_indices: AnyArray
fps: float
target_polygons: AnyArray
source_polygons: AnyArray
source_view_rect: ViewRect
target_view_rect: ViewRect
source_effective_rect: ViewRect
target_effective_rect: ViewRect
source_polygon_coordinate_space: str
target_polygon_coordinate_space: str
reference_mask: AnyArray
reference_region: dict[str, Any]
keyframe_indices: AnyArray
dark_polygons: list[list[AnyArray]]
calibration: dict[str, Any]
def _rect_to_array(rect: ViewRect) -> AnyArray:
"""把 ``ViewRect`` 编码为 ``(4,)`` int32 数组 ``[x1, y1, x2, y2]``(对齐参考 P:1676)。"""
return np.asarray([rect.x1, rect.y1, rect.x2, rect.y2], dtype=np.int32)
def _rect_from_array(values: AnyArray) -> ViewRect:
"""把 ``(4,)`` 整型数组解码回 ``ViewRect``;不变量校验委托给 ViewRect 构造。"""
if values.shape != (4,):
raise ValueError(f"矩形数组必须形如 (4,),得到 {values.shape}。")
x1, y1, x2, y2 = (int(v) for v in values.tolist())
return ViewRect(x1, y1, x2, y2)
def _serialize_dark_polygons(dark_polygons: list[list[AnyArray]]) -> str:
"""把逐帧暗区多边形(变长嵌套 ndarray)序列化为 JSON 字符串。
每个多边形 ``(M, 2)`` 转为嵌套 list;整体为 ``list[list[list[list[float]]]]``
(帧 → 多边形 → 顶点 → 坐标)。走 JSON 而非 npz object 数组,从源头禁 pickle。
"""
serializable: list[list[list[list[float]]]] = []
for frame_polygons in dark_polygons:
frame_repr: list[list[list[float]]] = []
for polygon in frame_polygons:
array = np.asarray(polygon, dtype=np.float32)
if array.ndim != 2 or array.shape[1] != 2:
raise ValueError(f"暗区多边形必须形如 (M, 2),得到 {array.shape}。")
frame_repr.append(array.tolist())
serializable.append(frame_repr)
return json.dumps(serializable, ensure_ascii=False)
def _deserialize_dark_polygons(payload: str) -> list[list[AnyArray]]:
"""把暗区多边形 JSON 字符串还原为 ``list[list[AnyArray]]``(float32)。
严格按 JSON 结构还原,不接受任何 pickle / object 输入;结构非法直接 raise。
"""
parsed: object = json.loads(payload)
if not isinstance(parsed, list):
raise ValueError("暗区多边形 JSON 顶层必须是逐帧列表。")
dark_polygons: list[list[AnyArray]] = []
for frame_repr in cast(list[object], parsed):
if not isinstance(frame_repr, list):
raise ValueError("暗区多边形每帧必须是多边形列表。")
frame_polygons: list[AnyArray] = []
for polygon_repr in cast(list[object], frame_repr):
array = np.asarray(polygon_repr, dtype=np.float32)
if array.ndim != 2 or array.shape[1] != 2:
raise ValueError(f"暗区多边形必须形如 (M, 2),得到 {array.shape}。")
frame_polygons.append(array)
dark_polygons.append(frame_polygons)
return dark_polygons
def _validate_geometry(trajectory: Trajectory) -> int:
"""校验轨迹核心几何形状的一致性,返回帧数 N;任何不一致直接 raise。
校验项:多边形 ``(N, 64, 2)`` 且源 / 目标帧数一致;``frame_indices`` 一维且长 N;
``reference_mask`` 二维;``keyframe_indices`` 一维;``dark_polygons`` 帧数为 N。
"""
target = trajectory.target_polygons
source = trajectory.source_polygons
if target.ndim != 3 or target.shape[1:] != (POLYGON_NUM_POINTS, 2):
raise ValueError(
f"target_polygons 必须形如 (N, {POLYGON_NUM_POINTS}, 2),得到 {target.shape}。"
)
if source.shape != target.shape:
raise ValueError(
f"source_polygons {source.shape} 与 target_polygons {target.shape} 形状不一致。"
)
num_frames = int(target.shape[0])
if trajectory.frame_indices.ndim != 1 or trajectory.frame_indices.shape[0] != num_frames:
raise ValueError(
f"frame_indices 必须一维且长度 {num_frames},得到 {trajectory.frame_indices.shape}。"
)
if trajectory.reference_mask.ndim != 2:
raise ValueError(
f"reference_mask 必须二维 (H, W),得到 {trajectory.reference_mask.shape}。"
)
if trajectory.keyframe_indices.ndim != 1:
raise ValueError(f"keyframe_indices 必须一维,得到 {trajectory.keyframe_indices.shape}。")
if len(trajectory.dark_polygons) != num_frames:
raise ValueError(
f"dark_polygons 帧数 {len(trajectory.dark_polygons)} 与多边形帧数 {num_frames} 不一致。"
)
return num_frames
def pack_trajectory(trajectory: Trajectory) -> bytes:
"""把 ``Trajectory`` 校验并打包为压缩 npz 字节(产物工件)。
参数:
trajectory: 待打包的逐帧轨迹载体。
返回:
压缩 npz 的原始字节(``np.savez_compressed`` 写入内存缓冲)。
关键实现:
先 ``_validate_geometry`` 钉死核心形状(不一致即 raise,不静默对齐);
字符串键存为 0 维 str 数组,``fps`` 存 ``(1,)`` float32,变长 dark / 字典键
统一 JSON 序列化为字符串,全程不写入任何 object 数组(禁 pickle)。
键名对齐参考 ``write_roi_trajectory_npz`` 并新增 ``keyframe_indices`` /
``dark_polygons`` / ``calibration_json``。
"""
_validate_geometry(trajectory)
buffer = io.BytesIO()
np.savez_compressed(
buffer,
frame_indices=trajectory.frame_indices.astype(np.int32),
fps=np.asarray([trajectory.fps], dtype=np.float32),
target_polygons=trajectory.target_polygons.astype(np.float32),
source_polygons=trajectory.source_polygons.astype(np.float32),
source_view_rect=_rect_to_array(trajectory.source_view_rect),
target_view_rect=_rect_to_array(trajectory.target_view_rect),
source_effective_rect=_rect_to_array(trajectory.source_effective_rect),
target_effective_rect=_rect_to_array(trajectory.target_effective_rect),
source_polygon_coordinate_space=np.array(trajectory.source_polygon_coordinate_space),
target_polygon_coordinate_space=np.array(trajectory.target_polygon_coordinate_space),
reference_mask=trajectory.reference_mask.astype(np.uint8),
reference_region_json=np.array(json.dumps(trajectory.reference_region, ensure_ascii=False)),
keyframe_indices=trajectory.keyframe_indices.astype(np.int32),
dark_polygons=np.array(_serialize_dark_polygons(trajectory.dark_polygons)),
calibration_json=np.array(json.dumps(trajectory.calibration, ensure_ascii=False)),
)
return buffer.getvalue()
def unpack_trajectory(raw: bytes) -> Trajectory:
"""把 npz 字节解包还原为 ``Trajectory``(禁 pickle,坏字节 raise)。
参数:
raw: ``pack_trajectory`` 产出的 npz 原始字节。
返回:
还原后的 ``Trajectory``,dtype 与 pack 时一致。
关键实现:
``np.load(..., allow_pickle=False)`` 硬禁 pickle —— 任何含 object pickle 的
恶意字节都会在 load 阶段抛错而非被反序列化执行;非法 npz / 缺键同样直接 raise
(``ValueError`` / ``OSError`` / ``KeyError``),不静默返回空。字符串键经 0 维
数组 ``.item()`` 取回,变长 / 字典键经 JSON 反序列化还原。
``fps`` 存档内为 float32(schema 保证),此处有意还原为 Python float
(领域消费侧统一用 float,不改此行为)。
"""
with np.load(io.BytesIO(raw), allow_pickle=False) as archive:
frame_indices = archive["frame_indices"].astype(np.int32)
fps = float(archive["fps"][0])
target_polygons = archive["target_polygons"].astype(np.float32)
source_polygons = archive["source_polygons"].astype(np.float32)
source_view_rect = _rect_from_array(archive["source_view_rect"])
target_view_rect = _rect_from_array(archive["target_view_rect"])
source_effective_rect = _rect_from_array(archive["source_effective_rect"])
target_effective_rect = _rect_from_array(archive["target_effective_rect"])
source_polygon_coordinate_space = str(archive["source_polygon_coordinate_space"].item())
target_polygon_coordinate_space = str(archive["target_polygon_coordinate_space"].item())
reference_mask = archive["reference_mask"].astype(np.uint8)
reference_region = json.loads(str(archive["reference_region_json"].item()))
keyframe_indices = archive["keyframe_indices"].astype(np.int32)
dark_polygons = _deserialize_dark_polygons(str(archive["dark_polygons"].item()))
calibration = json.loads(str(archive["calibration_json"].item()))
return Trajectory(
frame_indices=frame_indices,
fps=fps,
target_polygons=target_polygons,
source_polygons=source_polygons,
source_view_rect=source_view_rect,
target_view_rect=target_view_rect,
source_effective_rect=source_effective_rect,
target_effective_rect=target_effective_rect,
source_polygon_coordinate_space=source_polygon_coordinate_space,
target_polygon_coordinate_space=target_polygon_coordinate_space,
reference_mask=reference_mask,
reference_region=reference_region,
keyframe_indices=keyframe_indices,
dark_polygons=dark_polygons,
calibration=calibration,
)
+10
View File
@@ -0,0 +1,10 @@
"""segment:用 MedSAM2 把提示帧的病灶掩码传播成逐帧掩码。
不认识分屏和绿框:输入只是要分割的那一半画面和那一半局部坐标上的提示掩码。`cadx` 里只有这里
引用 `torch` 和 `sam2`。
"""
from cadx.segment._config import SegmentConfig
from cadx.segment._medsam2 import medsam2_commit, segment_video
__all__ = ["SegmentConfig", "medsam2_commit", "segment_video"]
+44
View File
@@ -0,0 +1,44 @@
"""分割设置:`config/ceus.yaml` 里 `segment` 段的字段定义和校验。
每个字段必填;多写或拼错的键报错;读入后不能修改(`AGENTS.md`「不设默认值」)。取值与出处写在
`config/ceus.yaml` 的注释里。路径字段原样使用,相对路径由调用者(`scripts/ceus_quant.py`)
先换成仓库根目录下的绝对路径。
"""
from pathlib import Path
from typing import Annotated
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field
def _reject_empty_path(value: object) -> object:
# Path("") 会变成 "."(当前目录),空串在转换前就拦下。
if value == "":
raise ValueError("路径不能是空串")
return value
# YAML 里的路径是字符串;路径字段是唯一允许从字符串转换类型的地方。
type _PathField = Annotated[Path, Field(strict=False), BeforeValidator(_reject_empty_path)]
class SegmentConfig(BaseModel):
"""MedSAM2 分割设置。
- `medsam2_dir`:MedSAM2 代码目录(含 `.git`,提交号从这里读;装进环境的 `sam2` 包必须来自
这个目录下的 `sam2/`);
- `checkpoint`:权重文件;
- `sam2_config`:模型配置的 Hydra 名,相对 `sam2` 包,如 `configs/sam2.1_hiera_t512.yaml`;
- `device`:推理设备,如 `cuda`、`cuda:0`、`cpu`;
- `offload_video_to_cpu`、`offload_state_to_cpu`:MedSAM2 `init_state` 的两个开关,把帧或推理
状态放在内存而不是显存,省显存、略慢,不改变结果。
"""
model_config = ConfigDict(strict=True, extra="forbid", frozen=True)
medsam2_dir: _PathField
checkpoint: _PathField
sam2_config: str
device: str
offload_video_to_cpu: bool
offload_state_to_cpu: bool
+166
View File
@@ -0,0 +1,166 @@
"""MedSAM2 逐帧分割:把若干提示帧的病灶掩码传播到整段视频的每一帧。
改写自旧项目 `debugs/medsam2_local_track.py`(当时在 conda 子进程里跑),行为照旧:帧以
`000000.jpg` 起的 jpg 序列(质量 95)写进一个目录交给 `init_state`;每个提示帧用 `add_new_mask`
加进同一个对象;从最早的提示帧向后传播到末帧,再从它向前传播到第 0 帧;logits > 0 为病灶。
现在在同一进程里调用,jpg 临时目录由这里建、用完即删。
"""
import os
import shutil
import subprocess
import tempfile
from collections.abc import Mapping, Sequence
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import numpy.typing as npt
import torch
from cadx.segment._config import SegmentConfig
type Frame = npt.NDArray[np.uint8]
type Mask = npt.NDArray[np.bool_]
# 只跟踪一个病灶,对象编号照旧脚本取 1。
_OBJECT_ID = 1
_JPEG_QUALITY = 95
def medsam2_commit(medsam2_dir: Path) -> str:
"""读 `medsam2_dir/.git` 的 HEAD 提交号(完整 40 位)。
用 `--git-dir` 指定仓库:只用 `-C` 时,目录里没有 `.git` 的话 git 会往上找到 CADx-4 自己的仓库。
"""
git = shutil.which("git")
if git is None:
raise RuntimeError("找不到 git,无法读 MedSAM2 的提交号")
completed = subprocess.run( # noqa: S603 参数是写死的 git 子命令和配置里的目录
[git, "--git-dir", str(medsam2_dir / ".git"), "rev-parse", "HEAD"],
check=True,
capture_output=True,
)
return completed.stdout.decode().strip()
def _check_inputs(
frames: Sequence[Frame], prompt_masks: Mapping[int, Mask], cfg: SegmentConfig
) -> tuple[int, int]:
"""校验输入并返回帧的 (高, 宽)。"""
for path in (cfg.medsam2_dir / "sam2" / cfg.sam2_config, cfg.checkpoint):
if not path.is_file():
raise FileNotFoundError(f"MedSAM2 缺少文件:{path}")
if len(frames) == 0:
raise ValueError("没有要分割的帧")
height, width = frames[0].shape[:2]
for index, frame in enumerate(frames):
if frame.dtype != np.uint8 or frame.shape != (height, width, 3):
raise ValueError(
f"第 {index} 帧应为 ({height}, {width}, 3) 的 uint8 BGR 图像,"
f"收到 {frame.shape} {frame.dtype}"
)
if len(prompt_masks) == 0:
raise ValueError("至少需要一个提示帧")
for index, mask in prompt_masks.items():
if not 0 <= index < len(frames):
raise ValueError(f"提示帧帧号 {index} 不在 [0, {len(frames)}) 内")
if mask.dtype != np.bool_ or mask.shape != (height, width):
raise ValueError(
f"提示帧 {index} 的掩码应为 ({height}, {width}) 的布尔数组,"
f"收到 {mask.shape} {mask.dtype}"
)
return height, width
def _check_device(device: str) -> None:
"""加载权重前确认请求的设备可见,提前暴露显卡可见性问题。"""
if not device.startswith("cuda"):
return
if not torch.cuda.is_available():
visible = os.environ.get("CUDA_VISIBLE_DEVICES", "<unset>")
raise RuntimeError(
f"请求用 CUDA 运行 MedSAM2,但当前进程看不到显卡。CUDA_VISIBLE_DEVICES={visible}"
)
if ":" in device and int(device.split(":", 1)[1]) >= torch.cuda.device_count():
raise RuntimeError(f"请求设备 {device},但只看得到 {torch.cuda.device_count()} 张显卡")
def _check_sam2_source(medsam2_dir: Path) -> None:
"""装进环境的 sam2 必须就是 `medsam2_dir` 里那份代码,否则记下的提交号对不上实际代码。"""
import sam2
loaded = Path(sam2.__file__).resolve().parent
expected = (medsam2_dir / "sam2").resolve()
if loaded != expected:
raise RuntimeError(f"环境里的 sam2 来自 {loaded},与配置的 {expected} 不一致")
def _write_jpgs(frames: Sequence[Frame], frames_dir: Path) -> None:
for index, frame in enumerate(frames):
path = frames_dir / f"{index:06d}.jpg"
if not cv2.imwrite(str(path), frame, [cv2.IMWRITE_JPEG_QUALITY, _JPEG_QUALITY]):
raise RuntimeError(f"写分割用的 jpg 失败:{path}")
def _to_mask(mask_logits: torch.Tensor) -> Mask:
return np.squeeze((mask_logits > 0.0).detach().cpu().numpy()).astype(np.bool_)
def _propagate(predictor: Any, state: Any, first_prompt: int) -> dict[int, Mask]:
"""从最早的提示帧向后传播到末帧,再向前传播到第 0 帧。"""
propagated: dict[int, Mask] = {}
with torch.inference_mode():
for frame_idx, obj_ids, mask_logits in predictor.propagate_in_video(
state, start_frame_idx=first_prompt
):
propagated[int(frame_idx)] = _to_mask(mask_logits[list(obj_ids).index(_OBJECT_ID)])
for frame_idx, obj_ids, mask_logits in predictor.propagate_in_video(
state,
start_frame_idx=first_prompt,
max_frame_num_to_track=first_prompt + 1,
reverse=True,
):
propagated[int(frame_idx)] = _to_mask(mask_logits[list(obj_ids).index(_OBJECT_ID)])
return propagated
def segment_video(
frames: Sequence[Frame], prompt_masks: Mapping[int, Mask], cfg: SegmentConfig
) -> Mask:
"""把提示帧的病灶掩码传播到每一帧。
`frames` 是逐帧 BGR 图像(同一尺寸),`prompt_masks` 是提示帧帧号 → 与帧同尺寸的布尔掩码。
返回 `(帧数, 高, 宽)` 的布尔数组,与输入帧一一对应。
"""
height, width = _check_inputs(frames, prompt_masks, cfg)
_check_device(cfg.device)
_check_sam2_source(cfg.medsam2_dir)
# sam2 没有类型标注,参数类型是 Unknown;返回的 predictor 按 Any 用。
from sam2.build_sam import build_sam2_video_predictor # pyright: ignore[reportUnknownVariableType]
predictor: Any = build_sam2_video_predictor(
config_file=cfg.sam2_config, ckpt_path=str(cfg.checkpoint), device=cfg.device
)
with tempfile.TemporaryDirectory(prefix="cadx-medsam2-") as tmp:
frames_dir = Path(tmp)
_write_jpgs(frames, frames_dir)
state: Any = predictor.init_state(
str(frames_dir),
offload_video_to_cpu=cfg.offload_video_to_cpu,
offload_state_to_cpu=cfg.offload_state_to_cpu,
)
for index in sorted(prompt_masks):
predictor.add_new_mask(state, frame_idx=index, obj_id=_OBJECT_ID, mask=prompt_masks[index])
propagated = _propagate(predictor, state, min(prompt_masks))
missing = [index for index in range(len(frames)) if index not in propagated]
if missing:
raise RuntimeError(f"MedSAM2 传播后缺 {len(missing)} 帧的掩码,前几帧:{missing[:5]}")
stack = np.stack([propagated[index] for index in range(len(frames))], axis=0)
if stack.shape != (len(frames), height, width):
raise RuntimeError(
f"MedSAM2 输出掩码形状 {stack.shape},应为 {(len(frames), height, width)}"
)
return stack
+392
View File
@@ -0,0 +1,392 @@
"""连续 Lognormal 锚点时刻到真实视频帧的映射测试(照搬旧项目 test_anchor_frames.py)。
旧测试里 `excel_video` 模式(D1 取医生标注的到达时间、推注时间按「推注发生在视频第 X 秒」换算)
的几条已删,见任务书 02「四处要改的地方」第 3、4 条;新语义的手算测试在文件末尾。
"""
import numpy as np
import pytest
from cadx.ceus import (
ANCHOR_SLOTS,
AnchorFrameLocation,
CeusConfig,
LognormalFitResult,
locate_anchor_frames,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
_ANCHOR_CFG = _CONFIG.tic_analysis.anchor_frames
def _fit(
times: np.ndarray,
*,
success: bool = True,
d1: float | None = 5.0,
d2: float | None = 6.0,
d3: float | None = 8.0,
d4: float | None = 11.0,
duplicate_indices: tuple[int, ...] = (),
baseline_source: str = "observed",
) -> LognormalFitResult:
duplicates = np.zeros(times.size, dtype=bool)
duplicates[list(duplicate_indices)] = True
values = np.ones(times.size, dtype=np.float64)
return LognormalFitResult(
success=success,
failure_reason="" if success else "fit_quality_failed",
algorithm_version="lognormal-v2",
curve_model="lognormal",
smoothing_method="",
baseline_source=baseline_source,
candidate=values.copy(),
fitted=values.copy(),
processed=values.copy() if success else np.full(times.size, np.nan),
valid_sample_mask=np.ones(times.size, dtype=bool),
duplicate_sample_mask=duplicates,
parameters=None,
r2=0.9,
nrmse=0.1,
residual_noise_ratio=1.0,
fit_start_s=float(times[0]),
fit_end_s=float(times[-1]),
multi_peak_flag=False,
d1_time_s=d1,
d2_time_s=d2,
d3_time_s=d3,
d4_time_s=d4,
)
def _locate(
*,
perf: LognormalFitResult | None = None,
offset: float | None = None,
lesion_fit_ready: bool = True,
baseline_end_index: int = 50,
) -> dict[str, AnchorFrameLocation]:
times = np.arange(0.0, 70.0, 0.1)
return locate_anchor_frames(
perf_fit=perf or _fit(times),
perf_baseline_end_index=baseline_end_index,
lesion_fit_ready=lesion_fit_ready,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=offset,
cfg=_ANCHOR_CFG,
)
def test_model_times_map_to_expected_frames() -> None:
locations = _locate(offset=2.0)
assert set(locations) == set(ANCHOR_SLOTS)
assert locations["B0"].frame_index == 47
assert locations["D1"].frame_index == 50
assert locations["D2"].frame_index == 60
assert locations["D3"].frame_index == 80
assert locations["D4"].frame_index == 110
assert locations["D3"].time_s == pytest.approx(10.0)
assert locations["D3"].anchor_time_video_s == pytest.approx(8.0)
assert locations["D3"].frame_time_video_s == pytest.approx(8.0)
assert locations["D3"].time_error_s == pytest.approx(0.0)
assert locations["D3"].source_curve == "roi_perf"
assert locations["F1"].frame_index == 180
assert locations["F2"].frame_index == 580
def test_mapping_skips_duplicate_frame_and_chooses_earlier_tie() -> None:
times = np.arange(0.0, 20.0, 0.1)
perf = _fit(times, d1=5.05, duplicate_indices=(50,))
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index == 51
assert locations["D1"].time_s is None
def test_discrete_mapping_does_not_reverse_anchor_order() -> None:
times = np.arange(0.0, 20.0, 0.1)
perf = _fit(times, d1=5.0, d2=5.01, d3=8.0, d4=11.0)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index == 50
assert locations["D2"].frame_index == 51
assert locations["D2"].status == "ok"
assert locations["D3"].frame_index == 80
def test_failed_fit_does_not_fall_back_to_candidate_curve() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, success=False), offset=2.0)
for slot in ("D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
def test_missing_washout_keeps_d4_undefined() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, d4=None), offset=2.0)
assert locations["D1"].status == "ok"
assert locations["D4"].status == "not_covered"
def test_b0_uses_perf_arrival_without_pan_reference() -> None:
locations = _locate(offset=2.0)
assert locations["B0"].status == "ok"
assert locations["B0"].frame_index == 47
assert locations["D1"].status == "ok"
def test_estimated_baseline_keeps_b0_undefined_but_maps_dynamic_anchors() -> None:
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, baseline_source="estimated"), offset=2.0)
assert locations["B0"].status == "anchor_undefined"
assert locations["D1"].status == "ok"
assert locations["D4"].status == "ok"
def test_b0_cannot_map_after_arrival_or_outside_observed_baseline() -> None:
times = np.asarray([0.0, 5.1, 6.0, 8.0, 11.0])
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=5.0, d2=6.0, d3=8.0, d4=11.0),
perf_baseline_end_index=1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["B0"].frame_index is None
assert locations["B0"].status == "no_frame_in_window"
def test_failed_second_lesion_curve_blocks_dynamic_screenshots() -> None:
locations = _locate(lesion_fit_ready=False, offset=2.0)
for slot in ("B0", "D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
def test_uncovered_d4_does_not_erase_available_earlier_slots() -> None:
times = np.arange(0.0, 10.1, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=5.0, d2=6.0, d3=8.0, d4=20.0),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].status == "ok"
assert locations["D2"].status == "ok"
assert locations["D3"].status == "ok"
assert locations["D4"].status == "not_covered"
def test_pre_video_d1_does_not_attach_to_first_frame() -> None:
times = np.arange(0.0, 10.1, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=-0.1, d2=1.0, d3=4.0, d4=8.0),
perf_baseline_end_index=-1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert locations["D1"].frame_index is None
assert locations["D1"].status == "not_covered"
assert locations["D2"].status == "ok"
def test_d4_does_not_map_to_fit_end_frame() -> None:
times = np.arange(0.0, 11.0, 1.0)
perf = _fit(times, d1=2.0, d2=3.0, d3=6.0, d4=9.7)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=2,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert perf.fit_end_s == pytest.approx(10.0)
assert locations["D4"].frame_index is None
assert locations["D4"].status == "no_frame_in_window"
def test_d4_reselects_eligible_frame_before_fit_end() -> None:
times = np.arange(0.0, 11.0, 1.0)
perf = _fit(times, d1=2.0, d2=3.0, d3=6.0, d4=9.6)
locations = locate_anchor_frames(
perf_fit=perf,
perf_baseline_end_index=2,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG.model_copy(update={"d4_window_s": 1.0}),
)
assert locations["D4"].frame_index == 9
assert locations["D4"].status == "ok"
def test_order_collision_has_distinct_status() -> None:
times = np.asarray([0.0, 2.0, 4.0])
locations = locate_anchor_frames(
perf_fit=_fit(times, d1=2.0, d2=2.0, d3=None, d4=None),
perf_baseline_end_index=1,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
assert {locations["D1"].status, locations["D2"].status} == {
"ok",
"no_ordered_frame",
}
def test_missing_injection_offset_only_disables_fixed_time_frames() -> None:
locations = _locate(offset=None)
assert locations["D1"].status == "ok"
assert locations["D1"].frame_index == 50
assert locations["D1"].time_s is None
assert locations["F1"].status == "not_covered"
assert locations["F2"].status == "not_covered"
def test_frame_indices_must_be_strictly_increasing() -> None:
times = np.arange(0.0, 20.0, 0.1)
indices = np.arange(times.size, dtype=np.int32)
indices[51] = indices[50]
with pytest.raises(ValueError, match="frame_indices 必须严格递增"):
locate_anchor_frames(
perf_fit=_fit(times),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=indices,
time_video_s=times,
injection_offset_s=None,
cfg=_ANCHOR_CFG,
)
# ---------- 任务书 02 第 4 条:推注时间 X 是视频第 0 秒时已推注的秒数 ----------
# 推注后第 T 秒在视频第 T − X 秒。时间轴每 0.1 秒一帧,帧号 = 视频时间 × 10。
def test_bolus_x7_puts_f1_at_13s_and_f2_at_53s() -> None:
"""X=7:F1(推注后 20 秒)在视频第 13 秒,F2(推注后 60 秒)在视频第 53 秒。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times), offset=7.0)
assert locations["F1"].status == "ok"
assert locations["F1"].anchor_time_video_s == pytest.approx(13.0)
assert locations["F1"].frame_index == 130
assert locations["F1"].frame_time_video_s == pytest.approx(13.0)
assert locations["F1"].time_s == pytest.approx(20.0)
assert locations["F1"].reason is None
assert locations["F2"].status == "ok"
assert locations["F2"].anchor_time_video_s == pytest.approx(53.0)
assert locations["F2"].frame_index == 530
assert locations["F2"].time_s == pytest.approx(60.0)
def test_bolus_x25_leaves_f1_before_video_unlocated() -> None:
"""X=25:F1 在视频第 −5 秒,落在视频之外记未定位、写原因,不拿第 0 帧补;F2 在第 35 秒照常。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times), offset=25.0)
assert locations["F1"].status == "not_covered"
assert locations["F1"].frame_index is None
assert locations["F1"].anchor_time_video_s == pytest.approx(-5.0)
assert locations["F1"].reason is not None
assert "-5.00" in locations["F1"].reason
assert locations["F2"].status == "ok"
assert locations["F2"].frame_index == 350
def test_bolus_time_past_video_end_is_unlocated() -> None:
"""X=0、视频只有 50 秒:F2(视频第 60 秒)超过视频末尾,记未定位。"""
times = np.arange(0.0, 50.0, 0.1)
locations = locate_anchor_frames(
perf_fit=_fit(times),
perf_baseline_end_index=50,
lesion_fit_ready=True,
frame_indices=np.arange(times.size, dtype=np.int32),
time_video_s=times,
injection_offset_s=0.0,
cfg=_ANCHOR_CFG,
)
assert locations["F1"].frame_index == 200
assert locations["F2"].status == "not_covered"
assert locations["F2"].frame_index is None
assert locations["F2"].anchor_time_video_s == pytest.approx(60.0)
@pytest.mark.parametrize("value", [float("nan"), -1.0])
def test_invalid_bolus_time_fails_explicitly(value: float) -> None:
"""推注时间不是有限非负秒数时直接报错,不当作「没有推注时间」。"""
with pytest.raises(ValueError, match="injection_offset_s"):
_locate(offset=value)
# ---------- 任务书 02 第 3 条:D1 只从拟合曲线算 ----------
def test_d1_comes_from_fitted_arrival_time() -> None:
"""D1 取拟合曲线上的到达时刻(这里 d1=5.0 秒 → 第 50 帧),来源记 roi_perf。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, d1=5.0), offset=0.0)
assert locations["D1"].frame_index == 50
assert locations["D1"].anchor_time_video_s == pytest.approx(5.0)
assert locations["D1"].source_curve == "roi_perf"
def test_failed_fit_unlocates_b0_to_d4_with_reason_and_keeps_f1_f2() -> None:
"""拟合没过质量门:B0–D4 未定位并写明原因,F1、F2 照常按推注时间取。"""
times = np.arange(0.0, 70.0, 0.1)
locations = _locate(perf=_fit(times, success=False), offset=7.0, lesion_fit_ready=False)
for slot in ("B0", "D1", "D2", "D3", "D4"):
assert locations[slot].status == "anchor_undefined"
assert locations[slot].frame_index is None
reason = locations[slot].reason
assert reason is not None
assert "质量门" in reason
assert locations["F1"].frame_index == 130
assert locations["F2"].frame_index == 530
+308
View File
@@ -0,0 +1,308 @@
"""暗区识别(dark_region)单元测试(照搬旧项目 test_ceus_dark_region.py)。
旧标题:B7 暗区识别(dark_region)单元测试。
合成"亮 ROI 内嵌暗斑"图像钉死六个 port 的真实行为,并核对评分公式的三个硬编码
常量(64.0 / 4.0 / 2.0)与时间一致性 IoU。测试用真实 OpenCV 像素管线(非 mock),
覆盖:otsu 检出暗斑、auto_ensemble 择优、min_area/min_area_ratio 过滤碎片、
exclude_prompt_color 剔绿、enabled=False 返回空且保留帧对齐、逐帧轮廓重采样 64 点。
"""
import numpy as np
from cadx.ceus import (
CeusConfig,
InteriorDarkRegionConfig,
apply_dark_morphology,
dark_candidate_mask,
extract_dark_region_masks,
extract_dark_region_polygons,
score_dark_candidate,
split_dark_components,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
# 剔绿框色用的 HSV 阈值:旧代码没传时回退的写死阈值与 prompt_source 相同,这里显式传入。
_PROMPT = _CONFIG.prompt_source
def _dark_cfg(**overrides: object) -> InteriorDarkRegionConfig:
"""ceus.yaml 的暗区段,按需改几项(代替旧的 `InteriorDarkRegionConfig(...)`)。"""
return _CONFIG.interior_dark_region.model_copy(update=overrides)
# ---------- 合成样本 ----------
_H, _W = 120, 160
# 整幅 ROI 多边形:占据画面中央的大矩形。
_ROI_POLYGON = np.array(
[[20.0, 20.0], [140.0, 20.0], [140.0, 100.0], [20.0, 100.0]], dtype=np.float32
)
# ROI 内的暗斑位置(一个明确的低灰度方块)。
_DARK_BOX = (slice(45, 75), slice(60, 100)) # (y, x),30×40=1200 px
def _bright_roi_with_dark_box() -> np.ndarray:
"""构造一帧 BGR:全黑画布上铺亮 ROI(灰度 200),其内嵌暗斑(灰度 40)。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
# 亮 ROI 区
frame[20:100, 20:140] = 200
# 暗斑
frame[_DARK_BOX] = 40
return frame
def _roi_mask() -> np.ndarray:
from cadx.ceus import polygon_to_mask
return polygon_to_mask(_ROI_POLYGON, _H, _W)
def _gray(frame: np.ndarray) -> np.ndarray:
import cv2
return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# ---------- dark_candidate_mask / split / morphology ----------
def test_otsu_candidate_detects_dark_box() -> None:
"""otsu 法应在 ROI 内检出暗斑(暗斑像素被标记,亮区不被标记)。"""
cfg = _dark_cfg()
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
dark = dark_candidate_mask(gray, sample, "otsu", cfg)
assert dark.dtype == bool
# 暗斑中心应被标记
assert dark[60, 80]
# 亮区中心不应被标记
assert not dark[30, 30]
# 暗斑覆盖率应显著(形态学后仍是大块)
assert dark.sum() > 500
def test_split_components_filters_small_fragments() -> None:
"""min_area / min_area_ratio 联合过滤:组件阈 = max(min_area, ROI面积×ratio)。"""
sample = _roi_mask()
roi_area = float(sample.sum())
# 造一个 ~1200px 暗块 + 一个 ~16px 碎片
dark = np.zeros((_H, _W), dtype=bool)
dark[45:75, 60:100] = True # 1200 px
dark[10:14, 10:14] = True # 16 px 碎片(且在 ROI 外,会被 roi_mask 裁掉)
dark[50:54, 30:34] = True # 16 px 碎片(ROI 内)
cfg = _dark_cfg(min_area=300, min_area_ratio=0.015)
# 阈值 = max(300, roi_area*0.015)
threshold = max(300.0, roi_area * 0.015)
assert threshold >= 300.0
components = split_dark_components(dark, sample, cfg)
# 只剩大暗块
assert len(components) == 1
assert components[0].sum() >= 300
def test_morphology_intersects_sample_mask() -> None:
"""形态学结果须与 sample_mask 取交(不外溢 ROI)。"""
cfg = _dark_cfg(morphology_kernel=5)
sample = _roi_mask()
dark = np.zeros((_H, _W), dtype=bool)
dark[45:75, 60:100] = True
dark[0:5, 0:5] = True # ROI 外的块
out = apply_dark_morphology(dark, sample, cfg)
assert not out[2, 2] # ROI 外被裁掉
assert (out & ~sample).sum() == 0
# ---------- score_dark_candidate(三硬编码常量) ----------
def test_score_contrast_constant_64() -> None:
"""明暗对比项 = max(0, (median_bright - median_dark) / 64.0)。
用无碎片惩罚 + 无时间项 + None preferred_range 的配置隔离对比项贡献。
"""
cfg = _dark_cfg(fragment_penalty_weight=0.0, temporal_consistency_weight=0.0)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
component = np.zeros((_H, _W), dtype=bool)
component[_DARK_BOX] = True
score = score_dark_candidate(gray, sample, [component], None, cfg)
union = component & sample
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
area_score = min(area_ratio, 1.0 - area_ratio) * 2.0
expected = contrast + area_score
assert score == expected
def test_score_empty_components_returns_sentinel() -> None:
"""空组件列表返回极小值哨兵(择优时永远不会被选)。"""
cfg = _dark_cfg()
gray = _gray(_bright_roi_with_dark_box())
sample = _roi_mask()
assert score_dark_candidate(gray, sample, [], None, cfg) == -1e9
def test_score_fragment_penalty_and_temporal_iou() -> None:
"""碎片惩罚 = w×max(0, 块数−1);时间一致性 = temporal_weight × IoU(union, prev)。"""
cfg = _dark_cfg(fragment_penalty_weight=0.08, temporal_consistency_weight=0.35)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
comp_a = np.zeros((_H, _W), dtype=bool)
comp_a[45:75, 60:80] = True
comp_b = np.zeros((_H, _W), dtype=bool)
comp_b[45:75, 80:100] = True
components = [comp_a, comp_b]
union = (comp_a | comp_b) & sample
prev = comp_a.copy() # 上一帧暗部 = comp_a
score = score_dark_candidate(gray, sample, components, prev, cfg)
from cadx.ceus import mask_iou
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
area_score = min(area_ratio, 1.0 - area_ratio) * 2.0
fragment_penalty = 0.08 * 1 # 2 块 → max(0, 2-1)=1
temporal = mask_iou(union, prev) * 0.35
expected = contrast + area_score + temporal - fragment_penalty
assert abs(score - expected) < 1e-9
def test_score_preferred_range_slope_4() -> None:
"""preferred_area_ratio_range 命中时 area_score=1,否则 max(0, 1 - dist×4.0)。"""
# 这个键旧 schema 里没有(旧测试用 object.__setattr__ 注入),现在是配置字段。
cfg = _dark_cfg(
fragment_penalty_weight=0.0,
temporal_consistency_weight=0.0,
preferred_area_ratio_range=[0.001, 0.005],
)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
component = np.zeros((_H, _W), dtype=bool)
component[_DARK_BOX] = True # 1200/roi_area
score = score_dark_candidate(gray, sample, [component], None, cfg)
union = component & sample
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
low, high = 0.001, 0.005
if low <= area_ratio <= high:
area_score = 1.0
else:
nearest = low if area_ratio < low else high
area_score = max(0.0, 1.0 - abs(area_ratio - nearest) * 4.0)
assert abs(score - (contrast + area_score)) < 1e-9
# ---------- extract_dark_region_masks ----------
def test_auto_ensemble_selects_dark_box() -> None:
"""auto_ensemble 跑 candidate_methods 择优,应选出覆盖暗斑的暗块。"""
cfg = _dark_cfg(threshold_method="auto_ensemble")
frame = _bright_roi_with_dark_box()
masks, union = extract_dark_region_masks(frame, _ROI_POLYGON, cfg, None, _PROMPT)
assert masks, "auto_ensemble 应检出暗块"
assert union is not None
# 暗斑中心被选中暗块覆盖
assert union[60, 80]
# 不外溢 ROI
assert (union & ~_roi_mask()).sum() == 0
def test_exclude_prompt_color_removes_green() -> None:
"""exclude_prompt_color=True 时 ROI 内绿像素从分析中剔除(不计入暗 / 亮统计)。"""
frame = _bright_roi_with_dark_box()
# 在 ROI 内画一块纯绿(BGR),它在 HSV 绿区间内,灰度也偏低
frame[80:95, 30:60] = (0, 255, 0)
cfg_on = _dark_cfg(exclude_prompt_color=True)
_masks_on, union_on = extract_dark_region_masks(frame, _ROI_POLYGON, cfg_on, None, _PROMPT)
# 绿块不应进入选中暗块
assert union_on is not None
assert not union_on[87, 45], "绿像素应被剔除,不计入暗块"
def test_disabled_returns_empty_polys_but_keeps_alignment() -> None:
"""enabled=False:每帧返回空多边形列表,但帧数与输入对齐(§5 不变量)。"""
cfg = _dark_cfg(enabled=False)
frames = [_bright_roi_with_dark_box() for _ in range(3)]
polys = [_ROI_POLYGON for _ in range(3)]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 3
assert all(frame_polys == [] for frame_polys in result)
def test_polygons_resampled_to_64_points_and_aligned() -> None:
"""逐帧暗块轮廓重采样到 contour_points=64 点;帧对齐保留(N 帧进 N 帧出)。"""
cfg = _dark_cfg(contour_points=64)
frames = [_bright_roi_with_dark_box() for _ in range(4)]
polys = [_ROI_POLYGON for _ in range(4)]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 4
# 每帧至少一个暗块多边形(暗斑稳定)
for frame_polys in result:
assert frame_polys, "每帧应检出暗斑多边形"
for poly in frame_polys:
assert poly.shape == (64, 2)
assert poly.dtype == np.float32
def _bright_roi_with_donut_dark() -> np.ndarray:
"""构造一帧 BGR:亮 ROI(200)内嵌暗斑(40),暗斑中心再挖一个亮洞(200)→ 甜甜圈暗区。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[20:100, 20:140] = 200 # 亮 ROI
frame[45:75, 60:100] = 40 # 暗斑(1200 px)
frame[55:65, 72:88] = 200 # 中心亮洞(10×16=160 px)→ 暗区成环
return frame
def test_preserve_holes_keeps_hole_contour() -> None:
"""preserve_holes=True:甜甜圈暗区的多边形列表须含外轮廓 + 洞轮廓(≥2 个)。
回归 Codex 发现的 Critical——洞由 split_dark_components 挖回 mask,
_significant_contours 须沿 hierarchy 追加子轮廓(参考 P:685-692),否则洞被丢弃。
"""
cfg = _dark_cfg(preserve_holes=True, contour_points=64)
frames = [_bright_roi_with_donut_dark()]
polys = [_ROI_POLYGON]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 1
frame_polys = result[0]
# 外环 + 中心洞 → 至少两个多边形
assert len(frame_polys) >= 2, f"应含外轮廓 + 洞,实得 {len(frame_polys)} 个"
# 每个多边形均重采样到 64 点
assert all(p.shape == (64, 2) for p in frame_polys)
# 存在一个明显小于外环的多边形(洞)——按面积区分
from cadx.ceus import polygon_area
areas = sorted(polygon_area(p) for p in frame_polys)
assert areas[0] < areas[-1], "洞多边形面积应显著小于外轮廓"
+358
View File
@@ -0,0 +1,358 @@
"""overlay 的单元测试(照搬旧项目 test_ceus_overlay.py;画层配置与图层的默认值移到测试里)。
测试哲学(CLAUDE.md §4.6):用已知几何构造真实可断言的场景,验证
- ``draw_polygon_overlay`` 在整帧坐标系画各层(左 ROI 黄 / 右 ROI 绿 / 暗区橙红 /
背景圆青),输出帧尺寸不变、对应位置出现对应 BGR 颜色像素、fill 做 alpha 混合;
- ``render_overlay`` 返回非空 mp4 字节,cv2 读回后帧数 / 尺寸与输入一致;
- 临时文件 finally 清理(渲染前后 tempfile 目录无 ``.mp4`` 残留);
- 无效 fps 直接报错(不静默兜底,呼应 findings §2E 不走参考的 25.0 回退)。
不做逐像素全图比对——只在已知轮廓位置抽样断言颜色,符合任务要求。
"""
import tempfile
from pathlib import Path
from typing import Any
import cv2
import numpy as np
import pytest
from cadx.ceus import (
OverlayConfig,
OverlayLayers,
ViewRect,
draw_polygon_overlay,
render_overlay,
)
# 整帧尺寸:左右两半各 100 宽,便于把 source / target 多边形放进各自视图。
_FRAME_HEIGHT = 120
_FRAME_WIDTH = 200
_LEFT_RECT = ViewRect(0, 0, 100, _FRAME_HEIGHT)
_RIGHT_RECT = ViewRect(100, 0, _FRAME_WIDTH, _FRAME_HEIGHT)
def _overlay_cfg(**overrides: bool) -> OverlayConfig:
"""旧 `OverlayConfig` 的默认值(findings §3.10),按需改几项;新代码里这些字段没有默认值。"""
values = {
"draw_source_roi": False,
"draw_target_polygon": True,
"draw_dark_region": True,
"draw_source_dark_region": False,
"draw_reference_region": True,
"fill_polygon": False,
}
values.update(overrides)
return OverlayConfig(**values)
def _layers(**fields: Any) -> OverlayLayers:
"""旧 `OverlayLayers` 的默认值(可选层为空),按需给几项;新代码里这些字段没有默认值。"""
values: dict[str, Any] = {
"dark_polygons": [],
"source_roi_polygon": None,
"source_rect": None,
"source_dark_polygons": [],
"reference_region": None,
}
values.update(fields)
return OverlayLayers(**values)
def _black_frame() -> np.ndarray:
"""构造全黑 BGR 整帧,作为干净画布(背景与所有彩色层都可区分)。"""
return np.zeros((_FRAME_HEIGHT, _FRAME_WIDTH, 3), dtype=np.uint8)
def _square_polygon(x: int, y: int, side: int) -> np.ndarray:
"""构造左上角 ``(x, y)``、边长 ``side`` 的局部坐标方形多边形 ``(4, 2)``。"""
return np.array(
[[x, y], [x + side, y], [x + side, y + side], [x, y + side]],
dtype=np.float32,
)
def _has_color(frame: np.ndarray, bgr: tuple[int, int, int]) -> bool:
"""整帧内是否存在与给定 BGR 完全相等的像素(用于断言某层已被画出)。"""
return bool(np.any(np.all(frame == np.array(bgr, dtype=np.uint8), axis=-1)))
def _count_mp4_in(directory: Path) -> int:
"""统计目录下 ``.mp4`` 文件数量(用于断言临时产物已清理)。"""
return len(list(directory.glob("*.mp4")))
def test_draw_keeps_frame_shape_and_does_not_mutate_input() -> None:
"""绘制后输出帧尺寸 / dtype 不变,且不就地修改入参(返回副本)。"""
frame = _black_frame()
original = frame.copy()
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
out = draw_polygon_overlay(frame, layers, _overlay_cfg())
assert out.shape == frame.shape
assert out.dtype == frame.dtype
assert np.array_equal(frame, original) # 入参未被污染
def test_target_polygon_drawn_green() -> None:
"""右侧 target ROI 轮廓默认画绿 (0,255,0),且坐标抬到整帧右半区。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (0, 255, 0))
# 绿色像素应落在右半区(x >= 100),证明做了 target_rect 偏移。
green = np.all(out == np.array((0, 255, 0), dtype=np.uint8), axis=-1)
xs = np.where(green)[1]
assert xs.min() >= 100
def test_source_roi_drawn_yellow_when_enabled() -> None:
"""开启 draw_source_roi 时左侧 ROI 轮廓画黄 (0,255,255) 并落在左半区。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_roi_polygon=_square_polygon(20, 20, 40),
source_rect=_LEFT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(draw_source_roi=True))
assert _has_color(out, (0, 255, 255))
yellow = np.all(out == np.array((0, 255, 255), dtype=np.uint8), axis=-1)
assert np.where(yellow)[1].max() < 100
def test_source_roi_not_drawn_when_disabled() -> None:
"""draw_source_roi 默认 False(findings §3.10),不应出现黄色像素。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_roi_polygon=_square_polygon(20, 20, 40),
source_rect=_LEFT_RECT,
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert not _has_color(out, (0, 255, 255))
def test_dark_region_drawn_orange_red() -> None:
"""暗区轮廓默认画橙红 (255,80,20),落在右半区(与 target 共 rect)。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 60),
target_rect=_RIGHT_RECT,
dark_polygons=[_square_polygon(30, 30, 20)],
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (255, 80, 20))
def test_reference_region_drawn_cyan_when_enabled() -> None:
"""背景圆启用时左侧画青色小圆 (255,255,0),落在左半区。
**颜色不能是紫 (255,0,255)**:紫色是医生画的 ``ROI_pan`` 轮廓的颜色,两圈紫叠在
同一画面上人眼分不出哪个是哪个(design/0030 决策四)。
"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_rect=_LEFT_RECT,
reference_region={"enabled": True, "center_xy": [40.0, 40.0], "radius_px": 12},
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert _has_color(out, (255, 255, 0))
assert not _has_color(out, (255, 0, 255)), "背景圆不许和医生的紫色轮廓撞色"
cyan = np.all(out == np.array((255, 255, 0), dtype=np.uint8), axis=-1)
assert np.where(cyan)[1].max() < 100
def test_reference_region_not_drawn_when_disabled() -> None:
"""reference enabled=False 时不画那个圆。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
source_rect=_LEFT_RECT,
reference_region={"enabled": False, "center_xy": [40.0, 40.0], "radius_px": 12},
)
out = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg())
assert not _has_color(out, (255, 255, 0))
def test_fill_polygon_alpha_blends_target() -> None:
"""开启 fill_polygon 时 ROI 内部做 alpha=0.18 混合:黑底上出现暗绿填充,
且其值约等于 round(255*0.18)=46(绿通道),区别于不填充时的纯黑内部。"""
layers = _layers(
target_polygon=_square_polygon(20, 20, 50),
target_rect=_RIGHT_RECT,
)
filled = draw_polygon_overlay(_black_frame(), layers, _overlay_cfg(fill_polygon=True))
# 取多边形内部一点(局部 (40,40) -> 整帧 (140,40)),应为混合后的暗绿。
inner = filled[40, 140]
assert inner[0] == 0 # B 通道仍为 0
assert 40 <= int(inner[1]) <= 52 # G 通道 ≈ 255*0.18
assert inner[2] == 0 # R 通道仍为 0
def test_render_overlay_returns_decodable_mp4_bytes() -> None:
"""render_overlay 返回非空 mp4 字节;cv2 读回帧数 / 尺寸与输入一致。"""
num_frames = 5
frames = [_black_frame() for _ in range(num_frames)]
per_frame_layers = [
_layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
for _ in range(num_frames)
]
data = render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert isinstance(data, bytes)
assert len(data) > 0
# 写回临时文件用 cv2 读回校验(mp4v 编码无法直接 in-memory 解码)。
with tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) as handle:
handle.write(data)
probe_path = Path(handle.name)
try:
cap = cv2.VideoCapture(str(probe_path))
try:
assert cap.isOpened()
assert int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) == _FRAME_WIDTH
assert int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) == _FRAME_HEIGHT
decoded = 0
while True:
ok, _ = cap.read()
if not ok:
break
decoded += 1
assert decoded == num_frames
finally:
cap.release()
finally:
probe_path.unlink(missing_ok=True)
def test_render_overlay_cleans_temp_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""渲染走临时文件再读回,结束后临时目录不留 .mp4 残留。"""
monkeypatch.setattr(tempfile, "tempdir", str(tmp_path))
assert _count_mp4_in(tmp_path) == 0
frames = [_black_frame() for _ in range(3)]
per_frame_layers = [
_layers(
target_polygon=_square_polygon(20, 20, 40),
target_rect=_RIGHT_RECT,
)
for _ in range(3)
]
render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert _count_mp4_in(tmp_path) == 0
def test_render_overlay_rejects_non_positive_fps() -> None:
"""fps 非正应直接报错(不静默回退 25.0,见 findings §2E / 项目防御原则)。"""
frames = [_black_frame()]
per_frame_layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
]
with pytest.raises(ValueError, match="fps"):
render_overlay(
frames,
per_frame_layers,
fps=0.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_length_mismatch() -> None:
"""帧序列与逐帧图层序列长度不一致是调用方缺陷,直接报错。"""
frames = [_black_frame(), _black_frame()]
per_frame_layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
]
# 流式之后这条由 zip(strict=True) 执行。**仍旧钉住是哪一条报的**:这个函数里有四条
# 路径抛 ValueError(fps / frame_size / 图层为空 / 长度不一致),裸 raises 会让其中
# 任何一条被写错都从这里溜过去。
with pytest.raises(ValueError, match=r"zip\(\)"):
render_overlay(
frames,
per_frame_layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_empty_frames() -> None:
"""一帧都没有就产不出视频,直接报错(不静默产出空视频)。
判据从「帧序列为空」改成「逐帧图层为空」:流式之后帧数要遍历完才知道,而图层那一侧
仍旧是个列表,长度当场可查。两者必须等长(由 zip 的 strict 执行),所以查哪一侧等价。
"""
with pytest.raises(ValueError, match="不能为空"):
render_overlay(
[],
[],
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
def test_render_overlay_rejects_non_positive_frame_size() -> None:
"""输出尺寸由调用方给,给错了当场报错——编码器拿非法尺寸只会打不开,消息里看不出是谁的错。"""
frames = [_black_frame()]
layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)]
with pytest.raises(ValueError, match="frame_size"):
render_overlay(frames, layers, fps=25.0, cfg=_overlay_cfg(), frame_size=(0, 10))
def test_render_overlay_拒绝尺寸和_frame_size_对不上的帧() -> None:
"""**尺寸不符的帧会被编码器静默丢掉**——实测写 6 帧读回 3 帧,只在 stderr 留一行警告。
后果是 overlay 视频短一截甚至是空的,而它照样有字节、照样被上传、台账照样记完成。
输出尺寸以前取自首帧,结构上不可能不符;改成由调用方给之后这条得自己守
(design/0024 落地后的独立评审报出)。
"""
layers = [_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)]
with pytest.raises(ValueError, match="帧尺寸"):
render_overlay(
[_black_frame()],
layers,
fps=25.0,
cfg=_overlay_cfg(),
# 宽高对调:合法的正尺寸,但和实际帧对不上
frame_size=(_FRAME_HEIGHT, _FRAME_WIDTH),
)
def test_render_overlay_接受迭代器而不只是列表() -> None:
"""**这条守的是流式本身**:服务喂进来的是解码器的生成器,不是列表(design/0024)。
改回 ``Sequence`` 的话,服务那边要么先把全片物化成列表(4.33 GB,正是要消灭的东西),
要么当场报 TypeError;后者会被别的测试抓到,前者不会。
"""
layers = [
_layers(target_polygon=_square_polygon(20, 20, 40), target_rect=_RIGHT_RECT)
for _ in range(3)
]
data = render_overlay(
(_black_frame() for _ in range(3)),
layers,
fps=25.0,
cfg=_overlay_cfg(),
frame_size=(_FRAME_WIDTH, _FRAME_HEIGHT),
)
assert isinstance(data, bytes) and len(data) > 0
+161
View File
@@ -0,0 +1,161 @@
"""Lognormal 拟合派生灌注指标的纯算法单元测试(照搬旧项目 test_perfusion_metrics.py)。"""
from dataclasses import replace
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
LognormalFitResult,
LognormalParameters,
compute_lognormal_perfusion_metrics,
evaluate_lognormal,
fit_lognormal_curve,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为读 config/ceus.yaml 的取值。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
@pytest.fixture(scope="module")
def complete_fit() -> LognormalFitResult:
times = np.linspace(0.0, 60.0, 601)
parameters = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
fit = fit_lognormal_curve(
times,
evaluate_lognormal(times, parameters),
baseline=parameters.baseline,
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,
)
assert fit.success is True
assert fit.d4_time_s is not None
return fit
def test_complete_bolus_metrics_match_curve_integrals(
complete_fit: LognormalFitResult,
) -> None:
metrics = compute_lognormal_perfusion_metrics(complete_fit)
assert metrics is not None
assert complete_fit.parameters is not None
assert complete_fit.d2_time_s is not None
assert complete_fit.d3_time_s is not None
assert complete_fit.d4_time_s is not None
assert metrics.ti_s is not None
assert metrics.to_s is not None
assert metrics.mtt_s is not None
assert metrics.wi_auc_au_s is not None
assert metrics.wo_auc_au_s is not None
assert metrics.wiwo_auc_au_s is not None
assert metrics.rt_s is not None
assert metrics.ft_s is not None
assert metrics.mttl_s is not None
assert metrics.wipi_au is not None
assert metrics.wor_au_per_s is not None
assert (
complete_fit.fit_start_s
<= metrics.ti_s
< complete_fit.d2_time_s
< metrics.ttp_s
< complete_fit.d4_time_s
< metrics.to_s
<= complete_fit.fit_end_s
)
assert metrics.pe_au == pytest.approx(
float(
evaluate_lognormal(np.asarray([metrics.ttp_s]), complete_fit.parameters)[0]
- complete_fit.parameters.baseline
)
)
assert metrics.ttp_s == pytest.approx(complete_fit.d3_time_s)
assert metrics.wir_au_per_s > 0.0
assert metrics.wor_au_per_s < 0.0
assert metrics.rt_s == pytest.approx(metrics.ttp_s - metrics.ti_s)
assert metrics.ft_s == pytest.approx(metrics.to_s - metrics.ttp_s)
assert metrics.wiwo_auc_au_s == pytest.approx(metrics.wi_auc_au_s + metrics.wo_auc_au_s)
assert metrics.wipi_au == pytest.approx(metrics.wi_auc_au_s / metrics.rt_s)
assert metrics.mttl_s == pytest.approx(metrics.mtt_s - metrics.ti_s)
assert metrics.ti_s < metrics.mtt_s < metrics.to_s
grid = np.linspace(metrics.ti_s, metrics.to_s, 100_001)
pulse = evaluate_lognormal(grid, complete_fit.parameters) - (complete_fit.parameters.baseline)
split = int(np.searchsorted(grid, metrics.ttp_s))
wi_grid = np.concatenate((grid[:split], np.asarray([metrics.ttp_s])))
wo_grid = np.concatenate((np.asarray([metrics.ttp_s]), grid[split:]))
wi_pulse = evaluate_lognormal(wi_grid, complete_fit.parameters) - (
complete_fit.parameters.baseline
)
wo_pulse = evaluate_lognormal(wo_grid, complete_fit.parameters) - (
complete_fit.parameters.baseline
)
assert metrics.wi_auc_au_s == pytest.approx(np.trapezoid(wi_pulse, wi_grid), rel=1e-7)
assert metrics.wo_auc_au_s == pytest.approx(np.trapezoid(wo_pulse, wo_grid), rel=1e-7)
assert metrics.mtt_s == pytest.approx(
np.trapezoid(grid * pulse, grid) / np.trapezoid(pulse, grid), rel=1e-7
)
def test_missing_washout_keeps_only_wash_in_metrics(
complete_fit: LognormalFitResult,
) -> None:
metrics = compute_lognormal_perfusion_metrics(replace(complete_fit, d4_time_s=None))
assert metrics is not None
assert metrics.pe_au > 0.0
assert metrics.ttp_s > 0.0
assert metrics.wir_au_per_s > 0.0
assert metrics.wi_auc_au_s is not None
assert metrics.rt_s is not None
assert metrics.wipi_au is not None
assert metrics.wor_au_per_s is None
assert metrics.wo_auc_au_s is None
assert metrics.wiwo_auc_au_s is None
assert metrics.ft_s is None
assert metrics.mttl_s is None
assert metrics.to_s is None
assert metrics.mtt_s is None
def test_failed_fit_has_no_metrics(complete_fit: LognormalFitResult) -> None:
assert compute_lognormal_perfusion_metrics(replace(complete_fit, success=False)) is None
def test_tangent_intersections_outside_observation_gate_dependents(
complete_fit: LognormalFitResult,
) -> None:
complete = compute_lognormal_perfusion_metrics(complete_fit)
assert complete is not None
assert complete.ti_s is not None
assert complete.to_s is not None
missing_wash_in = compute_lognormal_perfusion_metrics(
replace(complete_fit, fit_start_s=complete.ti_s + 0.01)
)
assert missing_wash_in is not None
assert missing_wash_in.ti_s is None
assert missing_wash_in.rt_s is None
assert missing_wash_in.wi_auc_au_s is None
assert missing_wash_in.wipi_au is None
assert missing_wash_in.wo_auc_au_s is not None
assert missing_wash_in.wiwo_auc_au_s is None
assert missing_wash_in.mttl_s is None
missing_wash_out = compute_lognormal_perfusion_metrics(
replace(complete_fit, fit_end_s=complete.to_s - 0.01)
)
assert missing_wash_out is not None
assert missing_wash_out.wor_au_per_s is not None
assert missing_wash_out.to_s is None
assert missing_wash_out.ft_s is None
assert missing_wash_out.wo_auc_au_s is None
assert missing_wash_out.wiwo_auc_au_s is None
assert missing_wash_out.mttl_s is None
+168
View File
@@ -0,0 +1,168 @@
"""ceus 对外两段接口的端到端测试:合成一段分屏造影视频,第二段喂合成掩膜(不调分割模型)。
视频 400×200、10 fps、40 秒(400 帧),分屏线在 x=200(这一列全黑)。造影画面在右半:病灶是整帧
[250, 369] × [60, 159] 的方块,医生的绿框是它的空心描边;病灶内部亮度按 Lognormal 团注曲线从
基线 40 升到约 170 再回落,内部另有一块一直发黑的囊(暗区);每帧加一点随机噪声,免得相邻帧
被当成重复帧。左半是不变的灰阶画面。
手算的预期:
- 绿框在右半 → 造影画面是右半,分割那一半是左半 [0, 200);
- 绿框挪到左半局部坐标:x = 250 − 200 = 50 到 369 − 200 = 169;
- 推注时间 X = 5 秒 → F1 在视频第 20 − 5 = 15 秒(第 150 帧),F2 在第 60 − 5 = 55 秒,
超过 40 秒的视频末尾,未定位。
"""
import json
import math
from pathlib import Path
import cv2
import numpy as np
import pytest
from cadx.ceus import (
ANCHOR_SLOTS,
CeusConfig,
LognormalParameters,
NoValidContourError,
SegmentationInput,
evaluate_lognormal,
keyframe_images,
pack_tic_curves,
pack_trajectory,
prepare_segmentation,
quant_summary,
quantify_case,
render_case_overlay,
render_tic_png,
tic_plot_markers,
unpack_tic_curves,
unpack_trajectory,
)
_WIDTH, _HEIGHT, _SPLIT = 400, 200, 200
_FPS, _FRAMES = 10.0, 400
_LESION = (250, 60, 369, 159) # 整帧坐标 x1, y1, x2, y2(含端点)
_CYST = (290, 95, 320, 125)
_BOLUS = LognormalParameters(area=1300.0, mu=2.4, sigma=0.45, arrival_offset_s=4.0, baseline=40.0)
_INJECTION_OFFSET_S = 5.0
def _write_video(path: Path) -> None:
rng = np.random.default_rng(3)
times = np.arange(_FRAMES) / _FPS
intensity = evaluate_lognormal(times, _BOLUS)
writer = cv2.VideoWriter(str(path), cv2.VideoWriter.fourcc(*"mp4v"), _FPS, (_WIDTH, _HEIGHT))
x1, y1, x2, y2 = _LESION
for index in range(_FRAMES):
frame = np.zeros((_HEIGHT, _WIDTH, 3), dtype=np.uint8)
frame[:, :_SPLIT] = 120
frame[:, _SPLIT + 1 :] = 30
lesion = np.clip(
intensity[index] + rng.normal(0.0, 6.0, (y2 - y1 + 1, x2 - x1 + 1)), 0, 255
)
frame[y1 : y2 + 1, x1 : x2 + 1] = lesion.astype(np.uint8)[:, :, None]
cx1, cy1, cx2, cy2 = _CYST
frame[cy1 : cy2 + 1, cx1 : cx2 + 1] = 10
cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), thickness=3)
writer.write(frame)
writer.release()
@pytest.fixture(scope="module")
def config() -> CeusConfig:
from conftest import load_ceus_section
return CeusConfig.model_validate(load_ceus_section())
@pytest.fixture(scope="module")
def prepared(tmp_path_factory: pytest.TempPathFactory, config: CeusConfig) -> SegmentationInput:
path = tmp_path_factory.mktemp("ceus") / "synthetic.mp4"
_write_video(path)
return prepare_segmentation(path, config)
def _lesion_masks(prepared: SegmentationInput) -> np.ndarray:
"""合成「分割结果」:每帧都是病灶方块挪到左半局部坐标后的位置(略往里收 3 像素)。"""
view = prepared.contrast.target_view
masks = np.zeros((prepared.frame_count, view.height, view.width), dtype=bool)
x1, y1, x2, y2 = _LESION
masks[:, y1 + 3 : y2 - 2, x1 - _SPLIT + 3 : x2 - _SPLIT - 2] = True
return masks
def test_prepare_segmentation_finds_contrast_half_and_maps_prompts(
prepared: SegmentationInput,
) -> None:
assert prepared.frame_count == _FRAMES
assert prepared.frame_size == (_WIDTH, _HEIGHT)
assert prepared.contrast.side == "right"
target = prepared.contrast.target_view
assert (target.x1, target.x2) == (0, prepared.split_x)
assert abs(prepared.split_x - _SPLIT) <= 1
assert len(prepared.target_frames) == _FRAMES
assert all(frame.shape == (target.height, target.width, 3) for frame in prepared.target_frames)
assert sorted(prepared.prompt_masks) == [p.frame_index for p in prepared.contrast.prompt_frames]
assert 0 in prepared.prompt_masks
xs = np.where(prepared.prompt_masks[0])[1]
# 描边 3 像素宽、找绿框时再膨胀 2 像素、mp4 压缩会糊开边缘,容差放到 5 像素。
assert abs(int(xs.min()) - 50) <= 5
assert abs(int(xs.max()) - 169) <= 5
def test_quantify_case_end_to_end(prepared: SegmentationInput, config: CeusConfig) -> None:
result = quantify_case(prepared, _lesion_masks(prepared), _INJECTION_OFFSET_S, config)
assert result.trajectory.source_polygons.shape == (_FRAMES, 64, 2)
assert result.lesion_fit_ready is True
assert list(result.anchors) == list(ANCHOR_SLOTS)
for slot in ("D1", "D2", "D3"):
assert result.anchors[slot].status == "ok", result.anchors[slot].reason
assert result.anchors["F1"].frame_index == 150
assert result.anchors["F1"].frame_time_video_s == pytest.approx(15.0, abs=0.06)
assert result.anchors["F2"].status == "not_covered"
assert result.anchors["F2"].reason is not None
assert result.perfusion["roi_perf"] is not None
peak_time = _BOLUS.arrival_offset_s + math.exp(_BOLUS.mu - _BOLUS.sigma**2)
assert result.perfusion["roi_perf"].ttp_s == pytest.approx(peak_time, abs=0.5)
# 画面上没有紫圈。
assert result.pan_detected_fraction == 0.0
assert result.tic.reference_valid is False
# 暗区(囊)被找出来,去暗区曲线比病灶整体亮。
peak_row = result.tic.rows[round(peak_time * _FPS)]
assert peak_row["dark_area_px"] > 0
assert peak_row["roi_perf_raw"] > peak_row["roi_all_raw"]
restored = unpack_trajectory(pack_trajectory(result.trajectory))
assert restored.calibration["contrast_side"] == "right"
curves = unpack_tic_curves(pack_tic_curves(result.curves))
assert curves.injection_offset_s == pytest.approx(_INJECTION_OFFSET_S)
summary = quant_summary(result)
json.dumps(summary, allow_nan=False)
assert summary["anchor_frames"]["F1"]["frame_index"] == 150
assert summary["perfusion_metrics"]["roi_perf"] is not None
markers = tic_plot_markers(result.anchors)
assert [marker.label.split()[0] for marker in markers][:3] == ["D1", "D2", "D3"]
assert render_tic_png(result.tic, markers=markers).startswith(b"\x89PNG")
assert len(render_case_overlay(prepared, result)) > 0
images = keyframe_images(prepared, result.anchors)
assert "F1" in images and "F2" not in images
assert all(image.shape == (_HEIGHT, _WIDTH, 3) for image in images.values())
def test_empty_segmentation_fails_case_at_step_4(
prepared: SegmentationInput, config: CeusConfig
) -> None:
empty = np.zeros_like(_lesion_masks(prepared))
with pytest.raises(NoValidContourError) as raised:
quantify_case(prepared, empty, _INJECTION_OFFSET_S, config)
assert raised.value.step == 4
def test_mask_shape_mismatch_is_rejected(prepared: SegmentationInput, config: CeusConfig) -> None:
masks = _lesion_masks(prepared)[:-1]
with pytest.raises(ValueError, match="分割掩膜"):
quantify_case(prepared, masks, _INJECTION_OFFSET_S, config)
+508
View File
@@ -0,0 +1,508 @@
"""polygons 的单元测试(照搬旧项目 test_ceus_polygons.py;坐标换算改为两个视图矩形,
去掉旧的有效区矩形和映射锚点参数)。
旧标题:B6 polygons.py 的单元测试:mask→平滑多边形 + 位置约束 + 绿框先验融合。
参考 ``references/ROI/roi_tracking_pipeline.py`` P:649-965。覆盖核心真实场景
(边缘场景靠实现内 assertion / 守卫,不在此堆假数据):
- ``masks_to_smoothed_polygons``:圆形 mask → (N, 64, 2) 平滑多边形;**空 mask 帧
保留在序列**(§5 帧对齐不变量,N 帧进 N 帧出),且其多边形由时间插值得到(非空)。
- ``apply_prompt_frame_position_constraints``(strength=1.0):把每帧质心拉到 keyframe
中心插值轨迹上(整体平移),**不改面积**;``enabled=False`` 原样返回。
- ``fuse_prompt_prior_with_medsam``:``source != "prompt_prior"`` 门关 → 直接返回纯
medsam;门开时 keyframe 帧用 ``keyframe_shape_strength`` 逐顶点混合,且面积被
收紧到 ``[min, max] × prior_area``(findings §3.7)。
"""
from dataclasses import dataclass
import cv2
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
ViewRect,
apply_prompt_frame_position_constraints,
fuse_prompt_prior_with_medsam,
masks_to_smoothed_polygons,
polygon_area,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
@dataclass
class _RawShapeCfg:
"""绕过 RoiShapeConfig 校验器的鸭子接口(直接喂超界 strength,验证函数内部 clip)。"""
source: str = "prompt_prior"
keyframe_shape_strength: float = 1.0
propagation_shape_strength: float = 0.75
min_area_ratio_to_prompt: float = 0.70
max_area_ratio_to_prompt: float = 1.25
@dataclass
class _RawConstraintCfg:
"""绕过 KeyframePositionConstraint 校验器(Literal mode + strength clip)的鸭子接口。"""
enabled: bool = True
mode: str = "center_interpolation"
strength: float = 1.0
def _disk_mask(height: int, width: int, center_xy: tuple[int, int], radius: int) -> np.ndarray:
"""生成 ``(height, width)`` 的圆盘二值 mask(前景为 True),供轮廓提取测试用。"""
canvas = np.zeros((height, width), dtype=np.uint8)
cv2.circle(canvas, (int(center_xy[0]), int(center_xy[1])), int(radius), 255, -1)
return canvas > 0
def _circle_polygon(center_xy: tuple[float, float], radius: float, num_points: int) -> np.ndarray:
"""生成绕指定中心的规则圆多边形 ``(num_points, 2)``(用作 medsam / prior 测试输入)。"""
angles = np.linspace(0.0, 2.0 * np.pi, num_points, endpoint=False)
poly = np.zeros((num_points, 2), dtype=np.float32)
poly[:, 0] = center_xy[0] + radius * np.cos(angles)
poly[:, 1] = center_xy[1] + radius * np.sin(angles)
return poly
def test_masks_to_smoothed_polygons_shape_and_frame_alignment() -> None:
"""圆盘 mask → (N, 64, 2);中间空 mask 帧保留在序列(§5 帧对齐不变量)。"""
frame_count = 5
contour_points = _CONFIG.smoothing.contour_points # 64
masks_by_frame = {
0: _disk_mask(120, 120, (60, 60), 20),
1: _disk_mask(120, 120, (62, 60), 20),
# frame 2 空(退化 / 漏检)——不得剔除,靠时间插值填补
3: _disk_mask(120, 120, (64, 60), 20),
4: _disk_mask(120, 120, (66, 60), 20),
}
polygons = masks_to_smoothed_polygons(masks_by_frame, frame_count, _CONFIG.smoothing)
assert len(polygons) == frame_count, "N 帧进必须 N 帧出(§5 帧对齐,不剔除空帧)。"
for poly in polygons:
assert poly.shape == (contour_points, 2)
assert poly.dtype == np.float32
# 空帧(index 2)由相邻帧时间插值得到,应为有效非退化多边形
assert polygon_area(polygons[2]) > 1.0, "空 mask 帧应被时间插值填补为有效多边形。"
def test_masks_to_smoothed_polygons_all_empty_raises() -> None:
"""全部帧无有效轮廓时无从插值,直接 RuntimeError(不静默兜底)。"""
import pytest
with pytest.raises(RuntimeError):
masks_to_smoothed_polygons({}, 3, _CONFIG.smoothing)
def test_position_constraint_pulls_center_to_keyframe_track_without_area_change() -> None:
"""strength=1.0 把每帧质心拉到 keyframe 中心插值轨迹,且整体平移不改面积。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
# 两个 keyframe:source mask 中心分别在 (40, 100) 与 (160, 100)
keyframes = [
{"frame_index": 0, "mask": _disk_mask(200, 200, (40, 100), 15)},
{"frame_index": 4, "mask": _disk_mask(200, 200, (160, 100), 15)},
]
# 当前多边形都偏在 (100, 30),期望被拉到插值中心轨迹(x 从 40 线性到 160)
polygons = [_circle_polygon((100.0, 30.0), 12.0, 64) for _ in range(5)]
areas_before = [polygon_area(poly) for poly in polygons]
corrected = apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
target_eff,
_CONFIG.constraints.keyframe_position, # enabled、center_interpolation、strength=1.0
)
assert len(corrected) == len(polygons)
# frame 0 质心 ≈ (40, 100),frame 4 ≈ (160, 100),frame 2 ≈ (100, 100)(线性插值)
expected_x = [40.0, 70.0, 100.0, 130.0, 160.0]
for frame_idx, poly in enumerate(corrected):
center = poly.mean(axis=0)
assert abs(float(center[0]) - expected_x[frame_idx]) < 3.0
assert abs(float(center[1]) - 100.0) < 3.0
# 整体平移:面积不变
for before, poly in zip(areas_before, corrected, strict=True):
assert abs(polygon_area(poly) - before) < 2.0
def test_position_constraint_disabled_returns_input() -> None:
"""enabled=False 时原样返回输入(门关)。"""
target_eff = ViewRect(0, 0, 100, 100)
source_view = ViewRect(0, 0, 100, 100)
polygons = [_circle_polygon((50.0, 50.0), 10.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(100, 100, (50, 50), 10)}]
out = apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
target_eff,
_CONFIG.constraints.keyframe_position.model_copy(update={"enabled": False}),
)
assert out is polygons
def test_fusion_gate_closed_returns_pure_medsam() -> None:
"""roi_shape.source != 'prompt_prior' → 直接返回纯 medsam(门关,findings §3.7)。"""
target_eff = ViewRect(0, 0, 100, 100)
source_view = ViewRect(0, 0, 100, 100)
medsam = [_circle_polygon((50.0, 50.0), 10.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(100, 100, (50, 50), 10)}]
out = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
_CONFIG.roi_shape.model_copy(update={"source": "medsam2"}),
_CONFIG.smoothing,
)
assert out is medsam
def test_fusion_keyframe_strength_one_yields_prior_shape() -> None:
"""keyframe 帧 strength=1.0:融合结果几何上贴近绿框先验(逐顶点混合 s=1)。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
# 绿框先验中心 (100, 100) 半径 30;medsam 偏移到 (130, 130) 半径 30
keyframes = [{"frame_index": 0, "mask": _disk_mask(200, 200, (100, 100), 30)}]
medsam = [_circle_polygon((130.0, 130.0), 30.0, 64)]
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
_CONFIG.roi_shape.model_copy(update={"keyframe_shape_strength": 1.0}),
_CONFIG.smoothing,
)
assert len(fused) == 1
center = fused[0].mean(axis=0)
# s=1 → 结果应贴近先验中心 (100, 100) 而非 medsam 中心 (130, 130)
assert abs(float(center[0]) - 100.0) < 8.0
assert abs(float(center[1]) - 100.0) < 8.0
def test_fusion_area_clamped_to_prior_band() -> None:
"""融合面积超过 max_area_ratio_to_prompt 时按 sqrt 绕先验质心缩放回界(findings §3.7)。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
# 关键帧只在 frame 0;frame 1 为传播帧(非 keyframe),用 propagation_shape_strength=0
# 让 medsam 形状主导,从而触发面积收紧。先验半径 30,medsam 半径 80(面积远超 1.25×)。
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 30)}]
medsam = [
_circle_polygon((200.0, 200.0), 30.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 80.0, 64), # frame 1(propagation,测收紧)
]
shape_cfg = _CONFIG.roi_shape.model_copy(
update={
"keyframe_shape_strength": 1.0,
"propagation_shape_strength": 0.0, # frame 1 形状 = 纯 medsam(半径80,超界)
"min_area_ratio_to_prompt": 0.70,
"max_area_ratio_to_prompt": 1.25,
}
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
# 真实先验面积取自 frame 0(keyframe,s=1 → 形状=离散化绿框先验),避免用解析圆面积失真
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# frame 1 s=0 → 形状=medsam(半径80,面积远超界),应被收紧到 ≈ max_ratio × prior_area
assert fused_area <= prior_area * 1.25 + 50.0, "超界面积应被收紧到 max_area_ratio。"
assert fused_area >= prior_area * 1.25 - 100.0, "应恰好收到上界附近,而非过度收缩。"
# Phase 4b: fuse_prompt_prior_with_medsam —— 关键证伪路径
def test_fusion_propagation_frame_linear_blend_without_tightening() -> None:
"""非 keyframe 帧 + 0<s<1:未触发面积收紧时逐顶点 ≈ medsam·(1−s) + prior·s。
构造 medsam 与 prior 半径相近(面积比落在 [min, max] 带内,不触发收紧),中心同位,
使逐顶点线性混合的几何结果可被精确预测(绕开起点对齐 / 收紧的扰动)。
"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
# 单关键帧(frame 0),frame 1 为传播帧;先验半径 50,medsam 半径 60(面积比≈1.44,
# 但 max_ratio 放宽到 2.0 → 不触发收紧),中心同在 (200, 200)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 50)}]
medsam = [
_circle_polygon((200.0, 200.0), 50.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 60.0, 64), # frame 1(传播帧,测线性混合)
]
strength = 0.5
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=strength,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00, # 放宽上界,确保 frame 1 不触发面积收紧
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
# frame 1 半径 ≈ medsam·(1−s) + prior·s。prior 取自 frame 0(离散化绿框,s=1)。
# 用面积反推等效半径,校验落在 medsam(60) 与 prior(≈50) 之间且约为 0.5 混合。
prior_area = polygon_area(fused[0])
prior_radius = float(np.sqrt(prior_area / np.pi))
fused_radius = float(np.sqrt(polygon_area(fused[1]) / np.pi))
expected_radius = 60.0 * (1.0 - strength) + prior_radius * strength
assert abs(fused_radius - expected_radius) < 3.0, (
f"传播帧应为线性混合半径 ≈ {expected_radius:.1f},实得 {fused_radius:.1f}。"
)
# 逐顶点直接证伪(非聚合面积):每个融合顶点到圆心的半径都应 ≈ 混合半径,
# 而不仅是整体面积匹配——bbox 整体插值或部分混合会在个别顶点暴露偏差。
center = np.array([200.0, 200.0], dtype=np.float64)
radii = np.linalg.norm(fused[1].astype(np.float64) - center, axis=1)
assert radii.shape == (64,)
assert np.all(np.abs(radii - expected_radius) < 3.0), (
f"逐顶点半径应均 ≈ {expected_radius:.1f}(顶点级线性混合),"
f"实得范围 [{radii.min():.1f}, {radii.max():.1f}]。"
)
def test_fusion_lower_bound_tightening_expands_area() -> None:
"""下界收紧:medsam 面积 < min_ratio × prior 面积 → 被放大到 ≈ min_ratio × prior。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
# 先验半径 60;medsam 传播帧半径 20(面积比≈0.11 < min_ratio 0.70 → 触发下界放大)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 60)}]
medsam = [
_circle_polygon((200.0, 200.0), 60.0, 64), # frame 0(keyframe,对照)
_circle_polygon((200.0, 200.0), 20.0, 64), # frame 1(传播帧,面积过小)
]
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=0.0, # frame 1 形状 = 纯 medsam(半径20,欠界)
min_area_ratio_to_prompt=0.70,
max_area_ratio_to_prompt=1.25,
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# 欠界面积应被放大到 ≈ min_ratio × prior_area(绕先验质心 sqrt 缩放)
assert fused_area >= prior_area * 0.70 - 100.0, "欠界面积应被放大到 min_area_ratio。"
assert fused_area <= prior_area * 0.70 + 200.0, "应恰好放大到下界附近,而非过度膨胀。"
def test_fusion_start_point_misalignment_does_not_distort_shape() -> None:
"""medsam 与 prior 起点错位但形状相同:融合后不因起点错位畸变(输出仍贴近该形状)。
把 medsam 顶点循环移位(起点错位)但形状 / 中心与 prior 完全一致,s=0.5:
若不先对齐起点,逐顶点混合会把不同角度的点平均出来导致半径塌缩;align 后应保形。
"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
radius = 50.0
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), int(radius))}]
base_circle = _circle_polygon((200.0, 200.0), radius, 64)
shifted_circle = np.roll(base_circle, 17, axis=0) # 起点错位 17 个顶点
medsam = [
_circle_polygon((200.0, 200.0), radius, 64), # frame 0(keyframe,对照)
shifted_circle, # frame 1(传播帧,起点错位)
]
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=0.5,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00, # 放宽上界,孤立起点对齐效应
)
fused = fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)
prior_area = polygon_area(fused[0])
fused_area = polygon_area(fused[1])
# 起点对齐后形状不塌缩:融合面积应与先验面积相当(错位若未对齐会显著缩小)
assert fused_area == pytest.approx(prior_area, rel=0.10), (
f"起点错位经 align 后应保形(≈{prior_area:.0f}),实得 {fused_area:.0f}。"
)
def test_fusion_propagation_strength_clipped_to_unit_interval() -> None:
"""传播权重越界(1.5 / −0.5)经函数内部 np.clip → 行为等价 1.0 / 0.0。"""
target_eff = ViewRect(0, 0, 400, 400)
source_view = ViewRect(0, 0, 400, 400)
keyframes = [{"frame_index": 0, "mask": _disk_mask(400, 400, (200, 200), 40)}]
medsam = [
_circle_polygon((200.0, 200.0), 40.0, 64), # frame 0(keyframe)
_circle_polygon((260.0, 260.0), 40.0, 64), # frame 1(传播帧,偏移到右下)
]
def _fuse(propagation_strength: float) -> np.ndarray:
shape_cfg = _RawShapeCfg(
keyframe_shape_strength=1.0,
propagation_shape_strength=propagation_strength,
min_area_ratio_to_prompt=0.10,
max_area_ratio_to_prompt=2.00,
)
return fuse_prompt_prior_with_medsam(
medsam,
keyframes,
source_view,
target_eff,
shape_cfg,
_CONFIG.smoothing,
)[1]
# s=1.5 被 clip 到 1.0 → 形状=纯 prior(中心贴近 (200,200));与显式 1.0 等价
over = _fuse(1.5).mean(axis=0)
one = _fuse(1.0).mean(axis=0)
assert np.allclose(over, one, atol=1.0), "s=1.5 应被 clip 到 1.0(行为等价)。"
# s=−0.5 被 clip 到 0.0 → 形状=纯 medsam(中心贴近 (260,260));与显式 0.0 等价
neg = _fuse(-0.5).mean(axis=0)
zero = _fuse(0.0).mean(axis=0)
assert np.allclose(neg, zero, atol=1.0), "s=−0.5 应被 clip 到 0.0(行为等价)。"
# Phase 5: 时间平滑 + 位置约束补测
def test_temporal_smoothing_damps_per_vertex_jitter_along_time() -> None:
"""逐帧抖动的顶点序列经时间轴三角平滑后抖动被削弱、形状趋稳。
构造一段「真值恒定 + 逐帧高频抖动」的多边形序列(顶点已逐点对应),
masks_to_smoothed_polygons 内的时间平滑应显著降低逐帧位移幅度。
"""
frame_count = 9
base_center = (200, 200)
radius = 30
rng = np.random.default_rng(7)
# 每帧在固定圆心附近抖动 ±4px(mask 级别抖动 → 平滑后中心轨迹应更平稳)
masks_by_frame: dict[int, np.ndarray] = {}
for frame_idx in range(frame_count):
jitter = rng.integers(-4, 5, size=2)
center = (int(base_center[0] + jitter[0]), int(base_center[1] + jitter[1]))
masks_by_frame[frame_idx] = _disk_mask(400, 400, center, radius)
polygons = masks_to_smoothed_polygons(
masks_by_frame, frame_count, _CONFIG.smoothing.model_copy(update={"temporal_window": 5})
)
# 平滑后逐帧质心位移幅度应小于原始抖动尺度(抖动被削弱)
centers = np.array([poly.mean(axis=0) for poly in polygons])
frame_to_frame_shift = np.linalg.norm(np.diff(centers, axis=0), axis=1)
assert frame_to_frame_shift.max() < 4.0, "时间平滑应把逐帧质心抖动削弱到原始 ±4px 尺度以下。"
# 形状趋稳:各帧面积方差小(半径恒定 → 平滑不应制造面积剧烈起伏)
areas = np.array([polygon_area(poly) for poly in polygons])
assert areas.std() / areas.mean() < 0.10, "恒定半径下平滑后面积应稳定。"
def test_temporal_smoothing_no_vertex_crossing_distortion() -> None:
"""相邻帧轮廓起点对齐使逐点对应,时间平滑不产生顶点错位畸变(形状保真)。
两帧同形同位的圆(仅起点天然可能错位),平滑后每帧仍是完整圆而非塌缩折线。
"""
frame_count = 3
masks_by_frame = {idx: _disk_mask(400, 400, (200, 200), 40) for idx in range(frame_count)}
polygons = masks_to_smoothed_polygons(
masks_by_frame, frame_count, _CONFIG.smoothing.model_copy(update={"temporal_window": 3})
)
# 起点对齐 + 平滑后:每帧面积应接近真实圆面积,未因顶点错位塌缩
analytic_area = np.pi * 40.0**2
for poly in polygons:
assert polygon_area(poly) == pytest.approx(analytic_area, rel=0.10), (
"起点对齐后时间平滑应保形,不出现顶点错位导致的面积塌缩。"
)
def test_position_constraint_invalid_mode_raises() -> None:
"""mode != 'center_interpolation' → RuntimeError(当前仅支持该模式,纵深防御)。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
polygons = [_circle_polygon((100.0, 100.0), 20.0, 64)]
keyframes = [{"frame_index": 0, "mask": _disk_mask(200, 200, (100, 100), 20)}]
with pytest.raises(RuntimeError):
apply_prompt_frame_position_constraints(
polygons,
keyframes,
source_view,
target_eff,
_RawConstraintCfg(mode="affine"),
)
def test_position_constraint_strength_clipped_to_unit_interval() -> None:
"""strength 越界(1.5 / −0.5)经函数内部 np.clip → 行为等价 1.0 / 0.0。"""
target_eff = ViewRect(0, 0, 200, 200)
source_view = ViewRect(0, 0, 200, 200)
keyframes = [
{"frame_index": 0, "mask": _disk_mask(200, 200, (40, 100), 15)},
{"frame_index": 2, "mask": _disk_mask(200, 200, (160, 100), 15)},
]
base_polys = [_circle_polygon((100.0, 30.0), 12.0, 64) for _ in range(3)]
def _correct(strength: float) -> np.ndarray:
corrected = apply_prompt_frame_position_constraints(
[poly.copy() for poly in base_polys],
keyframes,
source_view,
target_eff,
_RawConstraintCfg(strength=strength),
)
return np.array([poly.mean(axis=0) for poly in corrected])
# s=1.5 → clip 到 1.0(全量拉到 keyframe 轨迹);与显式 1.0 等价
assert np.allclose(_correct(1.5), _correct(1.0), atol=0.5)
# s=−0.5 → clip 到 0.0(不平移,质心保持原 (100, 30));与显式 0.0 等价
over_zero = _correct(-0.5)
explicit_zero = _correct(0.0)
assert np.allclose(over_zero, explicit_zero, atol=0.5)
assert np.allclose(over_zero[:, 1], 30.0, atol=2.0), "s=0 不平移,质心 y 应保持 30。"
+328
View File
@@ -0,0 +1,328 @@
"""背景圆选择(reference_region)单元测试(照搬旧项目 test_ceus_reference_region.py)。
旧标题:B8 参考区选择(reference_region)单元测试。
参考区是 ROI 外一块"干净背景"小圆,用于 TIC 的 ``background_circle`` 曲线。本测试
用真实 OpenCV 像素管线(非 mock)合成"亮 ROI + 四周可控背景"图像,钉死
``select_reference_region`` 的真实行为,覆盖:
- ``enabled=False`` → 返回空 mask + ``{"enabled": False}``;
- ``manual`` 模式:用 ``center_xy`` + ``radius_px`` 出圆 mask,metadata 键齐全且 shape=="circle";
- ``auto_near_roi`` 找到合规背景圆(ROI 外有干净非黑非绿背景);
- ``auto_near_roi`` 四周全黑 / 全绿无合规位 → 明确报错(不返回失真位);
- ``auto_near_roi`` 的干净背景判据把医生画的紫色轮廓也排掉(TIC 会扣掉这些像素)。
"""
import numpy as np
from cadx.ceus import (
CeusConfig,
ReferenceRegionConfig,
TicAnalysisConfig,
polygon_to_mask,
select_reference_region,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
def _ref_cfg(**overrides: object) -> ReferenceRegionConfig:
"""ceus.yaml 的背景圆段,按需改几项(代替旧的 `ReferenceRegionConfig(...)`)。"""
return _CONFIG.tic_analysis.reference_region.model_copy(update=overrides)
# ---------- 合成样本 ----------
_H, _W = 160, 200
# ROI 多边形:画面中央偏右上的方块(留出左下大片背景给 auto 模式落圆)。
_ROI_POLYGON = np.array(
[[110.0, 20.0], [170.0, 20.0], [170.0, 70.0], [110.0, 70.0]], dtype=np.float32
)
# 紫色(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。
_PURPLE_BGR = (255, 0, 212)
def _source_polygons() -> np.ndarray:
"""逐帧 ROI 多边形序列(参考区只看第一帧,单帧即可)。"""
return np.array([_ROI_POLYGON])
def _bright_background_frame() -> np.ndarray:
"""整幅亮背景(灰度 180)的 BGR 帧——四周都是干净非黑非绿背景。"""
return np.full((_H, _W, 3), 180, dtype=np.uint8)
def _black_frame_with_bright_roi() -> np.ndarray:
"""ROI 区域亮(180),ROI 之外全黑——auto 模式四周无合规背景圆。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[20:70, 110:170] = 180
return frame
def _green_background_frame() -> np.ndarray:
"""ROI 区亮、四周全是医生绿框色(BGR 纯绿)——auto 模式四周被剔绿后无合规位。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[:, :] = (0, 255, 0) # BGR 纯绿,落在默认 HSV [35,60,60]-[90,255,255] 内
frame[20:70, 110:170] = 180
return frame
def _tic_cfg(ref: ReferenceRegionConfig) -> TicAnalysisConfig:
"""构造带指定 reference_region 子配置的 TicAnalysisConfig。"""
return _CONFIG.tic_analysis.model_copy(update={"reference_region": ref})
# ---------- enabled=False ----------
def test_disabled_returns_empty_mask_and_flag() -> None:
"""enabled=False:返回全 False mask 且 metadata 仅含 {"enabled": False}。"""
ref = _ref_cfg(enabled=False)
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.shape == (_H, _W)
assert mask.dtype == bool
assert not mask.any()
assert meta == {"enabled": False}
# ---------- manual 模式 ----------
def test_manual_mode_circle_at_center() -> None:
"""manual:以 center_xy + radius_px 出圆 mask,metadata 键齐全且 shape=='circle'。"""
radius = 24
center = [40.0, 120.0]
ref = _ref_cfg(mode="manual", center_xy=center, radius_px=radius)
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.dtype == bool
assert mask[int(center[1]), int(center[0])] # 圆心被标记
assert not mask[0, 0] # 远处角点不在圆内
assert meta["enabled"] is True
assert meta["mode"] == "manual"
assert meta["shape"] == "circle"
assert meta["center_xy"] == center
assert meta["radius_px"] == float(radius)
assert meta["area_px"] == int(mask.sum())
assert meta["area_px"] > 0
def test_manual_circle_area_matches_radius() -> None:
"""manual 圆面积应接近 π r²(栅格化误差范围内)。"""
radius = 20
ref = _ref_cfg(mode="manual", center_xy=[40.0, 120.0], radius_px=radius)
frame = _bright_background_frame()
mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
expected = np.pi * radius * radius
assert abs(int(mask.sum()) - expected) / expected < 0.1
# ---------- auto_near_roi 找到 ----------
def test_auto_finds_clean_background_circle() -> None:
"""auto:四周干净亮背景时应找到合规圆,metadata 键齐全且 valid_fraction 达标。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _bright_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert mask.dtype == bool
assert mask.any()
assert meta["enabled"] is True
assert meta["mode"] == "auto_near_roi"
assert meta["shape"] == "circle"
assert len(meta["center_xy"]) == 2
assert meta["radius_px"] == float(ref.radius_px)
assert meta["area_px"] == int(mask.sum())
assert meta["valid_fraction_first_frame"] >= ref.min_valid_fraction
def test_auto_reference_circle_outside_roi() -> None:
"""auto:选中的参考圆不应与 ROI(含禁区膨胀)重叠。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _bright_background_frame()
mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
roi_mask = polygon_to_mask(_ROI_POLYGON, _H, _W)
assert not np.any(mask & roi_mask)
# ---------- auto_near_roi 找不到 → 空掩膜与显式原因 ----------
def test_auto_returns_unavailable_when_background_all_black() -> None:
"""auto:ROI 外全黑时不伪造背景圆,也不阻断病灶拟合。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _black_frame_with_bright_roi()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert not mask.any()
assert meta["available"] is False
assert meta["failure_reason"] == "no_valid_candidate"
def test_auto_returns_unavailable_when_background_all_green() -> None:
"""auto:ROI 外全绿时返回空掩膜,不返回失真位置。"""
ref = _ref_cfg(mode="auto_near_roi")
frame = _green_background_frame()
mask, meta = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert not mask.any()
assert meta["available"] is False
# ---------- 补强:精确键集 + 多帧 ROI 并集禁区 ----------
def test_manual_metadata_exact_key_set() -> None:
"""manual 分支 metadata 键集须精确(无缺键、无误增键)。"""
ref = _ref_cfg(mode="manual", center_xy=[40.0, 120.0], radius_px=18)
_, meta = select_reference_region(
_bright_background_frame(),
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert set(meta.keys()) == {
"enabled",
"mode",
"shape",
"center_xy",
"radius_px",
"area_px",
}
def test_auto_metadata_exact_key_set() -> None:
"""auto 分支 metadata 键集须精确,含 valid_fraction_first_frame。"""
ref = _ref_cfg(mode="auto_near_roi")
_, meta = select_reference_region(
_bright_background_frame(),
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
assert set(meta.keys()) == {
"enabled",
"mode",
"shape",
"center_xy",
"radius_px",
"area_px",
"valid_fraction_first_frame",
}
def test_auto_avoids_doctor_purple_contour() -> None:
"""auto:干净背景的判据要把医生画的紫色一起排掉,选中的圆不许压在紫色上。
紫色轮廓是医生画上去的线,TIC 把这些像素从掩膜里扣掉——背景圆压在它上面的话,
``valid_background_circle_px`` 会被悄悄削掉一块,而没有任何一处会说出来。紫色的灰度
不低(``nonblack_threshold`` 拦不住它),所以只排绿的判据会把它当成干净背景。
先在全亮画面上选一次,把选中的那个圆整个涂成紫色再选第二次:``min_valid_fraction``
设成 1.0,于是只要圆里沾上一个紫色像素就出局,第二次必须换个位置。
"""
ref = _ref_cfg(mode="auto_near_roi", min_valid_fraction=1.0)
frame = _bright_background_frame()
first_mask, _ = select_reference_region(
frame,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
painted = frame.copy()
painted[first_mask] = _PURPLE_BGR
second_mask, _ = select_reference_region(
painted,
_source_polygons(),
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
purple = np.all(painted == np.asarray(_PURPLE_BGR, dtype=np.uint8), axis=2)
assert second_mask.any(), "涂了一块紫色之后连一个合规位置都找不到了"
assert not np.any(second_mask & purple), "选中的背景圆压在紫色轮廓上"
def test_auto_keepout_uses_multiframe_roi_union() -> None:
"""auto 禁区取逐帧 ROI 并集:选中圆须避开所有帧 ROI 的并集(非仅首帧)。"""
ref = _ref_cfg(mode="auto_near_roi")
# 两帧不同位置的 ROI:首帧右上、次帧偏左下,构成更大的并集禁区
roi_a = _ROI_POLYGON
roi_b = np.array([[60.0, 90.0], [120.0, 90.0], [120.0, 140.0], [60.0, 140.0]], dtype=np.float32)
polys = np.array([roi_a, roi_b])
mask, _ = select_reference_region(
_bright_background_frame(),
polys,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_tic_cfg(ref),
)
union = polygon_to_mask(roi_a, _H, _W) | polygon_to_mask(roi_b, _H, _W)
assert not np.any(mask & union), "参考圆须避开逐帧 ROI 并集,而非仅首帧 ROI"
+607
View File
@@ -0,0 +1,607 @@
"""TIC 四曲线采样与基线(tic)单元测试(照搬旧项目 test_ceus_tic.py)。
旧标题:B9 TIC 四曲线 + 基线识别 + 平滑(tic.py)单元测试。
TIC(time-intensity curve,时间-强度曲线)刻画病灶亮度随时间变化。本模块在 source
视图上逐帧采四条灰度均值曲线——病灶整体、去掉暗区、紫色轮廓圈的邻近正常胰腺实质
(``ROI_pan``)、程序自动挑的背景圆——每条各自识别增强前基线、扣掉基线均值再平滑。
测试用真实 OpenCV 像素管线(非 mock)合成可控帧序列,钉死 ``tic.py`` 的真实行为:
- 掩膜构成:``valid_roi_all = roi & ~(绿 ∪ 紫)``、``valid_roi_perf = valid_roi_all &
~dark_union``、``valid_pan = 紫轮廓填充 & ~(绿 ∪ 紫)``、
``valid_background_circle = 背景圆 & ~(绿 ∪ 紫)``;
- ``mean_or_nan`` 空掩膜返回 NaN(不报错);
- 平滑链顺序 median→triangular,默认窗 5/15/1(以代码为准非 README);
- 基线识别:候选窗、稳定性缩窗、窗太短即无效(``mean`` / ``sd`` 为 ``None``);
- 撤掉合成零点之后,涨得最快的时刻落在真实陡升段而不是视频开头;
- 紫色检不到时 ``ROI_pan`` 整条 NaN,**不回退背景圆**;
- ``compute_tic_for_video`` 行集列名齐全,且本轮定量参数为 None。
"""
import math
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
PanSourceConfig,
TicAnalysisConfig,
compute_tic_for_video,
detect_baseline,
mean_or_nan,
)
from cadx.ceus import _tic as tic_module # pyright: ignore[reportPrivateUsage]
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
_TIC_NO_DUPLICATES = _CONFIG.tic_analysis.model_copy(
update={
"fitting": _CONFIG.tic_analysis.fitting.model_copy(
update={"detect_duplicate_frames": False}
)
}
)
# ---------- 合成样本 ----------
_H, _W = 80, 120
# ROI:画面中央偏右的方块(左下留干净背景给背景圆 / 掩膜对照)。
_ROI_POLYGON = np.array(
[[60.0, 10.0], [110.0, 10.0], [110.0, 50.0], [60.0, 50.0]], dtype=np.float32
)
# 暗区:ROI 内部一小块(用于验证 roi_perf = valid_roi_all & ~dark)。
_DARK_POLYGON = np.array([[70.0, 20.0], [90.0, 20.0], [90.0, 40.0], [70.0, 40.0]], dtype=np.float32)
# 紫色(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。
_PURPLE_BGR = (255, 0, 212)
def _frame(roi_gray: int, dark_gray: int, ref_gray: int) -> np.ndarray:
"""合成一帧 source BGR:ROI 区灰度 roi_gray、其内暗块 dark_gray、左下背景块 ref_gray。
其余背景为 0(黑),避免被背景圆误采。灰度对 BGR 三通道相等填充,cvtColor 后即该灰度值。
"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[10:50, 60:110] = roi_gray
frame[20:40, 70:90] = dark_gray
frame[10:40, 5:35] = ref_gray # 左下背景块
return frame
def _background_circle_mask() -> np.ndarray:
"""背景圆掩膜(左下背景块的子区域),与 _frame 的 ref 区重叠。"""
mask = np.zeros((_H, _W), dtype=bool)
mask[15:35, 10:30] = True
return mask
def _build_inputs(
frame_count: int = 3,
) -> tuple[list[np.ndarray], list[np.ndarray], list[list[np.ndarray]], np.ndarray]:
"""构造逐帧 source 帧 / ROI 多边形 / 逐帧暗区多边形列表 + 背景圆 mask。
暗区入参对齐 B7 ``extract_dark_region_polygons`` 输出(``list[list[np.ndarray]]``:
外层帧对齐、内层每帧 0~N 个暗区多边形),此处每帧给单个暗块 ``[[_DARK_POLYGON]]``。
这批帧里**一个紫色像素都没有**,所以 ``ROI_pan`` 整条无效——这正是「检不到紫色
不回退背景圆」那条测试要的场景。
"""
frames = [_frame(roi_gray=120, dark_gray=20, ref_gray=60) for _ in range(frame_count)]
roi_polygons = [_ROI_POLYGON for _ in range(frame_count)]
dark_polygons_per_frame = [[_DARK_POLYGON] for _ in range(frame_count)]
return frames, roi_polygons, dark_polygons_per_frame, _background_circle_mask()
def _run(
frames: list[np.ndarray],
roi_polygons: list[np.ndarray],
dark_polygons_per_frame: list[list[np.ndarray]],
background_circle_mask: np.ndarray,
*,
fps: float = 2.0,
pan_cfg: PanSourceConfig | None = None,
tic_cfg: TicAnalysisConfig | None = None,
) -> tic_module.TicResult:
"""按默认配置跑一次 ``compute_tic_for_video``(省掉各测试里重复的样板)。"""
return compute_tic_for_video(
source_frames=frames,
roi_polygons=roi_polygons,
dark_polygons_per_frame=dark_polygons_per_frame,
reference_mask=background_circle_mask,
fps=fps,
prompt_cfg=_CONFIG.prompt_source,
pan_cfg=pan_cfg or _CONFIG.pan_source,
tic_cfg=tic_cfg or _TIC_NO_DUPLICATES,
frame_times_s=None,
)
def _column(result: tic_module.TicResult, key: str) -> np.ndarray:
"""取一列逐帧值。"""
return np.asarray([row[key] for row in result.rows], dtype=np.float32)
# ---------- mean_or_nan:NaN 语义 ----------
def test_mean_or_nan_empty_mask_returns_nan() -> None:
"""空掩膜(无 True 像素)返回 NaN,而非报错或 0。"""
gray = np.full((_H, _W), 120, dtype=np.uint8)
empty = np.zeros((_H, _W), dtype=bool)
result = mean_or_nan(gray, empty)
assert math.isnan(result)
def test_mean_or_nan_returns_masked_mean() -> None:
"""非空掩膜返回该掩膜内灰度均值。"""
gray = np.zeros((_H, _W), dtype=np.uint8)
gray[0:10, 0:10] = 200
mask = np.zeros((_H, _W), dtype=bool)
mask[0:10, 0:10] = True
assert mean_or_nan(gray, mask) == pytest.approx(200.0)
# ---------- detect_baseline:纯函数层 ----------
def test_detect_baseline_flat_prefix_gives_mean_and_sd() -> None:
"""开头两秒平坦时,基线窗取满首选时长,``B`` 与 ``σ0`` 从这一段算出来。"""
values = np.concatenate(
[
np.full(20, 40.0, dtype=np.float32),
np.linspace(40, 200, 40, dtype=np.float32),
]
)
baseline = detect_baseline(
values, np.arange(values.size) / 10.0, _CONFIG.tic_analysis.baseline, None
)
assert baseline.valid is True
assert baseline.frame_start == 0
assert baseline.frame_end == 20
assert baseline.mean == pytest.approx(40.0)
assert baseline.sd == pytest.approx(0.0)
def test_detect_baseline_ignores_nan_samples() -> None:
"""NaN 帧不算基线采样:既不进均值,也不被当成 0 把基线拉低。"""
values = np.full(20, 40.0, dtype=np.float32)
values[3] = np.nan
values[11] = np.nan
baseline = detect_baseline(
values, np.arange(values.size) / 10.0, _CONFIG.tic_analysis.baseline, None
)
assert baseline.valid is True
assert baseline.mean == pytest.approx(40.0)
def test_detect_baseline_all_nan_is_invalid() -> None:
"""整条都是 NaN 时拿不到基线(不是拿到一个由零个样本算出来的数)。"""
baseline = detect_baseline(
np.full(30, np.nan, dtype=np.float32),
np.arange(30) / 10.0,
_CONFIG.tic_analysis.baseline,
None,
)
assert baseline.valid is False
assert baseline.mean is None
assert baseline.sd is None
assert baseline.frame_start == -1
assert baseline.frame_end == -1
def test_detect_baseline_rejects_non_1d() -> None:
"""二维输入直接报错,不静默摊平。"""
with pytest.raises(ValueError):
detect_baseline(
np.zeros((3, 4), dtype=np.float32),
np.arange(12).reshape(3, 4),
_CONFIG.tic_analysis.baseline,
None,
)
def test_detect_baseline_rejects_non_increasing_timestamps() -> None:
"""时间戳不严格递增直接报错。"""
with pytest.raises(ValueError):
detect_baseline(
np.zeros(30, dtype=np.float32), np.zeros(30), _CONFIG.tic_analysis.baseline, None
)
# ---------- 撤掉合成零点之后:最大流入斜率不落视频开头 ----------
# ---------- 基线窗不足最短时长:两种不足 ----------
def _assert_baseline_unusable(result: tic_module.TicResult, expected_reason: str) -> None:
"""观察基线拿不到时保留截断标志,失败拟合不回填 processed。"""
baseline = result.baselines["roi_all"]
assert baseline.valid is False
assert baseline.mean is None
assert baseline.sd is None
assert result.early_truncation_flag is True
assert result.fits["roi_all"].failure_reason == expected_reason
assert np.isnan(_column(result, "roi_all_processed")).all()
def test_baseline_invalid_when_video_shorter_than_minimum() -> None:
"""视频太短(0.8 秒)凑不出 1 秒基线:不合成、不外推,标 early_truncation。"""
frame_count = 8
frames = [_frame(roi_gray=70, dark_gray=70, ref_gray=60) for _ in range(frame_count)]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[] for _ in range(frame_count)],
_background_circle_mask(),
fps=10.0,
)
_assert_baseline_unusable(result, "insufficient_valid_samples")
def test_baseline_invalid_when_enhancement_already_rising_at_frame_zero() -> None:
"""视频够长但录晚了、开头就在涨:稳定性检查把窗缩到不足 1 秒,同样判无效。"""
frame_count = 60
frames = [
_frame(roi_gray=40 + 2 * index, dark_gray=40 + 2 * index, ref_gray=60)
for index in range(frame_count)
]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[] for _ in range(frame_count)],
_background_circle_mask(),
fps=10.0,
)
_assert_baseline_unusable(result, "credible_peak_not_found")
# ---------- ROI_pan:检不到紫色不回退背景圆 ----------
def test_pan_curve_not_backfilled_from_background_circle() -> None:
"""一个紫色像素都没有时,``ROI_pan`` 整条 NaN,而背景圆那条照常算出来。
只断言「pan 无效」不够——一个「检不到就复制背景圆」的实现照样能让前三条过。
第四条(两条曲线不逐点相等)才是要害。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert result.reference_valid is False
pan_raw = _column(result, "roi_pan_raw")
background_raw = _column(result, "background_circle_raw")
assert np.isnan(pan_raw).all()
assert result.baselines["roi_pan"].valid is False
assert result.baselines["roi_pan"].mean is None
assert np.isfinite(background_raw).all()
assert not np.array_equal(pan_raw, background_raw, equal_nan=True)
def _frame_with_purple_contour(interior_gray: int) -> np.ndarray:
"""在基础帧的左下角画一圈紫色轮廓,圈内填 ``interior_gray``。
轮廓画成空心矩形(医生画的是一条线,不是一块面),所以填充后的参照区内部是灰度、
只有那圈线是紫色。轮廓外面也铺同一片灰度:``extract_color_prompt`` 的形态学膨胀会
让填充区比画上去的那圈线略大一点,外面若是纯黑就会把均值拉低——真实画面上轮廓外
也是组织,这里照着来。
"""
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60)
frame[46:80, 1:50] = interior_gray
frame[52:55, 5:46] = _PURPLE_BGR
frame[74:77, 5:46] = _PURPLE_BGR
frame[52:77, 5:8] = _PURPLE_BGR
frame[52:77, 43:46] = _PURPLE_BGR
return frame
def test_pan_curve_reads_purple_contour_interior() -> None:
"""画面上有紫色轮廓时,``ROI_pan`` 采的是轮廓**内部**的灰度,轮廓线本身被剔掉。"""
interior_gray = 90
frame_count = 3
frames = [_frame_with_purple_contour(interior_gray) for _ in range(frame_count)]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
)
assert result.reference_valid is True
pan_raw = _column(result, "roi_pan_raw")
assert np.isfinite(pan_raw).all()
# 轮廓线(紫)被排除色掩膜剔掉,剩下的全是内部那块灰度。
np.testing.assert_allclose(pan_raw, interior_gray, atol=1.0)
assert result.rows[0]["valid_pan_px"] > 0
assert result.rows[0]["pan_area_px"] > result.rows[0]["valid_pan_px"]
def test_reference_valid_counts_sampled_frames_not_purple_area() -> None:
"""一整块实心紫色:紫色本身有面积,但排除色把它整块吞掉,一个像素都没采到。
界面上的实心紫色标记就长这样:``extract_color_prompt`` 填充出来的面积过得了
``min_area``,而排除掩膜把这块紫色连同外扩的一圈整个盖住,``valid_pan`` 是空的。
检出率若按填充面积数,台账上会写着「参照区没问题」,底下压着一条整段 NaN 的曲线,
而库上那条「``reference_valid`` 为假时 ``_pan`` 基线必须记 null」的 CHECK 只在
``reference_valid`` 为假时才管,此时一声不响。
``prompt_exclude_dilate`` 调到 5:检测那一路的形态学会把填充区外扩两像素,默认的
3 像素核只外扩一像素,边上会剩一圈采得到的像素——那不是本条要造的场景。
"""
frame_count = 3
frames: list[np.ndarray] = []
for _ in range(frame_count):
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60)
frame[50:78, 5:45] = _PURPLE_BGR # 实心紫块,不是一圈线
frames.append(frame)
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
tic_cfg=_CONFIG.tic_analysis.model_copy(update={"prompt_exclude_dilate": 5}),
)
assert result.rows[0]["pan_area_px"] > 0, "紫色轮廓本身确实有面积——前提没造对"
assert all(row["valid_pan_px"] == 0 for row in result.rows)
assert np.isnan(_column(result, "roi_pan_raw")).all()
assert result.reference_valid is False
def test_early_truncation_ignores_background_circle_baseline() -> None:
"""三条规范曲线基线都好、只有背景圆那条被缩短:质控位不许置起来。
背景圆是我们自己的整机增益漂移曲线,不喂任何规范字段(design/0030 决策四第三条),
而 ``early_truncation_flag`` 是规范 §14 的质控位。自动挑中的圆落在一块两秒内就开始
增强的组织上时,它的基线窗会被缩到不足最短时长——那和「这段视频被截短了」是两回事。
"""
frame_count = 6
frames: list[np.ndarray] = []
for index in range(frame_count):
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60 + 30 * index)
# 紫色轮廓(空心矩形)画在左下,和背景圆那块不重叠。
frame[46:80, 1:50] = 90
frame[52:55, 5:46] = _PURPLE_BGR
frame[74:77, 5:46] = _PURPLE_BGR
frame[52:77, 5:8] = _PURPLE_BGR
frame[52:77, 43:46] = _PURPLE_BGR
frames.append(frame)
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
fps=2.0,
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
)
for name in ("roi_all", "roi_perf", "roi_pan"):
assert result.baselines[name].valid is True, f"{name} 的基线该是好的——前提没造对"
assert result.baselines["background_circle"].valid is False, (
"背景圆那条该拿不到基线——前提没造对"
)
assert result.early_truncation_flag is False
def test_purple_pixels_are_excluded_from_roi_statistics() -> None:
"""紫色像素落进 ROI 时同样被剔除(排除色 = 绿 ∪ 紫,不再只排绿)。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
baseline_result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
baseline_valid_px = baseline_result.rows[0]["valid_roi_all_px"]
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
frames[0][12:18, 65:105] = _PURPLE_BGR
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert result.rows[0]["valid_roi_all_px"] < baseline_valid_px
# ---------- 删掉的名字不许回来 ----------
def test_synthetic_zero_timepoint_names_are_gone() -> None:
"""合成零点那一套的名字全部不存在,行集里也不再有 ``frame_index=-1`` 的合成行。
守的是 design/0030 决策二那次删除。理由写在 ``tic.TicResult`` 的 docstring 上:
人造点到首个真实帧那一跳是全曲线最大斜率,最大流入斜率的时刻会被永远钉在视频开头。
"""
assert not hasattr(tic_module, "prepend_zero_timepoint")
assert not hasattr(tic_module, "_zero_timepoint_row")
assert "prepend_zero_timepoint" not in tic_module.__all__
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert len(result.rows) == len(frames)
for index, row in enumerate(result.rows):
assert row["frame_index"] == index
assert "is_zero_timepoint" not in row
# ---------- compute_tic_for_video:掩膜构成 + 列名 + 参数 None ----------
def test_compute_tic_curve_mask_composition() -> None:
"""掩膜构成正确:ROI 区灰度 120、去暗区后仍 120(暗块被剔)、背景圆 60。
暗块灰度 20 远低于 ROI 主体 120,若 roi_perf 未剔暗块,其均值会被拉低;
剔除后 roi_perf 应等于 ROI 主体灰度 120。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
# roi_all_raw 含暗块(120 主体混 20 暗块)→ 被拉低到 120 与 20 之间。
assert 20.0 < sample["roi_all_raw"] < 120.0
# 暗块(20)被剔后 roi_perf 应接近纯 ROI 主体 120(栅格边界残留少量暗像素,放宽),
# 且严格高于含暗的 roi_all(剔暗确实抬升了均值)。
assert sample["roi_perf_raw"] == pytest.approx(120.0, abs=8.0)
assert sample["roi_perf_raw"] > sample["roi_all_raw"]
assert sample["background_circle_raw"] == pytest.approx(60.0, abs=1.0)
# 去暗后有效像素数 < 含暗的 valid_roi_all 像素数。
assert sample["valid_roi_perf_px"] < sample["valid_roi_all_px"]
def test_compute_tic_prompt_excluded_from_masks() -> None:
"""绿框像素从掩膜中剔除:在 ROI 内画绿条后,valid_roi_all 像素数应减少。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
# 在 ROI 内画一条绿色(BGR 绿,HSV 落在默认阈内)。
frames[0][12:18, 65:105] = (0, 255, 0)
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
# roi 区栅格面积固定,但剔绿后 valid_roi_all 有效像素严格少于 roi_area。
assert sample["valid_roi_all_px"] < sample["roi_area_px"]
def test_compute_tic_row_column_names_complete() -> None:
"""行集含四曲线 raw/candidate/fitted/processed 与样本质控列。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
expected_columns = {
"frame_index",
"time_sec",
"duplicate_frame",
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
"perf_mask_stable",
}
for name in tic_module.CURVE_NAMES:
expected_columns |= {
f"{name}_raw",
f"{name}_candidate",
f"{name}_fitted",
f"{name}_processed",
}
for row in result.rows:
assert set(row.keys()) == expected_columns
def test_compute_tic_uses_explicit_presentation_timestamps() -> None:
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
times = [0.0, 0.11, 0.24]
result = compute_tic_for_video(
frames,
roi_polygons,
dark_polygons_per_frame,
background,
10.0,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_TIC_NO_DUPLICATES,
times,
)
np.testing.assert_allclose(_column(result, "time_sec"), times)
def test_compute_tic_marks_consecutive_duplicate_roi_frames() -> None:
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = compute_tic_for_video(
frames,
roi_polygons,
dark_polygons_per_frame,
background,
2.0,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_CONFIG.tic_analysis,
None,
)
assert [row["duplicate_frame"] for row in result.rows] == [False, True, True]
def test_compute_tic_parameters_none_this_round() -> None:
"""本轮不算定量参数,``parameters`` 为 None。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
assert _run(frames, roi_polygons, dark_polygons_per_frame, background).parameters is None
def test_compute_tic_background_circle_excludes_green_pixels() -> None:
"""证伪 ``valid_background = 背景圆 & ~exclude``:圆内画绿后剔绿,统计须随之变化。
在背景圆内填入一片绿色(HSV 落默认阈内)。若掩膜未剔绿,绿像素会计入
``valid_background_circle_px`` 且把 ``background_circle_raw`` 拉向绿灰度;
剔绿后两者均应改变:有效像素数严格少于圆面积,均值贴近纯背景灰度而非被绿污染。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
# 背景圆掩膜为 [15:35, 10:30],背景块 ref_gray=60;在其内画一片绿。
frames[0][16:24, 12:28] = (0, 255, 0)
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
assert sample["valid_background_circle_px"] < sample["background_circle_area_px"]
# 均值未被绿(cvtColor 后灰度约 150)污染,仍贴近背景 60。
assert sample["background_circle_raw"] == pytest.approx(60.0, abs=2.0)
def test_compute_tic_dark_region_clipped_to_roi() -> None:
"""证伪 ``dark_union &= valid_roi_all``:越出 ROI 的暗区多边形,越界部分不计入统计。
给某帧一个跨越 ROI 右边界(x=110)外延的暗区多边形。若未收口到 ROI 内,
``dark_area_px`` 会含 ROI 外像素;收口后暗区面积应等于该暗块与 ROI 的交集。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=1)
# 暗块横跨 ROI 右界 x∈[100,140](ROI 右界 110),y∈[20,40] 落在 ROI 内 y 范围。
out_of_roi_dark = np.array(
[[100.0, 20.0], [140.0, 20.0], [140.0, 40.0], [100.0, 40.0]], dtype=np.float32
)
sample = _run(frames, roi_polygons, [[out_of_roi_dark]], background).rows[0]
# 暗块多边形宽 40,但仅 x∈[100,110] 落在 ROI 内(宽 10);收口后面积须 < 多边形整块。
full_dark_area = 40 * 20 # 多边形自身栅格面积上界
assert 0 < sample["dark_area_px"] < full_dark_area
# 收口后暗区不超过有效 ROI 像素数(不会从 ROI 外多剔像素)。
assert sample["dark_area_px"] <= sample["valid_roi_all_px"]
# roi_perf = valid_roi_all - 收口暗区,二者之差恰为收口后暗区面积。
assert sample["valid_roi_all_px"] - sample["valid_roi_perf_px"] == sample["dark_area_px"]
def test_compute_tic_dark_polygon_hole_not_filled_back() -> None:
"""证伪 even-odd 挖洞:暗区甜甜圈(外暗环 + 内亮洞)的洞不被计回暗区。
暗区多边形列表为 ``[外多边形, 洞多边形]``(B7 preserve_holes 扁平输出语义)。
若按「逐多边形栅格化再 OR」,洞会被当作独立暗块填回 → ``dark_area_px`` 含洞、
``roi_perf`` 偏小;正确的 even-odd 一次填充应把洞挖空。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=1)
# 外暗框 [65,15]-[105,45](全落 ROI x∈[60,110] / y∈[10,50] 内),内亮洞 [80,25]-[95,38]。
outer_dark = np.array(
[[65.0, 15.0], [105.0, 15.0], [105.0, 45.0], [65.0, 45.0]], dtype=np.float32
)
inner_hole = np.array(
[[80.0, 25.0], [95.0, 25.0], [95.0, 38.0], [80.0, 38.0]], dtype=np.float32
)
sample = _run(frames, roi_polygons, [[outer_dark, inner_hole]], background).rows[0]
outer_area = 40 * 30 # 外框栅格面积 1200
hole_area = 15 * 13 # 洞栅格面积 195
# 暗区不含洞:≈ 外框 − 洞(栅格边界放宽),严格小于把洞填回的「外框整块」。
assert sample["dark_area_px"] == pytest.approx(outer_area - hole_area, abs=120)
assert sample["dark_area_px"] < outer_area
# 洞内 ROI 像素仍算有效 ROI:roi_perf = valid_roi_all − 暗区(不含洞)。
assert sample["valid_roi_all_px"] - sample["valid_roi_perf_px"] == sample["dark_area_px"]
# 洞内像素确实回到 roi_perf:去暗后剩余像素 > 仅外框实心被剔时的剩余。
assert sample["valid_roi_perf_px"] > sample["valid_roi_all_px"] - outer_area
def test_compute_tic_dark_component_count_self_consistent() -> None:
"""暗块计数与暗区面积自洽:有暗块时计数=多边形数;面积为 0 时计数强制归 0。
第 0 帧给两个 ROI 内暗块 → count=2;第 1 帧暗块完全落在 ROI 外(收口后面积 0)
→ count 必须归 0(修复 area=0 却 count>0 的不自洽)。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=2)
dark_a = np.array([[65.0, 15.0], [78.0, 15.0], [78.0, 30.0], [65.0, 30.0]], dtype=np.float32)
dark_b = np.array([[82.0, 32.0], [95.0, 32.0], [95.0, 45.0], [82.0, 45.0]], dtype=np.float32)
# 完全落在 ROI(x∈[60,110])右侧之外,收口后面积为 0。
fully_outside = np.array(
[[115.0, 15.0], [119.0, 15.0], [119.0, 30.0], [115.0, 30.0]], dtype=np.float32
)
rows = _run(frames, roi_polygons, [[dark_a, dark_b], [fully_outside]], background).rows
# 第 0 帧:两个 ROI 内暗块 → 面积 > 0、计数 = 2。
assert rows[0]["dark_area_px"] > 0
assert rows[0]["dark_component_count"] == 2
# 第 1 帧:暗块全在 ROI 外 → 收口面积 0、计数强制归 0(自洽)。
assert rows[1]["dark_area_px"] == 0
assert rows[1]["dark_component_count"] == 0
+420
View File
@@ -0,0 +1,420 @@
"""TIC 曲线 npz 打包 / 解包(tic_curves)单元测试(照搬旧项目 test_ceus_tic_curves.py)。
钉死曲线工件的单一 schema:完整往返还原全部键、存档内各键 dtype / shape 精确、
NaN 原样往返(**不许被填成 0**——那会把「这一帧没采到」变成「这一帧亮度是 0」)、
基线无效时三个基线键的编码(NaN / NaN / (-1,-1) / 0),以及防御:坏字节解包 raise、
长度不一致 pack raise、禁 pickle(含 object pickle 的恶意 npz 解包被拒)。
另有一条从 ``TicResult`` 构造的往返,确认 ``from_tic_result`` 认得行键。
结构照 ``tests/unit/test_ceus_trajectory.py``。
"""
import io
import pickle
from typing import Any
import numpy as np
import pytest
from cadx.ceus import (
CURVE_NAMES,
CURVES_SCHEMA_VERSION,
CeusConfig,
TicCurves,
TicCurveSeries,
compute_tic_for_video,
from_tic_result,
pack_tic_curves,
unpack_tic_curves,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为读 config/ceus.yaml 的取值。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
_T = 5
# Phase 1: 构造一个真实形态的 TicCurves(合成数值)
def _series(offset: float, *, valid: bool) -> TicCurveSeries:
"""一条曲线:中间插一个 NaN(该帧没采到),基线按 valid 给或不给。"""
raw = np.asarray([10.0, 20.0, np.nan, 40.0, 50.0], dtype=np.float32) + offset
processed = np.asarray([11.0, 21.0, 31.0, 41.0, 51.0], dtype=np.float32) + offset
common: dict[str, Any] = {
"candidate": processed.copy(),
"fitted": processed.copy(),
"processed": processed.copy() if valid else np.full(_T, np.nan, np.float32),
"valid_sample_mask": np.isfinite(raw),
"duplicate_sample_mask": np.asarray([False, False, True, False, False]),
"fit_success": valid,
"fit_failure_reason": "" if valid else "baseline_invalid",
"curve_model": "lognormal",
"smoothing_method": "",
"baseline_source": "observed" if valid else "unavailable",
"fit_area": 123.0 if valid else float("nan"),
"fit_mu": 2.0 if valid else float("nan"),
"fit_sigma": 0.5 if valid else float("nan"),
"fit_arrival_offset_s": 1.0 if valid else float("nan"),
"fit_baseline": 14.5 + offset if valid else float("nan"),
"fit_r2": 0.9 if valid else float("nan"),
"fit_nrmse": 0.1 if valid else float("nan"),
"residual_noise_ratio": 1.2 if valid else float("nan"),
"fit_interval_s": (0.0, 4.0) if valid else (float("nan"), float("nan")),
"multi_peak_flag": False,
"anchor_times_s": (1.0, 2.0, 3.0, 4.0) if valid else (float("nan"),) * 4,
}
if valid:
return TicCurveSeries(
raw=raw,
baseline_mean=15.0 + offset,
baseline_sd=2.5,
baseline_frames=(0, 2),
baseline_valid=True,
**common,
)
return TicCurveSeries(
raw=raw,
baseline_mean=float("nan"),
baseline_sd=float("nan"),
baseline_frames=(-1, -1),
baseline_valid=False,
**common,
)
def _make_curves(*, injection_offset_s: float = 3.5) -> TicCurves:
counts = {
key: np.arange(_T, dtype=np.int32) + index
for index, key in enumerate(
(
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
)
)
}
frame_indices = np.arange(_T, dtype=np.int32)
return TicCurves(
frame_indices=frame_indices,
time_video_s=(frame_indices / np.float32(10.0)).astype(np.float32),
injection_offset_s=injection_offset_s,
time_origin="injection",
fps=10.0,
config_version="test-v1",
algorithm_version="lognormal-v2",
curves_schema_version=CURVES_SCHEMA_VERSION,
curves={
# roi_pan 那条给成基线无效,覆盖「紫色没检出」那一路的编码。
name: _series(float(index) * 100.0, valid=(name != "roi_pan"))
for index, name in enumerate(CURVE_NAMES)
},
perf_mask_stable=np.ones(_T, dtype=bool),
reference_valid=False,
early_truncation_flag=True,
**counts,
)
# Phase 2: 完整往返 —— 全部键还原
def test_roundtrip_preserves_all_keys() -> None:
curves = _make_curves()
restored = unpack_tic_curves(pack_tic_curves(curves))
np.testing.assert_array_equal(restored.frame_indices, curves.frame_indices)
np.testing.assert_allclose(restored.time_video_s, curves.time_video_s)
assert restored.injection_offset_s == pytest.approx(curves.injection_offset_s)
assert restored.time_origin == curves.time_origin
assert restored.fps == pytest.approx(curves.fps)
assert restored.curves_schema_version == curves.curves_schema_version
assert restored.reference_valid is curves.reference_valid
assert restored.early_truncation_flag is curves.early_truncation_flag
np.testing.assert_array_equal(restored.perf_mask_stable, curves.perf_mask_stable)
for key in (
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
):
np.testing.assert_array_equal(getattr(restored, key), getattr(curves, key))
assert set(restored.curves) == set(CURVE_NAMES)
for name in CURVE_NAMES:
original = curves.curves[name]
back = restored.curves[name]
np.testing.assert_allclose(back.raw, original.raw, equal_nan=True)
np.testing.assert_allclose(back.candidate, original.candidate, equal_nan=True)
np.testing.assert_allclose(back.fitted, original.fitted, equal_nan=True)
np.testing.assert_allclose(back.processed, original.processed, equal_nan=True)
np.testing.assert_array_equal(back.valid_sample_mask, original.valid_sample_mask)
assert back.baseline_frames == original.baseline_frames
assert back.baseline_valid is original.baseline_valid
assert back.baseline_source == original.baseline_source
assert back.fit_baseline == pytest.approx(original.fit_baseline, nan_ok=True)
def test_roundtrip_keeps_nan_as_nan() -> None:
"""NaN 原样往返:既不被填成 0,也不被插值掉。"""
restored = unpack_tic_curves(pack_tic_curves(_make_curves()))
for name in CURVE_NAMES:
assert np.isnan(restored.curves[name].raw[2])
def test_invalid_baseline_roundtrips_as_nan_and_minus_one() -> None:
"""基线无效那条:均值 / 标准差是 NaN,帧区间 ``(-1, -1)``,valid 为 False。
``0.0`` 是一个能一路算下去、把下游全部算错的合法数值;NaN 不是。
"""
restored = unpack_tic_curves(pack_tic_curves(_make_curves()))
pan = restored.curves["roi_pan"]
assert np.isnan(pan.baseline_mean)
assert np.isnan(pan.baseline_sd)
assert pan.baseline_frames == (-1, -1)
assert pan.baseline_valid is False
# Phase 3: 存档层 schema —— 直接在 npz 原始存档上断言键名 / dtype / shape
def test_archive_schema_keys_dtypes_shapes() -> None:
"""打开 pack 产出的原始 npz 存档,逐一断言键集 / 存储 dtype / shape。
与对象层往返互补:往返只验 ``unpack(pack(x)) == x``,发现不了 pack 写入存档时的
dtype / shape 漂移。
"""
archive_bytes = pack_tic_curves(_make_curves())
with np.load(io.BytesIO(archive_bytes), allow_pickle=False) as archive:
expected_keys = {
"frame_indices",
"time_video_s",
"injection_offset_s",
"time_origin",
"fps",
"config_version",
"algorithm_version",
"curves_schema_version",
"reference_valid",
"early_truncation_flag",
"perf_mask_stable",
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
}
for name in CURVE_NAMES:
expected_keys |= {
f"{name}_raw",
f"{name}_candidate",
f"{name}_fitted",
f"{name}_processed",
f"{name}_valid_sample_mask",
f"{name}_duplicate_sample_mask",
f"{name}_baseline_mean",
f"{name}_baseline_sd",
f"{name}_baseline_frames",
f"{name}_baseline_valid",
f"{name}_fit_success",
f"{name}_fit_failure_reason",
f"{name}_curve_model",
f"{name}_smoothing_method",
f"{name}_baseline_source",
f"{name}_fit_parameters",
f"{name}_fit_quality",
f"{name}_fit_interval_s",
f"{name}_multi_peak_flag",
f"{name}_anchor_times_s",
}
assert set(archive.files) == expected_keys
assert archive["frame_indices"].dtype == np.int32
assert archive["frame_indices"].shape == (_T,)
assert archive["time_video_s"].dtype == np.float32
assert archive["time_video_s"].shape == (_T,)
assert archive["injection_offset_s"].dtype == np.float32
assert archive["injection_offset_s"].shape == (1,)
assert archive["fps"].dtype == np.float32
assert archive["fps"].shape == (1,)
assert archive["reference_valid"].dtype == np.int8
assert archive["early_truncation_flag"].dtype == np.int8
for key in ("roi_area_px", "pan_area_px", "dark_component_count"):
assert archive[key].dtype == np.int32
assert archive[key].shape == (_T,)
for name in CURVE_NAMES:
assert archive[f"{name}_raw"].dtype == np.float32
assert archive[f"{name}_raw"].shape == (_T,)
assert archive[f"{name}_candidate"].dtype == np.float32
assert archive[f"{name}_fitted"].dtype == np.float32
assert archive[f"{name}_processed"].dtype == np.float32
assert archive[f"{name}_baseline_mean"].dtype == np.float32
assert archive[f"{name}_baseline_mean"].shape == (1,)
assert archive[f"{name}_baseline_sd"].shape == (1,)
assert archive[f"{name}_baseline_frames"].dtype == np.int32
assert archive[f"{name}_baseline_frames"].shape == (2,)
assert archive[f"{name}_baseline_valid"].dtype == np.int8
assert archive[f"{name}_fit_parameters"].dtype == np.float64
assert archive[f"{name}_fit_parameters"].shape == (5,)
# Phase 4: 不存的那三样确实不在文件里
def test_derivable_quantities_are_not_stored() -> None:
"""``S(t)``、``t_injection``、无灌注比例都不进文件(各自的理由见 TicCurves docstring)。"""
with np.load(io.BytesIO(pack_tic_curves(_make_curves())), allow_pickle=False) as a:
keys = set(a.files)
assert not {key for key in keys if "baseline_subtracted" in key}
assert "time_injection_s" not in keys
assert not {key for key in keys if "non_perfused" in key}
# Phase 5: 从 TicResult 构造
def _tic_result():
"""跑一次真实的 ``compute_tic_for_video``(合成帧),拿一个 TicResult。"""
height, width, frame_count = 60, 80, 4
roi_polygon = np.array(
[[30.0, 10.0], [70.0, 10.0], [70.0, 40.0], [30.0, 40.0]], dtype=np.float32
)
frames: list[np.ndarray] = []
for _ in range(frame_count):
frame = np.zeros((height, width, 3), dtype=np.uint8)
frame[10:40, 30:70] = 100
frame[10:30, 5:25] = 50
frames.append(frame)
background = np.zeros((height, width), dtype=bool)
background[12:28, 8:22] = True
return compute_tic_for_video(
source_frames=frames,
roi_polygons=[roi_polygon for _ in range(frame_count)],
dark_polygons_per_frame=[[] for _ in range(frame_count)],
reference_mask=background,
fps=4.0,
prompt_cfg=_CONFIG.prompt_source,
pan_cfg=_CONFIG.pan_source,
tic_cfg=_CONFIG.tic_analysis,
frame_times_s=None,
)
def test_from_tic_result_roundtrips() -> None:
"""``from_tic_result`` 认得行键,产物能原样打包再解回来。"""
result = _tic_result()
curves = from_tic_result(result, fps=4.0, injection_offset_s=1.25, config_version="test-v1")
restored = unpack_tic_curves(pack_tic_curves(curves))
assert restored.time_origin == "injection"
assert restored.injection_offset_s == pytest.approx(1.25)
assert restored.frame_indices.tolist() == [0, 1, 2, 3]
np.testing.assert_allclose(restored.time_video_s, [0.0, 0.25, 0.5, 0.75], atol=1e-6)
assert set(restored.curves) == set(CURVE_NAMES)
assert restored.reference_valid is result.reference_valid
np.testing.assert_allclose(
restored.curves["roi_all"].raw,
[row["roi_all_raw"] for row in result.rows],
equal_nan=True,
)
def test_from_tic_result_without_injection_offset_writes_nan_not_zero() -> None:
"""推注偏移取不到时写 NaN、时间零点记「视频起点」——**不填 0**。
填 0 等于断言「视频和推注同时开始」,而那是我们没有的信息(design/0030 决策三)。
"""
curves = from_tic_result(
_tic_result(), fps=4.0, injection_offset_s=None, config_version="test-v1"
)
restored = unpack_tic_curves(pack_tic_curves(curves))
assert np.isnan(restored.injection_offset_s)
assert restored.time_origin == "video_start"
def test_from_tic_result_rejects_non_positive_fps() -> None:
with pytest.raises(ValueError):
from_tic_result(
_tic_result(),
fps=0.0,
injection_offset_s=None,
config_version="test-v1",
)
# Phase 6: 防御 —— 坏字节 / 长度不一致 / 禁 pickle
def test_unpack_bad_bytes_raises() -> None:
with pytest.raises((ValueError, OSError)):
unpack_tic_curves(b"this-is-not-a-valid-npz-archive")
def test_unpack_rejects_old_schema_before_reading_new_keys() -> None:
packed = pack_tic_curves(_make_curves())
with np.load(io.BytesIO(packed), allow_pickle=False) as archive:
payload = {key: archive[key] for key in archive.files}
payload["curves_schema_version"] = np.array("1")
buffer = io.BytesIO()
np.savez_compressed(buffer, **payload)
with pytest.raises(ValueError, match="只支持 curves schema 3"):
unpack_tic_curves(buffer.getvalue())
def test_pack_rejects_non_monotonic_time_axis() -> None:
curves = _make_curves()
object.__setattr__(
curves,
"time_video_s",
np.asarray([0.0, 0.1, 0.1, 0.3, 0.4], dtype=np.float32),
)
with pytest.raises(ValueError, match="time_video_s"):
pack_tic_curves(curves)
def test_pack_rejects_mismatched_column_length() -> None:
curves = _make_curves()
object.__setattr__(curves, "roi_area_px", np.arange(_T + 2, dtype=np.int32))
with pytest.raises(ValueError):
pack_tic_curves(curves)
def test_pack_rejects_mismatched_curve_length() -> None:
curves = _make_curves()
short = _series(0.0, valid=False)
object.__setattr__(short, "raw", np.zeros(2, dtype=np.float32))
curves.curves["roi_all"] = short
with pytest.raises(ValueError):
pack_tic_curves(curves)
def test_unpack_rejects_pickled_object_payload() -> None:
"""构造真实含 object pickle 的恶意 .npz,解包须被拒(``allow_pickle=False``)。"""
buffer = io.BytesIO()
np.savez_compressed(buffer, roi_all_raw=np.array([{"evil": 1}], dtype=object))
with pytest.raises((ValueError, OSError, KeyError)):
unpack_tic_curves(buffer.getvalue())
def test_pickle_payload_cannot_smuggle_through_unpack() -> None:
"""即便 pickle 字节伪装,unpack 内部 ``allow_pickle=False`` 也不得执行反序列化。"""
with pytest.raises((ValueError, OSError, KeyError)):
unpack_tic_curves(pickle.dumps({"evil": "code"}))
+230
View File
@@ -0,0 +1,230 @@
"""约束 Lognormal TIC 拟合的纯算法单元测试(照搬旧项目 test_tic_fitting.py)。"""
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
LognormalParameters,
TicFittingConfig,
evaluate_lognormal,
fit_lognormal_curve,
)
from cadx.ceus._tic_fitting import _select_primary_peak # pyright: ignore[reportPrivateUsage]
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
def _fit(
times: np.ndarray,
values: np.ndarray,
*,
duplicates: np.ndarray | None = None,
baseline: float | None = 30.0,
noise: float | None = 0.2,
cfg: TicFittingConfig | None = None,
):
return fit_lognormal_curve(
times,
values,
baseline=baseline,
baseline_noise=noise,
baseline_end_index=20,
duplicate_mask=(np.zeros(times.size, dtype=bool) if duplicates is None else duplicates),
sample_mask=None,
cfg=cfg or _CONFIG.tic_analysis.fitting,
anchor_cfg=_CONFIG.tic_analysis.anchor_frames,
)
def test_recovers_clean_lognormal_and_continuous_anchors() -> None:
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(
area=400.0,
mu=2.2,
sigma=0.5,
arrival_offset_s=8.0,
baseline=30.0,
)
result = _fit(times, evaluate_lognormal(times, truth))
assert result.success is True
assert result.parameters is not None
assert result.r2 == pytest.approx(1.0, abs=1e-8)
assert result.parameters.arrival_offset_s == pytest.approx(8.0, abs=0.05)
assert result.parameters.sigma == pytest.approx(0.5, abs=0.02)
assert result.d1_time_s is not None
assert result.d1_time_s < result.d2_time_s < result.d3_time_s < result.d4_time_s # pyright: ignore[reportOperatorIssue]
np.testing.assert_allclose(result.processed, result.fitted)
def test_soft_l1_fit_survives_single_compression_spike() -> None:
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
values = evaluate_lognormal(times, truth)
values[180] += 80.0
result = _fit(times, values)
assert result.success is True
assert result.parameters is not None
assert result.d3_time_s == pytest.approx(
truth.arrival_offset_s + np.exp(truth.mu - truth.sigma**2), abs=0.25
)
def test_duplicate_samples_are_excluded_but_keep_original_mapping() -> None:
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
values = evaluate_lognormal(times, truth)
duplicates = np.zeros(times.size, dtype=bool)
duplicates[100:102] = True
result = _fit(times, values, duplicates=duplicates)
assert result.success is True
np.testing.assert_array_equal(result.duplicate_sample_mask, duplicates)
assert not result.valid_sample_mask[100:102].any()
assert result.processed.shape == values.shape
def test_missing_observed_baseline_estimates_prior_but_rejects_flat_curve() -> None:
times = np.linspace(0.0, 60.0, 601)
values = np.full(times.size, 30.0)
result = _fit(times, values, baseline=None, noise=None)
assert result.success is False
assert result.failure_reason == "credible_peak_not_found"
assert result.baseline_source == "estimated"
assert np.isnan(result.processed).all()
assert np.isnan(result.fitted).all()
def test_left_truncated_curve_uses_estimated_baseline_and_still_fits() -> None:
times = np.linspace(0.0, 50.0, 501)
truth = LognormalParameters(700.0, 2.7, 0.65, -4.0, 25.0)
result = _fit(
times,
evaluate_lognormal(times, truth),
baseline=None,
noise=None,
)
assert result.success is True
assert result.baseline_source == "estimated"
assert result.parameters is not None
assert result.d3_time_s is not None
def test_explicit_sample_mask_excludes_unstable_perf_frame() -> None:
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
values = evaluate_lognormal(times, truth)
values[180] += 100.0
sample_mask = np.ones(times.size, dtype=bool)
sample_mask[180] = False
result = fit_lognormal_curve(
times,
values,
baseline=30.0,
baseline_noise=0.2,
baseline_end_index=20,
duplicate_mask=np.zeros(times.size, dtype=bool),
sample_mask=sample_mask,
cfg=_CONFIG.tic_analysis.fitting,
anchor_cfg=_CONFIG.tic_analysis.anchor_frames,
)
assert result.success is True
assert result.valid_sample_mask[180] is np.False_
def test_quality_failure_keeps_rejected_fit_separate_from_processed() -> None:
rng = np.random.default_rng(9)
times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(160.0, 2.0, 0.55, 8.0, 30.0)
values = evaluate_lognormal(times, truth) + rng.normal(0.0, 18.0, times.size)
cfg = _CONFIG.tic_analysis.fitting.model_copy(update={"minimum_r2": 0.95, "maximum_nrmse": 0.1})
result = _fit(times, values, noise=1.0, cfg=cfg)
assert result.success is False
assert result.failure_reason in {
"fit_quality_failed",
"credible_peak_not_found",
"model_peak_on_boundary",
"parameter_on_boundary",
}
assert np.isnan(result.processed).all()
if result.failure_reason in {
"fit_quality_failed",
"model_peak_on_boundary",
"parameter_on_boundary",
}:
assert result.parameters is not None
assert np.isfinite(result.fitted).all()
assert np.isfinite(result.r2)
assert np.isfinite(result.nrmse)
def test_primary_peak_uses_local_finite_run_and_rejects_single_sample_spike() -> None:
grid = np.arange(12.0)
candidate = np.asarray([0.0, 0.0, 10.0, 0.0, np.nan, np.nan, 0.0, 2.5, 5.0, 2.5, 0.0, 0.0])
selected = _select_primary_peak([2, 8], grid, candidate, baseline=0.0)
assert selected == 8
def test_two_boluses_mark_multi_peak_and_fit_first_pass() -> None:
times = np.linspace(0.0, 70.0, 701)
first = evaluate_lognormal(times, LognormalParameters(300.0, 2.0, 0.4, 5.0, 30.0))
second = evaluate_lognormal(times, LognormalParameters(260.0, 1.8, 0.4, 28.0, 0.0))
result = _fit(times, first + second)
assert result.multi_peak_flag is True
assert result.fit_end_s < 34.0
def test_repeated_shallow_motion_peaks_do_not_truncate_first_bolus() -> None:
times = np.linspace(0.0, 70.0, 701)
truth = LognormalParameters(400.0, 2.4, 0.75, 5.0, 30.0)
broad_bolus = evaluate_lognormal(times, truth)
motion = np.where(times > 8.0, 2.0 * np.sin(times * 2.5), 0.0)
result = _fit(times, broad_bolus + motion)
assert result.multi_peak_flag is False
assert result.fit_end_s == pytest.approx(times[-1])
assert result.success is True
def test_truncated_washout_does_not_use_fit_boundary_as_d4() -> None:
full_times = np.linspace(0.0, 60.0, 601)
truth = LognormalParameters(400.0, 2.2, 0.5, 8.0, 30.0)
full_values = evaluate_lognormal(full_times, truth)
keep = full_times <= 17.0
result = _fit(full_times[keep], full_values[keep])
assert result.success is True
assert result.d3_time_s is not None
assert result.d4_time_s is None
def test_narrow_bolus_keeps_analytic_d4_in_long_video() -> None:
times = np.linspace(0.0, 90.0, 901)
truth = LognormalParameters(400.0, 2.2, 0.15, 8.0, 30.0)
result = _fit(times, evaluate_lognormal(times, truth))
sigma_squared = truth.sigma**2
wash_out_factor = (sigma_squared - np.sqrt(sigma_squared**2 + 4.0 * sigma_squared)) / 2.0
expected_d4 = truth.arrival_offset_s + np.exp(truth.mu - sigma_squared - wash_out_factor)
assert result.success is True
assert result.d4_time_s == pytest.approx(expected_d4, abs=0.05)
+313
View File
@@ -0,0 +1,313 @@
"""TIC 折线图单元测试:缺测不许画成零(照搬旧项目 test_ceus_tic_plot.py)。"""
from typing import Any
import cv2
import numpy as np
import pytest
from matplotlib.axes import Axes
from cadx.ceus import (
CURVE_NAMES,
BaselineResult,
LognormalFitResult,
TicPlotMarker,
TicResult,
render_tic_png,
)
_ROI_ALL_BGR = (184, 99, 37)
_ROI_PERF_BGR = (0, 103, 212)
_ROI_PERF_RAW_BGR = (194, 219, 245)
_ROI_PAN_BGR = (237, 58, 124)
_ROI_PAN_RAW_BGR = (251, 208, 224)
_MANUAL_AT_BGR = (30, 30, 220)
_FRAME_COUNT = 21
# 去暗区曲线中间这一段没采到(NaN),落在时间轴 35%~65% 之间。
_GAP = range(7, 14)
def _invalid_baselines() -> dict[str, BaselineResult]:
"""四条曲线都给一个无效基线——这张图只读 ``rows``,基线怎么样它不关心。"""
return {
name: BaselineResult(mean=None, sd=None, frame_start=-1, frame_end=-1, valid=False)
for name in CURVE_NAMES
}
def _result(
*,
perf_all_nan: bool,
perf_gap: bool,
pan_available: bool = False,
alternate_background: bool = False,
perf_fit_success: bool | None = None,
pan_fit_success: bool | None = None,
pan_fitted_all_nan: bool = False,
perf_processed_value: float | None = None,
perf_singleton: bool = False,
) -> TicResult:
"""造一份 ``roi_perf`` 可缺失或断开的 ``TicResult``。"""
rows: list[dict[str, Any]] = []
for index in range(_FRAME_COUNT):
perf = float("nan") if perf_all_nan else 90.0 + 10.0 * (index % 2)
if perf_singleton:
perf = 95.0 if index == _FRAME_COUNT // 2 else float("nan")
if perf_gap and index in _GAP:
perf = float("nan")
values = {
"roi_all": 10.0 * index,
"roi_perf": perf,
"roi_pan": 180.0 + index if pan_available else float("nan"),
"background_circle": (150.0 - index if alternate_background else 40.0 + index),
}
row: dict[str, Any] = {"frame_index": index, "time_sec": float(index)}
for name, value in values.items():
row[f"{name}_raw"] = value
row[f"{name}_processed"] = (
perf_processed_value
if name == "roi_perf" and perf_processed_value is not None
else value
)
rows.append(row)
fits: dict[str, LognormalFitResult] = {}
for name in CURVE_NAMES:
values = np.asarray([row[f"{name}_processed"] for row in rows], dtype=float)
success = bool(np.isfinite(values).any())
if name == "roi_perf" and perf_fit_success is not None:
success = perf_fit_success
if name == "roi_pan" and pan_fit_success is not None:
success = pan_fit_success
fitted = values.copy()
if name == "roi_pan" and pan_fitted_all_nan:
fitted[:] = np.nan
fits[name] = LognormalFitResult(
success=success,
failure_reason="" if success else "no_data",
algorithm_version="lognormal-v2",
curve_model="lognormal",
smoothing_method="",
baseline_source="observed",
candidate=values.copy(),
fitted=fitted,
processed=values.copy(),
valid_sample_mask=np.isfinite(values),
duplicate_sample_mask=np.zeros(_FRAME_COUNT, dtype=bool),
parameters=None,
r2=float("nan"),
nrmse=float("nan"),
residual_noise_ratio=float("nan"),
fit_start_s=0.0,
fit_end_s=float(_FRAME_COUNT - 1),
multi_peak_flag=False,
d1_time_s=None,
d2_time_s=None,
d3_time_s=None,
d4_time_s=None,
)
return TicResult(
rows=rows,
parameters=None,
baselines=_invalid_baselines(),
fits=fits,
early_truncation_flag=False,
reference_valid=pan_available,
pan_detected_fraction=1.0 if pan_available else 0.0,
)
def _decode(png: bytes) -> np.ndarray:
canvas = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_COLOR)
assert canvas is not None, "渲染出来的字节解不回一张图"
return canvas
def _render(*, perf_all_nan: bool, perf_gap: bool) -> np.ndarray:
return _decode(
render_tic_png(_result(perf_all_nan=perf_all_nan, perf_gap=perf_gap), markers=())
)
def _color_pixels(canvas: np.ndarray, color: tuple[int, int, int]) -> np.ndarray:
difference = np.abs(canvas.astype(np.int16) - np.asarray(color, dtype=np.int16))
return np.all(difference <= 8, axis=2)
def test_all_nan_curve_is_not_drawn_at_zero() -> None:
"""``roi_perf`` 整条 NaN 时不画;同一条有数据时画得出来。
第二句是要害:只断言「没画」的话,一个「什么都不画」的实现照样能过。
"""
absent = _color_pixels(_render(perf_all_nan=True, perf_gap=False), _ROI_PERF_BGR)
present = _color_pixels(_render(perf_all_nan=False, perf_gap=False), _ROI_PERF_BGR)
assert int(absent.sum()) == 0, "整条 NaN 的曲线被画出来了——多半是被当成了 0"
assert int(present.sum()) > 0, "同一条曲线有数据时反而没画出来"
def test_nan_gap_breaks_the_polyline_instead_of_dipping_to_zero() -> None:
canvas = _render(perf_all_nan=False, perf_gap=True)
perf_pixels = _color_pixels(canvas, _ROI_PERF_BGR)
height, width = canvas.shape[:2]
inside_gap = perf_pixels[
int(0.08 * height) : int(0.62 * height),
int(0.45 * width) : int(0.55 * width),
]
assert int(inside_gap.sum()) == 0, "缺口那一段仍有去暗区曲线像素——折线没有断开"
assert int(perf_pixels.sum()) > 0, "整条曲线都没画出来——断开断过头了"
def test_curves_with_data_are_still_drawn() -> None:
"""有数据的曲线照画不误(防止「遇 NaN 就整条放弃」)。"""
canvas = _render(perf_all_nan=True, perf_gap=True)
assert int(_color_pixels(canvas, _ROI_ALL_BGR).sum()) > 0
def test_isolated_raw_sample_is_visible() -> None:
result = _result(
perf_all_nan=True,
perf_gap=False,
perf_fit_success=False,
perf_singleton=True,
)
canvas = _decode(render_tic_png(result, markers=()))
height, width = canvas.shape[:2]
plot_center = _color_pixels(canvas, _ROI_PERF_RAW_BGR)[
int(0.2 * height) : int(0.65 * height),
int(0.45 * width) : int(0.55 * width),
]
assert int(plot_center.sum()) > 0
def test_rejected_fit_keeps_raw_but_hides_processed_curve() -> None:
result = _result(
perf_all_nan=False,
perf_gap=False,
perf_fit_success=False,
perf_processed_value=170.0,
)
canvas = _decode(render_tic_png(result, markers=()))
assert int(_color_pixels(canvas, _ROI_PERF_RAW_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PERF_BGR).sum()) == 0
def test_pan_reference_raw_and_fit_are_drawn_when_available() -> None:
result = _result(
perf_all_nan=False,
perf_gap=False,
pan_available=True,
)
canvas = _decode(render_tic_png(result, markers=()))
assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) > 0
def test_missing_pan_reference_is_not_drawn_at_zero() -> None:
canvas = _decode(render_tic_png(_result(perf_all_nan=False, perf_gap=False), markers=()))
assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) == 0
assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) == 0
def test_rejected_pan_fit_draws_diagnostic_fitted_curve() -> None:
result = _result(
perf_all_nan=False,
perf_gap=False,
pan_available=True,
pan_fit_success=False,
)
canvas = _decode(render_tic_png(result, markers=()))
assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) > 0
def test_rejected_pan_fit_is_dashed_and_labeled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
plotted_styles: list[dict[str, Any]] = []
original_plot = Axes.plot # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
def capture_plot(self: Axes, *args: Any, **kwargs: Any) -> Any:
plotted_styles.append(kwargs)
return original_plot(self, *args, **kwargs)
monkeypatch.setattr(Axes, "plot", capture_plot)
render_tic_png(
_result(
perf_all_nan=False,
perf_gap=False,
pan_available=True,
pan_fit_success=False,
),
markers=(),
)
reference_fit = next(
style
for style in plotted_styles
if style.get("label") == "roi_pan reference Lognormal fit [QC rejected]"
)
assert reference_fit["linestyle"] == "--"
def test_rejected_pan_fit_without_finite_model_does_not_fabricate_line() -> None:
result = _result(
perf_all_nan=False,
perf_gap=False,
pan_available=True,
pan_fit_success=False,
pan_fitted_all_nan=True,
)
canvas = _decode(render_tic_png(result, markers=()))
assert int(_color_pixels(canvas, _ROI_PAN_RAW_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PAN_BGR).sum()) == 0
def test_background_does_not_change_tic_plot() -> None:
regular = _result(perf_all_nan=False, perf_gap=False)
changed = _result(
perf_all_nan=False,
perf_gap=False,
alternate_background=True,
)
regular_canvas = _decode(render_tic_png(regular, markers=()))
changed_canvas = _decode(render_tic_png(changed, markers=()))
np.testing.assert_array_equal(regular_canvas, changed_canvas)
def test_plot_uses_review_ready_canvas_size() -> None:
canvas = _render(perf_all_nan=False, perf_gap=False)
assert canvas.shape == (800, 1600, 3)
def test_review_marker_is_drawn_without_changing_curve_data() -> None:
result = _result(perf_all_nan=False, perf_gap=False)
png = render_tic_png(
result,
markers=(TicPlotMarker("manual AT", 10.0, _MANUAL_AT_BGR),),
)
canvas = _decode(png)
assert int(_color_pixels(canvas, _MANUAL_AT_BGR).sum()) > 0
assert int(_color_pixels(canvas, _ROI_PERF_BGR).sum()) > 0
def test_marker_outside_observed_time_is_not_attached_to_boundary() -> None:
result = _result(perf_all_nan=False, perf_gap=False)
png = render_tic_png(
result,
markers=(TicPlotMarker("manual AT", 100.0, _MANUAL_AT_BGR),),
)
canvas = _decode(png)
assert int(_color_pixels(canvas, _MANUAL_AT_BGR).sum()) == 0
+260
View File
@@ -0,0 +1,260 @@
"""trajectory npz 打包/解包单元测试(照搬旧项目 test_ceus_trajectory.py)。
旧标题:B11 trajectory npz 打包/解包单元测试。
钉死轨迹工件的单一 schema:完整往返还原全部键(沿用参考 ``write_roi_trajectory_npz``
键集 + 新增 ``keyframe_indices`` / ``dark_polygons`` / ``calibration_json``)、
各键 dtype 精确(int32 / float32 / uint8 / str)、变长 ``dark_polygons``(逐帧
不同数量多边形)与 ``keyframe_indices`` 往返、``reference_region`` / ``calibration``
dict 往返,以及防御:坏字节解包 raise、形状不一致 pack raise、禁 pickle
(含 object pickle 的恶意 npz 解包被拒)。测试用真实数据的二次构造(合成多边形 /
掩膜),边缘场景靠实现里的 assertion/raise 保证。
"""
import io
import pickle
import numpy as np
import pytest
from cadx.ceus import (
Trajectory,
ViewRect,
pack_trajectory,
unpack_trajectory,
)
# Phase 1: 构造一个真实形态的 Trajectory(二次构造的合成几何)
def _make_polygon(offset: float) -> np.ndarray:
"""构造 (64, 2) float32 闭合多边形(单位圆平移),与重采样输出形态一致。"""
angles = np.linspace(0.0, 2.0 * np.pi, 64, endpoint=False)
points = np.stack([np.cos(angles), np.sin(angles)], axis=1) * 10.0 + offset
return points.astype(np.float32)
def _make_trajectory() -> Trajectory:
num_frames = 3
target_polygons = [_make_polygon(float(i)) for i in range(num_frames)]
source_polygons = [_make_polygon(float(i) + 100.0) for i in range(num_frames)]
# 逐帧暗区多边形数量不同:0 个 / 1 个 / 2 个(变长嵌套)
dark_polygons: list[list[np.ndarray]] = [
[],
[_make_polygon(5.0)],
[_make_polygon(6.0), _make_polygon(7.0)],
]
reference_mask = np.zeros((20, 30), dtype=np.uint8)
reference_mask[5:10, 5:15] = 1
return Trajectory(
frame_indices=np.arange(num_frames, dtype=np.int32),
fps=29.97,
target_polygons=np.asarray(target_polygons, dtype=np.float32),
source_polygons=np.asarray(source_polygons, dtype=np.float32),
source_view_rect=ViewRect(0, 0, 100, 200),
target_view_rect=ViewRect(100, 0, 200, 200),
source_effective_rect=ViewRect(5, 5, 95, 195),
target_effective_rect=ViewRect(105, 5, 195, 195),
source_polygon_coordinate_space="source_view_local",
target_polygon_coordinate_space="target_effective_view_local",
reference_mask=reference_mask,
reference_region={"frame_index": 1, "area": 50.0, "source": "auto"},
keyframe_indices=np.asarray([1, 2], dtype=np.int32),
dark_polygons=dark_polygons,
calibration={
"source": "config/ceus.yaml",
"config_hash": "abc123",
"cli_override": False,
"detected_split_x": 100,
},
)
# Phase 2: 完整往返 —— 全部键还原
def test_roundtrip_preserves_all_keys() -> None:
trajectory = _make_trajectory()
restored = unpack_trajectory(pack_trajectory(trajectory))
np.testing.assert_array_equal(restored.frame_indices, trajectory.frame_indices)
assert restored.fps == pytest.approx(trajectory.fps)
np.testing.assert_allclose(restored.target_polygons, trajectory.target_polygons)
np.testing.assert_allclose(restored.source_polygons, trajectory.source_polygons)
assert restored.source_view_rect == trajectory.source_view_rect
assert restored.target_view_rect == trajectory.target_view_rect
assert restored.source_effective_rect == trajectory.source_effective_rect
assert restored.target_effective_rect == trajectory.target_effective_rect
assert restored.source_polygon_coordinate_space == trajectory.source_polygon_coordinate_space
assert restored.target_polygon_coordinate_space == trajectory.target_polygon_coordinate_space
np.testing.assert_array_equal(restored.reference_mask, trajectory.reference_mask)
assert restored.reference_region == trajectory.reference_region
np.testing.assert_array_equal(restored.keyframe_indices, trajectory.keyframe_indices)
assert restored.calibration == trajectory.calibration
# Phase 3: dtype 精确
def test_roundtrip_dtypes_are_exact() -> None:
restored = unpack_trajectory(pack_trajectory(_make_trajectory()))
assert restored.frame_indices.dtype == np.int32
assert restored.target_polygons.dtype == np.float32
assert restored.source_polygons.dtype == np.float32
assert restored.reference_mask.dtype == np.uint8
assert restored.keyframe_indices.dtype == np.int32
assert isinstance(restored.fps, float)
assert isinstance(restored.source_polygon_coordinate_space, str)
assert isinstance(restored.target_polygon_coordinate_space, str)
def test_polygon_shapes_are_n_64_2() -> None:
restored = unpack_trajectory(pack_trajectory(_make_trajectory()))
assert restored.target_polygons.shape == (3, 64, 2)
assert restored.source_polygons.shape == (3, 64, 2)
# Phase 3b: 存档层 schema —— 直接在 npz 原始存档上断言键名 / dtype / shape
# (对象层往返不足以守住 pack 的存储 schema;pack 若 schema 漂移须在此被发现)
def test_archive_schema_keys_dtypes_shapes() -> None:
"""打开 pack 产出的原始 npz 存档,逐一断言键集 / 存储 dtype / shape。
与对象层往返互补:往返只验 unpack(pack(x))==x,无法发现 pack 写入存档时的
dtype / shape 漂移(如 fps 被写成 float64、polygons 退化)。此处用
``allow_pickle=False`` 直接读取存档,钉死存储 schema。
"""
archive_bytes = pack_trajectory(_make_trajectory())
with np.load(io.BytesIO(archive_bytes), allow_pickle=False) as archive:
# 键集合 == 全部必要键(无多无少)
expected_keys = {
"frame_indices",
"fps",
"target_polygons",
"source_polygons",
"source_view_rect",
"target_view_rect",
"source_effective_rect",
"target_effective_rect",
"source_polygon_coordinate_space",
"target_polygon_coordinate_space",
"reference_mask",
"reference_region_json",
"keyframe_indices",
"dark_polygons",
"calibration_json",
}
assert set(archive.files) == expected_keys
# 存档内 dtype 精确(存储 dtype 是 schema 的一部分)
assert archive["frame_indices"].dtype == np.int32
assert archive["keyframe_indices"].dtype == np.int32
assert archive["target_polygons"].dtype == np.float32
assert archive["source_polygons"].dtype == np.float32
# fps 存档 dtype 必须 float32(即使 in-memory unpack 还原为 Python float)
assert archive["fps"].dtype == np.float32
for rect_key in (
"source_view_rect",
"target_view_rect",
"source_effective_rect",
"target_effective_rect",
):
assert archive[rect_key].dtype == np.int32
assert archive["reference_mask"].dtype == np.uint8
# shape 精确
assert archive["target_polygons"].shape == (3, 64, 2)
assert archive["source_polygons"].shape == (3, 64, 2)
assert archive["frame_indices"].shape == (3,)
for rect_key in (
"source_view_rect",
"target_view_rect",
"source_effective_rect",
"target_effective_rect",
):
assert archive[rect_key].shape == (4,)
assert archive["reference_mask"].ndim == 2
# Phase 4: 变长 dark_polygons 往返
def test_roundtrip_variable_length_dark_polygons() -> None:
trajectory = _make_trajectory()
restored = unpack_trajectory(pack_trajectory(trajectory))
assert len(restored.dark_polygons) == 3
assert len(restored.dark_polygons[0]) == 0
assert len(restored.dark_polygons[1]) == 1
assert len(restored.dark_polygons[2]) == 2
for original_frame, restored_frame in zip(
trajectory.dark_polygons, restored.dark_polygons, strict=True
):
for original_poly, restored_poly in zip(original_frame, restored_frame, strict=True):
assert isinstance(restored_poly, np.ndarray)
assert restored_poly.dtype == np.float32
np.testing.assert_allclose(restored_poly, original_poly)
def test_roundtrip_empty_dark_polygons() -> None:
trajectory = _make_trajectory()
trajectory.dark_polygons = [[], [], []]
restored = unpack_trajectory(pack_trajectory(trajectory))
assert restored.dark_polygons == [[], [], []]
# Phase 5: 防御 —— 坏字节 / 形状不一致 / 禁 pickle
def test_unpack_bad_bytes_raises() -> None:
with pytest.raises((ValueError, OSError)):
unpack_trajectory(b"this-is-not-a-valid-npz-archive")
def test_pack_rejects_mismatched_frame_count() -> None:
trajectory = _make_trajectory()
# frame_indices 长度与多边形帧数不一致
trajectory.frame_indices = np.arange(5, dtype=np.int32)
with pytest.raises(ValueError):
pack_trajectory(trajectory)
def test_pack_rejects_wrong_polygon_shape() -> None:
trajectory = _make_trajectory()
# 把 target_polygons 改成 (N, 32, 2),违反 (N, 64, 2)
trajectory.target_polygons = np.zeros((3, 32, 2), dtype=np.float32)
with pytest.raises(ValueError):
pack_trajectory(trajectory)
def test_pack_rejects_non_2d_reference_mask() -> None:
trajectory = _make_trajectory()
trajectory.reference_mask = np.zeros((20, 30, 3), dtype=np.uint8)
with pytest.raises(ValueError):
pack_trajectory(trajectory)
def test_unpack_rejects_pickled_object_payload() -> None:
"""构造真实含 object pickle 的恶意 .npz(须 allow_pickle 才能 save/load),解包须被拒。
关键:用 ``np.savez_compressed`` 生成真正的 .npz 存档(而非 .npy),其中含一个
object dtype 数组(背后是 pickle payload)。这覆盖了「合法 .npz 容器、但成员需
pickle 反序列化」的注入面——``unpack_trajectory`` 内部 ``allow_pickle=False`` 必须
在访问该成员时 raise,绝不静默反序列化执行。
"""
buffer = io.BytesIO()
# 把 object dtype 数组放进 unpack 首先访问的必要键 frame_indices,确保命中
# allow_pickle=False 对 object 成员的拒绝路径(而非仅缺键 KeyError)。
# object dtype 数组在 savez 时即以 pickle 存储,构造真实 .npz 字节。
np.savez_compressed(buffer, frame_indices=np.array([{"evil": 1}], dtype=object))
raw = buffer.getvalue()
# 访问 frame_indices 这一 object 成员时,allow_pickle=False 必须 raise(ValueError)
with pytest.raises((ValueError, OSError, KeyError)):
unpack_trajectory(raw)
def test_pickle_payload_cannot_smuggle_through_unpack() -> None:
"""即便 pickle 字节伪装,unpack 内部 allow_pickle=False 也不得执行反序列化。"""
payload = pickle.dumps({"evil": "code"})
with pytest.raises((ValueError, OSError, KeyError)):
unpack_trajectory(payload)
+95
View File
@@ -0,0 +1,95 @@
"""segment:分割设置的校验,以及在合成小视频上真跑一次 MedSAM2(`gpu`)。"""
from pathlib import Path
from typing import Any, cast
import cv2
import numpy as np
import numpy.typing as npt
import pytest
import yaml
from pydantic import ValidationError
from cadx.segment import SegmentConfig, medsam2_commit, segment_video
REPO_ROOT = Path(__file__).resolve().parents[1]
CEUS_YAML = REPO_ROOT / "config" / "ceus.yaml"
def _segment_section() -> dict[str, Any]:
document = cast(dict[str, Any], yaml.safe_load(CEUS_YAML.read_text(encoding="utf-8")))
return cast(dict[str, Any], document["segment"])
def _absolute(section: dict[str, Any]) -> dict[str, Any]:
"""路径字段换成仓库根目录下的绝对路径(脚本也这样做)。"""
return {
**section,
"medsam2_dir": str(REPO_ROOT / section["medsam2_dir"]),
"checkpoint": str(REPO_ROOT / section["checkpoint"]),
}
def test_config_yaml_section_validates() -> None:
cfg = SegmentConfig.model_validate(_segment_section())
assert cfg.sam2_config == "configs/sam2.1_hiera_t512.yaml"
@pytest.mark.parametrize(
"field",
[
"medsam2_dir",
"checkpoint",
"sam2_config",
"device",
"offload_video_to_cpu",
"offload_state_to_cpu",
],
)
def test_config_missing_field_raises(field: str) -> None:
section = _segment_section()
del section[field]
with pytest.raises(ValidationError, match=field):
SegmentConfig.model_validate(section)
def test_config_extra_field_raises() -> None:
with pytest.raises(ValidationError, match="object_id"):
SegmentConfig.model_validate({**_segment_section(), "object_id": 1})
def test_config_bool_not_coerced() -> None:
with pytest.raises(ValidationError, match="offload_video_to_cpu"):
SegmentConfig.model_validate({**_segment_section(), "offload_video_to_cpu": "false"})
def _moving_disc_video(
frame_count: int, height: int, width: int, radius: int
) -> tuple[list[npt.NDArray[np.uint8]], list[npt.NDArray[np.bool_]]]:
"""暗背景上一个亮圆从左往右匀速移动;交出逐帧 BGR 图像和每帧圆的真值掩码。"""
frames: list[npt.NDArray[np.uint8]] = []
truths: list[npt.NDArray[np.bool_]] = []
yy, xx = np.mgrid[0:height, 0:width]
for index in range(frame_count):
cx = radius + 10 + index * 2
cy = height // 2
frame = np.full((height, width, 3), 30, dtype=np.uint8)
cv2.circle(frame, (cx, cy), radius, (220, 220, 220), thickness=-1)
frames.append(frame)
truths.append((xx - cx) ** 2 + (yy - cy) ** 2 <= radius**2)
return frames, truths
@pytest.mark.gpu
def test_segment_video_on_synthetic_clip() -> None:
cfg = SegmentConfig.model_validate(_absolute(_segment_section()))
frame_count, height, width = 40, 120, 200
frames, truths = _moving_disc_video(frame_count, height, width, radius=18)
# 两个提示帧,最早的不在第 0 帧,前向和反向传播都要走到。
prompt_masks = {5: truths[5], 25: truths[25]}
masks = segment_video(frames, prompt_masks, cfg)
assert masks.shape == (frame_count, height, width)
assert masks.dtype == np.bool_
assert len(medsam2_commit(cfg.medsam2_dir)) == 40
Generated
+946 -473
View File
File diff suppressed because it is too large Load Diff