150eb5f6ae
- 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>
608 lines
27 KiB
Python
608 lines
27 KiB
Python
"""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
|