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:
+39
-13
@@ -1,7 +1,8 @@
|
|||||||
# CEUS 定量的配置,给 scripts/ceus_quant.py 用。
|
# CEUS 定量的配置,给 scripts/ceus_quant.py 用。
|
||||||
#
|
#
|
||||||
# 分三段:paths(位置)、segment(分割设置,任务书 02 第二阶段加)、ceus(算法参数)。
|
# 分三段: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。
|
# 每个字段必填,可以没有的值写 null。
|
||||||
#
|
#
|
||||||
# ceus 段的数值逐个照抄旧项目 config/ceus_roi_default.yaml,没有改动(待定问题/02:参数何时冻结)。
|
# ceus 段的数值逐个照抄旧项目 config/ceus_roi_default.yaml,没有改动(待定问题/02:参数何时冻结)。
|
||||||
@@ -17,6 +18,14 @@ paths:
|
|||||||
runs_root: runs/ceus # 每次运行一个子目录,只追加不删
|
runs_root: runs/ceus # 每次运行一个子目录,只追加不删
|
||||||
work_root: work # 可删掉重算的中间文件(分割缓存)
|
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:
|
ceus:
|
||||||
config_version: "4" # 参数口径版本号,由人改。出自旧项目:Lognormal-v2 参数口径(design/0044)
|
config_version: "4" # 参数口径版本号,由人改。出自旧项目:Lognormal-v2 参数口径(design/0044)
|
||||||
|
|
||||||
@@ -65,6 +74,12 @@ ceus:
|
|||||||
threshold_offset: 0 # 出自旧项目 findings §3.8
|
threshold_offset: 0 # 出自旧项目 findings §3.8
|
||||||
min_area: 300 # findings §2A:非代码默认 64。出自旧项目 findings §3.8
|
min_area: 300 # findings §2A:非代码默认 64。出自旧项目 findings §3.8
|
||||||
min_area_ratio: 0.015 # findings §2A:非代码默认 0.0。出自旧项目 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
|
preserve_holes: true # 出自旧项目 findings §3.8
|
||||||
fill_holes: false # 出自旧项目 findings §3.8
|
fill_holes: false # 出自旧项目 findings §3.8
|
||||||
morphology_kernel: 5 # 出自旧项目 findings §3.8
|
morphology_kernel: 5 # 出自旧项目 findings §3.8
|
||||||
@@ -129,9 +144,11 @@ ceus:
|
|||||||
estimated_baseline_lookback_s: 10.0 # 允许 t0 位于视频起点前,但截图不外推。出自旧项目 design/0044
|
estimated_baseline_lookback_s: 10.0 # 允许 t0 位于视频起点前,但截图不外推。出自旧项目 design/0044
|
||||||
maximum_perf_area_step_fraction: 0.15 # 相邻帧去暗区面积比例最大变化。出自旧项目 design/0044
|
maximum_perf_area_step_fraction: 0.15 # 相邻帧去暗区面积比例最大变化。出自旧项目 design/0044
|
||||||
parameter_boundary_fraction: 0.001 # 形状参数贴边判据占允许区间的比例。出自旧项目 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),
|
# 下面这批数旧项目注明镜像自 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
|
arrival: # 到达判据,门槛取两个候选的高者。出自旧项目:《临床口径契约》5.2
|
||||||
baseline_sd_multiplier: 3.0 # 候选一:基线抖动 ×3.0。出自旧项目:《临床口径契约》5.2
|
baseline_sd_multiplier: 3.0 # 候选一:基线抖动 ×3.0。出自旧项目:《临床口径契约》5.2
|
||||||
relative_peak_threshold: 0.10 # 候选二:峰高 ×0.10。出自旧项目:《临床口径契约》5.2
|
relative_peak_threshold: 0.10 # 候选二:峰高 ×0.10。出自旧项目:《临床口径契约》5.2
|
||||||
@@ -139,17 +156,26 @@ ceus:
|
|||||||
# 主峰突出度门槛:基线抖动 ×3。与到达判据那个 3.0 是两个独立的数,改一个不连带改另一个。
|
# 主峰突出度门槛:基线抖动 ×3。与到达判据那个 3.0 是两个独立的数,改一个不连带改另一个。
|
||||||
# 出自旧项目:《临床口径契约》第六节
|
# 出自旧项目:《临床口径契约》第六节
|
||||||
peak_prominence_baseline_sd_multiplier: 3.0
|
peak_prominence_baseline_sd_multiplier: 3.0
|
||||||
baseline_frame_backoff_s: 0.3 # B0 锚点 = 到达时刻再往前 0.3 秒。出自旧项目:《临床口径契约》5.11
|
baseline_frame_backoff_s: 0.3 # B0 锚点 = 到达时刻再往前 0.3 秒。出自旧项目:《临床口径契约》5.11(临床方原规范 §13.2)
|
||||||
d1_window_after_s: 0.5 # D1 窗 [锚点, 锚点+0.5]。出自旧项目:《临床口径契约》第 12 题
|
# D1 窗 [锚点, 锚点+0.5 秒](不许取到达之前)。出自旧项目:《临床口径契约》第 12 题四个容差之一,
|
||||||
d2_window_s: 0.3 # D2 窗 ±0.3 秒。出自旧项目:《临床口径契约》第 12 题
|
# 对应临床方原规范 §13.3
|
||||||
d3_window_s: 0.5 # D3 窗 ±0.5 秒。出自旧项目:《临床口径契约》第 12 题
|
d1_window_after_s: 0.5
|
||||||
# D4 窗 ±0.5 秒:契约空着、第 12 题委托工程侧。旧项目工程侧定,理由是 D4 与 D2/D3 同属曲线
|
d2_window_s: 0.3 # D2 窗 ±0.3 秒。出自旧项目:《临床口径契约》第 12 题,临床方原规范 §13.4
|
||||||
# 形状锚点,洗出段比洗入段平缓、导数极值定位误差更大,取 D2/D3 中较宽的一档。无外部出处
|
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
|
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:
|
late_phase:
|
||||||
preferred_time_s: 60.0 # F2 锚点:推注后 60 秒。出自旧项目:《临床口径契约》
|
# F2 锚点:推注后 60 秒。出自旧项目:《临床口径契约》,临床方 2026-08-16 定(旧 glossary 记 HAI-75)
|
||||||
tolerance_s: 2.0 # F2 容差 ±2 秒。出自旧项目:《临床口径契约》第 12 题
|
preferred_time_s: 60.0
|
||||||
|
# F2 容差 ±2 秒(只属于 F2)。出自旧项目:《临床口径契约》第 12 题四个容差之四,临床方原规范
|
||||||
|
# §10.1、§13.7
|
||||||
|
tolerance_s: 2.0
|
||||||
# F1 锚点:推注后 20 秒。出自旧项目:《临床口径契约》(08-16 口头定)。
|
# F1 锚点:推注后 20 秒。出自旧项目:《临床口径契约》(08-16 口头定)。
|
||||||
# F1 没有容差键:旧项目工程侧暂定「无上限,取最近的一帧」,无出处
|
# F1 没有容差键:旧项目工程侧暂定「无上限,在视频有效帧范围内取最近的一帧」(旧
|
||||||
|
# tic-quantification-encoding.md 第 8 节),临床方没给容差,无外部出处
|
||||||
f1_anchor_s: 20.0
|
f1_anchor_s: 20.0
|
||||||
|
|||||||
+29
-4
@@ -4,12 +4,15 @@ version = "0.0.0"
|
|||||||
description = "胰腺囊性肿瘤多模态会诊诊断的研究代码"
|
description = "胰腺囊性肿瘤多模态会诊诊断的研究代码"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"matplotlib>=3.11.2",
|
||||||
|
"medsam2",
|
||||||
"numpy>=2.5.4",
|
"numpy>=2.5.4",
|
||||||
"opencv-python-headless>=5.0.0.93",
|
"opencv-python-headless>=5.0.0.93",
|
||||||
"openpyxl>=3.1.5",
|
"openpyxl>=3.1.5",
|
||||||
"pillow>=12.3.0",
|
"pillow>=12.3.0",
|
||||||
"pydantic>=2.14.1",
|
"pydantic>=2.14.1",
|
||||||
"pyyaml>=6.0.3",
|
"pyyaml>=6.0.3",
|
||||||
|
"scipy>=1.18.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -23,6 +26,14 @@ dev = [
|
|||||||
"types-openpyxl>=3.1.5.20260827",
|
"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]
|
[build-system]
|
||||||
requires = ["hatchling>=1.26"]
|
requires = ["hatchling>=1.26"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
@@ -50,7 +61,8 @@ pythonVersion = "3.12"
|
|||||||
typeCheckingMode = "strict"
|
typeCheckingMode = "strict"
|
||||||
venvPath = "."
|
venvPath = "."
|
||||||
venv = ".venv"
|
venv = ".venv"
|
||||||
extraPaths = ["tests"]
|
# medsam2 以 setuptools 的导入钩子方式可编辑安装,pyright 跟不过去,直接指给它源码目录。
|
||||||
|
extraPaths = ["tests", "reference/MedSAM2"]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
@@ -69,12 +81,25 @@ root_packages = ["cadx"]
|
|||||||
include_external_packages = true
|
include_external_packages = true
|
||||||
|
|
||||||
[[tool.importlinter.contracts]]
|
[[tool.importlinter.contracts]]
|
||||||
# design 0001:torch 与 MedSAM2 只许出现在分割模块(模块落地时在这里放行 cadx.segment)。
|
# design 0001:torch 与 MedSAM2 只许出现在分割模块。import-linter 不能写「除 segment 外的 cadx」
|
||||||
name = "torch 与 sam2 不进 cadx"
|
# (只有 ignore_imports 能开例外),所以逐个列出其余模块;新模块落地时加进 source_modules。
|
||||||
|
name = "cadx 里只有 segment 引用 torch、sam2"
|
||||||
type = "forbidden"
|
type = "forbidden"
|
||||||
source_modules = ["cadx"]
|
source_modules = [
|
||||||
|
"cadx.delivery", "cadx.library", "cadx.answers", "cadx.runs", "cadx.build", "cadx.ceus",
|
||||||
|
]
|
||||||
forbidden_modules = ["torch", "sam2"]
|
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]]
|
[[tool.importlinter.contracts]]
|
||||||
# 结构.md 第三部分依赖表:外部层与格式层的模块不依赖其他 cadx 模块。
|
# 结构.md 第三部分依赖表:外部层与格式层的模块不依赖其他 cadx 模块。
|
||||||
name = "delivery、library、answers、runs 互不依赖"
|
name = "delivery、library、answers、runs 互不依赖"
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ library/<构建号>/
|
|||||||
| MR 报告「所见」段 | 看不见 | 看不见 | 看得见 |
|
| MR 报告「所见」段 | 看不见 | 看不见 | 看得见 |
|
||||||
| 病理诊断、报告「印象 / 结论」段、医生标注工作簿、真实病例号、Excel 行序、姓名 | 看不见 | 看不见 | 看不见 |
|
| 病理诊断、报告「印象 / 结论」段、医生标注工作簿、真实病例号、Excel 行序、姓名 | 看不见 | 看不见 | 看不见 |
|
||||||
|
|
||||||
性别、年龄只给临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。
|
性别、年龄在资料库里只放临床一路:医院在两份报告工作簿里也复制了这两列,但影像两路的第一轮判断必须只凭影像证据,先看到它们会被人口学先验带偏(旧项目 design 0026 决定三)。这张表管的是资料库里每一路放什么。会诊时,影像两路在第一轮判断之后,需要时可以向临床专家提问,临床专家的回答可以含性别、年龄;这属于会诊流程,细节由会诊任务书定(负责人 2026-10-16)。
|
||||||
|
|
||||||
病例用化名,目录和清单按化名排序:交付文件名里带病例号(如 `U001_roi2.mp4`),行序可能和诊断有关;化名是按种子随机抽的 4 位小写字母加数字,按它排序后的顺序与交付顺序无关。图片写入时去掉文件元数据。构建完不再修改;要改就做一次新构建。
|
病例用化名,目录和清单按化名排序:交付文件名里带病例号(如 `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 里不合法。
|
文件格式:数组用 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 互不引用。
|
||||||
- delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。
|
- delivery、library、answers、runs 不引用其他 `cadx` 模块(build 和规划中的各模块)。
|
||||||
- ceus 不引用其他 `cadx` 模块(含 segment:分割结果由 `scripts/ceus_quant.py` 传进来)。
|
- ceus 不引用其他 `cadx` 模块(含 segment:分割结果由 `scripts/ceus_quant.py` 传进来)。
|
||||||
@@ -273,7 +274,7 @@ X 是超声报告工作簿的「推注开始时间s」,含义是视频第 0
|
|||||||
- 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。
|
- 只有 delivery 引用 `openpyxl`(交付包的 Excel 只有它碰)。
|
||||||
- 只有 library 引用 `PIL`(写资料库的图片时去掉元数据)。
|
- 只有 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 |
|
| 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,其中入库与数据库部分不迁 |
|
| 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 |
|
| library | 150–300 | 300 | `input_packages.py` 104 |
|
||||||
| answers | 100–200 | 201 | — |
|
| answers | 100–200 | 201 | — |
|
||||||
@@ -365,15 +366,26 @@ Python 环境用 uv;安装与检查命令见 `../guides/开发环境.md`。
|
|||||||
- `make_build_id(now, git)`:构建号 `YYYYMMDD-HHMMSS-<git 短哈希>`,工作区不干净时末尾加 `-dirty`。
|
- `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` 哈希、起止时间、各配置的哈希)。
|
- `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` 定义。
|
- `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()` 给逐帧显示时间戳。
|
- `VideoFile(path)`:按路径用 cv2 读视频:`probe()` 给 fps、帧数、宽高;`iter_frames()` 每次从第 0 帧逐帧读;`frame_times_s()` 给逐帧显示时间戳。
|
||||||
- `split_views(frames, views_cfg)`:第 1 步,在均匀抽的帧上找分屏线,交出分屏线和左右两半的 `ViewRect`;找不到可信的分屏线抛 `SplitDetectionError`。
|
- 其余导出(几何函数、各步的算法函数与结果类型,如 `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`)是两段接口的组成部分,供测试用。
|
||||||
- `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`:两半之间的坐标换算,平移量是两个视图左上角之差。
|
### segment(`src/cadx/segment/`)
|
||||||
- `CeusCaseError`:只落在这一例上的失败,带失败的步号和原因;`SplitDetectionError`、`ContrastSideError` 是它的子类。脚本据此把这一例记进 `failed.csv`、接着跑下一例。
|
|
||||||
- 其余导出(`ViewRect` 与几何函数、`extract_color_prompt`、`select_prompt_frames` 等)是各步的组成部分,供第二阶段的模块和测试用。
|
- `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/`)
|
### build(`src/cadx/build/`)
|
||||||
|
|
||||||
|
|||||||
@@ -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 同步 |
|
| `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/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`:同一台机器,后者慢几百倍。
|
远程服务器走 `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
@@ -1,10 +1,23 @@
|
|||||||
"""ceus:CEUS 定量的全部算法和参数,纯计算,不引用其他 cadx 模块。
|
"""ceus:CEUS 定量的全部算法和参数,纯计算,不引用其他 cadx 模块。
|
||||||
|
|
||||||
目前有第 1、2 步(视频解码、分屏、找绿框、选提示帧、判断造影画面在哪一半)和两半之间的坐标换算;
|
对外两段接口:`prepare_segmentation` 做第 1–2 步(解码、分屏、定造影那一半、选提示帧),交出分割的
|
||||||
第 3 步以后随任务书 02 第二阶段加入。
|
输入;`quantify_case` 拿分割结果做第 4–9 步(轮廓、暗区、背景圆、TIC、拟合、关键帧、灌注指标)。
|
||||||
某一例不满足算法前提时抛 `CeusCaseError`(带失败的步号和原因)。
|
第 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 (
|
from cadx.ceus._config import (
|
||||||
AnchorArrivalConfig,
|
AnchorArrivalConfig,
|
||||||
AnchorFramesConfig,
|
AnchorFramesConfig,
|
||||||
@@ -25,6 +38,15 @@ from cadx.ceus._config import (
|
|||||||
ViewsConfig,
|
ViewsConfig,
|
||||||
)
|
)
|
||||||
from cadx.ceus._contrast_side import ContrastSide, ContrastSideError, locate_contrast_side
|
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._errors import CeusCaseError
|
||||||
from cadx.ceus._geometry import (
|
from cadx.ceus._geometry import (
|
||||||
ViewRect,
|
ViewRect,
|
||||||
@@ -49,7 +71,33 @@ from cadx.ceus._green_prompt import (
|
|||||||
prompt_color_mask,
|
prompt_color_mask,
|
||||||
)
|
)
|
||||||
from cadx.ceus._mapping import map_source_mask_to_target, target_polygons_to_source, view_offset
|
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._prompt_frames import PromptFrame, select_prompt_frames
|
||||||
|
from cadx.ceus._reference_region import select_reference_region
|
||||||
from cadx.ceus._split_screen import (
|
from cadx.ceus._split_screen import (
|
||||||
SplitDetectionError,
|
SplitDetectionError,
|
||||||
detect_split_x,
|
detect_split_x,
|
||||||
@@ -57,12 +105,54 @@ from cadx.ceus._split_screen import (
|
|||||||
split_views,
|
split_views,
|
||||||
view_rect,
|
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
|
from cadx.ceus._video import VideoFile
|
||||||
|
|
||||||
__all__ = [
|
__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",
|
"AnchorArrivalConfig",
|
||||||
|
"AnchorFrameLocation",
|
||||||
"AnchorFramesConfig",
|
"AnchorFramesConfig",
|
||||||
"AnchorLatePhaseConfig",
|
"AnchorLatePhaseConfig",
|
||||||
|
"BaselineResult",
|
||||||
"CeusCaseError",
|
"CeusCaseError",
|
||||||
"CeusConfig",
|
"CeusConfig",
|
||||||
"ColorPrompt",
|
"ColorPrompt",
|
||||||
@@ -72,42 +162,91 @@ __all__ = [
|
|||||||
"InteriorDarkRegionConfig",
|
"InteriorDarkRegionConfig",
|
||||||
"KeyframePositionConstraint",
|
"KeyframePositionConstraint",
|
||||||
"KeyframesConfig",
|
"KeyframesConfig",
|
||||||
|
"LognormalFitResult",
|
||||||
|
"LognormalParameters",
|
||||||
|
"LognormalPerfusionMetrics",
|
||||||
|
"NoValidContourError",
|
||||||
|
"OverlayConfig",
|
||||||
|
"OverlayLayers",
|
||||||
"PanSourceConfig",
|
"PanSourceConfig",
|
||||||
"PromptFrame",
|
"PromptFrame",
|
||||||
"PromptObservation",
|
"PromptObservation",
|
||||||
"PromptSourceConfig",
|
"PromptSourceConfig",
|
||||||
|
"QuantResult",
|
||||||
"ReferenceRegionConfig",
|
"ReferenceRegionConfig",
|
||||||
"RoiShapeConfig",
|
"RoiShapeConfig",
|
||||||
|
"SegmentationInput",
|
||||||
"SmoothingConfig",
|
"SmoothingConfig",
|
||||||
"SplitDetectionError",
|
"SplitDetectionError",
|
||||||
"TicAnalysisConfig",
|
"TicAnalysisConfig",
|
||||||
"TicBaselineConfig",
|
"TicBaselineConfig",
|
||||||
|
"TicCurveSeries",
|
||||||
|
"TicCurves",
|
||||||
"TicFittingConfig",
|
"TicFittingConfig",
|
||||||
|
"TicPlotMarker",
|
||||||
|
"TicResult",
|
||||||
|
"Trajectory",
|
||||||
"VideoFile",
|
"VideoFile",
|
||||||
"ViewRect",
|
"ViewRect",
|
||||||
"ViewsConfig",
|
"ViewsConfig",
|
||||||
|
"align_polygon_to_previous",
|
||||||
|
"apply_dark_morphology",
|
||||||
|
"apply_prompt_frame_position_constraints",
|
||||||
"collect_prompt_observations",
|
"collect_prompt_observations",
|
||||||
|
"compute_lognormal_perfusion_metrics",
|
||||||
|
"compute_tic_for_video",
|
||||||
"crop_frame",
|
"crop_frame",
|
||||||
|
"dark_candidate_mask",
|
||||||
|
"detect_baseline",
|
||||||
"detect_split_x",
|
"detect_split_x",
|
||||||
|
"draw_polygon_overlay",
|
||||||
|
"evaluate_lognormal",
|
||||||
|
"evaluate_lognormal_derivative",
|
||||||
"extract_color_prompt",
|
"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",
|
"local_rect_within",
|
||||||
|
"locate_anchor_frames",
|
||||||
"locate_contrast_side",
|
"locate_contrast_side",
|
||||||
"map_source_mask_to_target",
|
"map_source_mask_to_target",
|
||||||
|
"mask_center",
|
||||||
"mask_iou",
|
"mask_iou",
|
||||||
|
"masks_to_smoothed_polygons",
|
||||||
|
"mean_or_nan",
|
||||||
"odd_window",
|
"odd_window",
|
||||||
"optional_positive_int",
|
"optional_positive_int",
|
||||||
|
"pack_tic_curves",
|
||||||
|
"pack_trajectory",
|
||||||
"polygon_area",
|
"polygon_area",
|
||||||
"polygon_center",
|
"polygon_center",
|
||||||
"polygon_to_mask",
|
"polygon_to_mask",
|
||||||
"polygons_to_mask",
|
"polygons_to_mask",
|
||||||
|
"prepare_segmentation",
|
||||||
"prompt_color_mask",
|
"prompt_color_mask",
|
||||||
|
"quant_summary",
|
||||||
|
"quantify_case",
|
||||||
"rect_from_values",
|
"rect_from_values",
|
||||||
|
"render_case_overlay",
|
||||||
|
"render_overlay",
|
||||||
|
"render_tic_png",
|
||||||
"resample_closed_contour",
|
"resample_closed_contour",
|
||||||
"sample_frames_for_split",
|
"sample_frames_for_split",
|
||||||
|
"score_dark_candidate",
|
||||||
"select_prompt_frames",
|
"select_prompt_frames",
|
||||||
|
"select_reference_region",
|
||||||
|
"split_dark_components",
|
||||||
"split_views",
|
"split_views",
|
||||||
"target_polygons_to_source",
|
"target_polygons_to_source",
|
||||||
|
"tic_plot_markers",
|
||||||
"triangular_smooth",
|
"triangular_smooth",
|
||||||
|
"unpack_tic_curves",
|
||||||
|
"unpack_trajectory",
|
||||||
"view_offset",
|
"view_offset",
|
||||||
"view_rect",
|
"view_rect",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -211,6 +211,10 @@ class InteriorDarkRegionConfig(BaseModel):
|
|||||||
threshold_offset: int
|
threshold_offset: int
|
||||||
min_area: int
|
min_area: int
|
||||||
min_area_ratio: float
|
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
|
preserve_holes: bool
|
||||||
fill_holes: bool
|
fill_holes: bool
|
||||||
morphology_kernel: int
|
morphology_kernel: int
|
||||||
@@ -235,6 +239,25 @@ class InteriorDarkRegionConfig(BaseModel):
|
|||||||
raise ValueError(f"interior_dark_region.adaptive_block_size 必须 ≥3,收到 {validated}")
|
raise ValueError(f"interior_dark_region.adaptive_block_size 必须 ≥3,收到 {validated}")
|
||||||
return 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")
|
@field_validator("percentile")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _percentile_within_bounds(cls, value: float) -> float:
|
def _percentile_within_bounds(cls, value: float) -> float:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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], "洞多边形面积应显著小于外轮廓"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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。"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"}))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user