From 2d0404e67c61ac51e1d617beef92403f28e57e67 Mon Sep 17 00:00:00 2001 From: Jirka Borovec <6035284+Borda@users.noreply.github.com> Date: Wed, 4 Feb 2026 01:28:33 +0900 Subject: [PATCH] Extend docstrings to test cases for enhanced readability (#2130) * Extend docstrings to test cases for enhanced readability and consistency across testing modules * Extend test case docstrings across multiple modules for improved clarity and consistency * Refactor test case docstrings for clarity and consistency in `test_annotators.py` --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- tests/annotators/test_core.py | 46 ++++++++++++++++---- tests/dataset/test_core.py | 7 +++ tests/detection/test_core.py | 47 +++++++++++++++----- tests/draw/test_color.py | 15 +++++++ tests/geometry/test_core.py | 14 ++++++ tests/geometry/test_utils.py | 8 ++++ tests/key_points/test_annotators.py | 66 ++++++++++++++++++++++++++--- tests/metrics/test_detection.py | 24 ++++++++++- tests/utils/test_video.py | 62 +++++++++++++++++++++++++++ 9 files changed, 261 insertions(+), 28 deletions(-) diff --git a/tests/annotators/test_core.py b/tests/annotators/test_core.py index 233b15c5..778418e8 100644 --- a/tests/annotators/test_core.py +++ b/tests/annotators/test_core.py @@ -59,17 +59,33 @@ def gradient_image() -> np.ndarray: class TestBoxAnnotator: - """Tests for BoxAnnotator class""" + """ + Verify that BoxAnnotator correctly draws bounding boxes on an image. - def test_annotate_with_no_detections(self, test_image): - """Test that annotate method returns unmodified image when no detections""" + Ensures that `BoxAnnotator` correctly draws bounding boxes on an image, which is + essential for users to visualize detection results. + """ + + def test_annotate_with_no_detections(self, test_image: np.ndarray) -> None: + """ + Verify that annotation with no detections does not change the image. + + Scenario: Annotating an image with an empty set of detections. + Expected: The scene remains unchanged, ensuring no ghost boxes are drawn. + """ detections = Detections.empty() annotator = BoxAnnotator() result = annotator.annotate(scene=test_image.copy(), detections=detections) assert np.array_equal(test_image, result) - def test_annotate_with_single_detection(self, test_image): - """Test that annotate method correctly draws a single bounding box""" + def test_annotate_with_single_detection(self, test_image: np.ndarray) -> None: + """ + Verify that annotation with a single detection draws a bounding box. + + Scenario: Annotating an image with a single bounding box. + Expected: The scene is modified by drawing a box, allowing users to identify + a single detected object. + """ detections = _create_detections(xyxy=[[10, 10, 90, 90]], class_id=[0]) annotator = BoxAnnotator( color=Color.WHITE, thickness=2, color_lookup=ColorLookup.INDEX @@ -77,8 +93,14 @@ class TestBoxAnnotator: result = annotator.annotate(scene=test_image.copy(), detections=detections) assert_image_mostly_same(test_image, result, similarity_threshold=0.85) - def test_annotate_with_multiple_detections(self, test_image): - """Test that annotate method correctly draws multiple bounding boxes""" + def test_annotate_with_multiple_detections(self, test_image: np.ndarray) -> None: + """ + Verify that annotation with multiple detections draws all bounding boxes. + + Scenario: Annotating an image with multiple bounding boxes of different classes. + Expected: All boxes are drawn, enabling visualization of complex scenes with + multiple objects. + """ detections = _create_detections( xyxy=[[10, 10, 40, 40], [60, 60, 90, 90], [10, 60, 40, 90]], class_id=[0, 1, 2], @@ -89,8 +111,14 @@ class TestBoxAnnotator: result = annotator.annotate(scene=test_image.copy(), detections=detections) assert_image_mostly_same(test_image, result, similarity_threshold=0.85) - def test_annotate_with_numpy_color_lookup(self, test_image): - """Test that annotate works when color lookup is a NumPy array""" + def test_annotate_with_numpy_color_lookup(self, test_image: np.ndarray) -> None: + """ + Verify that annotation respects custom NumPy color lookup array. + + Scenario: Providing a custom NumPy array for color lookup instead of class IDs. + Expected: Annotator respects the custom mapping, giving users flexible control + over box colors (e.g., coloring by tracking ID or custom criteria). + """ detections = Detections( xyxy=np.array([[10, 10, 20, 20], [30, 30, 40, 40]], dtype=np.float32), confidence=np.array([0.38, 0.21], dtype=np.float32), diff --git a/tests/dataset/test_core.py b/tests/dataset/test_core.py index 3be0e9ea..5293ac9e 100644 --- a/tests/dataset/test_core.py +++ b/tests/dataset/test_core.py @@ -177,6 +177,13 @@ def test_dataset_merge( expected_result: DetectionDataset | None, exception: Exception, ) -> None: + """ + Verify that multiple DetectionDataset objects can be successfully merged. + + Ensures that multiple `DetectionDataset` objects can be merged into single dataset. + This is vital for users who need to combine data from different sources or + augment their datasets with additional labeled examples. + """ with exception: result = DetectionDataset.merge(dataset_list=dataset_list) assert result == expected_result diff --git a/tests/detection/test_core.py b/tests/detection/test_core.py index 935a734c..66b8ce2a 100644 --- a/tests/detection/test_core.py +++ b/tests/detection/test_core.py @@ -132,6 +132,8 @@ TEST_DET_DIFFERENT_METADATA = Detections( @pytest.mark.parametrize( ("detections", "index", "expected_result", "exception"), [ + # Scenario: Filter detections by class ID using a boolean mask. + # Expected: Only detections matching the class ID are retained. ( DETECTIONS, DETECTIONS.class_id == 0, @@ -139,7 +141,9 @@ TEST_DET_DIFFERENT_METADATA = Detections( xyxy=[[2254, 906, 2447, 1353]], confidence=[0.90538], class_id=[0] ), DoesNotRaise(), - ), # take only detections with class_id = 0 + ), + # Scenario: Filter detections by confidence score threshold. + # Expected: Only high-confidence detections are kept, filtering out noise. ( DETECTIONS, DETECTIONS.confidence > 0.5, @@ -153,7 +157,9 @@ TEST_DET_DIFFERENT_METADATA = Detections( class_id=[0, 56, 39], ), DoesNotRaise(), - ), # take only detections with confidence > 0.5 + ), + # Scenario: Select all detections using a full boolean mask. + # Expected: Result is identical to input. ( DETECTIONS, np.array( @@ -161,7 +167,9 @@ TEST_DET_DIFFERENT_METADATA = Detections( ), DETECTIONS, DoesNotRaise(), - ), # take all detections + ), + # Scenario: Select no detections using an empty boolean mask. + # Expected: An empty Detections object with correct shapes. ( DETECTIONS, np.array( @@ -174,7 +182,9 @@ TEST_DET_DIFFERENT_METADATA = Detections( class_id=np.array([], dtype=int), ), DoesNotRaise(), - ), # take no detections + ), + # Scenario: Select specific detections using a list of integer indices. + # Expected: Only requested indices are returned in specified order. ( DETECTIONS, [0, 2], @@ -184,7 +194,9 @@ TEST_DET_DIFFERENT_METADATA = Detections( class_id=[0, 39], ), DoesNotRaise(), - ), # take only first and third detection using List[int] index + ), + # Scenario: Select specific detections using a NumPy array of indices. + # Expected: Only requested indices are returned. ( DETECTIONS, np.array([0, 2]), @@ -194,7 +206,9 @@ TEST_DET_DIFFERENT_METADATA = Detections( class_id=[0, 39], ), DoesNotRaise(), - ), # take only first and third detection using np.ndarray index + ), + # Scenario: Select a single detection using an integer index. + # Expected: A Detections object containing only that element. ( DETECTIONS, 0, @@ -202,7 +216,9 @@ TEST_DET_DIFFERENT_METADATA = Detections( xyxy=[[2254, 906, 2447, 1353]], confidence=[0.90538], class_id=[0] ), DoesNotRaise(), - ), # take only first detection by index + ), + # Scenario: Select a range of detections using a slice. + # Expected: Detections within the slice range are returned. ( DETECTIONS, slice(1, 3), @@ -212,9 +228,11 @@ TEST_DET_DIFFERENT_METADATA = Detections( class_id=[56, 39], ), DoesNotRaise(), - ), # take only first detection by index slice (1, 3) - (DETECTIONS, 10, None, pytest.raises(IndexError)), # index out of range - (DETECTIONS, [0, 2, 10], None, pytest.raises(IndexError)), # index out of range + ), + # Scenario: Index out of range. + # Expected: IndexError is raised. + (DETECTIONS, 10, None, pytest.raises(IndexError)), + (DETECTIONS, [0, 2, 10], None, pytest.raises(IndexError)), (DETECTIONS, np.array([0, 2, 10]), None, pytest.raises(IndexError)), ( DETECTIONS, @@ -224,12 +242,14 @@ TEST_DET_DIFFERENT_METADATA = Detections( None, pytest.raises(IndexError), ), + # Scenario: Filter an empty Detections object. + # Expected: Returns an empty Detections object without crashing. ( Detections.empty(), np.isin(Detections.empty()["class_name"], ["cat", "dog"]), Detections.empty(), DoesNotRaise(), - ), # Filter an empty detections by specific class names + ), ], ) def test_getitem( @@ -238,6 +258,11 @@ def test_getitem( expected_result: Detections | None, exception: Exception, ) -> None: + """ + Ensures that `Detections.__getitem__` (indexing/slicing) works correctly for various + input types. This is a core feature that allows users to filter and manipulate + detection results easily. + """ with exception: result = detections[index] assert result == expected_result diff --git a/tests/draw/test_color.py b/tests/draw/test_color.py index 076e8537..69870c40 100644 --- a/tests/draw/test_color.py +++ b/tests/draw/test_color.py @@ -28,6 +28,14 @@ from supervision.draw.color import Color def test_color_from_hex( color_hex, expected_result: Color | None, exception: Exception ) -> None: + """ + Verify that Color.from_hex correctly parses various hex string formats. + + Scenario: Creating a `Color` object from various hex string formats (3-digit, + 6-digit, with/without # prefix). + Expected: Correct RGB values are parsed, and invalid hex strings raise `ValueError`. + This allows users to define colors using familiar web formats. + """ with exception: result = Color.from_hex(color_hex=color_hex) assert result == expected_result @@ -47,6 +55,13 @@ def test_color_from_hex( def test_color_as_hex( color: Color, expected_result: str | None, exception: Exception ) -> None: + """ + Verify that Color.as_hex correctly converts Color objects to hex strings. + + Scenario: Converting a `Color` object back to a hex string. + Expected: Correct 6-digit hex string with # prefix is returned, ensuring + round-trip consistency for color definitions. + """ with exception: result = color.as_hex() assert result == expected_result diff --git a/tests/geometry/test_core.py b/tests/geometry/test_core.py index 5500fb3c..0908949e 100644 --- a/tests/geometry/test_core.py +++ b/tests/geometry/test_core.py @@ -29,6 +29,13 @@ from supervision.geometry.core import Point, Vector def test_vector_cross_product( vector: Vector, point: Point, expected_result: float ) -> None: + """ + Verify that Vector.cross_product correctly calculates the scalar value. + + Scenario: Computing the cross product between a vector and a point. + Expected: Correct scalar value is returned, which is used to determine which side + of a line a point lies on (essential for line crossing counting). + """ result = vector.cross_product(point=point) assert result == expected_result @@ -55,5 +62,12 @@ def test_vector_cross_product( ], ) def test_vector_magnitude(vector: Vector, expected_result: float) -> None: + """ + Verify that Vector.magnitude correctly calculates Euclidean distance. + + Scenario: Calculating the magnitude (length) of a vector. + Expected: Correct Euclidean distance between start and end points is returned, + fundamental for various spatial calculations. + """ result = vector.magnitude assert result == expected_result diff --git a/tests/geometry/test_utils.py b/tests/geometry/test_utils.py index b4cbb95c..59b3975b 100644 --- a/tests/geometry/test_utils.py +++ b/tests/geometry/test_utils.py @@ -48,5 +48,13 @@ def generate_test_polygon(n: int) -> np.ndarray: ], ) def test_get_polygon_center(polygon: np.ndarray, expected_result: Point) -> None: + """ + Verify that get_polygon_center correctly calculates the centroid of a polygon. + + Scenario: Calculating the center point (centroid) of various polygons. + Expected: The returned `Point` correctly represents the average position of all + polygon vertices, which is used for placing labels or markers at the center + of detected objects. + """ result = get_polygon_center(polygon) assert result == expected_result diff --git a/tests/key_points/test_annotators.py b/tests/key_points/test_annotators.py index fcef9882..3ecc40ce 100644 --- a/tests/key_points/test_annotators.py +++ b/tests/key_points/test_annotators.py @@ -5,8 +5,20 @@ from tests.helpers import assert_image_mostly_same class TestVertexAnnotator: + """ + Verify that VertexAnnotator correctly draws keypoints on an image. + + Ensures that `VertexAnnotator` correctly draws keypoints (vertices) on an image, + which is essential for human pose estimation or similar tasks. + """ + def test_annotate_with_default_parameters(self, scene, sample_key_points): - """Test annotation with default parameters.""" + """ + Verify that VertexAnnotator correctly draws keypoints with default parameters. + + Scenario: Annotating a scene using default vertex parameters. + Expected: Scene is modified, showing keypoints at their detected locations. + """ annotator = sv.VertexAnnotator() result = annotator.annotate(scene=scene.copy(), key_points=sample_key_points) @@ -16,7 +28,13 @@ class TestVertexAnnotator: ) def test_annotate_with_custom_color_and_radius(self, scene, sample_key_points): - """Test annotation with custom color and radius.""" + """ + Verify that VertexAnnotator respects custom color and radius settings. + + Scenario: Annotating a scene with user-specified color and radius. + Expected: Scene is modified according to custom style, allowing users to + distinguish keypoints more clearly or match specific branding. + """ color = sv.Color.RED radius = 5 annotator = sv.VertexAnnotator(color=color, radius=radius) @@ -28,7 +46,12 @@ class TestVertexAnnotator: ) def test_annotate_empty_key_points(self, scene, empty_key_points): - """Test annotation with empty key points returns unchanged scene.""" + """ + Verify that VertexAnnotator handles empty keypoints without modifying the scene. + + Scenario: Annotating a scene with no key points detected. + Expected: Original scene is returned untouched, preventing phantom annotations. + """ annotator = sv.VertexAnnotator() result = annotator.annotate(scene=scene.copy(), key_points=empty_key_points) @@ -37,8 +60,20 @@ class TestVertexAnnotator: class TestEdgeAnnotator: + """ + Verify that EdgeAnnotator correctly draws skeleton edges between keypoints. + + Ensures that `EdgeAnnotator` correctly draws connections (edges) between keypoints, + forming skeletons that help users interpret spatial relationships. + """ + def test_annotate_with_default_parameters(self, scene, sample_key_points): - """Test annotation with default parameters using COCO skeleton.""" + """ + Verify correctly draw skeleton edges with default parameters. + + Scenario: Annotating a scene with default skeleton (e.g., COCO). + Expected: Skeleton edges are drawn between corresponding keypoints. + """ annotator = sv.EdgeAnnotator() result = annotator.annotate(scene=scene.copy(), key_points=sample_key_points) @@ -48,7 +83,13 @@ class TestEdgeAnnotator: ) def test_annotate_with_custom_edges(self, scene, sample_key_points): - """Test annotation with custom edge definitions.""" + """ + Verify that EdgeAnnotator respects custom-defined skeleton structures. + + Scenario: Annotating a scene with a custom-defined skeleton structure. + Expected: Only the specified connections are drawn, giving users flexibility + for non-standard keypoint models. + """ edges = [(1, 2), (2, 3)] annotator = sv.EdgeAnnotator(edges=edges) result = annotator.annotate(scene=scene.copy(), key_points=sample_key_points) @@ -59,7 +100,12 @@ class TestEdgeAnnotator: ) def test_annotate_empty_key_points(self, scene, empty_key_points): - """Test annotation with empty key points returns unchanged scene.""" + """ + Verify that EdgeAnnotator handles empty keypoints without modifying the scene. + + Scenario: Annotating a scene with no key points for edge drawing. + Expected: Original scene is returned untouched. + """ annotator = sv.EdgeAnnotator() result = annotator.annotate(scene=scene.copy(), key_points=empty_key_points) @@ -67,7 +113,13 @@ class TestEdgeAnnotator: assert np.array_equal(result, scene) def test_annotate_no_edges_found(self, scene): - """Test annotation when no matching skeleton is found.""" + """ + Verify returning unmodified scene when no known skeleton matches. + + Scenario: Key points provided don't match any known or provided skeleton. + Expected: No edges are drawn, and the original scene is returned, avoiding + incorrect or nonsensical connections. + """ # Key points with more vertices than any skeleton large_key_points = sv.KeyPoints( xy=np.array([[[i * 10, i * 10] for i in range(100)]], dtype=np.float32), diff --git a/tests/metrics/test_detection.py b/tests/metrics/test_detection.py index 9f395b8d..526b12f8 100644 --- a/tests/metrics/test_detection.py +++ b/tests/metrics/test_detection.py @@ -16,6 +16,14 @@ from tests.helpers import _create_detections, assert_almost_equal class TestDetectionMetrics: + """ + Verify that detection metrics are computed accurately. + + Ensures that detection metrics (mAP, Conf. Matrix, etc.) are computed accurately. + These metrics are the primary way users evaluate the performance of their models + within the `supervision` ecosystem. + """ + CLASSES = np.arange(80) NUM_CLASSES = len(CLASSES) @@ -188,7 +196,14 @@ class TestDetectionMetrics: with_confidence: bool, expected_result: np.ndarray | None, exception: Exception, - ): + ) -> None: + """ + Verify that Detections objects are correctly converted to NumPy tensors. + + Scenario: Converting Detections objects to NumPy tensors. + Expected: Tensors are correctly formatted for consumption by metric functions, + preserving coordinates, class IDs, and optionally confidence scores. + """ with exception: result = detections_to_tensor( detections=detections, with_confidence=with_confidence @@ -436,6 +451,13 @@ class TestDetectionMetrics: expected_result: float, exception: Exception, ) -> None: + """ + Verify that Average Precision is correctly calculated from PR curve points. + + Scenario: Computing Average Precision (AP) from PR curve points. + Expected: AP is correctly calculated using the area under the curve, which is + the standard for evaluating detection models (mAP components). + """ with exception: result = MeanAveragePrecision.compute_average_precision( recall=recall, precision=precision diff --git a/tests/utils/test_video.py b/tests/utils/test_video.py index 7c9e1d98..980d192c 100644 --- a/tests/utils/test_video.py +++ b/tests/utils/test_video.py @@ -20,6 +20,13 @@ def dummy_video_path(tmp_path): def test_process_video_exception_handling(dummy_video_path, tmp_path): + """ + Verify that process_video correctly propagates exceptions from the callback. + + Scenario: Processing a video where the callback raises an exception. + Expected: `process_video` should propagate the exception, allowing users to + handle errors during video processing. + """ target_path = str(tmp_path / "target.mp4") def callback_with_exception(frame, index): @@ -36,6 +43,13 @@ def test_process_video_exception_handling(dummy_video_path, tmp_path): def test_process_video_success(dummy_video_path, tmp_path): + """ + Verify successful video processing with a pass-through callback. + + Scenario: Successfully processing a video with a simple pass-through callback. + Expected: The video is processed without error and the target file is created, + verifying the core functionality of `process_video`. + """ target_path = str(tmp_path / "target_success.mp4") def callback_success(frame, index): @@ -50,6 +64,12 @@ def test_process_video_success(dummy_video_path, tmp_path): def test_process_video_exception_with_small_buffer(dummy_video_path, tmp_path): + """ + Verify that process_video handles exceptions correctly even with small buffers. + + Scenario: Processing a video with minimal buffering where an exception occurs. + Expected: The exception is still correctly propagated even with low memory settings. + """ target_path = str(tmp_path / "target_exception_small_buffer.mp4") def callback_with_exception(frame, index): @@ -68,6 +88,13 @@ def test_process_video_exception_with_small_buffer(dummy_video_path, tmp_path): def test_process_video_max_frames(dummy_video_path, tmp_path): + """ + Verify that process_video respects the max_frames parameter. + + Scenario: Processing only a limited number of frames using `max_frames`. + Expected: Only the specified number of frames are processed, which is useful for + quick testing or sampling. + """ target_path = str(tmp_path / "target_max_frames.mp4") processed_indices = [] @@ -87,6 +114,13 @@ def test_process_video_max_frames(dummy_video_path, tmp_path): def test_process_video_custom_params(dummy_video_path, tmp_path): + """ + Verify that process_video works correctly with custom performance parameters. + + Scenario: Processing video with custom prefetch and buffer parameters. + Expected: Video is processed successfully, showing that these performance-tuning + parameters are correctly handled. + """ target_path = str(tmp_path / "target_custom_params.mp4") def callback(frame, index): @@ -105,6 +139,13 @@ def test_process_video_custom_params(dummy_video_path, tmp_path): def test_video_info(dummy_video_path): + """ + Verify that VideoInfo correctly retrieves metadata from a video file. + + Scenario: Retrieving metadata from a video file using `VideoInfo`. + Expected: Correct width, height, fps, and frame count are returned, which is + essential for initializing annotators or calculating statistics. + """ video_info = VideoInfo.from_video_path(dummy_video_path) assert video_info.width == 640 assert video_info.height == 480 @@ -114,6 +155,13 @@ def test_video_info(dummy_video_path): def test_get_video_frames_generator(dummy_video_path): + """ + Verify that get_video_frames_generator yields frames with correct shapes. + + Scenario: Iterating over video frames using a generator. + Expected: All frames are yielded in order as NumPy arrays with correct shapes, + enabling frame-by-frame processing loops. + """ generator = get_video_frames_generator(dummy_video_path) frames = list(generator) assert len(frames) == 10 @@ -122,12 +170,26 @@ def test_get_video_frames_generator(dummy_video_path): def test_get_video_frames_generator_with_stride(dummy_video_path): + """ + Verify that get_video_frames_generator correctly handles the stride parameter. + + Scenario: Iterating over video frames with specified stride (e.g., every 2nd frame). + Expected: The generator correctly skips frames according to the stride, allowing + for faster processing of high-FPS videos. + """ generator = get_video_frames_generator(dummy_video_path, stride=2) frames = list(generator) assert len(frames) == 5 def test_get_video_frames_generator_with_start_end(dummy_video_path): + """ + Verify that get_video_frames_generator respects start and end frame indices. + + Scenario: Iterating over a specific range of video frames using `start` and `end`. + Expected: Only frames within the specified range are yielded, enabling targeted + analysis of video segments. + """ generator = get_video_frames_generator(dummy_video_path, start=2, end=5) frames = list(generator) assert len(frames) == 3