diff --git a/docs/source/_static/detections/detection_labelled.png b/docs/source/_static/detections/detection_labelled.png new file mode 100644 index 000000000..a837122c7 Binary files /dev/null and b/docs/source/_static/detections/detection_labelled.png differ diff --git a/docs/source/_static/detections/detection_unlabelled.png b/docs/source/_static/detections/detection_unlabelled.png new file mode 100644 index 000000000..63f754fd2 Binary files /dev/null and b/docs/source/_static/detections/detection_unlabelled.png differ diff --git a/docs/source/_static/detections/label_anchors.png b/docs/source/_static/detections/label_anchors.png new file mode 100644 index 000000000..33b8919a5 Binary files /dev/null and b/docs/source/_static/detections/label_anchors.png differ diff --git a/docs/source/aplose.rst b/docs/source/aplose.rst index 646f60183..8267f050c 100644 --- a/docs/source/aplose.rst +++ b/docs/source/aplose.rst @@ -38,7 +38,9 @@ The :class:`osekit.core.detection.Detection` class inherits from the :class:`ose Plotting a detection ^^^^^^^^^^^^^^^^^^^^ -Detection boxes can be plotted on spectrograms thanks to the :method:`osekit.core.detection.Detection.to_rectangle` method: +Detection boxes can be plotted on spectrograms thanks to the :meth:`osekit.core.detection.Detection.plot` method. + +First, let's plot a spectrogram, and keep track of the ``Axes`` in which the spectrogram is plot (returned by the :meth:`osekit.core.spectro_data.SpectroData.plot` method): .. code-block:: python @@ -49,16 +51,69 @@ Detection boxes can be plotted on spectrograms thanks to the :method:`osekit.cor sd = SpectroData(...) detection = Detection(...) - fig, axs = plt.subplots() + # Plot the spectrogram and keep the Axes in which the plot is made + ax = sd.plot(ax=ax) + + +Now, we can plot the detection directly in the ``ax`` Axes. +The detection is plotted as a `matplotlib Rectangle `_. +Keyword arguments can be passed to the rectangle constructor thanks to the ``detection_rect_kwargs`` parameter: + +.. code-block:: python + + detection.plot( + ax=ax, + detection_rect_kwargs={ # Keyword arguments passed to the Rectangle constructor + "color": "#fde725", + "linewidth": 7, + }, + ) + + # Show the spectrogram with the detection plotted on top of it + plt.show() + +.. image:: + _static/detections/detection_unlabelled.png + :align: center + +Detection labels (:class:`osekit.core.detection.Label`) can be added to the detection rectangle thanks to the ``plot_label`` parameter. - # Plot the spectrogram - sd.plot(ax=ax) +Labels consist in a background `matplotlib Rectangle `_ and a foreground +`matplotlib Text `_. - # Get a rectangle from the detection - rectangle = detection.to_rectangle(fill = False) +Keyword arguments can be passed to the background rectangle thanks to the ``background_kwargs`` parameter and to the foreground text thanks to the +``text_kwargs`` parameter: + +.. code-block:: python - # Draw the detection - ax.add_patch(rectangle) + detection.plot( + ax=ax, + detection_rect_kwargs={ # Keyword arguments passed to the detection Rectangle constructor + "color": "#fde725", + "linewidth": 7, + }, + plot_label=True, + label_kwargs={ + "anchor": "bottom_left", + "inner_text": True, + "text_kwargs": { # Keyword arguments passed to the label Text + "color": "#440154", + "size": "x-large" + }, + "background_kwargs": {}, # Keyword arguments passed to the label background rectangle + }, + ) - # Show the spectrogram plt.show() + +.. image:: + _static/detections/detection_labelled.png + :align: center + +The label position (relative to the detection rectangle) can be set thanks to the ``anchor`` and ``inner_text`` parameters. + +The following figure displays all 8 possible combinations. In the notation ``x_y_z``, ``x_y`` represents the anchor (``T_R`` stands for ``"top_right"``) and ``z`` represents the ``inner_text`` parameter (``I`` for ``True`` (inner), ``O`` for ``False`` (outer)). + +.. image:: + _static/detections/label_anchors.png + :align: center diff --git a/docs/source/detection.rst b/docs/source/detection.rst index 463622806..c65eaa103 100644 --- a/docs/source/detection.rst +++ b/docs/source/detection.rst @@ -6,6 +6,9 @@ Detection .. autoclass:: osekit.core.detection.Detection :members: +.. autoclass:: osekit.core.detection.Label + :members: + .. autoclass:: osekit.core.detection.FrequencyBounds :members: diff --git a/docs/source/example_aplose_result.ipynb b/docs/source/example_aplose_result.ipynb index e8b27107c..7c485ef1f 100644 --- a/docs/source/example_aplose_result.ipynb +++ b/docs/source/example_aplose_result.ipynb @@ -2,6 +2,7 @@ "cells": [ { "cell_type": "code", + "execution_count": null, "id": "initial_id", "metadata": { "collapsed": true, @@ -9,6 +10,7 @@ "remove-cell" ] }, + "outputs": [], "source": [ "# Executing this cell will:\n", "\n", @@ -21,9 +23,7 @@ "from osekit import setup_logging\n", "\n", "setup_logging() # Overwrites the default logger to" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -36,8 +36,9 @@ ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "90049102bdc38599", + "metadata": {}, "source": [ "# Creating the Public Project\n", "\n", @@ -46,22 +47,24 @@ "First, we will build a project and run a transform that would be uploaded and annotated on APLOSE (see the [Public API documentation](https://project-osmose.github.io/OSEkit/publicapi_usage.html) for more info).\n", "\n", "The `_static/detections/aplose_results.csv` file used in this notebook simulates the results of this annotation campaign." - ], - "id": "90049102bdc38599" + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "e2d5321198880205", + "metadata": {}, "source": [ "## Build the Project\n", "\n", "First, we have to build the project from the raw audio files:" - ], - "id": "e2d5321198880205" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "3ab3cb447c59a857", + "metadata": {}, + "outputs": [], "source": [ "from pathlib import Path\n", "from osekit.public.project import Project\n", @@ -78,24 +81,24 @@ ")\n", "\n", "project.build()" - ], - "id": "3ab3cb447c59a857", - "outputs": [], - "execution_count": null + ] }, { - "metadata": {}, "cell_type": "markdown", + "id": "2f5510c9c396ee2f", + "metadata": {}, "source": [ "## Declare & Run the Transform\n", "\n", "Then we **declare** and **run** a `Transform` which would export the spectrograms to be annotated:" - ], - "id": "2f5510c9c396ee2f" + ] }, { - "metadata": {}, "cell_type": "code", + "execution_count": null, + "id": "d993991e8c23a2c0", + "metadata": {}, + "outputs": [], "source": [ "from osekit.public.transform import Transform, OutputType\n", "from osekit.utils.audio import Normalization\n", @@ -120,10 +123,7 @@ "ads.remove_empty_data(threshold=0.99)\n", "\n", "project.run(transform=transform, audio_dataset=ads)" - ], - "id": "d993991e8c23a2c0", - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -133,16 +133,16 @@ }, { "cell_type": "code", + "execution_count": null, "id": "1948b260fcaf03ab", "metadata": {}, + "outputs": [], "source": [ "from pathlib import Path\n", "from osekit.core.detection import Detection\n", "\n", "detections = Detection.from_csv(csv=Path(r\"_static/detections/aplose_results.csv\"))" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -171,8 +171,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "45892d179652235b", "metadata": {}, + "outputs": [], "source": [ "def does_satisfy_constraints(detection: Detection) -> bool:\n", " # Keeping only odontocete whistles\n", @@ -196,9 +198,7 @@ "filtered_detections = [\n", " detection for detection in detections if does_satisfy_constraints(detection)\n", "]" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -212,8 +212,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "237d399b7de6ffd5", "metadata": {}, + "outputs": [], "source": [ "# Recover the transform output (SpectroDataset)\n", "sds = project.get_output(output_name=\"example_transform\")\n", @@ -224,9 +226,7 @@ " for sd in sds.data\n", " if any(detection.overlaps(sd) for detection in filtered_detections)\n", "]" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "markdown", @@ -240,8 +240,10 @@ }, { "cell_type": "code", + "execution_count": null, "id": "d0d242240e791509", "metadata": {}, + "outputs": [], "source": [ "import matplotlib.pyplot as plt\n", "\n", @@ -261,25 +263,36 @@ " continue\n", "\n", " # Detections are plotted as matplotlib Rectangles\n", - " rectangle = detection.to_rectangle(fill=False)\n", - " ax.add_patch(rectangle)\n", + " detection.plot(\n", + " ax=ax,\n", + " plot_label=True, # We can plot the label along with the detection rectangle\n", + " detection_rect_kwargs={ # kwargs passed to the detection matplotlib Rectangle\n", + " \"color\": \"#fde725\"\n", + " },\n", + " label_kwargs={ # kwargs passed to the Label osekit object\n", + " \"anchor\": \"bottom_left\",\n", + " \"inner_text\": False,\n", + " \"background_kwargs\": {}, # kwargs passed to the label background matplotlib Rectangle\n", + " \"text_kwargs\": {\n", + " \"color\": \"#440154\"\n", + " }, # kwargs passed to the label matplotlib Text\n", + " },\n", + " )\n", "\n", "# Let's take a look at the output figure\n", "plt.show()" - ], - "outputs": [], - "execution_count": null + ] }, { "cell_type": "code", + "execution_count": null, "id": "449dc442b4a5df75", "metadata": {}, + "outputs": [], "source": [ "# Reset the project to get all files back to place.\n", "project.reset()" - ], - "outputs": [], - "execution_count": null + ] } ], "metadata": { diff --git a/src/osekit/core/detection.py b/src/osekit/core/detection.py index b907dde84..e4b12065b 100644 --- a/src/osekit/core/detection.py +++ b/src/osekit/core/detection.py @@ -6,8 +6,11 @@ from typing import Any, Literal, Self import pandas as pd +from matplotlib.axes import Axes from matplotlib.patches import Rectangle -from pandas import Timestamp +from matplotlib.text import Text +from matplotlib.transforms import TransformedBbox +from pandas import Timedelta, Timestamp from osekit.core.event import Event from osekit.utils.core import is_empty_dataclass @@ -328,6 +331,152 @@ def to_dict(self) -> dict: } +class Label: + """Class that represents a label of a detection. + + It contains helper methods to plot the label next to the + detection rectangle using pyplot. + """ + + def __init__( + self, + text: str, + anchor: Literal[ + "top_left", + "top_right", + "bottom_right", + "bottom_left", + ] = "top_left", + *, + inner_text: bool = False, + text_kwargs: dict | None = None, + background_kwargs: dict | None = None, + ) -> None: + """Initialize the label object. + + Parameters + ---------- + text: str + Text of the label. + anchor: Literal["top_left", "top_right", "bottom_right", "bottom_left"] + Anchor of the label relative to the detection rectangle. + color: str + Color of the label rectangle. + text_color: str + Color of the label text. + inner_text: bool + If ``True``, the label rectangle is plotted inside the detection rectangle. + fill: bool + If ``True``, the label rectangle is plotted as a fill. + text_kwargs: dict|None + Additional kwargs to pass to the ``Text``. + background_kwargs: dict|None + Additional kwargs to pass to the background ``Rectangle``. + + """ + self.text = text + self.anchor = anchor + self.inner_text = inner_text + self.text_kwargs = text_kwargs or {} + self.background_kwargs = background_kwargs or {} + + def get_text_size(self, ax: Axes) -> tuple[Timedelta, float]: + """Return the width and height of the label text. + + The size is given as a ``Timedelta`` on the X axis and a float on the + Y axis. + + Parameters + ---------- + ax: Axes + Axes in which the text is drawn. + + Returns + ------- + Timedelta: + Width of the label text. + float: + Height of the label text. + + """ + # We add a Text object with the given text to the Axes + # to measure its size, then remove it + text = Text(text=self.text, **self.text_kwargs) + ax.add_artist(text) + renderer = ax.get_figure().canvas.get_renderer() + text_bbox = text.get_window_extent(renderer=renderer) # display coordinates + text.remove() + + # Conversion of the bbox width in Timedelta + text_bbox = TransformedBbox(bbox=text_bbox, transform=ax.transData.inverted()) + return Timedelta(days=text_bbox.width), text_bbox.height + + def get_coordinates( + self, + ax: Axes, + labelled_rect: Rectangle, + ) -> tuple[float, float]: + """Return the coordinates of the bottom left point of the label. + + The X coordinate is given as a ``Timestamp``, the Y coordinate + as a float. + + Parameters + ---------- + ax: Axes + Axes in which the text is drawn. + labelled_rect: Rectangle + Rectangle that is labelled by the label. + + Returns + ------- + Timestamp: + X coordinate of the label. + float: + Y Coordinate of the label. + + """ + x0, y0 = labelled_rect.xy + x1 = x0 + labelled_rect.get_width() + y1 = y0 + labelled_rect.get_height() + + label_width, label_height = self.get_text_size(ax=ax) + + vertical_anchor, horizontal_anchor = self.anchor.split("_", maxsplit=1) + + label_x = x0 if horizontal_anchor == "left" else (x1 - label_width) + if self.inner_text: + label_y = y0 if vertical_anchor == "bottom" else y1 - label_height + else: + label_y = y0 - label_height if vertical_anchor == "bottom" else y1 + return label_x, label_y + + def get_rectangle(self, ax: Axes, labelled_rect: Rectangle) -> Rectangle: + """Return the background rectangle of the label. + + Parameters + ---------- + ax: Axes + Axes in which the label is drawn. + labelled_rect: Rectangle + Rectangle that is labelled by the label. + + Returns + ------- + Rectangle + Background rectangle of the label + + """ + xy = self.get_coordinates(ax=ax, labelled_rect=labelled_rect) + width, height = self.get_text_size(ax=ax) + return Rectangle( + xy=xy, + height=height, + width=width, + **self.background_kwargs, + ) + + class Detection(Event): """Class that represents a detection made on APLOSE.""" @@ -516,6 +665,58 @@ def to_dict(self) -> dict: for verificator, verification in kvp.to_dict().items() } + def plot( + self, + ax: Axes, + *, + plot_label: bool = False, + detection_rect_kwargs: dict | None = None, + label_kwargs: dict | None = None, + ) -> None: + """Plot the detection on the given ``Axes``. + + Parameters + ---------- + ax: Axes + Axes in which to plot the detection box. + plot_label: bool + Whether or not to add the label in the detection plot. + detection_rect_kwargs: dict|None + Additional kwargs to pass to the detection rectangle. + label_kwargs: dict|None + Additional kwargs to pass to the ``Label`` object. + + """ + detection_rect_kwargs = detection_rect_kwargs or {} + label_kwargs = label_kwargs or {"text_kwargs": {}, "background_kwargs": {}} + + detection_rectangle = self.to_rectangle(**detection_rect_kwargs) + ax.add_patch(p=detection_rectangle) + + if not self.label or not plot_label: + return + + # Default color is rectangle color + if "color" in detection_rect_kwargs and "color" not in label_kwargs.get( + "background_kwargs", + {}, + ): + if "background_kwargs" not in label_kwargs: + label_kwargs["background_kwargs"] = {} + label_kwargs["background_kwargs"]["color"] = detection_rect_kwargs["color"] + + label = Label( + text=self.label, + **label_kwargs, + ) + label_rectangle = label.get_rectangle(ax=ax, labelled_rect=detection_rectangle) + ax.add_patch(p=label_rectangle) + ax.annotate( + text=label.text, + xy=label.get_coordinates(ax=ax, labelled_rect=detection_rectangle), + **label.text_kwargs, + ) + def to_rectangle(self, *, fill: bool = False, **kwargs: Any) -> Rectangle: """Return a matplotlib Rectangle representing the detection. @@ -575,7 +776,7 @@ def from_csv(cls, csv: Path | list[Path], **kwargs: Any) -> list[Self]: Path of the detections csv file. If csv is a list, all detections from the multiple csv files are concatenated together. - **kwargs: Any + kwargs: Any Additional keyword arguments passed to the ``pandas.read_csv()`` method. Returns diff --git a/tests/conftest.py b/tests/conftest.py index bda459ea9..18ed2abfe 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,6 +11,9 @@ import pandas as pd import pytest import soundfile as sf +from matplotlib import pyplot as plt +from matplotlib.axes import Axes +from pandas import Timestamp from osekit import config from osekit.audio_backend.soundfile_backend import SoundFileBackend @@ -299,3 +302,22 @@ def patch_afm_info( return 48_000, 48_000, 1 monkeypatch.setattr(audio_file_manager, "info", patch_afm_info) + + +@pytest.fixture +def custom_axes() -> typing.Generator[Axes, None, None]: + """Create a deterministic Matplotlib axes with a datetime X axis.""" + fig, ax = plt.subplots(figsize=(10, 5), dpi=100) + + ax.set_xlim( + Timestamp("2020-01-01 00:00:00"), + Timestamp("2020-01-01 00:01:00"), + ) + ax.set_ylim(0, 1) + + # Make sure the renderer and transforms are initialized. + fig.canvas.draw() + + yield ax + + plt.close(fig) diff --git a/tests/test_detection.py b/tests/test_detection.py index 966fb9e39..a5f44636f 100644 --- a/tests/test_detection.py +++ b/tests/test_detection.py @@ -1,9 +1,12 @@ from contextlib import AbstractContextManager, nullcontext from pathlib import Path +from typing import Any, Literal import numpy as np import pytest -from pandas import DataFrame, Timestamp +from matplotlib.axes import Axes +from matplotlib.patches import Rectangle +from pandas import DataFrame, Timedelta, Timestamp from osekit.core.detection import ( ConfidenceIndicator, @@ -11,6 +14,7 @@ DetectionMetaData, DetectorInfo, FrequencyBounds, + Label, SignalParameters, Verification, ) @@ -398,3 +402,372 @@ def test_detections_from_csv_list() -> None: ) assert len(detections) == 4 + + +def test_label_init() -> None: + text = "cool" + anchor = "bottom_left" + inner_text = True + text_kwargs = { + "fontsize": 12, + "color": "red", + } + background_kwargs = {"fill": True, "color": "blue"} + + label = Label( + text=text, + anchor=anchor, + inner_text=inner_text, + text_kwargs=text_kwargs, + background_kwargs=background_kwargs, + ) + + assert label.text == text + assert label.anchor == anchor + assert label.inner_text == inner_text + assert label.text_kwargs == text_kwargs + assert label.background_kwargs == background_kwargs + + +def test_label_get_text_size_is_positive(custom_axes: Axes) -> None: + label = Label("cool") + + width, height = label.get_text_size(ax=custom_axes) + + assert isinstance(width, Timedelta) + + assert width > Timedelta(0) + assert height > 0 + + +def test_label_get_size_longer_for_longer_text(custom_axes: Axes) -> None: + short_label = Label("cool") + long_label = Label("ultra cool stuff") + + width1, height1 = short_label.get_text_size(ax=custom_axes) + width2, height2 = long_label.get_text_size(ax=custom_axes) + + assert width1 < width2 + assert height1 == pytest.approx(height2) + + +def test_label_get_size_depends_on_text_size(custom_axes: Axes) -> None: + small_text = Label("cool", text_kwargs={"fontsize": 12}) + large_text = Label("cool", text_kwargs={"fontsize": 24}) + + small_width, small_height = small_text.get_text_size(ax=custom_axes) + large_width, large_height = large_text.get_text_size(ax=custom_axes) + + assert small_width < large_width + assert small_height < large_height + + +def test_get_size_removes_text_from_ax(custom_axes: Axes) -> None: + initial_artists = len(custom_axes.texts) + + Label("cool").get_text_size(ax=custom_axes) + + assert len(custom_axes.texts) == initial_artists + + +@pytest.mark.parametrize( + ("anchor", "inner", "expected_x", "expected_y"), + [ + pytest.param( + "bottom_left", + True, + Timestamp("2020-01-01 00:00:00"), + 100, + id="bottom_left_inner", + ), + pytest.param( + "bottom_left", + False, + Timestamp("2020-01-01 00:00:00"), + 80, + id="bottom_left_outer", + ), + pytest.param( + "top_left", + True, + Timestamp("2020-01-01 00:00:00"), + 130, + id="top_left_inner", + ), + pytest.param( + "top_left", + False, + Timestamp("2020-01-01 00:00:00"), + 150, + id="top_left_outer", + ), + pytest.param( + "bottom_right", + True, + Timestamp("2020-01-01 00:00:20"), + 100, + id="bottom_right_inner", + ), + pytest.param( + "bottom_right", + False, + Timestamp("2020-01-01 00:00:20"), + 80, + id="bottom_right_outer", + ), + pytest.param( + "top_right", + True, + Timestamp("2020-01-01 00:00:20"), + 130, + id="top_right_inner", + ), + pytest.param( + "top_right", + False, + Timestamp("2020-01-01 00:00:20"), + 150, + id="top_right_outer", + ), + ], +) +def test_label_get_coordinates( + monkeypatch: pytest.MonkeyPatch, + anchor: Literal["top_left", "top_right", "bottom_right", "bottom_left"], + inner: bool, + expected_x: Timestamp, + expected_y: float, +) -> None: + monkeypatch.setattr( + Label, + "get_text_size", + lambda self, ax: (Timedelta(seconds=10), 20), + ) + + detection_rectangle = Rectangle( + xy=(Timestamp("2020-01-01 00:00:00"), 100), + width=Timedelta(seconds=30), + height=50, + ) + + x, y = Label("", anchor=anchor, inner_text=inner).get_coordinates( + ax=None, + labelled_rect=detection_rectangle, + ) + + assert x == expected_x + assert y == expected_y + + +def test_label_get_rectangle(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + Label, + "get_coordinates", + lambda self, ax, labelled_rect: (Timestamp("2020-01-01 00:00:00"), 100), + ) + + monkeypatch.setattr( + Label, + "get_text_size", + lambda self, ax: (Timedelta(seconds=10), 20), + ) + + label = Label( + text="cool", + background_kwargs={"color": (0.0, 0.0, 1.0), "alpha": 0.3}, + ) + + detection_rectangle = Rectangle( + xy=(Timestamp("2020-01-01 00:00:00"), 100), + width=Timedelta(seconds=30), + height=50, + ) + + rectangle = label.get_rectangle(ax=None, labelled_rect=detection_rectangle) + + assert rectangle.xy == (Timestamp("2020-01-01 00:00:00"), 100) + assert rectangle.get_width() == Timedelta(seconds=10) + assert rectangle.get_height() == 20 + + assert rectangle.get_facecolor()[:-1] == (0.0, 0.0, 1.0) + assert rectangle.get_alpha() == 0.3 + + +def test_detection_plot_passes_rect_kwargs( + custom_axes: Axes, + sample_detection: Detection, +) -> None: + sample_detection.plot( + ax=custom_axes, + detection_rect_kwargs={"color": (0.0, 1.0, 0.0)}, + ) + + assert len(custom_axes.patches) == 1 + + rectangle: Rectangle = custom_axes.patches[0] + assert rectangle.get_facecolor()[:-1] == (0.0, 1.0, 0.0) + + +def test_detection_plot_doesnt_plot_label_if_parameter_is_false( + custom_axes: Axes, + sample_detection: Detection, +) -> None: + sample_detection.plot(ax=custom_axes, plot_label=False) + assert len(custom_axes.patches) == 1 + assert len(custom_axes.texts) == 0 + + +def test_detection_plot_doesnt_plot_label_if_no_label( + custom_axes: Axes, + sample_detection: Detection, +) -> None: + sample_detection.label = None + sample_detection.plot(ax=custom_axes, plot_label=True) + assert len(custom_axes.patches) == 1 + assert len(custom_axes.texts) == 0 + + +def test_detection_plot_passes_label_kwargs( + custom_axes: Axes, + sample_detection: Detection, + monkeypatch: pytest.MonkeyPatch, +) -> None: + label_kwargs = { + "anchor": "bottom_right", + "inner_text": True, + "text_kwargs": { + "fontsize": 42, + "color": "red", + }, + "background_kwargs": { + "color": "blue", + "alpha": 0.3, + }, + } + + initialized_labels_kwargs = [] + label_init = Label.__init__ + + def mock_label_init(*args: Any, **kwargs: Any) -> None: + initialized_labels_kwargs.append(kwargs) + label_init(*args, **kwargs) + + monkeypatch.setattr(Label, "__init__", mock_label_init) + + sample_detection.plot( + ax=custom_axes, + plot_label=True, + label_kwargs=label_kwargs, + ) + + assert len(initialized_labels_kwargs) == 1 + + # Check that all label_kwargs have been passed to the Label init + assert label_kwargs.items() <= initialized_labels_kwargs[0].items() + assert initialized_labels_kwargs[0]["text"] == sample_detection.label + + +@pytest.mark.parametrize( + "label_kwargs", + [ + pytest.param( + { + "anchor": "bottom_right", + "inner_text": True, + "text_kwargs": { + "fontsize": 42, + "color": "red", + }, + }, + id="without_background_kwargs", + ), + pytest.param( + { + "anchor": "bottom_right", + "inner_text": True, + "text_kwargs": { + "fontsize": 42, + "color": "red", + }, + "background_kwargs": { + "alpha": 0.3, + }, + }, + id="with_background_kwargs", + ), + ], +) +def test_default_label_color_is_detection_color( + custom_axes: Axes, + sample_detection: Detection, + monkeypatch: pytest.MonkeyPatch, + label_kwargs: dict, +) -> None: + detection_rect_kwargs = { + "color": (0.0, 1.0, 0.0), + } + + label_get_rectangle = Label.get_rectangle + spied_label_rectangles = [] + + def spy_label_rectangle(*args: Any, **kwargs: Any) -> Rectangle: + output = label_get_rectangle(*args, **kwargs) + spied_label_rectangles.append(output) + return output + + monkeypatch.setattr(Label, "get_rectangle", spy_label_rectangle) + + sample_detection.plot( + ax=custom_axes, + detection_rect_kwargs=detection_rect_kwargs, + label_kwargs=label_kwargs, + plot_label=True, + ) + + assert len(spied_label_rectangles) == 1 + + spied_rectangle = spied_label_rectangles[0] + assert spied_rectangle.get_facecolor()[:-1] == detection_rect_kwargs["color"] + + +def test_detection_plot_with_label_adds_patch_and_text( + custom_axes: Axes, + sample_detection: Detection, +) -> None: + label_kwargs = { + "anchor": "bottom_right", + "inner_text": True, + "text_kwargs": { + "fontsize": 42, + "color": "red", + }, + "background_kwargs": { + "alpha": 0.3, + "color": (0.0, 0.0, 1.0), + }, + } + + detection_rect_kwargs = { + "color": (0.0, 1.0, 0.0), + } + + sample_detection.plot( + ax=custom_axes, + detection_rect_kwargs=detection_rect_kwargs, + label_kwargs=label_kwargs, + plot_label=True, + ) + + assert len(custom_axes.patches) == 2 # Detection rect + label background + assert len(custom_axes.texts) == 1 # Label text + + detection_rect, label_background = custom_axes.patches + + assert detection_rect.get_facecolor()[:-1] == detection_rect_kwargs["color"] + assert ( + label_background.get_facecolor()[:-1] + == label_kwargs["background_kwargs"]["color"] + ) + + text = custom_axes.texts[0] + assert text.get_fontsize() == label_kwargs["text_kwargs"]["fontsize"]