fix(sinks): slice list and tuple custom_data values per row (#2216)
* test: add regression tests for list/tuple custom_data slicing * fix: slice list and tuple custom_data values per row * docs: document custom_data slicing contract in append() docstrings * docs: add docstring to _slice_value in CSVSink and JSONSink * docs: add docstring to parse_detection_data in CSVSink and JSONSink * test: add test for detections.data with plain Python list values * test: add _slice_value edge-case unit tests * docs: add per-row slicing note to CSVSink and JSONSink class docstrings --------- Co-authored-by: jirka <6035284+Borda@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
parent
5d165e04c2
commit
06beb6ff2f
|
|
@ -39,6 +39,9 @@ class CSVSink:
|
|||
|
||||
CSVSink allows passing custom data alongside detection fields, providing
|
||||
flexibility for logging various types of information.
|
||||
When a list or tuple value in custom_data (or detections.data) has the
|
||||
same length as the detection count, each element is written to the
|
||||
corresponding detection row; any other value is broadcast to all rows.
|
||||
|
||||
Args:
|
||||
file_name: The name of the CSV file where the detections will be stored.
|
||||
|
|
@ -113,17 +116,56 @@ class CSVSink:
|
|||
self.file.close()
|
||||
|
||||
@staticmethod
|
||||
def _slice_value(value: Any, i: int) -> Any:
|
||||
def _slice_value(value: Any, i: int, n: int) -> Any:
|
||||
"""
|
||||
Return the i-th element when the value stores per-detection data.
|
||||
|
||||
Dispatch rules:
|
||||
- np.ndarray with ndim == 0: return as-is for broadcasting
|
||||
- np.ndarray with ndim >= 1: return value[i]
|
||||
- list or tuple with len equal to n: return value[i]
|
||||
- any other type: return as-is for broadcasting
|
||||
|
||||
Args:
|
||||
value: Custom-data field value.
|
||||
i: Zero-based detection index.
|
||||
n: Total number of detections.
|
||||
|
||||
Returns:
|
||||
Element at position i if value is a per-detection sequence,
|
||||
otherwise value unchanged.
|
||||
"""
|
||||
if isinstance(value, np.ndarray):
|
||||
return value if value.ndim == 0 else value[i]
|
||||
if isinstance(value, (list, tuple)) and len(value) == n:
|
||||
return value[i]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def parse_detection_data(
|
||||
detections: Detections, custom_data: dict[str, Any] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Convert detections and optional custom data into per-detection rows.
|
||||
|
||||
Builds one dictionary per detection containing bounding box coordinates,
|
||||
detection attributes, and any values from ``detections.data`` or
|
||||
``custom_data``. List and tuple values in ``custom_data`` with length
|
||||
equal to ``len(detections.xyxy)`` are sliced one element per row; all
|
||||
other values are broadcast to every row.
|
||||
|
||||
Args:
|
||||
detections: Detection data to serialize into row dictionaries.
|
||||
custom_data: Optional extra fields to include in each row.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries, one per detection, containing ``xyxy``
|
||||
coordinates, ``class_id``, ``confidence``, ``tracker_id``, and any
|
||||
values from ``detections.data`` or ``custom_data``.
|
||||
"""
|
||||
parsed_rows = []
|
||||
for i in range(len(detections.xyxy)):
|
||||
n = len(detections.xyxy)
|
||||
for i in range(n):
|
||||
row = {
|
||||
"x_min": detections.xyxy[i][0],
|
||||
"y_min": detections.xyxy[i][1],
|
||||
|
|
@ -142,11 +184,11 @@ class CSVSink:
|
|||
|
||||
if hasattr(detections, "data"):
|
||||
for key, value in detections.data.items():
|
||||
row[key] = CSVSink._slice_value(value, i)
|
||||
row[key] = CSVSink._slice_value(value, i, n)
|
||||
|
||||
if custom_data:
|
||||
for key, value in custom_data.items():
|
||||
row[key] = CSVSink._slice_value(value, i)
|
||||
row[key] = CSVSink._slice_value(value, i, n)
|
||||
|
||||
parsed_rows.append(row)
|
||||
return parsed_rows
|
||||
|
|
@ -159,7 +201,11 @@ class CSVSink:
|
|||
|
||||
Args:
|
||||
detections: The detection data.
|
||||
custom_data: Custom data to include.
|
||||
custom_data: Custom data to include. Scalars, dictionaries, and
|
||||
other non-sequence values are broadcast to every detection in
|
||||
this batch. NumPy arrays, lists, and tuples with length equal
|
||||
to ``len(detections)`` are sliced per detection; other lists
|
||||
and tuples are broadcast unchanged.
|
||||
"""
|
||||
if not self.writer:
|
||||
raise Exception(
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ class JSONSink:
|
|||
|
||||
JSONSink allows passing custom data alongside detection fields, providing
|
||||
flexibility for logging various types of information.
|
||||
When a list or tuple value in custom_data (or detections.data) has the
|
||||
same length as the detection count, each element is written to the
|
||||
corresponding detection row; any other value is broadcast to all rows.
|
||||
|
||||
Args:
|
||||
file_name: The name of the JSON file where the detections will be stored.
|
||||
|
|
@ -85,17 +88,56 @@ class JSONSink:
|
|||
self.file.close()
|
||||
|
||||
@staticmethod
|
||||
def _slice_value(value: Any, i: int) -> Any:
|
||||
def _slice_value(value: Any, i: int, n: int) -> Any:
|
||||
"""
|
||||
Return the i-th element when the value stores per-detection data.
|
||||
|
||||
Dispatch rules:
|
||||
- np.ndarray with ndim == 0: return as-is for broadcasting
|
||||
- np.ndarray with ndim >= 1: return value[i]
|
||||
- list or tuple with len equal to n: return value[i]
|
||||
- any other type: return as-is for broadcasting
|
||||
|
||||
Args:
|
||||
value: Custom-data field value.
|
||||
i: Zero-based detection index.
|
||||
n: Total number of detections.
|
||||
|
||||
Returns:
|
||||
Element at position i if value is a per-detection sequence,
|
||||
otherwise value unchanged.
|
||||
"""
|
||||
if isinstance(value, np.ndarray):
|
||||
return value if value.ndim == 0 else value[i]
|
||||
if isinstance(value, (list, tuple)) and len(value) == n:
|
||||
return value[i]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def parse_detection_data(
|
||||
detections: Detections, custom_data: dict[str, Any] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Convert detections and optional custom data into per-detection rows.
|
||||
|
||||
Builds one dictionary per detection containing bounding box coordinates,
|
||||
detection attributes, and any values from ``detections.data`` or
|
||||
``custom_data``. List and tuple values in ``custom_data`` with length
|
||||
equal to ``len(detections.xyxy)`` are sliced one element per row; all
|
||||
other values are broadcast to every row.
|
||||
|
||||
Args:
|
||||
detections: Detection data to serialize into row dictionaries.
|
||||
custom_data: Optional extra fields to include in each row.
|
||||
|
||||
Returns:
|
||||
A list of dictionaries, one per detection, containing ``xyxy``
|
||||
coordinates, ``class_id``, ``confidence``, ``tracker_id``, and any
|
||||
values from ``detections.data`` or ``custom_data``.
|
||||
"""
|
||||
parsed_rows = []
|
||||
for i in range(len(detections.xyxy)):
|
||||
n = len(detections.xyxy)
|
||||
for i in range(n):
|
||||
row = {
|
||||
"x_min": float(detections.xyxy[i][0]),
|
||||
"y_min": float(detections.xyxy[i][1]),
|
||||
|
|
@ -114,11 +156,11 @@ class JSONSink:
|
|||
|
||||
if hasattr(detections, "data"):
|
||||
for key, value in detections.data.items():
|
||||
row[key] = str(JSONSink._slice_value(value, i))
|
||||
row[key] = str(JSONSink._slice_value(value, i, n))
|
||||
|
||||
if custom_data:
|
||||
for key, value in custom_data.items():
|
||||
v = JSONSink._slice_value(value, i)
|
||||
v = JSONSink._slice_value(value, i, n)
|
||||
row[key] = str(v) if isinstance(value, np.ndarray) else v
|
||||
|
||||
parsed_rows.append(row)
|
||||
|
|
@ -132,7 +174,11 @@ class JSONSink:
|
|||
|
||||
Args:
|
||||
detections: The detection data.
|
||||
custom_data: Custom data to include.
|
||||
custom_data: Custom data to include. Scalars, dictionaries, and
|
||||
other non-sequence values are broadcast to every detection in
|
||||
this batch. NumPy arrays, lists, and tuples with length equal
|
||||
to ``len(detections)`` are sliced per detection; other lists
|
||||
and tuples are broadcast unchanged.
|
||||
"""
|
||||
parsed_rows = JSONSink.parse_detection_data(detections, custom_data)
|
||||
self.data.extend(parsed_rows)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import os
|
||||
from typing import Any
|
||||
|
|
@ -6,6 +8,7 @@ import numpy as np
|
|||
import pytest
|
||||
|
||||
import supervision as sv
|
||||
from supervision.detection.tools.csv_sink import CSVSink
|
||||
from tests.helpers import _create_detections
|
||||
|
||||
|
||||
|
|
@ -225,19 +228,85 @@ from tests.helpers import _create_detections
|
|||
["15.0", "25.0", "35.0", "45.0", "2", "0.7", "", "100.0", "6"],
|
||||
],
|
||||
), # mixed custom_data: ndarray sliced per row, scalar broadcast to all rows
|
||||
(
|
||||
_create_detections(
|
||||
xyxy=[[10, 20, 30, 40], [50, 60, 70, 80]],
|
||||
confidence=[0.9, 0.8],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
{"ids": ["a", "b"], "tags": ("x", "y"), "frame": 7},
|
||||
_create_detections(
|
||||
xyxy=[[15, 25, 35, 45]],
|
||||
confidence=[0.7],
|
||||
class_id=[2],
|
||||
),
|
||||
{"ids": ["c"], "tags": ("z",), "frame": 8},
|
||||
"test_detections_list_custom_data.csv",
|
||||
[
|
||||
[
|
||||
"x_min",
|
||||
"y_min",
|
||||
"x_max",
|
||||
"y_max",
|
||||
"class_id",
|
||||
"confidence",
|
||||
"tracker_id",
|
||||
"frame",
|
||||
"ids",
|
||||
"tags",
|
||||
],
|
||||
["10.0", "20.0", "30.0", "40.0", "0", "0.9", "", "7", "a", "x"],
|
||||
["50.0", "60.0", "70.0", "80.0", "1", "0.8", "", "7", "b", "y"],
|
||||
["15.0", "25.0", "35.0", "45.0", "2", "0.7", "", "8", "c", "z"],
|
||||
],
|
||||
), # list/tuple custom_data matching detection count is sliced per row
|
||||
(
|
||||
sv.Detections(
|
||||
xyxy=np.array([[10, 20, 30, 40], [50, 60, 70, 80]]),
|
||||
data={"labels": ["person", "car"]},
|
||||
),
|
||||
None,
|
||||
sv.Detections(
|
||||
xyxy=np.array([[15, 25, 35, 45]]),
|
||||
data={"labels": ["bus"]},
|
||||
),
|
||||
None,
|
||||
"test_detections_plain_list_data.csv",
|
||||
[
|
||||
[
|
||||
"x_min",
|
||||
"y_min",
|
||||
"x_max",
|
||||
"y_max",
|
||||
"class_id",
|
||||
"confidence",
|
||||
"tracker_id",
|
||||
"labels",
|
||||
],
|
||||
["10", "20", "30", "40", "", "", "", "person"],
|
||||
["50", "60", "70", "80", "", "", "", "car"],
|
||||
["15", "25", "35", "45", "", "", "", "bus"],
|
||||
],
|
||||
), # plain Python list in detections.data is sliced per row without custom_data
|
||||
],
|
||||
)
|
||||
def test_csv_sink(
|
||||
detections: sv.Detections,
|
||||
custom_data: dict[str, Any],
|
||||
custom_data: dict[str, Any] | None,
|
||||
second_detections: sv.Detections,
|
||||
second_custom_data: dict[str, Any],
|
||||
second_custom_data: dict[str, Any] | None,
|
||||
file_name: str,
|
||||
expected_result: list[list[Any]],
|
||||
) -> None:
|
||||
with sv.CSVSink(file_name) as sink:
|
||||
sink.append(detections, custom_data)
|
||||
sink.append(second_detections, second_custom_data)
|
||||
if custom_data is None:
|
||||
sink.append(detections)
|
||||
else:
|
||||
sink.append(detections, custom_data)
|
||||
if second_custom_data is None:
|
||||
sink.append(second_detections)
|
||||
else:
|
||||
sink.append(second_detections, second_custom_data)
|
||||
|
||||
assert_csv_equal(file_name, expected_result)
|
||||
|
||||
|
|
@ -455,3 +524,18 @@ def assert_csv_equal(file_name, expected_rows):
|
|||
)
|
||||
|
||||
os.remove(file_name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "i", "n", "expected"),
|
||||
[
|
||||
(["x"], 0, 1, "x"),
|
||||
(["a", "b", "c"], 0, 2, ["a", "b", "c"]),
|
||||
([42, "hello", None], 1, 3, "hello"),
|
||||
([["a", "b"], ["c", "d"]], 0, 2, ["a", "b"]),
|
||||
("ab", 0, 2, "ab"),
|
||||
(("z",), 0, 1, "z"),
|
||||
],
|
||||
)
|
||||
def test_csv_sink_slice_value(value: Any, i: int, n: int, expected: Any) -> None:
|
||||
assert CSVSink._slice_value(value, i, n) == expected
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
|
@ -275,19 +277,120 @@ from tests.helpers import _create_detections
|
|||
},
|
||||
],
|
||||
), # numpy array in custom_data sliced per detection row
|
||||
(
|
||||
_create_detections(
|
||||
xyxy=[[10, 20, 30, 40], [50, 60, 70, 80]],
|
||||
confidence=[0.9, 0.8],
|
||||
class_id=[0, 1],
|
||||
),
|
||||
{"ids": ["a", "b"], "tags": ("x", "y")},
|
||||
_create_detections(
|
||||
xyxy=[[15, 25, 35, 45]],
|
||||
confidence=[0.7],
|
||||
class_id=[2],
|
||||
),
|
||||
{"ids": ["c"], "tags": ("z",)},
|
||||
"test_detections_list_custom_data.json",
|
||||
[
|
||||
{
|
||||
"x_min": 10,
|
||||
"y_min": 20,
|
||||
"x_max": 30,
|
||||
"y_max": 40,
|
||||
"class_id": 0,
|
||||
"confidence": 0.8999999761581421,
|
||||
"tracker_id": "",
|
||||
"ids": "a",
|
||||
"tags": "x",
|
||||
},
|
||||
{
|
||||
"x_min": 50,
|
||||
"y_min": 60,
|
||||
"x_max": 70,
|
||||
"y_max": 80,
|
||||
"class_id": 1,
|
||||
"confidence": 0.800000011920929,
|
||||
"tracker_id": "",
|
||||
"ids": "b",
|
||||
"tags": "y",
|
||||
},
|
||||
{
|
||||
"x_min": 15,
|
||||
"y_min": 25,
|
||||
"x_max": 35,
|
||||
"y_max": 45,
|
||||
"class_id": 2,
|
||||
"confidence": 0.699999988079071,
|
||||
"tracker_id": "",
|
||||
"ids": "c",
|
||||
"tags": "z",
|
||||
},
|
||||
],
|
||||
), # list/tuple custom_data matching detection count is sliced per row
|
||||
(
|
||||
sv.Detections(
|
||||
xyxy=np.array([[10, 20, 30, 40], [50, 60, 70, 80]]),
|
||||
data={"labels": ["person", "car"]},
|
||||
),
|
||||
None,
|
||||
sv.Detections(
|
||||
xyxy=np.array([[15, 25, 35, 45]]),
|
||||
data={"labels": ["bus"]},
|
||||
),
|
||||
None,
|
||||
"test_detections_plain_list_data.json",
|
||||
[
|
||||
{
|
||||
"x_min": 10.0,
|
||||
"y_min": 20.0,
|
||||
"x_max": 30.0,
|
||||
"y_max": 40.0,
|
||||
"class_id": "",
|
||||
"confidence": "",
|
||||
"tracker_id": "",
|
||||
"labels": "person",
|
||||
},
|
||||
{
|
||||
"x_min": 50.0,
|
||||
"y_min": 60.0,
|
||||
"x_max": 70.0,
|
||||
"y_max": 80.0,
|
||||
"class_id": "",
|
||||
"confidence": "",
|
||||
"tracker_id": "",
|
||||
"labels": "car",
|
||||
},
|
||||
{
|
||||
"x_min": 15.0,
|
||||
"y_min": 25.0,
|
||||
"x_max": 35.0,
|
||||
"y_max": 45.0,
|
||||
"class_id": "",
|
||||
"confidence": "",
|
||||
"tracker_id": "",
|
||||
"labels": "bus",
|
||||
},
|
||||
],
|
||||
), # plain Python list in detections.data is sliced per row without custom_data
|
||||
],
|
||||
)
|
||||
def test_json_sink(
|
||||
detections: sv.Detections,
|
||||
custom_data: dict[str, Any],
|
||||
custom_data: dict[str, Any] | None,
|
||||
second_detections: sv.Detections,
|
||||
second_custom_data: dict[str, Any],
|
||||
second_custom_data: dict[str, Any] | None,
|
||||
file_name: str,
|
||||
expected_result: list[list[Any]],
|
||||
) -> None:
|
||||
with sv.JSONSink(file_name) as sink:
|
||||
sink.append(detections, custom_data)
|
||||
sink.append(second_detections, second_custom_data)
|
||||
if custom_data is None:
|
||||
sink.append(detections)
|
||||
else:
|
||||
sink.append(detections, custom_data)
|
||||
if second_custom_data is None:
|
||||
sink.append(second_detections)
|
||||
else:
|
||||
sink.append(second_detections, second_custom_data)
|
||||
|
||||
assert_json_equal(file_name, expected_result)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue