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
+328
View File
@@ -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"