File size: 24,103 Bytes
ed29c11
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
# annot.py
from io import StringIO
from random import sample
from collections import OrderedDict
import timecode as tc
from .behavior import Behavior
from sortedcontainers import SortedKeyList
from qtpy.QtCore import QObject, QRectF, Signal, Slot
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QGraphicsItem

class Bout(object):
    """

    """

    def __init__(self, start, end, behavior):
        self._start = start
        self._end = end
        self._behavior = behavior

    def __lt__(self, b):
        if type(b) is tc.Timecode:
            return self._start.float < b.float
        elif type(b) is Bout:
            return self._start < b._start
        else:
            raise NotImplementedError(f"Comparing Bout with {type(b)} not supported")

    def __le__(self, b):
        if type(b) is tc.Timecode:
            return self._start.float <= b.is_float
        elif type(b) is Bout:
            return self._start <= b._start
        else:
            raise NotImplementedError(f'Comparing Bout with {type(b)} not supported')

    def is_at(self, t):
        return self._start <= t and self._end >= t

    def start(self):
        return self._start

    def set_start(self, start):
        self._start = start

    def end(self):
        return self._end

    def set_end(self, end):
        self._end = end

    def len(self):
        return self._end - self._start + tc.Timecode(self._start.framerate, frames=1)

    def behavior(self):
        return self._behavior

    def name(self):
        return self._behavior.get_name()

    def color(self):
        return self._behavior.get_color()

    def is_active(self):
        return self._behavior.is_active()

    def is_visible(self):
        return self._behavior.is_visible() and self._behavior.is_active()

    def __repr__(self):
        return f"Bout: start = {self.start()}, end = {self.end()}, behavior: {self.behavior()}"

