Repository: openvinotoolkit/datumaro

Issue:
Return format is not uniform
https://github.com/openvinotoolkit/datumaro/blob/b3eaf4a5035ad37c4cf591643b5d0f4b18f64992/datumaro/util/annotation_util.py#L182

is different from 

https://github.com/openvinotoolkit/datumaro/blob/b3eaf4a5035ad37c4cf591643b5d0f4b18f64992/datumaro/util/annotation_util.py#L213


Discussion:
Thanks, you're right.

Resolving patch:
diff --git a/datumaro/components/operations.py b/datumaro/components/operations.py
--- a/datumaro/components/operations.py
+++ b/datumaro/components/operations.py
@@ -28,8 +28,8 @@
 from datumaro.components.extractor import CategoriesInfo
 from datumaro.util import filter_dict, find
 from datumaro.util.annotation_util import (
-    OKS, bbox_iou, find_instances, max_bbox, mean_bbox, segment_iou,
-    smooth_line,
+    OKS, approximate_line, bbox_iou, find_instances, max_bbox, mean_bbox,
+    segment_iou,
 )
 from datumaro.util.attrs_util import default_if_none, ensure_cls
 
@@ -401,8 +401,7 @@ def _merge_categories(self, sources):
 
         label_cat = self._merge_label_categories(sources)
         if label_cat is None:
-            return dst_categories
-
+            label_cat = LabelCategories()
         dst_categories[AnnotationType.label] = label_cat
 
         points_cat = self._merge_point_categories(sources, label_cat)
@@ -768,21 +767,46 @@ def distance(self, a, b):
 class LineMatcher(_ShapeMatcher):
     @staticmethod
     def distance(a, b):
-        a_bbox = a.get_bbox()
-        b_bbox = b.get_bbox()
-        bbox = max_bbox([a_bbox, b_bbox])
-        area = bbox[2] * bbox[3]
-        if not area:
+        # Compute inter-line area by using the Trapezoid formulae
+        # https://en.wikipedia.org/wiki/Trapezoidal_rule
+        # Normalize by common bbox and get the bbox fill ratio
+        # Call this ratio the "distance"
+
+        # The box area is an early-exit filter for non-intersected figures
+        bbox = max_bbox([a, b])
+        box_area = bbox[2] * bbox[3]
+        if not box_area:
             return 1
 
