- 前端按 polygonRingCounts 维护外圈/内洞 ring 分组,中空 mask 在调整多边形时显示内洞顶点和插点手柄。 - 保存与回显标注时将中空结构拆分为 mask_data.polygons 和 mask_data.holes,导入/普通 mask 共享同一编辑体验。 - 自动传播 seed 携带 holes,SAM 2 seed 栅格化时扣除内洞,避免中空 mask 以实心形式传播。 - 传播结果轮廓提取改为保留层级内洞,并在同步传播和 Celery 传播落库时写回 holes 与 hasHoles。 - 传播 seed 签名纳入 holes,并加固保存结果时 holes 与原始 polygon 索引对齐。 - 补充前端保存/回显、Canvas 内洞编辑和后端 SAM 2 hole 处理测试。 - 更新 AGENTS、接口契约、需求冻结、设计冻结和测试计划文档,移除中空结构未实现的旧描述。
105 lines
3.2 KiB
Python
105 lines
3.2 KiB
Python
import numpy as np
|
|
|
|
from services.sam2_engine import DEFAULT_SAM2_MODEL_ID, SAM2Engine
|
|
|
|
|
|
class _FakePredictor:
|
|
def __init__(self, masks, scores):
|
|
self.masks = masks
|
|
self.scores = scores
|
|
self.calls = []
|
|
|
|
def set_image(self, _image):
|
|
pass
|
|
|
|
def predict(self, **kwargs):
|
|
self.calls.append(kwargs)
|
|
return self.masks, self.scores, None
|
|
|
|
|
|
def _mask(offset=0):
|
|
mask = np.zeros((32, 32), dtype=np.uint8)
|
|
mask[4 + offset:20 + offset, 5 + offset:22 + offset] = 1
|
|
return mask
|
|
|
|
|
|
def _ready_engine(monkeypatch, predictor):
|
|
monkeypatch.setattr("services.sam2_engine.SAM2_AVAILABLE", True)
|
|
engine = SAM2Engine()
|
|
engine._model_loaded[DEFAULT_SAM2_MODEL_ID] = True
|
|
engine._predictors[DEFAULT_SAM2_MODEL_ID] = predictor
|
|
return engine
|
|
|
|
|
|
def test_sam2_point_prediction_requests_single_best_mask(monkeypatch):
|
|
predictor = _FakePredictor(
|
|
np.array([_mask()], dtype=np.uint8),
|
|
np.array([0.92], dtype=np.float32),
|
|
)
|
|
engine = _ready_engine(monkeypatch, predictor)
|
|
|
|
polygons, scores = engine.predict_points(
|
|
DEFAULT_SAM2_MODEL_ID,
|
|
np.zeros((32, 32, 3), dtype=np.uint8),
|
|
[[0.5, 0.5]],
|
|
[1],
|
|
)
|
|
|
|
assert predictor.calls[0]["multimask_output"] is False
|
|
assert len(polygons) == 1
|
|
assert scores == [0.9200000166893005]
|
|
|
|
|
|
def test_sam2_auto_prediction_keeps_single_best_mask(monkeypatch):
|
|
predictor = _FakePredictor(
|
|
np.array([_mask(0), _mask(2), _mask(4)], dtype=np.uint8),
|
|
np.array([0.8, 0.7, 0.6], dtype=np.float32),
|
|
)
|
|
engine = _ready_engine(monkeypatch, predictor)
|
|
|
|
polygons, scores = engine.predict_auto(DEFAULT_SAM2_MODEL_ID, np.zeros((32, 32, 3), dtype=np.uint8))
|
|
|
|
assert predictor.calls[0]["multimask_output"] is False
|
|
assert len(polygons) == 1
|
|
assert scores == [0.800000011920929]
|
|
|
|
|
|
def test_sam2_status_exposes_selectable_variants(monkeypatch, tmp_path):
|
|
checkpoint = tmp_path / "sam2.1_hiera_small.pt"
|
|
checkpoint.write_bytes(b"model")
|
|
monkeypatch.setattr("services.sam2_engine.settings.sam_model_path", str(tmp_path / "sam2.1_hiera_tiny.pt"))
|
|
engine = SAM2Engine()
|
|
|
|
status = engine.status("sam2.1_hiera_small")
|
|
|
|
assert engine.normalize_model_id("sam2") == DEFAULT_SAM2_MODEL_ID
|
|
assert "sam2.1_hiera_small" in engine.variant_ids()
|
|
assert status["id"] == "sam2.1_hiera_small"
|
|
assert status["label"] == "SAM 2.1 Small"
|
|
assert status["checkpoint_exists"] is True
|
|
assert status["checkpoint_path"].endswith("sam2.1_hiera_small.pt")
|
|
|
|
|
|
def test_sam2_seed_mask_subtracts_holes():
|
|
mask = SAM2Engine._polygons_to_mask(
|
|
polygons=[[[0.1, 0.1], [0.9, 0.1], [0.9, 0.9], [0.1, 0.9]]],
|
|
width=100,
|
|
height=100,
|
|
holes_by_polygon=[[[[0.4, 0.4], [0.6, 0.4], [0.6, 0.6], [0.4, 0.6]]]],
|
|
)
|
|
|
|
assert bool(mask[20, 20]) is True
|
|
assert bool(mask[50, 50]) is False
|
|
|
|
|
|
def test_sam2_mask_to_polygon_data_preserves_holes():
|
|
mask = np.zeros((100, 100), dtype=np.uint8)
|
|
mask[10:90, 10:90] = 1
|
|
mask[40:60, 40:60] = 0
|
|
|
|
polygons, holes = SAM2Engine._mask_to_polygon_data(mask)
|
|
|
|
assert len(polygons) == 1
|
|
assert len(holes) == 1
|
|
assert len(holes[0]) == 1
|