class Channel(QGraphicsItem):
    """

    """

    contentChanged = Signal()

    def __init__(self, chan = None):
        super().__init__()
        if not chan is None:
            self._bouts_by_start = chan._bouts_by_start
            self._bouts_by_end = chan._bouts_by_end
            self._top = chan._top
        else:
            self._bouts_by_start = SortedKeyList(key=lambda bout: bout.start().float)
            self._bouts_by_end = SortedKeyList(key=lambda bout: bout.end().float)
            self._top = 0.
        self.cur_ix = 0
        self.fakeFirstBout = Bout(
            tc.Timecode('30.0', '0:0:0:0'),
            tc.Timecode('30.0', '0:0:0:0'),
            Behavior('none', '', QColor.fromRgbF(0., 0., 0.), active = True)
        )
        self.fakeLastBout = Bout(
            tc.Timecode('30.0', '23:59:59:29'),
            tc.Timecode('30.0', '23:59:59:29'),
            Behavior('none', '', QColor.fromRgbF(0., 0., 0.), active = True)
        )

    def add(self, b):
        if isinstance(b, Bout):
            self._bouts_by_start.add(b)
            self._bouts_by_end.add(b)
        else:
            raise TypeError("Can only add Bouts to Channel")

    def remove(self, b):
        # can raise ValueError if b is not in the channel
        self._bouts_by_start.remove(b)
        self._bouts_by_end.remove(b)

    def update_start(self, b, new_start):
        """

        Update the starting time of a bout while

        preserving the _bouts_by_start access order

        """
        self._bouts_by_start.remove(b)
        b.set_start(new_start)
        self._bouts_by_start.add(b)

    def update_end(self, b, new_end):
        """

        Update the ending time of a bout while

        preserving the _bouts_by_end access order

        """
        self._bouts_by_end.remove(b)
        b.set_end(new_end)
        self._bouts_by_end.add(b)

    def __add__(self, b):
        self.add(b)

    def _get_next(self, t, sortedlist):
        ix = sortedlist.bisect_key_right(t.float)
        l = len(sortedlist)
        if ix == l:
            return self.fakeLastBout, t.next()
        return sortedlist[ix], t.next()

    def _get_prev(self, t, sortedlist):
        ix = sortedlist.bisect_key_left(t.float)
        if ix == 0:
            return self.fakeFirstBout, t.back()
        return sortedlist[ix-1], t.back()

    def _get_inner(self, t, sortedList, op):
        t_local = t + 0 # kludgy copy constructor!
        visible = False
        while not visible:
            # no need to check for the end, because the fake first and last bouts are visible
            bout, t_local = op(t_local, sortedList)
            visible = bout.is_visible()
        return bout

    def get_next_start(self, t):
        return self._get_inner(t, self._bouts_by_start, self._get_next)

    def get_next_end(self, t):
        return self._get_inner(t, self._bouts_by_end, self._get_next)

    def get_prev_start(self, t):
        return self._get_inner(t, self._bouts_by_start, self._get_prev)

    def get_prev_end(self, t):
        return self._get_inner(t, self._bouts_by_end, self._get_prev)

    def get_in_range(self, start, end):
        """

        get all bouts that intersect the range [start, end]

        """
        return [bout for bout in self._bouts_by_start
            if bout.start().float <= end.float and bout.end().float >= start.float]

    def get_at(self, t):
        """

        get all bouts that span time t

        """
        return self.get_in_range(t, t)

    def __iter__(self):
        return iter(self._bouts_by_start)

    def irange(self, start_time, end_time):
        if isinstance(start_time, tc.Timecode):
            start_time = start_time.float
        if isinstance(end_time, tc.Timecode):
            end_time = end_time.float
        if not isinstance(start_time, float):
            raise TypeError(f"Can't handle start_time of type {type(start_time)}")
        if not isinstance(end_time, float):
            raise TypeError(f"Can't handle end_time of type {type(end_time)}")
        return self._bouts_by_start.irange_key(start_time, end_time)

    def top(self):
        return self._top

    def set_top(self, top):
        self._top = top

    def boundingRect(self):
        width = self.fakeLastBout.end().float
        return QRectF(0., self.top(), width, 1.)

    def paint(self, painter, option, widget=None):
        boundingRect = option.rect
        in_range_bouts = self._bouts_by_start.irange_key(boundingRect.left(), boundingRect.right())
        while True:
            try:
                bout = next(in_range_bouts)
            except StopIteration:
                break
            if bout.is_visible():
                painter.fillRect(
                    QRectF(bout.start().float, self.top(), bout.len().float, 1.),
                    bout.color()
                    )

    def _delete_all_inner(self, predicate):
        to_delete = list()
        # can't alter the bouts within the iterator
        for bout in iter(self):
            if predicate(bout):
                to_delete.append(bout)
        deleted_names = set()
        for bout in to_delete:
            deleted_names.add(bout.name())
            self.remove(bout)
        return deleted_names

    def delete_bouts_by_name(self, behavior_name):
        return self._delete_all_inner(lambda bout: bout.name() == behavior_name)

    def delete_inactive_bouts(self):
        return self._delete_all_inner(lambda bout: not bout.is_active())

    def truncate_or_remove_bouts(self, behavior, start, end, delete_all=False):
        """

        Delete bouts entirely within the range [start, end], and

        truncate bouts that extend outside the range.

        If behavior matches _deleteBehavior, the activity affects

        all bouts.  Otherwise, it only affects bouts with matching behavior.

        """
        items = self.get_in_range(start, end)
        for item in items:
            if not delete_all and behavior.get_name() != item.name():
                continue
            # Delete bouts that are entirely within the range
            if item.start() >= start and item.end() < end:
                print(f"removing {item} from active channel")
                self.remove(item)

            # Truncate and duplicate bouts that extend out both sides of the range
            if item.start() < start and item.end() > end:
                self.add(Bout(end, item.end(), item.behavior()))
                self.update_end(item, start)

            # Truncate bouts at the start boundary that start before the range
            elif item.start() < start and item.end() <= end:
                self.update_end(item, start)

            # Truncate bouts at the end boundary that end after the range
            elif item.start() >= start and item.end() > end:
                self.update_start(item, end)

            else:
                print(f"truncate_or_delete_bouts: Unexpected bout {item}")

    def coalesce_bouts(self, start, end):
        """

        combine overlapping bouts of the same behavior within [start, end]

        """
        to_delete = []
        items = self.get_in_range(start, end)
        # items will be ordered by start time
        for ix, first in enumerate(items):
            if first in to_delete:
                # previously coalesced
                continue
            if ix == len(items)-1:
                break
            for second in items[ix+1:]:
                if (first.name() == second.name() and
                    first.end() >= second.start()):
                    if first.end() < second.end():
                        self.update_end(first, second.end())
                    to_delete.append(second)
        for item in to_delete:
            self.remove(item)

