Source code for pyannote.core.notebook

# The MIT License (MIT)

# Copyright (c) 2014-2019 CNRS

# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:

# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.

# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# AUTHORS
# Hervé BREDIN - http://herve.niderb.fr

"""
#############
Visualization
#############

:class:`pyannote.core.Segment`, :class:`pyannote.core.Timeline`,
:class:`pyannote.core.Annotation` and :class:`pyannote.core.SlidingWindowFeature`
instances can be directly visualized in notebooks.

You will however need to install ``pyannote.core``'s additional dependencies
for notebook representations (namely, matplotlib):


.. code-block:: bash

    pip install pyannote.core[notebook]


Segments
--------

.. code-block:: ipython

  In [1]: from pyannote.core import Segment

  In [2]: segment = Segment(start=5, end=15)
    ....: segment

.. plot:: pyplots/segment.py


Timelines
---------

.. code-block:: ipython

  In [25]: from pyannote.core import Timeline, Segment

  In [26]: timeline = Timeline()
     ....: timeline.add(Segment(1, 5))
     ....: timeline.add(Segment(6, 8))
     ....: timeline.add(Segment(12, 18))
     ....: timeline.add(Segment(7, 20))
     ....: timeline

.. plot:: pyplots/timeline.py


Annotations
-----------


.. code-block:: ipython

    In [1]: from pyannote.core import Annotation, Segment

    In [6]: annotation = Annotation()
       ...: annotation[Segment(1, 5)] = 'Carol'
       ...: annotation[Segment(6, 8)] = 'Bob'
       ...: annotation[Segment(12, 18)] = 'Carol'
       ...: annotation[Segment(7, 20)] = 'Alice'
       ...: annotation

.. plot:: pyplots/annotation.py

"""

from collections.abc import Iterable
from itertools import cycle, groupby, product

import numpy as np

from .annotation import Annotation
from .feature import SlidingWindowFeature
from .segment import Segment, SlidingWindow
from .timeline import Timeline
from .utils.types import Label, LabelStyle, Resource

try:
    import matplotlib  # noqa: F401 -- probe whether the optional dependency imports
except ImportError:
    MATPLOTLIB_IS_AVAILABLE = False
else:
    MATPLOTLIB_IS_AVAILABLE = True

MATPLOTLIB_WARNING = (
    "Couldn't import matplotlib to render the vizualization "
    "for object {klass}. To enable, install the required dependencies "
    "with 'pip install pyannore.core[notebook]'"
)


