"""暗区识别(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], "洞多边形面积应显著小于外轮廓"