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