class Notebook:
    def __init__(self):
        self.reset()

    def reset(self):
        from matplotlib.pyplot import get_cmap

        linewidth = [3, 1]
        linestyle = ["solid", "dashed", "dotted"]

        cm = get_cmap("Set1")
        colors = [cm(1.0 * i / 8) for i in range(9)]

        self._style_generator = cycle(product(linestyle, linewidth, colors))
        self._style: dict[Label | None, LabelStyle] = {
            None: ("solid", 1, (0.0, 0.0, 0.0))
        }
        del self.crop
        del self.width

    @property
    def crop(self):
        """The crop property."""
        return self._crop

    @crop.setter
    def crop(self, segment: Segment):
        self._crop = segment

    @crop.deleter
    def crop(self):
        self._crop = None

    @property
    def width(self):
        """The width property"""
        return self._width

    @width.setter
    def width(self, value: int):
        self._width = value

    @width.deleter
    def width(self):
        self._width = 20

    def __getitem__(self, label: Label) -> LabelStyle:
        """Get line style for a given label"""
        if label not in self._style:
            self._style[label] = next(self._style_generator)
        return self._style[label]

    def setup(self, ax=None, ylim=(0, 1), yaxis=False, time=True):
        import matplotlib.pyplot as plt

        if ax is None:
            ax = plt.gca()
        ax.set_xlim(self.crop)
        if time:
            ax.set_xlabel("Time")
        else:
            ax.set_xticklabels([])
        ax.set_ylim(ylim)
        ax.axes.get_yaxis().set_visible(yaxis)
        return ax

    def draw_segment(self, ax, segment: Segment, y, label=None, boundaries=True):

        # do nothing if segment is empty
        if not segment:
            return

        linestyle, linewidth, color = self[label]

        # draw segment
        ax.hlines(
            y,
            segment.start,
            segment.end,
            color,
            linewidth=linewidth,
            linestyle=linestyle,
            label=label,
        )
        if boundaries:
            ax.vlines(
                segment.start, y + 0.05, y - 0.05, color, linewidth=1, linestyle="solid"
            )
            ax.vlines(
                segment.end, y + 0.05, y - 0.05, color, linewidth=1, linestyle="solid"
            )

        if label is None:
            return

    def get_y(self, segments: Iterable[Segment]) -> np.ndarray:
        """

        Parameters
        ----------
        segments : Iterable
            `Segment` iterable (sorted)

        Returns
        -------
        y : np.array
            y coordinates of each segment

        """

        # up_to stores the largest end time
        # displayed in each line (at the current iteration)
        # (at the beginning, there is only one empty line)
        up_to = [-np.inf]

        # y[k] indicates on which line to display kth segment
        y = []

        for segment in segments:
            # so far, we do not know which line to use
            found = False
            # try each line until we find one that is ok
            for i, u in enumerate(up_to):
                # if segment starts after the previous one
                # on the same line, then we add it to the line
                if segment.start >= u:
                    found = True
                    y.append(i)
                    up_to[i] = segment.end
                    break
            # in case we went out of lines, create a new one
            if not found:
                y.append(len(up_to))
                up_to.append(segment.end)

        # from line numbers to actual y coordinates
        y = 1.0 - 1.0 / (len(up_to) + 1) * (1 + np.array(y))

        return y

    def __call__(self, resource: Resource, time: bool = True, legend: bool = True):

        if isinstance(resource, Segment):
            self.plot_segment(resource, time=time)

        elif isinstance(resource, Timeline):
            self.plot_timeline(resource, time=time)

        elif isinstance(resource, Annotation):
            self.plot_annotation(resource, time=time, legend=legend)

        elif isinstance(resource, SlidingWindowFeature):
            self.plot_feature(resource, time=time)

    def plot_segment(self, segment, ax=None, time=True):

        if not self.crop:
            self.crop = segment

        ax = self.setup(ax=ax, time=time)
        self.draw_segment(ax, segment, 0.5)

    def plot_timeline(self, timeline: Timeline, ax=None, time=True):

        if not self.crop and timeline:
            self.crop = timeline.extent()

        cropped = timeline.crop(self.crop, mode="loose")

        ax = self.setup(ax=ax, time=time)

        for segment, y in zip(cropped, self.get_y(cropped)):
            self.draw_segment(ax, segment, y)

        # ax.set_aspect(3. / self.crop.duration)

    def plot_annotation(self, annotation: Annotation, ax=None, time=True, legend=True):

        if not self.crop:
            self.crop = annotation.get_timeline(copy=False).extent()

        cropped = annotation.crop(self.crop, mode="intersection")
        segments = [s for s, _ in cropped.itertracks()]

        ax = self.setup(ax=ax, time=time)

        for (segment, track, label), y in zip(
            cropped.itertracks(yield_label=True), self.get_y(segments)
        ):
            self.draw_segment(ax, segment, y, label=label)

        if legend:
            H, L = ax.get_legend_handles_labels()

            # corner case when no segment is visible
            if not H:
                return

            # this gets exactly one legend handle and one legend label per label
            # (avoids repeated legends for repeated tracks with same label)
            HL = groupby(
                sorted(zip(H, L), key=lambda h_l: h_l[1]), key=lambda h_l: h_l[1]
            )
            H, L = zip(*[(next(h_l)[0], l) for l, h_l in HL])
            ax.legend(
                H,
                L,
                bbox_to_anchor=(0, 1),
                loc=3,
                ncol=5,
                borderaxespad=0.0,
                frameon=False,
            )

    def plot_feature(
        self, feature: SlidingWindowFeature, ax=None, time=True, ylim=None
    ):

        if not self.crop:
            self.crop = feature.extent

        window = feature.sliding_window
        n, _dimension = feature.data.shape
        ((start, stop),) = window.crop(self.crop, mode="loose", return_ranges=True)
        xlim = (window[start].middle, window[stop].middle)

        start = max(0, start)
        stop = min(stop, n)
        t = window[0].middle + window.step * np.arange(start, stop)
        data = feature[start:stop]

        if ylim is None:
            m = np.nanmin(data)
            M = np.nanmax(data)
            ylim = (m - 0.1 * (M - m), M + 0.1 * (M - m))

        ax = self.setup(ax=ax, yaxis=False, ylim=ylim, time=time)
        ax.plot(t, data)
        ax.set_xlim(xlim)


