"""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