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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 05:07:02 -04:00
parent 9a916c65a5
commit 150eb5f6ae
35 changed files with 10655 additions and 505 deletions
+607
View File
@@ -0,0 +1,607 @@
"""TIC 四曲线采样与基线(tic)单元测试(照搬旧项目 test_ceus_tic.py)。
旧标题:B9 TIC 四曲线 + 基线识别 + 平滑(tic.py)单元测试。
TIC(time-intensity curve,时间-强度曲线)刻画病灶亮度随时间变化。本模块在 source
视图上逐帧采四条灰度均值曲线——病灶整体、去掉暗区、紫色轮廓圈的邻近正常胰腺实质
(``ROI_pan``)、程序自动挑的背景圆——每条各自识别增强前基线、扣掉基线均值再平滑。
测试用真实 OpenCV 像素管线(非 mock)合成可控帧序列,钉死 ``tic.py`` 的真实行为:
- 掩膜构成:``valid_roi_all = roi & ~(绿 ∪ 紫)``、``valid_roi_perf = valid_roi_all &
~dark_union``、``valid_pan = 紫轮廓填充 & ~(绿 ∪ 紫)``、
``valid_background_circle = 背景圆 & ~(绿 ∪ 紫)``;
- ``mean_or_nan`` 空掩膜返回 NaN(不报错);
- 平滑链顺序 median→triangular,默认窗 5/15/1(以代码为准非 README);
- 基线识别:候选窗、稳定性缩窗、窗太短即无效(``mean`` / ``sd`` 为 ``None``);
- 撤掉合成零点之后,涨得最快的时刻落在真实陡升段而不是视频开头;
- 紫色检不到时 ``ROI_pan`` 整条 NaN,**不回退背景圆**;
- ``compute_tic_for_video`` 行集列名齐全,且本轮定量参数为 None。
"""
import math
import numpy as np
import pytest
from cadx.ceus import (
CeusConfig,
PanSourceConfig,
TicAnalysisConfig,
compute_tic_for_video,
detect_baseline,
mean_or_nan,
)
from cadx.ceus import _tic as tic_module # pyright: ignore[reportPrivateUsage]
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
_TIC_NO_DUPLICATES = _CONFIG.tic_analysis.model_copy(
update={
"fitting": _CONFIG.tic_analysis.fitting.model_copy(
update={"detect_duplicate_frames": False}
)
}
)
# ---------- 合成样本 ----------
_H, _W = 80, 120
# ROI:画面中央偏右的方块(左下留干净背景给背景圆 / 掩膜对照)。
_ROI_POLYGON = np.array(
[[60.0, 10.0], [110.0, 10.0], [110.0, 50.0], [60.0, 50.0]], dtype=np.float32
)
# 暗区:ROI 内部一小块(用于验证 roi_perf = valid_roi_all & ~dark)。
_DARK_POLYGON = np.array([[70.0, 20.0], [90.0, 20.0], [90.0, 40.0], [70.0, 40.0]], dtype=np.float32)
# 紫色(BGR):H≈145、S=255、V=255,落在 PanSourceConfig 默认阈 [125,60,60]~[165,255,255] 内。
_PURPLE_BGR = (255, 0, 212)
def _frame(roi_gray: int, dark_gray: int, ref_gray: int) -> np.ndarray:
"""合成一帧 source BGR:ROI 区灰度 roi_gray、其内暗块 dark_gray、左下背景块 ref_gray。
其余背景为 0(黑),避免被背景圆误采。灰度对 BGR 三通道相等填充,cvtColor 后即该灰度值。
"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[10:50, 60:110] = roi_gray
frame[20:40, 70:90] = dark_gray
frame[10:40, 5:35] = ref_gray # 左下背景块
return frame
def _background_circle_mask() -> np.ndarray:
"""背景圆掩膜(左下背景块的子区域),与 _frame 的 ref 区重叠。"""
mask = np.zeros((_H, _W), dtype=bool)
mask[15:35, 10:30] = True
return mask
def _build_inputs(
frame_count: int = 3,
) -> tuple[list[np.ndarray], list[np.ndarray], list[list[np.ndarray]], np.ndarray]:
"""构造逐帧 source 帧 / ROI 多边形 / 逐帧暗区多边形列表 + 背景圆 mask。
暗区入参对齐 B7 ``extract_dark_region_polygons`` 输出(``list[list[np.ndarray]]``:
外层帧对齐、内层每帧 0~N 个暗区多边形),此处每帧给单个暗块 ``[[_DARK_POLYGON]]``。
这批帧里**一个紫色像素都没有**,所以 ``ROI_pan`` 整条无效——这正是「检不到紫色
不回退背景圆」那条测试要的场景。
"""
frames = [_frame(roi_gray=120, dark_gray=20, ref_gray=60) for _ in range(frame_count)]
roi_polygons = [_ROI_POLYGON for _ in range(frame_count)]
dark_polygons_per_frame = [[_DARK_POLYGON] for _ in range(frame_count)]
return frames, roi_polygons, dark_polygons_per_frame, _background_circle_mask()
def _run(
frames: list[np.ndarray],
roi_polygons: list[np.ndarray],
dark_polygons_per_frame: list[list[np.ndarray]],
background_circle_mask: np.ndarray,
*,
fps: float = 2.0,
pan_cfg: PanSourceConfig | None = None,
tic_cfg: TicAnalysisConfig | None = None,
) -> tic_module.TicResult:
"""按默认配置跑一次 ``compute_tic_for_video``(省掉各测试里重复的样板)。"""
return compute_tic_for_video(
source_frames=frames,
roi_polygons=roi_polygons,
dark_polygons_per_frame=dark_polygons_per_frame,
reference_mask=background_circle_mask,
fps=fps,
prompt_cfg=_CONFIG.prompt_source,
pan_cfg=pan_cfg or _CONFIG.pan_source,
tic_cfg=tic_cfg or _TIC_NO_DUPLICATES,
frame_times_s=None,
)
def _column(result: tic_module.TicResult, key: str) -> np.ndarray:
"""取一列逐帧值。"""
return np.asarray([row[key] for row in result.rows], dtype=np.float32)
# ---------- mean_or_nan:NaN 语义 ----------
def test_mean_or_nan_empty_mask_returns_nan() -> None:
"""空掩膜(无 True 像素)返回 NaN,而非报错或 0。"""
gray = np.full((_H, _W), 120, dtype=np.uint8)
empty = np.zeros((_H, _W), dtype=bool)
result = mean_or_nan(gray, empty)
assert math.isnan(result)
def test_mean_or_nan_returns_masked_mean() -> None:
"""非空掩膜返回该掩膜内灰度均值。"""
gray = np.zeros((_H, _W), dtype=np.uint8)
gray[0:10, 0:10] = 200
mask = np.zeros((_H, _W), dtype=bool)
mask[0:10, 0:10] = True
assert mean_or_nan(gray, mask) == pytest.approx(200.0)
# ---------- detect_baseline:纯函数层 ----------
def test_detect_baseline_flat_prefix_gives_mean_and_sd() -> None:
"""开头两秒平坦时,基线窗取满首选时长,``B`` 与 ``σ0`` 从这一段算出来。"""
values = np.concatenate(
[
np.full(20, 40.0, dtype=np.float32),
np.linspace(40, 200, 40, dtype=np.float32),
]
)
baseline = detect_baseline(
values, np.arange(values.size) / 10.0, _CONFIG.tic_analysis.baseline, None
)
assert baseline.valid is True
assert baseline.frame_start == 0
assert baseline.frame_end == 20
assert baseline.mean == pytest.approx(40.0)
assert baseline.sd == pytest.approx(0.0)
def test_detect_baseline_ignores_nan_samples() -> None:
"""NaN 帧不算基线采样:既不进均值,也不被当成 0 把基线拉低。"""
values = np.full(20, 40.0, dtype=np.float32)
values[3] = np.nan
values[11] = np.nan
baseline = detect_baseline(
values, np.arange(values.size) / 10.0, _CONFIG.tic_analysis.baseline, None
)
assert baseline.valid is True
assert baseline.mean == pytest.approx(40.0)
def test_detect_baseline_all_nan_is_invalid() -> None:
"""整条都是 NaN 时拿不到基线(不是拿到一个由零个样本算出来的数)。"""
baseline = detect_baseline(
np.full(30, np.nan, dtype=np.float32),
np.arange(30) / 10.0,
_CONFIG.tic_analysis.baseline,
None,
)
assert baseline.valid is False
assert baseline.mean is None
assert baseline.sd is None
assert baseline.frame_start == -1
assert baseline.frame_end == -1
def test_detect_baseline_rejects_non_1d() -> None:
"""二维输入直接报错,不静默摊平。"""
with pytest.raises(ValueError):
detect_baseline(
np.zeros((3, 4), dtype=np.float32),
np.arange(12).reshape(3, 4),
_CONFIG.tic_analysis.baseline,
None,
)
def test_detect_baseline_rejects_non_increasing_timestamps() -> None:
"""时间戳不严格递增直接报错。"""
with pytest.raises(ValueError):
detect_baseline(
np.zeros(30, dtype=np.float32), np.zeros(30), _CONFIG.tic_analysis.baseline, None
)
# ---------- 撤掉合成零点之后:最大流入斜率不落视频开头 ----------
# ---------- 基线窗不足最短时长:两种不足 ----------
def _assert_baseline_unusable(result: tic_module.TicResult, expected_reason: str) -> None:
"""观察基线拿不到时保留截断标志,失败拟合不回填 processed。"""
baseline = result.baselines["roi_all"]
assert baseline.valid is False
assert baseline.mean is None
assert baseline.sd is None
assert result.early_truncation_flag is True
assert result.fits["roi_all"].failure_reason == expected_reason
assert np.isnan(_column(result, "roi_all_processed")).all()
def test_baseline_invalid_when_video_shorter_than_minimum() -> None:
"""视频太短(0.8 秒)凑不出 1 秒基线:不合成、不外推,标 early_truncation。"""
frame_count = 8
frames = [_frame(roi_gray=70, dark_gray=70, ref_gray=60) for _ in range(frame_count)]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[] for _ in range(frame_count)],
_background_circle_mask(),
fps=10.0,
)
_assert_baseline_unusable(result, "insufficient_valid_samples")
def test_baseline_invalid_when_enhancement_already_rising_at_frame_zero() -> None:
"""视频够长但录晚了、开头就在涨:稳定性检查把窗缩到不足 1 秒,同样判无效。"""
frame_count = 60
frames = [
_frame(roi_gray=40 + 2 * index, dark_gray=40 + 2 * index, ref_gray=60)
for index in range(frame_count)
]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[] for _ in range(frame_count)],
_background_circle_mask(),
fps=10.0,
)
_assert_baseline_unusable(result, "credible_peak_not_found")
# ---------- ROI_pan:检不到紫色不回退背景圆 ----------
def test_pan_curve_not_backfilled_from_background_circle() -> None:
"""一个紫色像素都没有时,``ROI_pan`` 整条 NaN,而背景圆那条照常算出来。
只断言「pan 无效」不够——一个「检不到就复制背景圆」的实现照样能让前三条过。
第四条(两条曲线不逐点相等)才是要害。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert result.reference_valid is False
pan_raw = _column(result, "roi_pan_raw")
background_raw = _column(result, "background_circle_raw")
assert np.isnan(pan_raw).all()
assert result.baselines["roi_pan"].valid is False
assert result.baselines["roi_pan"].mean is None
assert np.isfinite(background_raw).all()
assert not np.array_equal(pan_raw, background_raw, equal_nan=True)
def _frame_with_purple_contour(interior_gray: int) -> np.ndarray:
"""在基础帧的左下角画一圈紫色轮廓,圈内填 ``interior_gray``。
轮廓画成空心矩形(医生画的是一条线,不是一块面),所以填充后的参照区内部是灰度、
只有那圈线是紫色。轮廓外面也铺同一片灰度:``extract_color_prompt`` 的形态学膨胀会
让填充区比画上去的那圈线略大一点,外面若是纯黑就会把均值拉低——真实画面上轮廓外
也是组织,这里照着来。
"""
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60)
frame[46:80, 1:50] = interior_gray
frame[52:55, 5:46] = _PURPLE_BGR
frame[74:77, 5:46] = _PURPLE_BGR
frame[52:77, 5:8] = _PURPLE_BGR
frame[52:77, 43:46] = _PURPLE_BGR
return frame
def test_pan_curve_reads_purple_contour_interior() -> None:
"""画面上有紫色轮廓时,``ROI_pan`` 采的是轮廓**内部**的灰度,轮廓线本身被剔掉。"""
interior_gray = 90
frame_count = 3
frames = [_frame_with_purple_contour(interior_gray) for _ in range(frame_count)]
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
)
assert result.reference_valid is True
pan_raw = _column(result, "roi_pan_raw")
assert np.isfinite(pan_raw).all()
# 轮廓线(紫)被排除色掩膜剔掉,剩下的全是内部那块灰度。
np.testing.assert_allclose(pan_raw, interior_gray, atol=1.0)
assert result.rows[0]["valid_pan_px"] > 0
assert result.rows[0]["pan_area_px"] > result.rows[0]["valid_pan_px"]
def test_reference_valid_counts_sampled_frames_not_purple_area() -> None:
"""一整块实心紫色:紫色本身有面积,但排除色把它整块吞掉,一个像素都没采到。
界面上的实心紫色标记就长这样:``extract_color_prompt`` 填充出来的面积过得了
``min_area``,而排除掩膜把这块紫色连同外扩的一圈整个盖住,``valid_pan`` 是空的。
检出率若按填充面积数,台账上会写着「参照区没问题」,底下压着一条整段 NaN 的曲线,
而库上那条「``reference_valid`` 为假时 ``_pan`` 基线必须记 null」的 CHECK 只在
``reference_valid`` 为假时才管,此时一声不响。
``prompt_exclude_dilate`` 调到 5:检测那一路的形态学会把填充区外扩两像素,默认的
3 像素核只外扩一像素,边上会剩一圈采得到的像素——那不是本条要造的场景。
"""
frame_count = 3
frames: list[np.ndarray] = []
for _ in range(frame_count):
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60)
frame[50:78, 5:45] = _PURPLE_BGR # 实心紫块,不是一圈线
frames.append(frame)
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
tic_cfg=_CONFIG.tic_analysis.model_copy(update={"prompt_exclude_dilate": 5}),
)
assert result.rows[0]["pan_area_px"] > 0, "紫色轮廓本身确实有面积——前提没造对"
assert all(row["valid_pan_px"] == 0 for row in result.rows)
assert np.isnan(_column(result, "roi_pan_raw")).all()
assert result.reference_valid is False
def test_early_truncation_ignores_background_circle_baseline() -> None:
"""三条规范曲线基线都好、只有背景圆那条被缩短:质控位不许置起来。
背景圆是我们自己的整机增益漂移曲线,不喂任何规范字段(design/0030 决策四第三条),
而 ``early_truncation_flag`` 是规范 §14 的质控位。自动挑中的圆落在一块两秒内就开始
增强的组织上时,它的基线窗会被缩到不足最短时长——那和「这段视频被截短了」是两回事。
"""
frame_count = 6
frames: list[np.ndarray] = []
for index in range(frame_count):
frame = _frame(roi_gray=120, dark_gray=20, ref_gray=60 + 30 * index)
# 紫色轮廓(空心矩形)画在左下,和背景圆那块不重叠。
frame[46:80, 1:50] = 90
frame[52:55, 5:46] = _PURPLE_BGR
frame[74:77, 5:46] = _PURPLE_BGR
frame[52:77, 5:8] = _PURPLE_BGR
frame[52:77, 43:46] = _PURPLE_BGR
frames.append(frame)
result = _run(
frames,
[_ROI_POLYGON for _ in range(frame_count)],
[[_DARK_POLYGON] for _ in range(frame_count)],
_background_circle_mask(),
fps=2.0,
pan_cfg=_CONFIG.pan_source.model_copy(update={"min_area": 500}),
)
for name in ("roi_all", "roi_perf", "roi_pan"):
assert result.baselines[name].valid is True, f"{name} 的基线该是好的——前提没造对"
assert result.baselines["background_circle"].valid is False, (
"背景圆那条该拿不到基线——前提没造对"
)
assert result.early_truncation_flag is False
def test_purple_pixels_are_excluded_from_roi_statistics() -> None:
"""紫色像素落进 ROI 时同样被剔除(排除色 = 绿 ∪ 紫,不再只排绿)。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
baseline_result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
baseline_valid_px = baseline_result.rows[0]["valid_roi_all_px"]
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
frames[0][12:18, 65:105] = _PURPLE_BGR
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert result.rows[0]["valid_roi_all_px"] < baseline_valid_px
# ---------- 删掉的名字不许回来 ----------
def test_synthetic_zero_timepoint_names_are_gone() -> None:
"""合成零点那一套的名字全部不存在,行集里也不再有 ``frame_index=-1`` 的合成行。
守的是 design/0030 决策二那次删除。理由写在 ``tic.TicResult`` 的 docstring 上:
人造点到首个真实帧那一跳是全曲线最大斜率,最大流入斜率的时刻会被永远钉在视频开头。
"""
assert not hasattr(tic_module, "prepend_zero_timepoint")
assert not hasattr(tic_module, "_zero_timepoint_row")
assert "prepend_zero_timepoint" not in tic_module.__all__
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
assert len(result.rows) == len(frames)
for index, row in enumerate(result.rows):
assert row["frame_index"] == index
assert "is_zero_timepoint" not in row
# ---------- compute_tic_for_video:掩膜构成 + 列名 + 参数 None ----------
def test_compute_tic_curve_mask_composition() -> None:
"""掩膜构成正确:ROI 区灰度 120、去暗区后仍 120(暗块被剔)、背景圆 60。
暗块灰度 20 远低于 ROI 主体 120,若 roi_perf 未剔暗块,其均值会被拉低;
剔除后 roi_perf 应等于 ROI 主体灰度 120。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
# roi_all_raw 含暗块(120 主体混 20 暗块)→ 被拉低到 120 与 20 之间。
assert 20.0 < sample["roi_all_raw"] < 120.0
# 暗块(20)被剔后 roi_perf 应接近纯 ROI 主体 120(栅格边界残留少量暗像素,放宽),
# 且严格高于含暗的 roi_all(剔暗确实抬升了均值)。
assert sample["roi_perf_raw"] == pytest.approx(120.0, abs=8.0)
assert sample["roi_perf_raw"] > sample["roi_all_raw"]
assert sample["background_circle_raw"] == pytest.approx(60.0, abs=1.0)
# 去暗后有效像素数 < 含暗的 valid_roi_all 像素数。
assert sample["valid_roi_perf_px"] < sample["valid_roi_all_px"]
def test_compute_tic_prompt_excluded_from_masks() -> None:
"""绿框像素从掩膜中剔除:在 ROI 内画绿条后,valid_roi_all 像素数应减少。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
# 在 ROI 内画一条绿色(BGR 绿,HSV 落在默认阈内)。
frames[0][12:18, 65:105] = (0, 255, 0)
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
# roi 区栅格面积固定,但剔绿后 valid_roi_all 有效像素严格少于 roi_area。
assert sample["valid_roi_all_px"] < sample["roi_area_px"]
def test_compute_tic_row_column_names_complete() -> None:
"""行集含四曲线 raw/candidate/fitted/processed 与样本质控列。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = _run(frames, roi_polygons, dark_polygons_per_frame, background)
expected_columns = {
"frame_index",
"time_sec",
"duplicate_frame",
"roi_area_px",
"dark_area_px",
"pan_area_px",
"background_circle_area_px",
"valid_roi_all_px",
"valid_roi_perf_px",
"valid_pan_px",
"valid_background_circle_px",
"dark_component_count",
"perf_mask_stable",
}
for name in tic_module.CURVE_NAMES:
expected_columns |= {
f"{name}_raw",
f"{name}_candidate",
f"{name}_fitted",
f"{name}_processed",
}
for row in result.rows:
assert set(row.keys()) == expected_columns
def test_compute_tic_uses_explicit_presentation_timestamps() -> None:
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
times = [0.0, 0.11, 0.24]
result = compute_tic_for_video(
frames,
roi_polygons,
dark_polygons_per_frame,
background,
10.0,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_TIC_NO_DUPLICATES,
times,
)
np.testing.assert_allclose(_column(result, "time_sec"), times)
def test_compute_tic_marks_consecutive_duplicate_roi_frames() -> None:
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
result = compute_tic_for_video(
frames,
roi_polygons,
dark_polygons_per_frame,
background,
2.0,
_CONFIG.prompt_source,
_CONFIG.pan_source,
_CONFIG.tic_analysis,
None,
)
assert [row["duplicate_frame"] for row in result.rows] == [False, True, True]
def test_compute_tic_parameters_none_this_round() -> None:
"""本轮不算定量参数,``parameters`` 为 None。"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs()
assert _run(frames, roi_polygons, dark_polygons_per_frame, background).parameters is None
def test_compute_tic_background_circle_excludes_green_pixels() -> None:
"""证伪 ``valid_background = 背景圆 & ~exclude``:圆内画绿后剔绿,统计须随之变化。
在背景圆内填入一片绿色(HSV 落默认阈内)。若掩膜未剔绿,绿像素会计入
``valid_background_circle_px`` 且把 ``background_circle_raw`` 拉向绿灰度;
剔绿后两者均应改变:有效像素数严格少于圆面积,均值贴近纯背景灰度而非被绿污染。
"""
frames, roi_polygons, dark_polygons_per_frame, background = _build_inputs(frame_count=1)
# 背景圆掩膜为 [15:35, 10:30],背景块 ref_gray=60;在其内画一片绿。
frames[0][16:24, 12:28] = (0, 255, 0)
sample = _run(frames, roi_polygons, dark_polygons_per_frame, background).rows[0]
assert sample["valid_background_circle_px"] < sample["background_circle_area_px"]
# 均值未被绿(cvtColor 后灰度约 150)污染,仍贴近背景 60。
assert sample["background_circle_raw"] == pytest.approx(60.0, abs=2.0)
def test_compute_tic_dark_region_clipped_to_roi() -> None:
"""证伪 ``dark_union &= valid_roi_all``:越出 ROI 的暗区多边形,越界部分不计入统计。
给某帧一个跨越 ROI 右边界(x=110)外延的暗区多边形。若未收口到 ROI 内,
``dark_area_px`` 会含 ROI 外像素;收口后暗区面积应等于该暗块与 ROI 的交集。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=1)
# 暗块横跨 ROI 右界 x∈[100,140](ROI 右界 110),y∈[20,40] 落在 ROI 内 y 范围。
out_of_roi_dark = np.array(
[[100.0, 20.0], [140.0, 20.0], [140.0, 40.0], [100.0, 40.0]], dtype=np.float32
)
sample = _run(frames, roi_polygons, [[out_of_roi_dark]], background).rows[0]
# 暗块多边形宽 40,但仅 x∈[100,110] 落在 ROI 内(宽 10);收口后面积须 < 多边形整块。
full_dark_area = 40 * 20 # 多边形自身栅格面积上界
assert 0 < sample["dark_area_px"] < full_dark_area
# 收口后暗区不超过有效 ROI 像素数(不会从 ROI 外多剔像素)。
assert sample["dark_area_px"] <= sample["valid_roi_all_px"]
# roi_perf = valid_roi_all - 收口暗区,二者之差恰为收口后暗区面积。
assert sample["valid_roi_all_px"] - sample["valid_roi_perf_px"] == sample["dark_area_px"]
def test_compute_tic_dark_polygon_hole_not_filled_back() -> None:
"""证伪 even-odd 挖洞:暗区甜甜圈(外暗环 + 内亮洞)的洞不被计回暗区。
暗区多边形列表为 ``[外多边形, 洞多边形]``(B7 preserve_holes 扁平输出语义)。
若按「逐多边形栅格化再 OR」,洞会被当作独立暗块填回 → ``dark_area_px`` 含洞、
``roi_perf`` 偏小;正确的 even-odd 一次填充应把洞挖空。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=1)
# 外暗框 [65,15]-[105,45](全落 ROI x∈[60,110] / y∈[10,50] 内),内亮洞 [80,25]-[95,38]。
outer_dark = np.array(
[[65.0, 15.0], [105.0, 15.0], [105.0, 45.0], [65.0, 45.0]], dtype=np.float32
)
inner_hole = np.array(
[[80.0, 25.0], [95.0, 25.0], [95.0, 38.0], [80.0, 38.0]], dtype=np.float32
)
sample = _run(frames, roi_polygons, [[outer_dark, inner_hole]], background).rows[0]
outer_area = 40 * 30 # 外框栅格面积 1200
hole_area = 15 * 13 # 洞栅格面积 195
# 暗区不含洞:≈ 外框 − 洞(栅格边界放宽),严格小于把洞填回的「外框整块」。
assert sample["dark_area_px"] == pytest.approx(outer_area - hole_area, abs=120)
assert sample["dark_area_px"] < outer_area
# 洞内 ROI 像素仍算有效 ROI:roi_perf = valid_roi_all − 暗区(不含洞)。
assert sample["valid_roi_all_px"] - sample["valid_roi_perf_px"] == sample["dark_area_px"]
# 洞内像素确实回到 roi_perf:去暗后剩余像素 > 仅外框实心被剔时的剩余。
assert sample["valid_roi_perf_px"] > sample["valid_roi_all_px"] - outer_area
def test_compute_tic_dark_component_count_self_consistent() -> None:
"""暗块计数与暗区面积自洽:有暗块时计数=多边形数;面积为 0 时计数强制归 0。
第 0 帧给两个 ROI 内暗块 → count=2;第 1 帧暗块完全落在 ROI 外(收口后面积 0)
→ count 必须归 0(修复 area=0 却 count>0 的不自洽)。
"""
frames, roi_polygons, _, background = _build_inputs(frame_count=2)
dark_a = np.array([[65.0, 15.0], [78.0, 15.0], [78.0, 30.0], [65.0, 30.0]], dtype=np.float32)
dark_b = np.array([[82.0, 32.0], [95.0, 32.0], [95.0, 45.0], [82.0, 45.0]], dtype=np.float32)
# 完全落在 ROI(x∈[60,110])右侧之外,收口后面积为 0。
fully_outside = np.array(
[[115.0, 15.0], [119.0, 15.0], [119.0, 30.0], [115.0, 30.0]], dtype=np.float32
)
rows = _run(frames, roi_polygons, [[dark_a, dark_b], [fully_outside]], background).rows
# 第 0 帧:两个 ROI 内暗块 → 面积 > 0、计数 = 2。
assert rows[0]["dark_area_px"] > 0
assert rows[0]["dark_component_count"] == 2
# 第 1 帧:暗块全在 ROI 外 → 收口面积 0、计数强制归 0(自洽)。
assert rows[1]["dark_area_px"] == 0
assert rows[1]["dark_component_count"] == 0