16031aae96
CPU tests Workflow / Testing (ubuntu-latest, 3.12) (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.13) (push) Failing after 0s
Mypy Type Check / Type Check (push) Failing after 0s
Docs/Test WorkFlow / Test docs build (push) Failing after 1s
PR Conflict Labeler / labeling (push) Failing after 1s
Dependency resolution / Resolve [tflite] extra — Python 3.12 (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.10) (push) Failing after 0s
Smoke Tests / try-all-models (ubuntu-latest, 3.13) (push) Failing after 1s
CPU tests Workflow / build-pkg (push) Failing after 1s
CPU tests Workflow / Testing (ubuntu-latest, 3.10) (push) Failing after 0s
CPU tests Workflow / Testing (ubuntu-latest, 3.11) (push) Failing after 0s
Smoke Tests / try-all-models (macos-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (macos-latest, 3.13) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.10) (push) Has been cancelled
Smoke Tests / try-all-models (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (macos-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.10) (push) Has been cancelled
CPU tests Workflow / Testing (windows-latest, 3.13) (push) Has been cancelled
CPU tests Workflow / testing-guardian (push) Has been cancelled
GPU tests Workflow / Testing (push) Has been cancelled
602 lines
23 KiB
Python
602 lines
23 KiB
Python
# ------------------------------------------------------------------------
|
|
# RF-DETR
|
|
# Copyright (c) 2025 Roboflow. All Rights Reserved.
|
|
# Licensed under the Apache License, Version 2.0 [see LICENSE for details]
|
|
# ------------------------------------------------------------------------
|
|
import json
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import supervision as sv
|
|
|
|
from rfdetr.datasets.synthetic import (
|
|
DEFAULT_SPLIT_RATIOS,
|
|
SYNTHETIC_SHAPES,
|
|
DatasetSplitRatios,
|
|
_calculate_polygon_area,
|
|
_write_coco_json,
|
|
calculate_boundary_overlap,
|
|
draw_synthetic_shape,
|
|
generate_coco_dataset,
|
|
generate_synthetic_sample,
|
|
)
|
|
|
|
|
|
class TestCalculateBoundaryOverlap:
|
|
@pytest.mark.parametrize(
|
|
"bbox,expected_overlap",
|
|
[
|
|
pytest.param(np.array([40.0, 40.0, 60.0, 60.0]), 0.0, id="fully_inside"),
|
|
pytest.param(np.array([-10.0, 40.0, 10.0, 60.0]), 0.5, id="half_outside_horizontally"),
|
|
pytest.param(np.array([110.0, 40.0, 130.0, 60.0]), 1.0, id="fully_outside"),
|
|
pytest.param(np.array([0.0, 0.0, 50.0, 50.0]), 0.0, id="exactly_at_boundary"),
|
|
pytest.param(np.array([50.0, 50.0, 100.0, 100.0]), 0.0, id="exactly_at_max_boundary"),
|
|
],
|
|
)
|
|
def test_overlap_values(self, bbox, expected_overlap):
|
|
result = calculate_boundary_overlap(bbox, img_size=100)
|
|
assert result == pytest.approx(expected_overlap)
|
|
|
|
|
|
class TestDrawSyntheticShape:
|
|
@pytest.mark.parametrize(
|
|
"shape,color",
|
|
[
|
|
pytest.param("square", sv.Color.RED, id="square_red"),
|
|
pytest.param("triangle", sv.Color.GREEN, id="triangle_green"),
|
|
pytest.param("circle", sv.Color.BLUE, id="circle_blue"),
|
|
],
|
|
)
|
|
def test_pixels_are_modified(self, shape, color):
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
img_modified, polygon = draw_synthetic_shape(img.copy(), shape, color, (50, 50), 20)
|
|
assert not np.array_equal(img, img_modified)
|
|
assert len(polygon) >= 6
|
|
assert len(polygon) % 2 == 0
|
|
|
|
@pytest.mark.parametrize(
|
|
"shape,cx,cy,size",
|
|
[
|
|
pytest.param("square", 50, 50, 20, id="square"),
|
|
pytest.param("triangle", 50, 50, 20, id="triangle"),
|
|
pytest.param("circle", 50, 50, 20, id="circle"),
|
|
],
|
|
)
|
|
def test_polygon_min_points(self, shape, cx, cy, size):
|
|
"""Returned polygon must have at least 3 points (6 values) for COCO."""
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
_, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (cx, cy), size)
|
|
assert len(poly) >= 6, f"{shape} polygon has fewer than 6 values: {poly}"
|
|
assert len(poly) % 2 == 0, f"{shape} polygon has an odd number of values: {poly}"
|
|
|
|
@pytest.mark.parametrize(
|
|
"shape,cx,cy,size,expected_n_coords",
|
|
[
|
|
pytest.param("square", 50, 50, 20, 8, id="square_4pts"),
|
|
pytest.param("triangle", 50, 50, 20, 6, id="triangle_3pts"),
|
|
pytest.param("circle", 50, 50, 20, 64, id="circle_32pts"),
|
|
],
|
|
)
|
|
def test_polygon_coord_count(self, shape, cx, cy, size, expected_n_coords):
|
|
"""Each shape must return the expected number of flat coordinate values."""
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
_, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (cx, cy), size)
|
|
assert len(poly) == expected_n_coords
|
|
|
|
def test_square_polygon_matches_bbox(self):
|
|
"""Square polygon corners must align with the drawn rectangle bounds."""
|
|
cx, cy, size = 60, 40, 30
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
_, poly = draw_synthetic_shape(img, "square", sv.Color.WHITE, (cx, cy), size)
|
|
hs = size // 2
|
|
expected = [
|
|
float(cx - hs),
|
|
float(cy - hs),
|
|
float(cx - hs + size),
|
|
float(cy - hs),
|
|
float(cx - hs + size),
|
|
float(cy - hs + size),
|
|
float(cx - hs),
|
|
float(cy - hs + size),
|
|
]
|
|
assert poly == pytest.approx(expected)
|
|
|
|
def test_unknown_shape_returns_empty_polygon(self):
|
|
"""An unrecognised shape name must return an empty polygon without crashing."""
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
_, poly = draw_synthetic_shape(img, "hexagon", sv.Color.WHITE, (50, 50), 20)
|
|
assert poly == []
|
|
|
|
|
|
class TestGenerateSyntheticSample:
|
|
@pytest.mark.parametrize(
|
|
"img_size,min_objects,max_objects,class_mode",
|
|
[
|
|
pytest.param(100, 1, 3, "shape", id="small_shape_mode"),
|
|
pytest.param(200, 2, 5, "color", id="medium_color_mode"),
|
|
pytest.param(100, 1, 1, "shape", id="single_object"),
|
|
pytest.param(100, 0, 0, "shape", id="zero_objects"),
|
|
],
|
|
)
|
|
def test_output_shape_and_detection_count(self, img_size, min_objects, max_objects, class_mode):
|
|
img, detections = generate_synthetic_sample(
|
|
img_size=img_size, min_objects=min_objects, max_objects=max_objects, class_mode=class_mode
|
|
)
|
|
assert img.shape == (img_size, img_size, 3)
|
|
assert min_objects <= len(detections) <= max_objects
|
|
assert hasattr(detections, "xyxy")
|
|
assert hasattr(detections, "class_id")
|
|
|
|
def test_polygon_data_present(self):
|
|
"""detections.data must contain a 'polygons' array with one entry per detection."""
|
|
_, detections = generate_synthetic_sample(img_size=100, min_objects=2, max_objects=4, class_mode="shape")
|
|
assert "polygons" in detections.data
|
|
assert len(detections.data["polygons"]) == len(detections)
|
|
|
|
def test_polygon_data_non_empty(self):
|
|
"""Each stored polygon must be a non-empty list of floats."""
|
|
_, detections = generate_synthetic_sample(img_size=100, min_objects=1, max_objects=3, class_mode="shape")
|
|
for poly in detections.data["polygons"]:
|
|
assert isinstance(poly, list)
|
|
assert len(poly) >= 6
|
|
|
|
def test_zero_objects_polygon_data(self):
|
|
"""With zero objects the polygon data array must be present but empty."""
|
|
_, detections = generate_synthetic_sample(img_size=100, min_objects=0, max_objects=0, class_mode="shape")
|
|
assert "polygons" in detections.data
|
|
assert len(detections.data["polygons"]) == 0
|
|
|
|
def test_polygon_bbox_consistency(self):
|
|
"""detections.xyxy must match the min/max of the corresponding polygon."""
|
|
_, detections = generate_synthetic_sample(img_size=200, min_objects=3, max_objects=5, class_mode="shape")
|
|
for i in range(len(detections)):
|
|
poly = detections.data["polygons"][i]
|
|
poly_array = np.asarray(poly, dtype=float).reshape(-1, 2)
|
|
expected_x_min = float(np.min(poly_array[:, 0]))
|
|
expected_y_min = float(np.min(poly_array[:, 1]))
|
|
expected_x_max = float(np.max(poly_array[:, 0]))
|
|
expected_y_max = float(np.max(poly_array[:, 1]))
|
|
x_min, y_min, x_max, y_max = detections.xyxy[i]
|
|
assert x_min == pytest.approx(expected_x_min), f"detection {i} x_min mismatch"
|
|
assert y_min == pytest.approx(expected_y_min), f"detection {i} y_min mismatch"
|
|
assert x_max == pytest.approx(expected_x_max), f"detection {i} x_max mismatch"
|
|
assert y_max == pytest.approx(expected_y_max), f"detection {i} y_max mismatch"
|
|
|
|
|
|
class TestGenerateCocoDataset:
|
|
@pytest.mark.parametrize(
|
|
"num_images,img_size,class_mode,split_ratios,expected_splits",
|
|
[
|
|
# Test with dictionary (legacy support)
|
|
pytest.param(
|
|
5,
|
|
100,
|
|
"shape",
|
|
{"train": 0.6, "val": 0.2, "test": 0.2},
|
|
["train", "val", "test"],
|
|
id="shape_mode_all_splits_dict",
|
|
),
|
|
pytest.param(
|
|
3,
|
|
64,
|
|
"color",
|
|
{"train": 0.5, "val": 0.5},
|
|
["train", "val"],
|
|
id="color_mode_two_splits_dict",
|
|
),
|
|
pytest.param(
|
|
2,
|
|
128,
|
|
"shape",
|
|
{"train": 1.0},
|
|
["train"],
|
|
id="single_split_only_dict",
|
|
),
|
|
# Test with DatasetSplitRatios dataclass
|
|
pytest.param(
|
|
4,
|
|
100,
|
|
"shape",
|
|
DatasetSplitRatios(train=0.7, val=0.2, test=0.1),
|
|
["train", "val", "test"],
|
|
id="split_ratios_dataclass",
|
|
),
|
|
pytest.param(
|
|
3,
|
|
64,
|
|
"color",
|
|
DatasetSplitRatios(train=0.8, val=0.2, test=0.0),
|
|
["train", "val"],
|
|
id="split_ratios_no_test",
|
|
),
|
|
# Test with tuple
|
|
pytest.param(
|
|
4,
|
|
100,
|
|
"shape",
|
|
(0.7, 0.2, 0.1),
|
|
["train", "val", "test"],
|
|
id="split_ratios_tuple_three",
|
|
),
|
|
pytest.param(
|
|
3,
|
|
64,
|
|
"color",
|
|
(0.8, 0.2),
|
|
["train", "val"],
|
|
id="split_ratios_tuple_two",
|
|
),
|
|
# Test with default
|
|
pytest.param(
|
|
10,
|
|
64,
|
|
"shape",
|
|
DEFAULT_SPLIT_RATIOS,
|
|
["train", "val", "test"],
|
|
id="split_ratios_default",
|
|
),
|
|
],
|
|
)
|
|
def test_splits_created(self, num_images, img_size, class_mode, split_ratios, expected_splits, tmp_path):
|
|
output_dir = tmp_path / "test_dataset"
|
|
generate_coco_dataset(
|
|
output_dir=str(output_dir),
|
|
num_images=num_images,
|
|
img_size=img_size,
|
|
class_mode=class_mode,
|
|
split_ratios=split_ratios,
|
|
)
|
|
|
|
assert output_dir.exists()
|
|
for split in expected_splits:
|
|
split_dir = output_dir / split
|
|
assert split_dir.exists()
|
|
assert (split_dir / "_annotations.coco.json").exists()
|
|
|
|
with open(split_dir / "_annotations.coco.json") as f:
|
|
data = json.load(f)
|
|
assert "images" in data
|
|
assert "annotations" in data
|
|
assert "categories" in data
|
|
for img_info in data["images"]:
|
|
assert (split_dir / img_info["file_name"]).exists()
|
|
|
|
@pytest.mark.parametrize(
|
|
"num_images,split_ratios",
|
|
[
|
|
pytest.param(10, (0.33, 0.33, 0.34), id="truncating_ratios"),
|
|
pytest.param(7, (0.7, 0.2, 0.1), id="standard_ratios"),
|
|
pytest.param(5, (0.8, 0.2), id="two_split"),
|
|
],
|
|
)
|
|
def test_split_image_count_equals_total(self, num_images, split_ratios, tmp_path):
|
|
"""Total images assigned across all splits must equal num_images."""
|
|
output_dir = tmp_path / "test_dataset"
|
|
generate_coco_dataset(
|
|
output_dir=str(output_dir),
|
|
num_images=num_images,
|
|
img_size=64,
|
|
class_mode="shape",
|
|
split_ratios=split_ratios,
|
|
)
|
|
total_images = 0
|
|
for split_dir in output_dir.iterdir():
|
|
ann_file = split_dir / "_annotations.coco.json"
|
|
if ann_file.exists():
|
|
with open(ann_file) as fh:
|
|
total_images += len(json.load(fh)["images"])
|
|
assert total_images == num_images
|
|
|
|
@pytest.mark.parametrize(
|
|
"split_ratios,error_message",
|
|
[
|
|
pytest.param(
|
|
(1.1, -0.1),
|
|
"Split ratios must be non-negative",
|
|
id="tuple_negative_ratio",
|
|
),
|
|
pytest.param(
|
|
{"train": 1.1, "val": -0.1},
|
|
"Split ratios must be non-negative",
|
|
id="dict_negative_ratio",
|
|
),
|
|
pytest.param(
|
|
(0.5, 0.3),
|
|
"Split ratios must sum to 1.0",
|
|
id="tuple_invalid_sum",
|
|
),
|
|
],
|
|
)
|
|
def test_invalid_split_ratios(self, split_ratios, error_message, tmp_path):
|
|
output_dir = tmp_path / "test_dataset"
|
|
with pytest.raises(ValueError, match=error_message):
|
|
generate_coco_dataset(
|
|
output_dir=str(output_dir),
|
|
num_images=5,
|
|
img_size=100,
|
|
class_mode="shape",
|
|
split_ratios=split_ratios,
|
|
)
|
|
|
|
|
|
class TestGenerateCocoDatasetWithSegmentation:
|
|
def test_write_coco_json_raises_when_polygons_key_missing(self, tmp_path):
|
|
"""with_segmentation=True must raise if detections.data has no 'polygons' key."""
|
|
annotations_path = tmp_path / "_annotations.coco.json"
|
|
detections = sv.Detections(
|
|
xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
|
|
class_id=np.array([0], dtype=int),
|
|
data={}, # intentionally no "polygons" key
|
|
)
|
|
with pytest.raises(ValueError, match="no 'polygons' found"):
|
|
_write_coco_json(
|
|
annotations_path=annotations_path,
|
|
classes=["shape"],
|
|
file_paths=["/tmp/synthetic.png"],
|
|
detections_list=[detections],
|
|
img_size=64,
|
|
with_segmentation=True,
|
|
)
|
|
|
|
def test_write_coco_json_raises_for_mismatched_inputs(self, tmp_path):
|
|
"""Mismatched file/detection list lengths must raise to avoid silent truncation."""
|
|
annotations_path = tmp_path / "_annotations.coco.json"
|
|
detections = sv.Detections(
|
|
xyxy=np.empty((0, 4), dtype=float),
|
|
class_id=np.empty((0,), dtype=int),
|
|
data={"polygons": np.empty(0, dtype=object)},
|
|
)
|
|
|
|
with pytest.raises(ValueError, match="file_paths and detections_list must have the same length"):
|
|
_write_coco_json(
|
|
annotations_path=annotations_path,
|
|
classes=["shape"],
|
|
file_paths=["/tmp/a.png", "/tmp/b.png"],
|
|
detections_list=[detections],
|
|
img_size=64,
|
|
)
|
|
|
|
def test_creates_files(self, tmp_path):
|
|
"""with_segmentation=True must create the same directory/file structure as the default."""
|
|
output_dir = tmp_path / "seg_dataset"
|
|
generate_coco_dataset(
|
|
output_dir=str(output_dir),
|
|
num_images=4,
|
|
img_size=64,
|
|
class_mode="shape",
|
|
split_ratios={"train": 0.75, "val": 0.25},
|
|
with_segmentation=True,
|
|
)
|
|
for split in ("train", "val"):
|
|
assert (output_dir / split / "_annotations.coco.json").exists()
|
|
|
|
def test_json_structure(self, tmp_path):
|
|
"""COCO JSON produced with segmentation must have the required top-level keys."""
|
|
output_dir = tmp_path / "seg_dataset"
|
|
generate_coco_dataset(
|
|
output_dir=str(output_dir),
|
|
num_images=4,
|
|
img_size=64,
|
|
class_mode="shape",
|
|
split_ratios={"train": 1.0},
|
|
with_segmentation=True,
|
|
)
|
|
with open(output_dir / "train" / "_annotations.coco.json") as fh:
|
|
data = json.load(fh)
|
|
assert "images" in data
|
|
assert "annotations" in data
|
|
assert "categories" in data
|
|
|
|
def test_has_polygon_field(self, tmp_path):
|
|
"""Every annotation must have a non-empty segmentation polygon."""
|
|
output_dir = tmp_path / "seg_dataset"
|
|
generate_coco_dataset(
|
|
output_dir=str(output_dir),
|
|
num_images=3,
|
|
img_size=64,
|
|
class_mode="shape",
|
|
min_objects=1,
|
|
max_objects=2,
|
|
split_ratios={"train": 1.0},
|
|
with_segmentation=True,
|
|
)
|
|
with open(output_dir / "train" / "_annotations.coco.json") as fh:
|
|
data = json.load(fh)
|
|
assert len(data["annotations"]) > 0, "Expected at least one annotation"
|
|
for ann in data["annotations"]:
|
|
assert "segmentation" in ann
|
|
assert isinstance(ann["segmentation"], list)
|
|
assert len(ann["segmentation"]) == 1, "Expected exactly one polygon per annotation"
|
|
assert len(ann["segmentation"][0]) >= 6, "Polygon must have at least 3 points"
|
|
|
|
def test_area_uses_polygon_when_segmentation_enabled(self, tmp_path):
|
|
"""COCO area must match polygon area when segmentation annotations are present."""
|
|
annotations_path = tmp_path / "_annotations.coco.json"
|
|
polygon_data = np.empty(1, dtype=object)
|
|
polygon_data[0] = [0.0, 0.0, 10.0, 0.0, 0.0, 10.0] # Right triangle area = 50
|
|
detections = sv.Detections(
|
|
xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
|
|
class_id=np.array([0], dtype=int),
|
|
data={"polygons": polygon_data},
|
|
)
|
|
|
|
_write_coco_json(
|
|
annotations_path=annotations_path,
|
|
classes=["shape"],
|
|
file_paths=["/tmp/synthetic.png"],
|
|
detections_list=[detections],
|
|
img_size=64,
|
|
with_segmentation=True,
|
|
)
|
|
|
|
with open(annotations_path) as fh:
|
|
data = json.load(fh)
|
|
|
|
assert len(data["annotations"]) == 1
|
|
assert data["annotations"][0]["area"] == pytest.approx(50.0)
|
|
|
|
def test_sparse_category_ids(self, tmp_path):
|
|
"""Category IDs must use sparse 1-based encoding (1, 3, 5, …)."""
|
|
output_dir = tmp_path / "seg_dataset"
|
|
generate_coco_dataset(
|
|
output_dir=str(output_dir),
|
|
num_images=4,
|
|
img_size=64,
|
|
class_mode="shape",
|
|
split_ratios={"train": 1.0},
|
|
with_segmentation=True,
|
|
)
|
|
with open(output_dir / "train" / "_annotations.coco.json") as fh:
|
|
data = json.load(fh)
|
|
cat_ids = {c["id"] for c in data["categories"]}
|
|
expected_ids = {idx * 2 + 1 for idx in range(len(SYNTHETIC_SHAPES))}
|
|
assert cat_ids == expected_ids
|
|
ann_cat_ids = {a["category_id"] for a in data["annotations"]}
|
|
assert ann_cat_ids.issubset(expected_ids)
|
|
|
|
def test_images_exist(self, tmp_path):
|
|
"""All images referenced in the JSON must exist on disk."""
|
|
output_dir = tmp_path / "seg_dataset"
|
|
generate_coco_dataset(
|
|
output_dir=str(output_dir),
|
|
num_images=3,
|
|
img_size=64,
|
|
class_mode="shape",
|
|
split_ratios={"train": 1.0},
|
|
with_segmentation=True,
|
|
)
|
|
split_dir = output_dir / "train"
|
|
with open(split_dir / "_annotations.coco.json") as fh:
|
|
data = json.load(fh)
|
|
for img_info in data["images"]:
|
|
assert (split_dir / img_info["file_name"]).exists()
|
|
|
|
def test_empty_polygon_falls_back_to_empty_segmentation(self, tmp_path):
|
|
"""An empty polygon entry silently falls back to ``segmentation=[]``.
|
|
|
|
The ``len(polygon_data) < len(detections)`` guard only checks array length, not contents. An element that is an
|
|
empty list passes the guard and takes the ``else`` branch producing ``segmentation=[]``. This test documents the
|
|
existing silent-fallback behaviour.
|
|
"""
|
|
annotations_path = tmp_path / "_annotations.coco.json"
|
|
polygon_data = np.empty(1, dtype=object)
|
|
polygon_data[0] = [] # empty polygon — passes length guard
|
|
detections = sv.Detections(
|
|
xyxy=np.array([[0.0, 0.0, 10.0, 10.0]], dtype=float),
|
|
class_id=np.array([0], dtype=int),
|
|
data={"polygons": polygon_data},
|
|
)
|
|
_write_coco_json(
|
|
annotations_path=annotations_path,
|
|
classes=["shape"],
|
|
file_paths=["/tmp/synthetic.png"],
|
|
detections_list=[detections],
|
|
img_size=64,
|
|
with_segmentation=True,
|
|
)
|
|
with open(annotations_path) as fh:
|
|
data = json.load(fh)
|
|
assert data["annotations"][0]["segmentation"] == []
|
|
|
|
|
|
class TestCalculatePolygonArea:
|
|
@pytest.mark.parametrize(
|
|
"polygon,expected_area",
|
|
[
|
|
pytest.param(
|
|
[0.0, 0.0, 10.0, 0.0, 0.0, 10.0],
|
|
50.0,
|
|
id="right_triangle",
|
|
),
|
|
pytest.param(
|
|
[0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0],
|
|
100.0,
|
|
id="unit_square_10x10",
|
|
),
|
|
pytest.param(
|
|
[0.0, 0.0, 5.0, 0.0, 10.0, 0.0],
|
|
0.0,
|
|
id="collinear_points_degenerate",
|
|
),
|
|
pytest.param(
|
|
[0.0, 0.0, 1.0, 1.0],
|
|
0.0,
|
|
id="fewer_than_3_points",
|
|
),
|
|
pytest.param(
|
|
[],
|
|
0.0,
|
|
id="empty_polygon",
|
|
),
|
|
],
|
|
)
|
|
def test_area(self, polygon, expected_area):
|
|
assert _calculate_polygon_area(polygon) == pytest.approx(expected_area)
|
|
|
|
|
|
class TestDrawSyntheticShapeEdgeCases:
|
|
def test_square_polygon_respects_half_size_and_image_bounds_for_odd_size(self):
|
|
"""For odd sizes, the square polygon should:
|
|
|
|
* Have all vertices within the image bounds.
|
|
* Be horizontally contained within ``cx ± size / 2``.
|
|
"""
|
|
cx, cy, size = 50, 50, 21
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
_, poly = draw_synthetic_shape(img, "square", sv.Color.WHITE, (cx, cy), size)
|
|
|
|
half_size = size / 2.0
|
|
xs = [poly[i] for i in range(0, len(poly), 2)]
|
|
ys = [poly[i] for i in range(1, len(poly), 2)]
|
|
|
|
# All vertices must be inside the image
|
|
assert min(xs) >= 0.0
|
|
assert max(xs) <= float(img.shape[1])
|
|
assert min(ys) >= 0.0
|
|
assert max(ys) <= float(img.shape[0])
|
|
|
|
# Horizontal extent should not exceed the intended half-size around cx
|
|
assert min(xs) >= cx - half_size - 1.0
|
|
assert max(xs) <= cx + half_size + 1.0
|
|
|
|
def test_triangle_vertices_within_half_size_and_image_bounds(self):
|
|
"""Triangle vertices should:
|
|
|
|
* Have all vertices within the image bounds.
|
|
* Be vertically contained within ``cy ± size / 2`` so the apex does not
|
|
extend beyond the intended half-size boundary.
|
|
"""
|
|
cx, cy, size = 50, 50, 20
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
_, poly = draw_synthetic_shape(img, "triangle", sv.Color.WHITE, (cx, cy), size)
|
|
|
|
half_size = size / 2.0
|
|
xs = [poly[i] for i in range(0, len(poly), 2)]
|
|
ys = [poly[i] for i in range(1, len(poly), 2)]
|
|
|
|
# All vertices must be inside the image
|
|
assert min(xs) >= 0.0
|
|
assert max(xs) <= float(img.shape[1])
|
|
assert min(ys) >= 0.0
|
|
assert max(ys) <= float(img.shape[0])
|
|
|
|
# Vertical extent should not exceed the intended half-size around cy
|
|
assert min(ys) >= cy - half_size - 1.0
|
|
assert max(ys) <= cy + half_size + 1.0
|
|
|
|
@pytest.mark.parametrize(
|
|
"shape,size,expected_n_coords",
|
|
[
|
|
pytest.param("square", 0, 8, id="square_size_0"),
|
|
pytest.param("square", 1, 8, id="square_size_1"),
|
|
pytest.param("circle", 0, 64, id="circle_size_0"),
|
|
pytest.param("circle", 1, 64, id="circle_size_1"),
|
|
],
|
|
)
|
|
def test_degenerate_size_returns_polygon_without_crashing(self, shape, size, expected_n_coords):
|
|
"""draw_synthetic_shape with size=0 or size=1 must not raise and must return the expected number of flat
|
|
coordinate values."""
|
|
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
|
_, poly = draw_synthetic_shape(img, shape, sv.Color.WHITE, (50, 50), size)
|
|
assert len(poly) == expected_n_coords
|