Skip to content

Commit ffbbb37

Browse files
committed
Merge branch 'release/4.4'
2 parents 424b479 + 94b5151 commit ffbbb37

File tree

9 files changed

+166
-64
lines changed

9 files changed

+166
-64
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,4 +58,7 @@ doc/.ipynb_checkpoints
5858
# emacs temporary files
5959
*~
6060

61+
# PyCharm
62+
.idea/
63+
6164
.mypy_cache/

doc/source/changelog.rst

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22
Changelog
33
#########
44

5+
Version 4.4 (2022-03-09)
6+
~~~~~~~~~~~~~~~~~~~~~~~~
7+
8+
- BREAKING: remove empty segments in Timeline.__init__
9+
- BREAKING: Timeline.extent() returns Segment(0.0, 0.0) for empty timelines
10+
- feat: add "duration" option to Annotation.discretize
11+
- fix: handle various corner cases in 1D pdist and cdist
12+
- fix: fix documentation of {Timeline | Annotation}.__bool__
13+
- test: check robustness to Segment.set_precision
14+
515
Version 4.3 (2021-10-11)
616
~~~~~~~~~~~~~~~~~~~~~~~~
717

pyannote/core/annotation.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -244,9 +244,9 @@ def __bool__(self):
244244
"""Emptiness
245245
246246
>>> if annotation:
247-
... # annotation is empty
248-
... else:
249247
... # annotation is not empty
248+
... else:
249+
... # annotation is empty
250250
"""
251251
return len(self._tracks) > 0
252252

@@ -1375,9 +1375,10 @@ def __mul__(self, other: "Annotation") -> np.ndarray:
13751375

13761376
def discretize(
13771377
self,
1378-
support: Segment = None,
1379-
resolution: Union[float, SlidingWindow] = 0.1,
1380-
labels: List[Hashable] = None,
1378+
support: Optional[Segment] = None,
1379+
resolution: Union[float, SlidingWindow] = 0.01,
1380+
labels: Optional[List[Hashable]] = None,
1381+
duration: Optional[float] = None,
13811382
):
13821383
"""Discretize
13831384
@@ -1390,6 +1391,10 @@ def discretize(
13901391
Defaults to 10ms frames.
13911392
labels : list of labels, optional
13921393
Defaults to self.labels()
1394+
duration : float, optional
1395+
Overrides support duration and ensures that the number of
1396+
returned frames is fixed (which might otherwise not be the case
1397+
because of rounding errors).
13931398
13941399
Returns
13951400
-------
@@ -1416,8 +1421,11 @@ def discretize(
14161421
)
14171422

14181423
start_frame = resolution.closest_frame(start_time)
1419-
end_frame = resolution.closest_frame(end_time)
1420-
num_frames = end_frame - start_frame
1424+
if duration is None:
1425+
end_frame = resolution.closest_frame(end_time)
1426+
num_frames = end_frame - start_frame
1427+
else:
1428+
num_frames = int(round(duration / resolution.step))
14211429

14221430
data = np.zeros((num_frames, len(labels)), dtype=np.uint8)
14231431
for k, label in enumerate(labels):

pyannote/core/timeline.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,9 @@ def __init__(self,
146146
if segments is None:
147147
segments = ()
148148

149-
# set of segments (used for checking inclusion)
150-
segments_set = set(segments)
151-
152-
if any(not segment for segment in segments_set):
153-
raise ValueError('Segments must not be empty.')
149+
# set of segments (used for checking inclusion)
150+
# Store only non-empty Segments.
151+
segments_set = set([segment for segment in segments if segment])
154152

155153
self.segments_set_ = segments_set
156154

@@ -179,9 +177,9 @@ def __bool__(self):
179177
"""Emptiness
180178
181179
>>> if timeline:
182-
... # timeline is empty
183-
... else:
184180
... # timeline is not empty
181+
... else:
182+
... # timeline is empty
185183
"""
186184
return len(self.segments_set_) > 0
187185

@@ -796,11 +794,10 @@ def extent(self) -> Segment:
796794
start = segments_boundaries_[0]
797795
end = segments_boundaries_[-1]
798796
return Segment(start=start, end=end)
799-
else:
800-
import numpy as np
801-
return Segment(start=np.inf, end=-np.inf)
802797

803-
def support_iter(self, collar: float = 0.) -> Iterator[Segment]:
798+
return Segment(start=0.0, end=0.0)
799+
800+
def support_iter(self, collar: float = 0.0) -> Iterator[Segment]:
804801
"""Like `support` but returns a segment generator instead
805802
806803
See also