class Annotations(QObject):
    """

    """

    # Signals
    annotations_changed = Signal()
    active_annotations_changed = Signal()

    def __init__(self, behaviors):
        super().__init__()
        self._channels = OrderedDict()
        self._behaviors = behaviors
        self._movies = []
        self._start_frame = None
        self._end_frame = None
        self._sample_rate = None
        self._stimulus = None
        self._format = None
        self.annotation_names = []
        behaviors.behaviors_changed.connect(self.note_annotations_changed)

    def read(self, fn):
        with open(fn, "r") as f:
            line = f.readline()
            line = line.strip().lower()
            if line.endswith("annotation file"):
                self._format = 'Caltech'
                self._read_caltech(f)
            elif line.startswith("scorevideo log"):
                self._format = 'Ethovision'
                self._read_ethovision(f)
            else:
                print("Unsupported annotation file format")

    def read_io(self, uploaded_file):
        text_str = uploaded_file.getvalue().decode("utf-8")
        with StringIO(text_str) as f:
            f.__setattr__('name', uploaded_file.name)
            line = f.readline()
            line = line.strip().lower()
            if line.endswith("annotation file"):
                self._format = 'Caltech'
                self._read_caltech(f)
            elif line.startswith("scorevideo log"):
                self._format = 'Ethovision'
                self._read_ethovision(f)
            else:
                print("Unsupported annotation file format")

    def _read_caltech(self, f):
        found_movies = False
        found_timecode = False
        found_stimulus = False
        found_channel_names = False
        found_annotation_names = False
        found_all_channels = False
        found_all_annotations = False
        new_behaviors_activated = False
        reading_channel = False
        to_activate = []
        channel_names = []
        current_channel = None
        current_bout = None

        self._format = 'Caltech'

        line = f.readline()
        while line:
            if found_annotation_names and not new_behaviors_activated:
                self.ensure_and_activate_behaviors(to_activate)
                new_behaviors_activated = True

            line.strip()

            if not line:
                if reading_channel:
                    reading_channel = False
                    current_channel = None
                    current_bout = None
            elif line.lower().startswith("movie file"):
                items = line.split()
                for item in items:
                    if item.lower().startswith("movie"):
                        continue
                    if item.lower().startswith("file"):
                        continue
                    self._movies.append(item)
                found_movies = True
            elif line.lower().startswith("stimulus name"):
                # TODO: do something when we know what one of these looks like
                found_stimulus = True
            elif line.lower().startswith("annotation start frame") or line.lower().startswith("annotation start time"):
                items = line.split()
                if len(items) > 3:
                    try:
                        self._start_frame = int(items[3])
                    except:
                        self._start_frame = int(float(items[3]))
                    if self._end_frame and self._sample_rate:
                        found_timecode = True
            elif line.lower().startswith("annotation stop frame") or  line.lower().startswith("annotation stop time"):
                items = line.split()
                if len(items) > 3:
                    try:
                        self._end_frame = int(items[3])
                    except:
                        self._end_frame = int(float(items[3]))
                    if self._start_frame and self._sample_rate:
                        found_timecode = True
            elif line.lower().startswith("annotation framerate"):
                items = line.split()
                if len(items) > 2:
                    self._sample_rate = float(items[2])
                    if self._start_frame and self._end_frame:
                        found_timecode = True
            elif line.lower().startswith("list of channels"):
                line = f.readline()
                while line:
                    line = line.strip()
                    if not line:
                        break # blank line -- end of section
                    channel_names.append(line)
                    line = f.readline()
                found_channel_names = True
            elif line.lower().startswith("list of annotations"):
                line = f.readline()
                while line:
                    line = line.strip()
                    if not line:
                        break # blank line -- end of section
                    to_activate.append(line)
                    line = f.readline().strip()
                found_annotation_names = True
            elif line.strip().lower().endswith("---"):
                for ch_name in channel_names:
                    if line.startswith(ch_name):
                        self._channels[ch_name] = Channel()
                        current_channel = ch_name
                        reading_channel = True
                        break
                if reading_channel:
                    reading_annot = False
                    line = f.readline()
                    while line:
                        line = line.strip()
                        if not line: # blank line
                            if reading_annot:
                                reading_annot = False
                                current_bout = None
                            else:
                                # second blank line, so done with channel
                                reading_channel = False
                                current_channel = None
                                break
                        elif line.startswith(">"):
                            current_bout = line[1:]
                            reading_annot = True
                        elif line.lower().startswith("start"):
                            pass # skip a header line
                        else:
                            items = line.split()
                            is_float = '.' in items[0] or '.' in items[1] or '.' in items[2]
                            bout_start = items[0]
                            if float(bout_start) < 1:
                                bout_start = 1
                            bout_end = items[1]
                            if float(bout_end) < 1:
                                bout_end = 1
                            self.add_bout(
                                Bout(
                                    tc.Timecode(self._sample_rate, start_seconds=float(bout_start)) if is_float
                                        else tc.Timecode(self._sample_rate, frames=int(bout_start)),
                                    tc.Timecode(self._sample_rate, start_seconds=float(bout_end)) if is_float
                                        else tc.Timecode(self._sample_rate, frames=int(bout_end)),
                                    self._behaviors.get(current_bout)),
                                current_channel)
                        line = f.readline()
            line = f.readline()
        print(f"Done reading Caltech annotation file {f.name}")
        self.note_annotations_changed()

    def write_caltech(self, f, video_files, stimulus):
        if not f.writable():
            raise RuntimeError("File not writable")
        f.write("Bento annotation file\n")
        f.write("Movie file(s):")
        for file in video_files:
            f.write(' ' + file)
        f.write('\n\n')

        f.write(f"Stimulus name: {stimulus}\n")
        f.write(f"Annotation start frame: {self._start_frame}\n")
        f.write(f"Annotation stop frame: {self._end_frame}\n")
        f.write(f"Annotation framerate: {self._sample_rate}\n")
        f.write("\n")

        f.write("List of Channels:\n")
        for ch in self.channel_names():
            f.write(ch + "\n")
        f.write("\n")

        f.write("List of annotations:\n")
        for annot in self.annotation_names:
            f.write(annot + "\n")
        f.write("\n")

        for ch in self.channel_names():
            by_name = {}
            f.write(f"{ch}----------\n")

            for bout in self.channel(ch):
                if not by_name.get(bout.name()):
                    by_name[bout.name()] = []
                by_name[bout.name()].append(bout)

            for annot in by_name:
                f.write(f">{annot}\n")
                f.write("Start\tStop\tDuration\n")
                for bout in by_name[annot]:
                    start = bout.start().frames
                    end = bout.end().frames
                    f.write(f"{start}\t{end}\t{end - start}\n")
                f.write("\n")

            f.write("\n")

        f.close()
        print(f"Done writing Caltech annotation file {f.name}")

    def _read_ethovision(self, f):
        print("Ethovision annotations not yet supported")

    def clear_channels(self):
        self._channels.clear()

    def channel_names(self):
        return list(self._channels.keys())

    def channel(self, ch: str) -> Channel:
        return self._channels[ch]

    def addEmptyChannel(self, ch: str):
        if ch not in self.channel_names():
            self._channels[ch] = Channel()

    def add_bout(self, bout, channel):
        if bout.name() not in self.annotation_names:
            self.annotation_names.append(bout.name())
        self._channels[channel].add(bout)
        if bout.end() > self.end_time():
            self.set_end_frame(bout.end())

    def start_time(self):
        """

        At some point we will need to support a start time distinct from

        frame number, perhaps derived from the OS file modify time

        or the start time of the corresponding video (or other media) file

        """
        if not self._start_frame or not self._sample_rate:
            return tc.Timecode('30.0', '0:0:0:0')
        return tc.Timecode(self._sample_rate, frames=self._start_frame)

    def start_frame(self):
        return self._start_frame

    def set_start_frame(self, t):
        if isinstance(t, int):
            self._start_frame = t
        elif isinstance(t, tc.Timecode):
            self._start_frame = t.frames
        else:
            raise TypeError("Expected a frame number or Timecode")

    def end_time(self):
        if not self._end_frame or not self._sample_rate:
            return tc.Timecode('30.0', '23:59:59:29')
        return tc.Timecode(self._sample_rate, frames=self._end_frame)

    def end_frame(self):
        return self._end_frame

    def set_end_frame(self, t):
        if isinstance(t, int):
            self._end_frame = t
        elif isinstance(t, tc.Timecode):
            self._end_frame = t.frames
        else:
            raise TypeError("Expected a frame number or Timecode")

    def sample_rate(self):
        return self._sample_rate

    def set_sample_rate(self, sample_rate):
        self._sample_rate = sample_rate

    def format(self):
        return self._format

    def set_format(self, format):
        self._format = format

    def delete_bouts_by_name(self, behavior_name):
        deleted_names = set()
        for chan_name in self.channel_names():
            deleted_names.update(self.channel(chan_name).delete_bouts_by_name(behavior_name))
        for name in deleted_names:
            self.annotation_names.remove(name)

    def delete_inactive_bouts(self):
        deleted_names = set()
        for chan_name in self.channel_names():
            deleted_names.update(self.channel(chan_name).delete_inactive_bouts())
        for name in deleted_names:
            self.annotation_names.remove(name)

    def ensure_and_activate_behaviors(self, toActivate):
        behaviorSetUpdated = False
        for behaviorName in toActivate:
            behaviorSetUpdated |= self._behaviors.addIfMissing(behaviorName)
            self.annotation_names.append(behaviorName)
            self._behaviors.get(behaviorName).set_active(True)
        if behaviorSetUpdated:
            self.annotations_changed.emit()
        self.active_annotations_changed.emit()

    def ensure_active_behaviors(self):
        for behavior in self._behaviors:
            if behavior.is_active() and behavior.get_name() not in self.annotation_names:
                self.annotation_names.append(behavior.get_name())

    def truncate_or_remove_bouts(self, behavior, start, end, chan):
        """

        Delete bouts entirely within the range [start, end], or

        truncate bouts that extend outside the range.

        If behavior matches _deleteBehavior, the activity affects

        all bouts.  Otherwise, it only affects bouts with matching behavior.

        """
        delete_all = (behavior.get_name() == self._behaviors.getDeleteBehavior().get_name())
        self._channels[chan].truncate_or_remove_bouts(behavior, start, end, delete_all)
        self.note_annotations_changed()

    def coalesce_bouts(self, start, end, chan):
        """

        combine overlapping bouts of the same behavior within [start, end]

        """
        self._channels[chan].coalesce_bouts(start, end)
        self.note_annotations_changed()

    @Slot()
    def note_annotations_changed(self):
        self.annotations_changed.emit()