Files
CADx-4/tests/test_ceus_tic.py
iomgaa 150eb5f6ae 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>
2026-10-16 05:07:02 -04:00

608 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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