pyannote/core/utils/distance.py

Lines changed: 57 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,11 @@ def l2_normalize(X: np.ndarray):
4747
"""
4848

4949
norm = np.sqrt(np.sum(X ** 2, axis=1))
50-
norm[norm == 0] = 1.
50+
norm[norm == 0] = 1.0
5151
return (X.T / norm).T
5252

5353

54-
def dist_range(metric='euclidean', normalize=False):
54+
def dist_range(metric="euclidean", normalize=False):
5555
"""Return range of possible distance between two vectors
5656
5757
Parameters
@@ -67,42 +67,44 @@ def dist_range(metric='euclidean', normalize=False):
6767
Range of possible distance.
6868
"""
6969

70-
if metric == 'euclidean':
70+
if metric == "euclidean":
7171
if normalize:
72-
return (0., 2.)
73-
return (0., np.inf)
72+
return (0.0, 2.0)
73+
return (0.0, np.inf)
7474

75-
if metric == 'sqeuclidean':
75+
if metric == "sqeuclidean":
7676
if normalize:
77-
return (0., 4.)
78-
return (0., np.inf)
77+
return (0.0, 4.0)
78+
return (0.0, np.inf)
7979

80-
if metric == 'cosine':
81-
return (0., 2.)
80+
if metric == "cosine":
81+
return (0.0, 2.0)
8282

83-
if metric == 'angular':
84-
return (0., np.pi)
83+
if metric == "angular":
84+
return (0.0, np.pi)
8585

86-
msg = f'dist_range does not support {metric} metric.'
86+
msg = f"dist_range does not support {metric} metric."
8787
raise NotImplementedError(msg)
8888

8989

9090
def _pdist_func_1D(X, func):
9191
"""Helper function for pdist"""
9292

93-
X = X.squeeze()
94-
n_items, = X.shape
93+
(n_items,) = X.shape
94+
95+
if n_items < 2:
96+
return np.array([])
9597

9698
distances = []
9799

98100
for i in range(n_items - 1):
99-
distance = func(X[i], X[i+1:])
101+
distance = func(X[i], X[i + 1 :])
100102
distances.append(distance)
101103

102104
return np.hstack(distances)
103105

104106

105-
def pdist(fX, metric='euclidean', **kwargs):
107+
def pdist(fX, metric="euclidean", **kwargs):
106108
"""Same as scipy.spatial.distance with support for additional metrics
107109
108110
* 'angular': pairwise angular distance
@@ -112,35 +114,36 @@ def pdist(fX, metric='euclidean', **kwargs):
112114
* 'average': pairwise average (only for 1-dimensional fX)
113115
"""
114116

115-
if metric == 'angular':
116-
cosine = scipy.spatial.distance.pdist(
117-
fX, metric='cosine', **kwargs)
117+
if metric == "angular":
118+
cosine = scipy.spatial.distance.pdist(fX, metric="cosine", **kwargs)
118119
return np.arccos(np.clip(1.0 - cosine, -1.0, 1.0))
119120

120-
elif metric == 'equal':
121+
elif metric == "equal":
122+
assert fX.ndim == 1, f"'{metric}' metric only supports 1-dimensional fX."
121123
return _pdist_func_1D(fX, lambda x, X: x == X)
122124

123-
elif metric == 'minimum':
125+
elif metric == "minimum":
126+
assert fX.ndim == 1, f"'{metric}' metric only supports 1-dimensional fX."
124127
return _pdist_func_1D(fX, np.minimum)
125128

126-
elif metric == 'maximum':
129+
elif metric == "maximum":
130+
assert fX.ndim == 1, f"'{metric}' metric only supports 1-dimensional fX."
127131
return _pdist_func_1D(fX, np.maximum)
128132

129-
elif metric == 'average':
130-
return _pdist_func_1D(fX, lambda x, X: .5 * (x + X))
133+
elif metric == "average":
134+
assert fX.ndim == 1, f"'{metric}' metric only supports 1-dimensional fX."
135+
return _pdist_func_1D(fX, lambda x, X: 0.5 * (x + X))
131136

132137
else:
133138
return scipy.spatial.distance.pdist(fX, metric=metric, **kwargs)
134139

135140

136141
def _cdist_func_1D(X_trn, X_tst, func):
137142
"""Helper function for cdist"""
138-
X_trn = X_trn.squeeze()
139-
X_tst = X_tst.squeeze()
140143
return np.vstack(func(x_trn, X_tst) for x_trn in iter(X_trn))
141144

142145

143-
def cdist(fX_trn, fX_tst, metric='euclidean', **kwargs):
146+
def cdist(fX_trn, fX_tst, metric="euclidean", **kwargs):
144147
"""Same as scipy.spatial.distance.cdist with support for additional metrics
145148
146149
* 'angular': pairwise angular distance
@@ -150,28 +153,38 @@ def cdist(fX_trn, fX_tst, metric='euclidean', **kwargs):
150153
* 'average': pairwise average (only for 1-dimensional fX)
151154
"""
152155

153-
if metric == 'angular':
154-
cosine = scipy.spatial.distance.cdist(
155-
fX_trn, fX_tst, metric='cosine', **kwargs)
156+
if metric == "angular":
157+
cosine = scipy.spatial.distance.cdist(fX_trn, fX_tst, metric="cosine", **kwargs)
156158
return np.arccos(np.clip(1.0 - cosine, -1.0, 1.0))
157159

158-
elif metric == 'equal':
159-
return _cdist_func_1D(fX_trn, fX_tst,
160-
lambda x_trn, X_tst: x_trn == X_tst)
160+
elif metric == "equal":
161+
assert (
162+
fX_trn.ndim == 1 and fX_tst.ndim == 1
163+
), f"'{metric}' metric only supports 1-dimensional fX_trn and fX_tst."
164+
return _cdist_func_1D(fX_trn, fX_tst, lambda x_trn, X_tst: x_trn == X_tst)
161165

162-
elif metric == 'minimum':
166+
elif metric == "minimum":
167+
assert (
168+
fX_trn.ndim == 1 and fX_tst.ndim == 1
169+
), f"'{metric}' metric only supports 1-dimensional fX_trn and fX_tst."
163170
return _cdist_func_1D(fX_trn, fX_tst, np.minimum)
164171

165-
elif metric == 'maximum':
172+
elif metric == "maximum":
173+
assert (
174+
fX_trn.ndim == 1 and fX_tst.ndim == 1
175+
), f"'{metric}' metric only supports 1-dimensional fX_trn and fX_tst."
166176
return _cdist_func_1D(fX_trn, fX_tst, np.maximum)
167177

168-
elif metric == 'average':
169-
return _cdist_func_1D(fX_trn, fX_tst,
170-
lambda x_trn, X_tst: .5 * (x_trn + X_tst))
178+
elif metric == "average":
179+
assert (
180+
fX_trn.ndim == 1 and fX_tst.ndim == 1
181+
), f"'{metric}' metric only supports 1-dimensional fX_trn and fX_tst."
182+
return _cdist_func_1D(
183+
fX_trn, fX_tst, lambda x_trn, X_tst: 0.5 * (x_trn + X_tst)
184+
)
171185

172186
else:
173-
return scipy.spatial.distance.cdist(
174-
fX_trn, fX_tst, metric=metric, **kwargs)
187+
return scipy.spatial.distance.cdist(fX_trn, fX_tst, metric=metric, **kwargs)
175188

176189

177190
def to_condensed(n, i, j):
@@ -200,7 +213,7 @@ def to_condensed(n, i, j):
200213
"""
201214
i, j = np.array(i), np.array(j)
202215
if np.any(i == j):
203-
raise ValueError('i and j should be different.')
216+
raise ValueError("i and j should be different.")
204217
i, j = np.minimum(i, j), np.maximum(i, j)
205218
return np.int64(i * n - i * i / 2 - 3 * i / 2 + j - 1)
206219