-        # compute inter-line area, normalize by common bbox
-        point_count = max(max(len(a.points) // 2, len(b.points) // 2), 5)
-        a, sa = smooth_line(a.points, point_count)
-        b, sb = smooth_line(b.points, point_count)
+        def _approx(line, segments):
+            if len(line) // 2 != segments + 1:
+                line = approximate_line(line, segments=segments)
+            return np.reshape(line, (-1, 2))
+        segments = max(len(a.points) // 2, len(b.points) // 2, 5) - 1
+
+        a = _approx(a.points, segments)
+        b = _approx(b.points, segments)
         dists = np.linalg.norm(a - b, axis=1)
-        dists = (dists[:-1] + dists[1:]) * 0.5
-        s = np.sum(dists) * 0.5 * (sa + sb) / area
-        return abs(1 - s)
+        dists = dists[:-1] + dists[1:]
+        a_steps = np.linalg.norm(a[1:] - a[:-1], axis=1)
+        b_steps = np.linalg.norm(b[1:] - b[:-1], axis=1)
+
+        # For the common bbox we can't use
+        # - the AABB (axis-alinged bbox) of a point set
+        # - the exterior of a point set
+        # - the convex hull of a point set
+        # because these soultions won't be correctly normalized.
+        # The lines can have multiple self-intersections, which can give
+        # the inter-line area more than internal area of the options above,
+        # producing the value of the distance outside of the [0; 1] range.
+        #
+        # Instead, we can compute the upper boundary for the inter-line
+        # area based on the maximum point distance and line length.
+        max_area = np.max(dists) * max(np.sum(a_steps), np.sum(b_steps))
+
+        area = np.dot(dists, a_steps + b_steps) * 0.5 * 0.5 / max_area
+
+        return abs(1 - area)
 
 @attrs
 class CaptionsMatcher(AnnotationMatcher):
diff --git a/datumaro/util/annotation_util.py b/datumaro/util/annotation_util.py
--- a/datumaro/util/annotation_util.py
+++ b/datumaro/util/annotation_util.py
@@ -3,7 +3,9 @@
 # SPDX-License-Identifier: MIT
 
 from itertools import groupby
-from typing import Callable, Dict, Optional, Tuple, Union
+from typing import (
+    Callable, Dict, Iterable, NewType, Optional, Sequence, Tuple, Union,
+)
 
 from typing_extensions import Literal
 import numpy as np
@@ -28,13 +30,28 @@ def find_instances(instance_anns):
 def find_group_leader(group):
     return max(group, key=lambda x: x.get_area())
 
-def _get_bbox(ann):
+BboxCoords = Tuple[float, float, float, float]
+Shape = NewType('Shape', _Shape)
+SpatialAnnotation = Union[Shape, Mask]
+
+def _get_bbox(ann: Union[Sequence, SpatialAnnotation]) -> BboxCoords:
     if isinstance(ann, (_Shape, Mask)):
         return ann.get_bbox()
-    else:
+    elif hasattr(ann, '__len__') and len(ann) == 4:
         return ann
+    else:
+        raise ValueError("The value of type '%s' can't be treated as a "
+            "bounding box" % type(ann))
+
+def max_bbox(annotations: Iterable[Union[BboxCoords, SpatialAnnotation]]) \
+        -> BboxCoords:
+    """
+    Computes the maximum bbox for the set of spatial annotations and boxes.
+
+    Returns:
+      bbox (tuple): (x, y, w, h)
+    """
 
-def max_bbox(annotations):
     boxes = [_get_bbox(ann) for ann in annotations]
     x0 = min((b[0] for b in boxes), default=0)
     y0 = min((b[1] for b in boxes), default=0)
@@ -42,7 +59,15 @@ def max_bbox(annotations):
     y1 = max((b[1] + b[3] for b in boxes), default=0)
     return [x0, y0, x1 - x0, y1 - y0]
 
-def mean_bbox(annotations):
+def mean_bbox(annotations: Iterable[Union[BboxCoords, SpatialAnnotation]]) \
+        -> BboxCoords:
+    """
+    Computes the mean bbox for the set of spatial annotations and boxes.
+
+    Returns:
+      bbox (tuple): (x, y, w, h)
+    """
+
     le = len(annotations)
     boxes = [_get_bbox(ann) for ann in annotations]
     mlb = sum(b[0] for b in boxes) / le
@@ -178,11 +203,23 @@ def OKS(a, b, sigma=None, bbox=None, scale=None):
     dists = np.linalg.norm(p1 - p2, axis=1)
     return np.sum(np.exp(-(dists ** 2) / (2 * scale * (2 * sigma) ** 2)))
 
-def smooth_line(points, segments):
-    assert 2 <= len(points) // 2 and len(points) % 2 == 0
+def approximate_line(points: Sequence[float], segments: int) -> np.ndarray:
+    """
+    Approximates a 2d line to the required number of segments. The new points
+    are distributed uniformly across the input line.
 
-    if len(points) // 2 == segments:
-        return points
+    Args:
+      points (Sequence): an array of line point coordinates.
+        The size is [points * 2], the layout is [x0, y0, x1, y1, ...].
+      segments (int): the required numebr of segments in the resulting line.
+
+    Returns:
+      new_points (ndarray): an array of new line point coordinates.
+        The size is [(segments + 1) * 2], the layout is [x0, y0, x1, y1, ...].
+    """
+
+    assert 2 <= len(points) // 2 and len(points) % 2 == 0
+    assert 0 < segments
 
     points = list(points)
     if len(points) == 2:
@@ -213,7 +250,7 @@ def smooth_line(points, segments):
 
         new_points[new_segment] = prev_p * (1 - r) + next_p * r
 
-    return new_points, step
+    return np.reshape(new_points, (-1, ))
 
 def make_label_id_mapping(
         src_labels: LabelCategories, dst_labels: LabelCategories,
@@ -225,14 +262,14 @@ def make_label_id_mapping(
             Dict[int, str]
         ]:
     """
-    Maps label ids from source to destination. Fallback is used for missing
+    Maps label ids from source to destination. Fallback id is used for missing
     labels.
 
     Returns:
-      function to map labels: src id -> dst id
-        dict: src id -> dst id
-        dict: src id -> src label
-        dict: dst id -> dst label
+      map_id (callable): src id -> dst id
+      id_mapping (dict): src id -> dst id
+      src_labels (dict): src id -> src label
+      dst_labels (dict): dst id -> dst label
     """
 
     source_labels = { id: label.name


Test patch:
diff --git a/tests/test_ops.py b/tests/test_ops.py
--- a/tests/test_ops.py
+++ b/tests/test_ops.py
@@ -388,6 +388,32 @@ def test_can_match_shapes(self):
                 key=lambda e: len(e.sources))
         )
 
+    @mark_requirement(Requirements.DATUM_BUG_219)
+    def test_can_match_lines_when_line_not_approximated(self):
+        source0 = Dataset.from_iterable([
+            DatasetItem(1, annotations=[
+                PolyLine([1, 1, 2, 1, 3, 5, 5, 5, 8, 3]),
+            ]),
+        ])
+
+        source1 = Dataset.from_iterable([
+            DatasetItem(1, annotations=[
+                PolyLine([1, 1, 8, 3]),
+            ]),
+        ])
+
+        expected = Dataset.from_iterable([
+            DatasetItem(1, annotations=[
+                PolyLine([1, 1, 2, 1, 3, 5, 5, 5, 8, 3]),
+            ]),
+        ], categories=[])
+
+        merger = IntersectMerge(conf={'quorum': 1, 'pairwise_dist': 0.1})
+        merged = merger([source0, source1])
+
+        compare_datasets(self, expected, merged, ignored_attrs={'score'})
+        self.assertEqual(0, len(merger.errors))
+
     @mark_requirement(Requirements.DATUM_GENERAL_REQ)
     def test_attributes(self):
         source0 = Dataset.from_iterable([