Skip to content

Commit 1a995ff

Browse files
authored
1.10: Add position information to PointsCategories (#1702)
- This PR adds optional position information as meta-info for (key)point annotations. - The position information can be used to define one or multiple default relative positions of a set of keypoints. - This type of meta-information about points data is useful in annotation tools.
2 parents 811641f + 676bd10 commit 1a995ff

File tree

8 files changed

+281
-10
lines changed

8 files changed

+281
-10
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## Q4 2024 Release 1.10.0
99

1010
### New features
11+
- Add default position information to PointsCategories class
12+
(<https://github.com/openvinotoolkit/datumaro/pull/1702>)
1113
- Support KITTI 3D format
1214
(<https://github.com/openvinotoolkit/datumaro/pull/1619>)
1315
(<https://github.com/openvinotoolkit/datumaro/pull/1621>)

src/datumaro/components/annotation.py

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (C) 2021-2024 Intel Corporation
1+
# Copyright (C) 2021-2025 Intel Corporation
22
#
33
# SPDX-License-Identifier: MIT
44

@@ -16,6 +16,7 @@
1616
Iterator,
1717
List,
1818
Optional,
19+
Sequence,
1920
Set,
2021
Tuple,
2122
Type,
@@ -31,6 +32,7 @@
3132

3233
from datumaro.components.media import Image
3334
from datumaro.util.attrs_util import default_if_none, not_empty
35+
from datumaro.util.points_util import normalize_points
3436

3537

3638
class AnnotationType(IntEnum):
@@ -1414,14 +1416,57 @@ class Category:
14141416
# Pairs of connected point indices
14151417
joints: Set[Tuple[int, int]] = field(factory=set, validator=default_if_none(set))
14161418

1419+
# Set of default x, y coordinates of the points
1420+
positions: List[float] = field(default=[])
1421+
1422+
@positions.validator
1423+
def positions_validator(
1424+
self, attribute: attr.Attribute[list], positions: list[float] | None
1425+
) -> None:
1426+
"""
1427+
Validate a list of point positions in the format [x1, y1, x2, y2, ..., xn, yn].
1428+
1429+
To be used as an attrs validator for the positions field of PointsCategories.Category.
1430+
1431+
Args:
1432+
attribute (attr.Attribute[list]): The attribute being validated.
1433+
positions (list[float]): A list of point positions.
1434+
1435+
Raises:
1436+
ValueError: If the provided value cannot be converted to a list of floats,
1437+
or if the number of positions is invalid.
1438+
"""
1439+
if positions is None:
1440+
positions = []
1441+
else:
1442+
# convert to a list of floats
1443+
try:
1444+
positions = list(map(float, positions))
1445+
except (TypeError, ValueError):
1446+
msg = "Cannot convert positions to list of floats. Check your input data."
1447+
raise ValueError(msg)
1448+
# check if number of coordinates is even
1449+
if len(positions) % 2 != 0:
1450+
msg = "positions must have an even number of elements"
1451+
raise ValueError(msg)
1452+
if len(positions) > 0:
1453+
# check if the number of positions is equal to the number of labels
1454+
if len(self.labels) > 0 and len(positions) != len(self.labels) * 2:
1455+
msg = "The number of positions should be equal to the number of labels"
1456+
raise ValueError(msg)
1457+
# normalize the positions
1458+
positions = normalize_points(positions)
1459+
setattr(self, attribute.name, positions)
1460+
14171461
items: Dict[int, Category] = field(factory=dict, validator=default_if_none(dict))
14181462

14191463
@classmethod
14201464
def from_iterable(
14211465
cls,
14221466
iterable: Union[
1423-
Tuple[int, List[str]],
1424-
Tuple[int, List[str], Set[Tuple[int, int]]],
1467+
Iterable[Sequence[int, List[str]]],
1468+
Iterable[Sequence[int, List[str], Set[Tuple[int, int]]]],
1469+
Iterable[Sequence[int, List[str], Set[Tuple[int, int]], List[float]]],
14251470
],
14261471
) -> PointsCategories:
14271472
"""
@@ -1447,11 +1492,12 @@ def add(
14471492
label_id: int,
14481493
labels: Optional[Iterable[str]] = None,
14491494
joints: Iterable[Tuple[int, int]] = None,
1495+
positions: Iterable[float] = None,
14501496
):
14511497
if joints is None:
14521498
joints = []
14531499
joints = set(map(tuple, joints))
1454-
self.items[label_id] = self.Category(labels, joints)
1500+
self.items[label_id] = self.Category(labels, joints, positions)
14551501

14561502
def __contains__(self, idx: int) -> bool:
14571503
return idx in self.items

src/datumaro/plugins/data_formats/datumaro/base.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (C) 2024 Intel Corporation
1+
# Copyright (C) 2025 Intel Corporation
22
#
33
# SPDX-License-Identifier: MIT
44

@@ -115,7 +115,12 @@ def _load_categories(parsed) -> Dict:
115115
if parsed_points_cat:
116116
point_categories = PointsCategories()
117117
for item in parsed_points_cat["items"]:
118-
point_categories.add(int(item["label_id"]), item["labels"], joints=item["joints"])
118+
point_categories.add(
119+
int(item["label_id"]),
120+
item["labels"],
121+
joints=item["joints"],
122+
positions=item.get("positions"),
123+
)
119124

120125
categories[AnnotationType.points] = point_categories
121126

src/datumaro/plugins/data_formats/datumaro/exporter.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (C) 2024 Intel Corporation
1+
# Copyright (C) 2025 Intel Corporation
22
#
33
# SPDX-License-Identifier: MIT
44

@@ -107,6 +107,7 @@ def _convert_points_categories(cls, obj):
107107
"label_id": int(label_id),
108108
"labels": [cast(label, str) for label in item.labels],
109109
"joints": [list(map(int, j)) for j in item.joints],
110+
"positions": list(map(float, item.positions)),
110111
}
111112
)
112113
return converted

src/datumaro/util/points_util.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Copyright (C) 2025 Intel Corporation
2+
#
3+
# SPDX-License-Identifier: MIT
4+
5+
import numpy as np
6+
7+
8+
def normalize_points(positions: list[float]) -> list[float]:
9+
"""
10+
Normalize a set of keypoints to fit within a unit bounding box [0, 1] x [0, 1],
11+
maintaining the aspect ratio.
12+
13+
Parameters:
14+
keypoints (list[float]): A list of point positions in the format [x1, y1, x2, y2, ..., xn, yn].
15+
16+
Returns:
17+
list[float]: Normalized keypoints within the unit bounding box, preserving aspect ratio.
18+
"""
19+
# Convert keypoints to a NumPy array for easier manipulation
20+
positions = np.array(positions, dtype=float).reshape(-1, 2)
21+
22+
# Find the minimum and maximum values for x and y
23+
min = positions.min(axis=0)
24+
max = positions.max(axis=0)
25+
26+
# Compute the width and height of the bounding box
27+
size = max - min
28+
29+
# Handle edge case where all keypoints are the same (zero width or height)
30+
size[np.where(size == 0)] = 1e-6
31+
32+
# Determine the scaling factor to maintain aspect ratio
33+
scale = size.max()
34+
35+
# Normalize the keypoints to fit within [0, 1] x [0, 1], preserving aspect ratio
36+
normalized_positions = (positions - min) / scale
37+
38+
return list(map(float, normalized_positions.flatten()))

tests/unit/data_formats/datumaro/conftest.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (C) 2024 Intel Corporation
1+
# Copyright (C) 2025 Intel Corporation
22
#
33
# SPDX-License-Identifier: MIT
44

@@ -680,3 +680,54 @@ def fxt_legacy_dataset_pair(test_dir):
680680
)
681681

682682
yield source_dataset, target_dataset
683+
684+
685+
@pytest.fixture
686+
def fxt_test_pointscategories_without_positions():
687+
points_categories = PointsCategories()
688+
for index in range(5):
689+
points_categories.add(index, labels=["label1", "label2", "label3"], joints=[[0, 1], [1, 2]])
690+
691+
return Dataset.from_iterable(
692+
[
693+
DatasetItem(
694+
id=100,
695+
subset="train",
696+
media=Image.from_numpy(data=np.ones((10, 6, 3))),
697+
annotations=[
698+
Points([1, 2, 0, 0, 1, 1]),
699+
],
700+
),
701+
],
702+
categories={
703+
AnnotationType.points: points_categories,
704+
},
705+
)
706+
707+
708+
@pytest.fixture
709+
def fxt_test_pointscategories_with_positions():
710+
points_categories = PointsCategories()
711+
for index in range(5):
712+
points_categories.add(
713+
index,
714+
labels=["label1", "label2", "label3"],
715+
joints=[[0, 1], [1, 2]],
716+
positions=[0, 1, 1, 2, 2, 3],
717+
)
718+
719+
return Dataset.from_iterable(
720+
[
721+
DatasetItem(
722+
id=100,
723+
subset="train",
724+
media=Image.from_numpy(data=np.ones((10, 6, 3))),
725+
annotations=[
726+
Points([1, 2, 0, 0, 1, 1]),
727+
],
728+
),
729+
],
730+
categories={
731+
AnnotationType.points: points_categories,
732+
},
733+
)

tests/unit/data_formats/datumaro/test_datumaro_format.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (C) 2024 Intel Corporation
1+
# Copyright (C) 2025 Intel Corporation
22
#
33
# SPDX-License-Identifier: MIT
44

@@ -122,6 +122,18 @@ def _test_save_and_load(
122122
True,
123123
id="test_can_save_and_load_infos",
124124
),
125+
pytest.param(
126+
"fxt_test_pointscategories_without_positions",
127+
compare_datasets,
128+
False,
129+
id="test_pointscategories_without_positions",
130+
),
131+
pytest.param(
132+
"fxt_test_pointscategories_with_positions",
133+
compare_datasets,
134+
False,
135+
id="test_pointscategories_with_positions",
136+
),
125137
],
126138
)
127139
@pytest.mark.parametrize("stream", [True, False])

tests/unit/test_annotation.py

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Copyright (C) 2020-2024 Intel Corporation
1+
# Copyright (C) 2020-2025 Intel Corporation
22
#
33
# SPDX-License-Identifier: MIT
44

@@ -17,9 +17,11 @@
1717
ExtractedMask,
1818
HashKey,
1919
Mask,
20+
PointsCategories,
2021
RotatedBbox,
2122
)
2223
from datumaro.util.image import lazy_image
24+
from datumaro.util.points_util import normalize_points
2325

2426

2527
class EllipseTest:
@@ -142,3 +144,117 @@ def test_get_semantic_seg_mask_binary_mask(self, fxt_index_mask, dtype):
142144
semantic_seg_mask = annotations.get_semantic_seg_mask(ignore_index=255, dtype=dtype)
143145

144146
assert np.allclose(semantic_seg_mask, fxt_index_mask)
147+
148+
149+
class PointsCategoriesTest:
150+
@pytest.mark.parametrize(
151+
"positions, expected",
152+
[
153+
(
154+
[2, 3, 4, 6, 3, 5],
155+
[0.0, 0.0, 0.666667, 1.0, 0.333333, 0.666667],
156+
), # basic functionality
157+
([1, 1, 1, 1, 1, 1], [0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), # all points are the same
158+
([1, 1, 3, 1, 5, 1], [0.0, 0.0, 0.5, 0.0, 1.0, 0.0]), # points form horizontal line
159+
([1, 1, 1, 3, 1, 5], [0.0, 0.0, 0.0, 0.5, 0.0, 1.0]), # points form vertical line
160+
(
161+
[-2, -3, -4, -6, -3, -5],
162+
[0.666667, 1.0, 0.0, 0.0, 0.333333, 0.333333],
163+
), # negative coords
164+
(
165+
[1000, 2000, 4000, 6000, 3000, 5000],
166+
[0.0, 0.0, 0.75, 1.0, 0.50, 0.75],
167+
), # large range
168+
(
169+
[0.001, 0.002, 0.004, 0.006, 0.003, 0.005],
170+
[0.0, 0.0, 0.75, 1.0, 0.50, 0.75],
171+
), # small range
172+
([2, 3], [0.0, 0.0]), # single point
173+
],
174+
)
175+
def test_normalize_positions(self, positions, expected):
176+
result = normalize_points(positions)
177+
assert np.allclose(result, expected), f"Expected {expected}, got {result}"
178+
179+
class PointsPositionsValidatorTest:
180+
"""Tests for the validator of the `positions` field in PointsCategories.Category."""
181+
182+
@staticmethod
183+
def test_empty_positions_list():
184+
"""Test that an empty list of positions is allowed."""
185+
obj = PointsCategories.Category(positions=[])
186+
assert obj.positions == [] # Should allow empty list
187+
188+
@staticmethod
189+
def test_empty_positions_tuple():
190+
"""Test that an empty list of positions is allowed."""
191+
obj = PointsCategories.Category(positions=())
192+
assert obj.positions == [] # Should allow empty list
193+
194+
@staticmethod
195+
def test_none_positions():
196+
"""Test that None is allowed and converted to an empty list."""
197+
obj = PointsCategories.Category(positions=None)
198+
assert obj.positions == [] # Should allow None and convert to empty list
199+
200+
@staticmethod
201+
def test_valid_positions():
202+
"""Test that valid positions are allowed."""
203+
labels = ["p1", "p2"]
204+
positions = [1.0, 2.0, 3.0, 4.0]
205+
obj = PointsCategories.Category(labels=labels, positions=positions)
206+
assert obj
207+
208+
@staticmethod
209+
def test_type_not_list():
210+
"""Test that a non-list type for positions raises an error."""
211+
with pytest.raises(ValueError, match="Cannot convert positions to list of floats"):
212+
PointsCategories.Category(positions=56)
213+
214+
@staticmethod
215+
def test_coordinates_as_string():
216+
"""Test that the coordinates may be represented as a string."""
217+
labels = ["p1", "p2"]
218+
positions = ["1", "2", "3", "4"]
219+
obj = PointsCategories.Category(labels=labels, positions=positions)
220+
assert obj
221+
222+
@staticmethod
223+
def test_non_numeric_elements():
224+
"""Test that passing non-numeric elements raises an error."""
225+
labels = ["p1", "p2"]
226+
positions = [1.0, 2.0, 3.0, "not_a_number"]
227+
with pytest.raises(ValueError, match="Cannot convert positions to list of floats"):
228+
PointsCategories.Category(labels=labels, positions=positions)
229+
230+
@staticmethod
231+
def test_uneven_number_of_elements():
232+
"""Test that an uneven number of elements raises an error."""
233+
with pytest.raises(ValueError, match="positions must have an even number of elements"):
234+
PointsCategories.Category(positions=[1.0, 2.0, 3.0])
235+
236+
@staticmethod
237+
def test_num_positions_not_same_as_num_labels():
238+
"""Test that the number of positions must match the number of labels."""
239+
labels = ["p1", "p2", "p3"] # 3 labels
240+
positions = [1.0, 2.0, 3.0, 4.0] # 2 positions
241+
with pytest.raises(
242+
ValueError, match="number of positions should be equal to the number of labels"
243+
):
244+
PointsCategories.Category(labels=labels, positions=positions)
245+
246+
@staticmethod
247+
def test_positions_allowed_with_empty_labels():
248+
"""Test that the the number of coordinates check is skipped when labels is empty."""
249+
labels = [] # no labels
250+
positions = [1.0, 2.0, 3.0, 4.0] # 2 positions
251+
obj = PointsCategories.Category(labels=labels, positions=positions)
252+
assert obj
253+
254+
@staticmethod
255+
def test_labels_allowed_with_empty_positions():
256+
"""Test that the the number of coordinates check is skipped when positions is empty."""
257+
labels = ["p1", "p2", "p3"] # 3 labels
258+
positions = [] # no positions
259+
obj = PointsCategories.Category(labels=labels, positions=positions)
260+
assert obj

0 commit comments

Comments
 (0)