Files
CADx-4/tests/test_ceus_dark_region.py
T
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

309 lines
12 KiB
Python
Raw 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.
"""暗区识别(dark_region)单元测试(照搬旧项目 test_ceus_dark_region.py)。
旧标题:B7 暗区识别(dark_region)单元测试。
合成"亮 ROI 内嵌暗斑"图像钉死六个 port 的真实行为,并核对评分公式的三个硬编码
常量(64.0 / 4.0 / 2.0)与时间一致性 IoU。测试用真实 OpenCV 像素管线(非 mock),
覆盖:otsu 检出暗斑、auto_ensemble 择优、min_area/min_area_ratio 过滤碎片、
exclude_prompt_color 剔绿、enabled=False 返回空且保留帧对齐、逐帧轮廓重采样 64 点。
"""
import numpy as np
from cadx.ceus import (
CeusConfig,
InteriorDarkRegionConfig,
apply_dark_morphology,
dark_candidate_mask,
extract_dark_region_masks,
extract_dark_region_polygons,
score_dark_candidate,
split_dark_components,
)
from conftest import load_ceus_section
# 配置不设默认值:旧测试靠默认值构造的配置,改为在 config/ceus.yaml 的取值上 model_copy。
_CONFIG = CeusConfig.model_validate(load_ceus_section())
# 剔绿框色用的 HSV 阈值:旧代码没传时回退的写死阈值与 prompt_source 相同,这里显式传入。
_PROMPT = _CONFIG.prompt_source
def _dark_cfg(**overrides: object) -> InteriorDarkRegionConfig:
"""ceus.yaml 的暗区段,按需改几项(代替旧的 `InteriorDarkRegionConfig(...)`)。"""
return _CONFIG.interior_dark_region.model_copy(update=overrides)
# ---------- 合成样本 ----------
_H, _W = 120, 160
# 整幅 ROI 多边形:占据画面中央的大矩形。
_ROI_POLYGON = np.array(
[[20.0, 20.0], [140.0, 20.0], [140.0, 100.0], [20.0, 100.0]], dtype=np.float32
)
# ROI 内的暗斑位置(一个明确的低灰度方块)。
_DARK_BOX = (slice(45, 75), slice(60, 100)) # (y, x),30×40=1200 px
def _bright_roi_with_dark_box() -> np.ndarray:
"""构造一帧 BGR:全黑画布上铺亮 ROI(灰度 200),其内嵌暗斑(灰度 40)。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
# 亮 ROI 区
frame[20:100, 20:140] = 200
# 暗斑
frame[_DARK_BOX] = 40
return frame
def _roi_mask() -> np.ndarray:
from cadx.ceus import polygon_to_mask
return polygon_to_mask(_ROI_POLYGON, _H, _W)
def _gray(frame: np.ndarray) -> np.ndarray:
import cv2
return cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
# ---------- dark_candidate_mask / split / morphology ----------
def test_otsu_candidate_detects_dark_box() -> None:
"""otsu 法应在 ROI 内检出暗斑(暗斑像素被标记,亮区不被标记)。"""
cfg = _dark_cfg()
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
dark = dark_candidate_mask(gray, sample, "otsu", cfg)
assert dark.dtype == bool
# 暗斑中心应被标记
assert dark[60, 80]
# 亮区中心不应被标记
assert not dark[30, 30]
# 暗斑覆盖率应显著(形态学后仍是大块)
assert dark.sum() > 500
def test_split_components_filters_small_fragments() -> None:
"""min_area / min_area_ratio 联合过滤:组件阈 = max(min_area, ROI面积×ratio)。"""
sample = _roi_mask()
roi_area = float(sample.sum())
# 造一个 ~1200px 暗块 + 一个 ~16px 碎片
dark = np.zeros((_H, _W), dtype=bool)
dark[45:75, 60:100] = True # 1200 px
dark[10:14, 10:14] = True # 16 px 碎片(且在 ROI 外,会被 roi_mask 裁掉)
dark[50:54, 30:34] = True # 16 px 碎片(ROI 内)
cfg = _dark_cfg(min_area=300, min_area_ratio=0.015)
# 阈值 = max(300, roi_area*0.015)
threshold = max(300.0, roi_area * 0.015)
assert threshold >= 300.0
components = split_dark_components(dark, sample, cfg)
# 只剩大暗块
assert len(components) == 1
assert components[0].sum() >= 300
def test_morphology_intersects_sample_mask() -> None:
"""形态学结果须与 sample_mask 取交(不外溢 ROI)。"""
cfg = _dark_cfg(morphology_kernel=5)
sample = _roi_mask()
dark = np.zeros((_H, _W), dtype=bool)
dark[45:75, 60:100] = True
dark[0:5, 0:5] = True # ROI 外的块
out = apply_dark_morphology(dark, sample, cfg)
assert not out[2, 2] # ROI 外被裁掉
assert (out & ~sample).sum() == 0
# ---------- score_dark_candidate(三硬编码常量) ----------
def test_score_contrast_constant_64() -> None:
"""明暗对比项 = max(0, (median_bright - median_dark) / 64.0)。
用无碎片惩罚 + 无时间项 + None preferred_range 的配置隔离对比项贡献。
"""
cfg = _dark_cfg(fragment_penalty_weight=0.0, temporal_consistency_weight=0.0)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
component = np.zeros((_H, _W), dtype=bool)
component[_DARK_BOX] = True
score = score_dark_candidate(gray, sample, [component], None, cfg)
union = component & sample
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
area_score = min(area_ratio, 1.0 - area_ratio) * 2.0
expected = contrast + area_score
assert score == expected
def test_score_empty_components_returns_sentinel() -> None:
"""空组件列表返回极小值哨兵(择优时永远不会被选)。"""
cfg = _dark_cfg()
gray = _gray(_bright_roi_with_dark_box())
sample = _roi_mask()
assert score_dark_candidate(gray, sample, [], None, cfg) == -1e9
def test_score_fragment_penalty_and_temporal_iou() -> None:
"""碎片惩罚 = w×max(0, 块数−1);时间一致性 = temporal_weight × IoU(union, prev)。"""
cfg = _dark_cfg(fragment_penalty_weight=0.08, temporal_consistency_weight=0.35)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
comp_a = np.zeros((_H, _W), dtype=bool)
comp_a[45:75, 60:80] = True
comp_b = np.zeros((_H, _W), dtype=bool)
comp_b[45:75, 80:100] = True
components = [comp_a, comp_b]
union = (comp_a | comp_b) & sample
prev = comp_a.copy() # 上一帧暗部 = comp_a
score = score_dark_candidate(gray, sample, components, prev, cfg)
from cadx.ceus import mask_iou
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
area_score = min(area_ratio, 1.0 - area_ratio) * 2.0
fragment_penalty = 0.08 * 1 # 2 块 → max(0, 2-1)=1
temporal = mask_iou(union, prev) * 0.35
expected = contrast + area_score + temporal - fragment_penalty
assert abs(score - expected) < 1e-9
def test_score_preferred_range_slope_4() -> None:
"""preferred_area_ratio_range 命中时 area_score=1,否则 max(0, 1 - dist×4.0)。"""
# 这个键旧 schema 里没有(旧测试用 object.__setattr__ 注入),现在是配置字段。
cfg = _dark_cfg(
fragment_penalty_weight=0.0,
temporal_consistency_weight=0.0,
preferred_area_ratio_range=[0.001, 0.005],
)
frame = _bright_roi_with_dark_box()
gray = _gray(frame)
sample = _roi_mask()
component = np.zeros((_H, _W), dtype=bool)
component[_DARK_BOX] = True # 1200/roi_area
score = score_dark_candidate(gray, sample, [component], None, cfg)
union = component & sample
area_ratio = float(union.sum()) / max(1.0, float(sample.sum()))
median_bright = float(np.median(gray[(~union) & sample]))
median_dark = float(np.median(gray[union & sample]))
contrast = max(0.0, (median_bright - median_dark) / 64.0)
low, high = 0.001, 0.005
if low <= area_ratio <= high:
area_score = 1.0
else:
nearest = low if area_ratio < low else high
area_score = max(0.0, 1.0 - abs(area_ratio - nearest) * 4.0)
assert abs(score - (contrast + area_score)) < 1e-9
# ---------- extract_dark_region_masks ----------
def test_auto_ensemble_selects_dark_box() -> None:
"""auto_ensemble 跑 candidate_methods 择优,应选出覆盖暗斑的暗块。"""
cfg = _dark_cfg(threshold_method="auto_ensemble")
frame = _bright_roi_with_dark_box()
masks, union = extract_dark_region_masks(frame, _ROI_POLYGON, cfg, None, _PROMPT)
assert masks, "auto_ensemble 应检出暗块"
assert union is not None
# 暗斑中心被选中暗块覆盖
assert union[60, 80]
# 不外溢 ROI
assert (union & ~_roi_mask()).sum() == 0
def test_exclude_prompt_color_removes_green() -> None:
"""exclude_prompt_color=True 时 ROI 内绿像素从分析中剔除(不计入暗 / 亮统计)。"""
frame = _bright_roi_with_dark_box()
# 在 ROI 内画一块纯绿(BGR),它在 HSV 绿区间内,灰度也偏低
frame[80:95, 30:60] = (0, 255, 0)
cfg_on = _dark_cfg(exclude_prompt_color=True)
_masks_on, union_on = extract_dark_region_masks(frame, _ROI_POLYGON, cfg_on, None, _PROMPT)
# 绿块不应进入选中暗块
assert union_on is not None
assert not union_on[87, 45], "绿像素应被剔除,不计入暗块"
def test_disabled_returns_empty_polys_but_keeps_alignment() -> None:
"""enabled=False:每帧返回空多边形列表,但帧数与输入对齐(§5 不变量)。"""
cfg = _dark_cfg(enabled=False)
frames = [_bright_roi_with_dark_box() for _ in range(3)]
polys = [_ROI_POLYGON for _ in range(3)]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 3
assert all(frame_polys == [] for frame_polys in result)
def test_polygons_resampled_to_64_points_and_aligned() -> None:
"""逐帧暗块轮廓重采样到 contour_points=64 点;帧对齐保留(N 帧进 N 帧出)。"""
cfg = _dark_cfg(contour_points=64)
frames = [_bright_roi_with_dark_box() for _ in range(4)]
polys = [_ROI_POLYGON for _ in range(4)]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 4
# 每帧至少一个暗块多边形(暗斑稳定)
for frame_polys in result:
assert frame_polys, "每帧应检出暗斑多边形"
for poly in frame_polys:
assert poly.shape == (64, 2)
assert poly.dtype == np.float32
def _bright_roi_with_donut_dark() -> np.ndarray:
"""构造一帧 BGR:亮 ROI(200)内嵌暗斑(40),暗斑中心再挖一个亮洞(200)→ 甜甜圈暗区。"""
frame = np.zeros((_H, _W, 3), dtype=np.uint8)
frame[20:100, 20:140] = 200 # 亮 ROI
frame[45:75, 60:100] = 40 # 暗斑(1200 px)
frame[55:65, 72:88] = 200 # 中心亮洞(10×16=160 px)→ 暗区成环
return frame
def test_preserve_holes_keeps_hole_contour() -> None:
"""preserve_holes=True:甜甜圈暗区的多边形列表须含外轮廓 + 洞轮廓(≥2 个)。
回归 Codex 发现的 Critical——洞由 split_dark_components 挖回 mask,
_significant_contours 须沿 hierarchy 追加子轮廓(参考 P:685-692),否则洞被丢弃。
"""
cfg = _dark_cfg(preserve_holes=True, contour_points=64)
frames = [_bright_roi_with_donut_dark()]
polys = [_ROI_POLYGON]
result = extract_dark_region_polygons(frames, polys, cfg, _PROMPT)
assert len(result) == 1
frame_polys = result[0]
# 外环 + 中心洞 → 至少两个多边形
assert len(frame_polys) >= 2, f"应含外轮廓 + 洞,实得 {len(frame_polys)} 个"
# 每个多边形均重采样到 64 点
assert all(p.shape == (64, 2) for p in frame_polys)
# 存在一个明显小于外环的多边形(洞)——按面积区分
from cadx.ceus import polygon_area
areas = sorted(polygon_area(p) for p in frame_polys)
assert areas[0] < areas[-1], "洞多边形面积应显著小于外轮廓"