@@ -222,6 +235,6 @@ def to_squared(n, k):
222235
223236
"""
224237
k = np.array(k)
225-
i = np.int64(n - np.sqrt(-8*k + 4*n**2 - 4*n + 1)/2 - 1/2)
226-
j = np.int64(i**2/2 - i*n + 3*i/2 + k + 1)
238+
i = np.int64(n - np.sqrt(-8 * k + 4 * n ** 2 - 4 * n + 1) / 2 - 1 / 2)
239+
j = np.int64(i ** 2 / 2 - i * n + 3 * i / 2 + k + 1)
227240
return i, j

tests/__init__.py

Whitespace-only changes.

tests/test_segment.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from pyannote.core import Segment
2+
from tests.utils import preserve_segment_state
23

34

45
def test_creation():
@@ -46,7 +47,9 @@ def test_other_operation():
4647
assert segment ^ other_segment == Segment(9, 14)
4748

4849

50+
@preserve_segment_state
4951
def test_segment_precision_mode():
50-
assert not Segment(90/1000, 90/1000+240/1000) == Segment(90/1000, 330/1000)
52+
Segment.set_precision(None)
53+
assert not Segment(90 / 1000, 90 / 1000 + 240 / 1000) == Segment(90 / 1000, 330 / 1000)
5154
Segment.set_precision(4)
52-
assert Segment(90/1000, 90/1000+240/1000) == Segment(90/1000, 330/1000)
55+
assert Segment(90 / 1000, 90 / 1000 + 240 / 1000) == Segment(90 / 1000, 330 / 1000)

tests/test_timeline.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
from pyannote.core import Annotation
3232
from pyannote.core import Segment
3333
from pyannote.core import Timeline
34+
from pyannote.core import segment
35+
from tests.utils import preserve_segment_state
3436

3537

3638
@pytest.fixture
@@ -118,6 +120,14 @@ def test_gaps(timeline):
118120
Segment(8, 8.5)]
119121

120122

123+
@preserve_segment_state
124+
def test_empty_gaps():
125+
empty_timeline = Timeline(uri='MyEmptyGaps')
126+
assert list(empty_timeline.gaps()) == []
127+
Segment.set_precision(3)
128+
assert list(empty_timeline.gaps()) == []
129+
130+
121131
def test_crop(timeline):
122132
selection = Segment(3, 7)
123133

@@ -223,3 +233,43 @@ def test_extrude():
223233
expected_answer.add(Segment(6, 7))
224234

225235
assert timeline.extrude(removed, mode='loose') == expected_answer
236+
237+
def test_initialized_with_empty_segments():
238+
# The first timeline includes empty segments.
239+
first_timeline = Timeline([Segment(1, 5), Segment(6, 6), Segment(7, 7), Segment(8, 10)])
240+
241+
# The second has no empty segments.
242+
second_timeline = Timeline([Segment(1, 5), Segment(8, 10)])
243+
244+
assert first_timeline == second_timeline
245+
246+
247+
def test_added_empty_segments():
248+
# The first timeline includes empty segments.
249+
first_timeline = Timeline()
250+
first_timeline.add(Segment(1, 5))
251+
first_timeline.add(Segment(6, 6))
252+
first_timeline.add(Segment(7, 7))
253+
first_timeline.add(Segment(8, 10))
254+
255+
# The second has no empty segments.
256+
second_timeline = Timeline()
257+
second_timeline.add(Segment(1, 5))
258+
second_timeline.add(Segment(8, 10))
259+
260+
assert first_timeline == second_timeline
261+
262+
263+
def test_consistent_timelines_with_empty_segments():
264+
# The first timeline is initialized with Segments, some empty.
265+
first_timeline = Timeline([Segment(1, 5), Segment(6, 6), Segment(7, 7), Segment(8, 10)])
266+
267+
# The second timeline adds one Segment at a time, including empty ones.
268+
second_timeline = Timeline()
269+
second_timeline.add(Segment(1, 5))
270+
second_timeline.add(Segment(6, 6))
271+
second_timeline.add(Segment(7, 7))
272+
second_timeline.add(Segment(8, 10))
273+
274+
assert first_timeline == second_timeline
275+

0 commit comments

Comments
 (0)