notebook = Notebook()


[docs] def repr_segment(segment: Segment): """Get `png` data for `segment`""" import matplotlib.pyplot as plt from IPython.core.pylabtools import print_figure figsize = plt.rcParams["figure.figsize"] plt.rcParams["figure.figsize"] = (notebook.width, 1) fig, ax = plt.subplots() notebook.plot_segment(segment, ax=ax) data = print_figure(fig, "png") plt.close(fig) plt.rcParams["figure.figsize"] = figsize return data
[docs] def repr_timeline(timeline: Timeline): """Get `png` data for `timeline`""" import matplotlib.pyplot as plt from IPython.core.pylabtools import print_figure figsize = plt.rcParams["figure.figsize"] plt.rcParams["figure.figsize"] = (notebook.width, 1) fig, ax = plt.subplots() notebook.plot_timeline(timeline, ax=ax) data = print_figure(fig, "png") plt.close(fig) plt.rcParams["figure.figsize"] = figsize return data
[docs] def repr_annotation(annotation: Annotation): """Get `png` data for `annotation`""" import matplotlib.pyplot as plt from IPython.core.pylabtools import print_figure figsize = plt.rcParams["figure.figsize"] plt.rcParams["figure.figsize"] = (notebook.width, 2) fig, ax = plt.subplots() notebook.plot_annotation(annotation, ax=ax) data = print_figure(fig, "png") plt.close(fig) plt.rcParams["figure.figsize"] = figsize return data
[docs] def repr_feature(feature: SlidingWindowFeature): """Get `png` data for `feature`""" import matplotlib.pyplot as plt from IPython.core.pylabtools import print_figure figsize = plt.rcParams["figure.figsize"] if feature.data.ndim == 2: plt.rcParams["figure.figsize"] = (notebook.width, 2) fig, ax = plt.subplots() notebook.plot_feature(feature, ax=ax) data = print_figure(fig, "png") plt.close(fig) elif feature.data.ndim == 3: num_chunks = len(feature) if notebook.crop is None: notebook.crop = Segment( start=feature.sliding_window.start, end=feature.sliding_window[num_chunks - 1].end, ) else: feature = feature.crop(notebook.crop, mode="loose", return_data=False) num_overlap = ( round(feature.sliding_window.duration // feature.sliding_window.step) + 1 ) num_overlap = min(num_chunks, num_overlap) plt.rcParams["figure.figsize"] = (notebook.width, 1.5 * num_overlap) fig, axes = plt.subplots(nrows=num_overlap, ncols=1, squeeze=False) axes = axes.flatten() mini, maxi = np.nanmin(feature.data), np.nanmax(feature.data) ylim = (mini - 0.2 * (maxi - mini), maxi + 0.2 * (maxi - mini)) for c, (window, data) in enumerate(feature): ax = axes[c % num_overlap] step = duration = window.duration / len(data) frames = SlidingWindow(start=window.start, step=step, duration=duration) window_feature = SlidingWindowFeature(data, frames, labels=feature.labels) notebook.plot_feature( window_feature, ax=ax, time=c % num_overlap == (num_overlap - 1), ylim=ylim, ) ax.set_prop_cycle(None) data = print_figure(fig, "png") plt.close(fig) plt.rcParams["figure.figsize"] = figsize return data