diff --git a/docs/source/_static/detections/aplose_results.csv b/docs/source/_static/detections/aplose_results.csv
new file mode 100644
index 00000000..6d067338
--- /dev/null
+++ b/docs/source/_static/detections/aplose_results.csv
@@ -0,0 +1,53 @@
+project,filename,annotation_id,is_update_of_id,start_time,end_time,start_frequency,end_frequency,min_frequency,max_frequency,annotation,annotator,annotator_expertise,start_datetime,end_datetime,is_box,type,confidence_indicator_label,confidence_indicator_level,comments,signal_quantity,signal_is_intensity_too_low,signal_does_overlap_other_signals,signal_start_frequency,signal_end_frequency,signal_relative_min_frequency_count,signal_relative_max_frequency_count,signal_steps_count,signal_has_harmonics,signal_trend,signal_sidebands,signal_subharmonics,signal_frequency_jumps,signal_deterministic_chaos,created_at_phase,ben,leslie
+doc_osekit,2022_09_25_22_35_15_000000,593717,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,ron,EXPERT,2022-09-25T22:35:15.000+00:00,2022-09-25T22:35:22.000+00:00,0,WEAK,100% sure!,3/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_15_000000,593718,,4.931,6.703,6665.0,22181.0,6665.0,22181.0,Odontocete whistle,ron,EXPERT,2022-09-25T22:35:19.931+00:00,2022-09-25T22:35:21.703+00:00,1,BOX,100% sure!,3/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_15_000000,593727,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,april,,2022-09-25T22:35:15.000+00:00,2022-09-25T22:35:22.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_15_000000,593728,,4.967,5.631,8025.0,15197.0,8025.0,15197.0,Odontocete whistle,april,,2022-09-25T22:35:19.967+00:00,2022-09-25T22:35:20.631+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_15_000000,593729,,6.16,6.658,7463.0,22135.0,7463.0,22135.0,Odontocete whistle,april,,2022-09-25T22:35:21.160+00:00,2022-09-25T22:35:21.658+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_15_000000,593737,,5.658,6.122,8447.0,20400.0,8447.0,20400.0,Odontocete whistle,april,,2022-09-25T22:35:20.658+00:00,2022-09-25T22:35:21.122+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_15_000000,593772,,5.936,5.936,20399.0,20399.0,20399.0,20399.0,Odontocete whistle,ben,EXPERT,2022-09-25T22:35:20.936+00:00,2022-09-25T22:35:20.936+00:00,1,POINT,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
+doc_osekit,2022_09_25_22_35_15_000000,593773,,5.659,6.681,7260.0,20448.0,7260.0,20448.0,Odontocete whistle,ben,EXPERT,2022-09-25T22:35:20.659+00:00,2022-09-25T22:35:21.681+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
+doc_osekit,2022_09_25_22_35_22_000000,593719,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,ron,EXPERT,2022-09-25T22:35:22.000+00:00,2022-09-25T22:35:29.000+00:00,0,WEAK,100% sure!,3/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_22_000000,593720,,4.239,5.39,5728.0,20540.0,5728.0,20540.0,Odontocete whistle,ron,EXPERT,2022-09-25T22:35:26.239+00:00,2022-09-25T22:35:27.390+00:00,1,BOX,Quite sure,2/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_22_000000,593738,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,april,,2022-09-25T22:35:22.000+00:00,2022-09-25T22:35:29.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_22_000000,593739,,4.855,5.299,6525.0,15947.0,6525.0,15947.0,Odontocete whistle,april,,2022-09-25T22:35:26.855+00:00,2022-09-25T22:35:27.299+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_22_000000,593740,,4.164,4.77,9479.0,19182.0,9479.0,19182.0,Odontocete whistle,april,,2022-09-25T22:35:26.164+00:00,2022-09-25T22:35:26.770+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,False,True
+doc_osekit,2022_09_25_22_35_29_000000,593721,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete click,ron,EXPERT,2022-09-25T22:35:29.000+00:00,2022-09-25T22:35:36.000+00:00,0,WEAK,Quite sure,2/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_29_000000,593722,,6.498,7.0,9571.0,23868.0,9571.0,23868.0,Odontocete click,ron,EXPERT,2022-09-25T22:35:35.498+00:00,2022-09-25T22:35:36.000+00:00,1,BOX,Quite sure,2/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_29_000000,593741,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete click,april,,2022-09-25T22:35:29.000+00:00,2022-09-25T22:35:36.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_29_000000,593742,,6.485,6.971,1322.0,24000.0,1322.0,24000.0,Odontocete click,april,,2022-09-25T22:35:35.485+00:00,2022-09-25T22:35:35.971+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_35_29_000000,593743,,0.0,7.0,0.0,24000.0,0.0,24000.0,Boat,april,,2022-09-25T22:35:29.000+00:00,2022-09-25T22:35:36.000+00:00,0,WEAK,Not sure at all,1/3,,,,,,,,,,,,,,,,ANNOTATION,True,False
+doc_osekit,2022_09_25_22_35_29_000000,593744,,3.431,5.786,1979.0,5447.0,1979.0,5447.0,Boat,april,,2022-09-25T22:35:32.431+00:00,2022-09-25T22:35:34.786+00:00,1,BOX,Not sure at all,1/3,,,,,,,,,,,,,,,,ANNOTATION,False,False
+doc_osekit,2022_09_25_22_36_04_000000,593723,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,ron,EXPERT,2022-09-25T22:36:04.000+00:00,2022-09-25T22:36:11.000+00:00,0,WEAK,Quite sure,2/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_04_000000,593724,,5.042,7.0,8493.0,21337.0,8493.0,21337.0,Odontocete whistle,ron,EXPERT,2022-09-25T22:36:09.042+00:00,2022-09-25T22:36:11.000+00:00,1,BOX,Quite sure,2/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,False
+doc_osekit,2022_09_25_22_36_04_000000,593747,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,april,,2022-09-25T22:36:04.000+00:00,2022-09-25T22:36:11.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_04_000000,593748,,5.913,6.589,12104.0,16135.0,12104.0,16135.0,Odontocete whistle,april,,2022-09-25T22:36:09.913+00:00,2022-09-25T22:36:10.589+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_04_000000,593767,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,ann,AVERAGE,2022-09-25T22:36:04.000+00:00,2022-09-25T22:36:11.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_04_000000,593768,,4.975,6.724,11400.0,19041.0,11400.0,19041.0,Odontocete whistle,ann,AVERAGE,2022-09-25T22:36:08.975+00:00,2022-09-25T22:36:10.724+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,False
+doc_osekit,2022_09_25_22_36_04_000000,593805,593724,5.918468342784749,6.571428453600085,13133.0,21336.0,13133.0,21336.0,Odontocete whistle,leslie,EXPERT,2022-09-25T22:36:09.918+00:00,2022-09-25T22:36:10.571+00:00,1,BOX,Quite sure,2/3,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
+doc_osekit,2022_09_25_22_36_04_000000,593806,593768,5.913256081835184,6.596589371964738,12806.0,19040.0,12806.0,19040.0,Odontocete whistle,leslie,EXPERT,2022-09-25T22:36:09.913+00:00,2022-09-25T22:36:10.596+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
+doc_osekit,2022_09_25_22_36_11_000000,593725,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete click,ron,EXPERT,2022-09-25T22:36:11.000+00:00,2022-09-25T22:36:18.000+00:00,0,WEAK,Quite sure,2/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_11_000000,593726,,5.008,7.0,12337.0,23868.0,12337.0,23868.0,Odontocete click,ron,EXPERT,2022-09-25T22:36:16.008+00:00,2022-09-25T22:36:18.000+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_11_000000,593749,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete click,april,,2022-09-25T22:36:11.000+00:00,2022-09-25T22:36:18.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_11_000000,593750,,5.048,6.971,11729.0,23916.0,11729.0,23916.0,Odontocete click,april,,2022-09-25T22:36:16.048+00:00,2022-09-25T22:36:17.971+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_11_000000,593751,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete buzz,april,,2022-09-25T22:36:11.000+00:00,2022-09-25T22:36:18.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,False
+doc_osekit,2022_09_25_22_36_11_000000,593752,,3.616,3.774,4229.0,23447.0,4229.0,23447.0,Odontocete buzz,april,,2022-09-25T22:36:14.616+00:00,2022-09-25T22:36:14.774+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,False,False
+doc_osekit,2022_09_25_22_36_11_000000,593769,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete click,ann,AVERAGE,2022-09-25T22:36:11.000+00:00,2022-09-25T22:36:18.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_11_000000,593770,,0.0,1.245,13041.0,24000.0,13041.0,24000.0,Odontocete click,ann,AVERAGE,2022-09-25T22:36:11.000+00:00,2022-09-25T22:36:12.245+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_11_000000,593771,,5.18,6.986,10040.0,24000.0,10040.0,24000.0,Odontocete click,ann,AVERAGE,2022-09-25T22:36:16.180+00:00,2022-09-25T22:36:17.986+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_11_000000,593807,,3.857,3.857,6337.0,6337.0,6337.0,6337.0,Odontocete buzz,leslie,EXPERT,2022-09-25T22:36:14.857+00:00,2022-09-25T22:36:14.857+00:00,1,POINT,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
+doc_osekit,2022_09_25_22_36_18_000000,593730,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,ron,EXPERT,2022-09-25T22:36:18.000+00:00,2022-09-25T22:36:25.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_18_000000,593731,,1.977,3.533,5071.0,20399.0,5071.0,20399.0,Odontocete whistle,ron,EXPERT,2022-09-25T22:36:19.977+00:00,2022-09-25T22:36:21.533+00:00,1,BOX,100% sure!,3/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,False,True
+doc_osekit,2022_09_25_22_36_18_000000,593732,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete click,ron,EXPERT,2022-09-25T22:36:18.000+00:00,2022-09-25T22:36:25.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_18_000000,593733,,0.0,0.9,12665.0,24000.0,12665.0,24000.0,Odontocete click,ron,EXPERT,2022-09-25T22:36:18.000+00:00,2022-09-25T22:36:18.900+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_18_000000,593734,,0.0,7.0,0.0,24000.0,0.0,24000.0,Boat,ron,EXPERT,2022-09-25T22:36:18.000+00:00,2022-09-25T22:36:25.000+00:00,0,WEAK,Not sure at all,1/3,,,,,,,,,,,,,,,,ANNOTATION,True,False
+doc_osekit,2022_09_25_22_36_18_000000,593735,,6.178,6.405,3524.0,24000.0,3524.0,24000.0,Boat,ron,EXPERT,2022-09-25T22:36:24.178+00:00,2022-09-25T22:36:24.405+00:00,1,BOX,Quite sure,2/3,,,,,,,,,,,,,,,,ANNOTATION,False,False
+doc_osekit,2022_09_25_22_36_18_000000,593736,,1.737,3.722,4086.0,11539.0,4086.0,11539.0,Boat,ron,EXPERT,2022-09-25T22:36:19.737+00:00,2022-09-25T22:36:21.722+00:00,1,BOX,100% sure!,3/3,,,,,,,,,,,,,,,,ANNOTATION,False,False
+doc_osekit,2022_09_25_22_36_18_000000,593753,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,april,,2022-09-25T22:36:18.000+00:00,2022-09-25T22:36:25.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_18_000000,593754,,1.689,4.095,2869.0,22744.0,2869.0,22744.0,Odontocete whistle,april,,2022-09-25T22:36:19.689+00:00,2022-09-25T22:36:22.095+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,False,False
+doc_osekit,2022_09_25_22_36_18_000000,593774,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete click,ann,AVERAGE,2022-09-25T22:36:18.000+00:00,2022-09-25T22:36:25.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_18_000000,593775,,4.234,6.423,2822.0,24000.0,2822.0,24000.0,Odontocete click,ann,AVERAGE,2022-09-25T22:36:22.234+00:00,2022-09-25T22:36:24.423+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_18_000000,593776,,0.0,0.797,9244.0,24000.0,9244.0,24000.0,Odontocete click,ann,AVERAGE,2022-09-25T22:36:18.000+00:00,2022-09-25T22:36:18.797+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_18_000000,593777,,0.0,7.0,0.0,24000.0,0.0,24000.0,Odontocete whistle,ann,AVERAGE,2022-09-25T22:36:18.000+00:00,2022-09-25T22:36:25.000+00:00,0,WEAK,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+doc_osekit,2022_09_25_22_36_18_000000,593778,,2.079,3.384,6104.0,20213.0,6104.0,20213.0,Odontocete whistle,ann,AVERAGE,2022-09-25T22:36:20.079+00:00,2022-09-25T22:36:21.384+00:00,1,BOX,Not sure at all,1/3,,SINGLE,,,11494.0,6666.0,1,1,,,MOD,,,,,ANNOTATION,False,True
+doc_osekit,2022_09_25_22_36_18_000000,593808,593754,2.1098456069769544,3.164478905872949,6290.0,20493.0,6290.0,20493.0,Odontocete whistle,leslie,EXPERT,2022-09-25T22:36:20.109+00:00,2022-09-25T22:36:21.164+00:00,1,BOX,Not sure at all,1/3,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
diff --git a/docs/source/aplose.rst b/docs/source/aplose.rst
new file mode 100644
index 00000000..646f6018
--- /dev/null
+++ b/docs/source/aplose.rst
@@ -0,0 +1,64 @@
+.. _aplose:
+
+Working with APLOSE results
+---------------------------
+
+`APLOSE `_ is **OSmOSE**'s web-based annotation platform.
+
+**APLOSE** campaigns `results `_ are provided as csv files
+that can be parsed in **OSEkit** as :class:`osekit.core.detection.Detection` instances.
+
+Loading an APLOSE results file
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+``Detections`` can be extracted from **APLOSE** results files thanks to the :meth:`osekit.core.detection.Detection.from_csv` method:
+
+.. code-block:: python
+
+ from pathlib import Path
+ from osekit.core.detection import Detection
+
+ detections = Detection.from_csv(csv=Path(r"_static/detections/aplose_results.csv"))
+
+Detection / Audio interaction
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The :class:`osekit.core.detection.Detection` class inherits from the :class:`osekit.core.event.Event` class: detections can easily be used to filter audio and spectro data:
+
+.. code-block:: python
+
+ from osekit.core.spectro_dataset import SpectroDataset
+
+ detection = Detection(...) # Generally Detection.from_csv(...)[i]
+ spectro_dataset = SpectroDataset(...)
+
+ # Find all SpectroData in which detection appear:
+ positive_spectrograms = SpectroDataset([sd for sd in spectro_dataset.data if sd.overlaps(detection)])
+
+Plotting a detection
+^^^^^^^^^^^^^^^^^^^^
+
+Detection boxes can be plotted on spectrograms thanks to the :method:`osekit.core.detection.Detection.to_rectangle` method:
+
+.. code-block:: python
+
+ import matplotlib.pyplot as plt
+ from osekit.core.spectro_data import SpectroData
+ from osekit.core.detection import Detection
+
+ sd = SpectroData(...)
+ detection = Detection(...)
+
+ fig, axs = plt.subplots()
+
+ # Plot the spectrogram
+ sd.plot(ax=ax)
+
+ # Get a rectangle from the detection
+ rectangle = detection.to_rectangle(fill = False)
+
+ # Draw the detection
+ ax.add_patch(rectangle)
+
+ # Show the spectrogram
+ plt.show()
diff --git a/docs/source/coreapi.rst b/docs/source/coreapi.rst
index 20d96e7f..2c27fe69 100644
--- a/docs/source/coreapi.rst
+++ b/docs/source/coreapi.rst
@@ -23,3 +23,4 @@ Core
ltasdata
audiofilemanager
frequencyscale
+ detection
diff --git a/docs/source/detection.rst b/docs/source/detection.rst
new file mode 100644
index 00000000..46362280
--- /dev/null
+++ b/docs/source/detection.rst
@@ -0,0 +1,25 @@
+.. _detection:
+
+Detection
+----------
+
+.. autoclass:: osekit.core.detection.Detection
+ :members:
+
+.. autoclass:: osekit.core.detection.FrequencyBounds
+ :members:
+
+.. autoclass:: osekit.core.detection.DetectorInfo
+ :members:
+
+.. autoclass:: osekit.core.detection.SignalParameters
+ :members:
+
+.. autoclass:: osekit.core.detection.ConfidenceIndicator
+ :members:
+
+.. autoclass:: osekit.core.detection.DetectionMetaData
+ :members:
+
+.. autoclass:: osekit.core.detection.Verification
+ :members:
diff --git a/docs/source/example_aplose_result.ipynb b/docs/source/example_aplose_result.ipynb
new file mode 100644
index 00000000..e8b27107
--- /dev/null
+++ b/docs/source/example_aplose_result.ipynb
@@ -0,0 +1,306 @@
+{
+ "cells": [
+ {
+ "cell_type": "code",
+ "id": "initial_id",
+ "metadata": {
+ "collapsed": true,
+ "tags": [
+ "remove-cell"
+ ]
+ },
+ "source": [
+ "# Executing this cell will:\n",
+ "\n",
+ "# Disable all TQDM outputs in stdout.\n",
+ "import os\n",
+ "\n",
+ "os.environ[\"DISABLE_TQDM\"] = \"True\"\n",
+ "\n",
+ "# Setup the python logger for the Public API\n",
+ "from osekit import setup_logging\n",
+ "\n",
+ "setup_logging() # Overwrites the default logger to"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8c395a2079d86493",
+ "metadata": {},
+ "source": [
+ "# Using APLOSE detection results [^download]\n",
+ "\n",
+ "[^download]: This notebook can be downloaded as **{nb-download}`example_aplose_result.ipynb`**."
+ ]
+ },
+ {
+ "metadata": {},
+ "cell_type": "markdown",
+ "source": [
+ "# Creating the Public Project\n",
+ "\n",
+ "[APLOSE](https://osmose.ifremer.fr/doc/)-compatible projects are build thanks to OSEkit's `Public API`.\n",
+ "\n",
+ "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",
+ "source": [
+ "## Build the Project\n",
+ "\n",
+ "First, we have to build the project from the raw audio files:"
+ ],
+ "id": "e2d5321198880205"
+ },
+ {
+ "metadata": {},
+ "cell_type": "code",
+ "source": [
+ "from pathlib import Path\n",
+ "from osekit.public.project import Project\n",
+ "from osekit.core.instrument import Instrument\n",
+ "\n",
+ "folder = Path(r\"_static/sample_audio/timestamped\")\n",
+ "strptime_format = r\"%y%m%d_%H%M%S\"\n",
+ "\n",
+ "project = Project(\n",
+ " folder=folder,\n",
+ " strptime_format=strptime_format,\n",
+ " instrument=Instrument(end_to_end_db=150.0),\n",
+ " timezone=\"UTC\",\n",
+ ")\n",
+ "\n",
+ "project.build()"
+ ],
+ "id": "3ab3cb447c59a857",
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "metadata": {},
+ "cell_type": "markdown",
+ "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",
+ "source": [
+ "from osekit.public.transform import Transform, OutputType\n",
+ "from osekit.utils.audio import Normalization\n",
+ "from pandas import Timestamp, Timedelta\n",
+ "from scipy.signal import ShortTimeFFT\n",
+ "from scipy.signal.windows import hamming\n",
+ "\n",
+ "transform = Transform(\n",
+ " output_type=OutputType.SPECTROGRAM,\n",
+ " begin=Timestamp(\"2022-09-25 22:35:15+0000\"),\n",
+ " end=Timestamp(\"2022-09-25 22:36:25+0000\"),\n",
+ " data_duration=Timedelta(seconds=7.5),\n",
+ " normalization=Normalization.DC_REJECT,\n",
+ " fft=ShortTimeFFT(win=hamming(1024), hop=128, fs=project.origin_dataset.sample_rate),\n",
+ " v_lim=(50.0, 120.0), # Boundaries of the spectrograms\n",
+ " colormap=\"viridis\", # Default value\n",
+ " name=\"example_transform\",\n",
+ ")\n",
+ "\n",
+ "# We remove all spectrograms that contain silent parts\n",
+ "ads = project.prepare_audio(transform=transform)\n",
+ "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",
+ "id": "5f23b72a5303ce5e",
+ "metadata": {},
+ "source": "Parse the detection result csv file thanks to the `Detection` class:"
+ },
+ {
+ "cell_type": "code",
+ "id": "1948b260fcaf03ab",
+ "metadata": {},
+ "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",
+ "id": "3630993dfeb0b359",
+ "metadata": {},
+ "source": [
+ "## Filtering the detections\n",
+ "\n",
+ "We can use basic python filtering to access specific detections.\n",
+ "\n",
+ "Here, we will:\n",
+ "- Keep only `Odontocete whistle` detections of type `BOX` with a strong **confidence level**\n",
+ "- Filter the `SpectroDataset` to keep only the files in which such detections were made\n",
+ "- Plot the detections as `Rectangle`s on the spectrograms"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ab4b0325c81590d0",
+ "metadata": {},
+ "source": [
+ "### Keeping specific detections\n",
+ "\n",
+ "Filtering out unwanted detections can be done with a basic list comprehension:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "45892d179652235b",
+ "metadata": {},
+ "source": [
+ "def does_satisfy_constraints(detection: Detection) -> bool:\n",
+ " # Keeping only odontocete whistles\n",
+ " if detection.label != \"Odontocete whistle\":\n",
+ " return False\n",
+ "\n",
+ " # Keeping only BOX detections\n",
+ " if detection.type != \"BOX\":\n",
+ " return False\n",
+ "\n",
+ " # Keeping only maximum confidence level detections\n",
+ " if (\n",
+ " detection.confidence_indicator.level\n",
+ " < detection.confidence_indicator.maximum_level\n",
+ " ):\n",
+ " return False\n",
+ "\n",
+ " return True\n",
+ "\n",
+ "\n",
+ "filtered_detections = [\n",
+ " detection for detection in detections if does_satisfy_constraints(detection)\n",
+ "]"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "markdown",
+ "id": "e6599f8129aff523",
+ "metadata": {},
+ "source": [
+ "## Filtering the SpectroDataset\n",
+ "\n",
+ "`Detection`s inherit from the `Event` class, which allows for an easy filtering of the `SpectroData`:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "237d399b7de6ffd5",
+ "metadata": {},
+ "source": [
+ "# Recover the transform output (SpectroDataset)\n",
+ "sds = project.get_output(output_name=\"example_transform\")\n",
+ "\n",
+ "# Keeping only SpectroDatas that contain filtered detections\n",
+ "sds.data = [\n",
+ " sd\n",
+ " for sd in sds.data\n",
+ " if any(detection.overlaps(sd) for detection in filtered_detections)\n",
+ "]"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "markdown",
+ "id": "10f7f194ff60a9f9",
+ "metadata": {},
+ "source": [
+ "## Plotting the Spectrograms along with detections\n",
+ "\n",
+ "We then want to plot detection boxes directly the spectrograms:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "d0d242240e791509",
+ "metadata": {},
+ "source": [
+ "import matplotlib.pyplot as plt\n",
+ "\n",
+ "# Create a figure with one spectrogram per row\n",
+ "fig, axs = plt.subplots(nrows=len(sds.data), ncols=1)\n",
+ "\n",
+ "# Plot spectrograms\n",
+ "for idx, sd in enumerate(sds.data):\n",
+ " # We want to plot each spectrogram in a specific ax\n",
+ " ax = axs[idx]\n",
+ "\n",
+ " sd.plot(ax=ax)\n",
+ "\n",
+ " # We want to plot all detections related to this spectrogram\n",
+ " for detection in filtered_detections:\n",
+ " if not detection.overlaps(sd):\n",
+ " continue\n",
+ "\n",
+ " # Detections are plotted as matplotlib Rectangles\n",
+ " rectangle = detection.to_rectangle(fill=False)\n",
+ " ax.add_patch(rectangle)\n",
+ "\n",
+ "# Let's take a look at the output figure\n",
+ "plt.show()"
+ ],
+ "outputs": [],
+ "execution_count": null
+ },
+ {
+ "cell_type": "code",
+ "id": "449dc442b4a5df75",
+ "metadata": {},
+ "source": [
+ "# Reset the project to get all files back to place.\n",
+ "project.reset()"
+ ],
+ "outputs": [],
+ "execution_count": null
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 2
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython2",
+ "version": "2.7.6"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/source/examples.rst b/docs/source/examples.rst
index 7f6bb68f..37dbf83a 100644
--- a/docs/source/examples.rst
+++ b/docs/source/examples.rst
@@ -55,6 +55,12 @@ In the ``docs/source/_static/sample_audio/timestamped`` folder, files are just n
Compute, plot and export a **L**\ ong-\ **T**\ erm **A**\ verage **S**\ pectrum (**LTAS**).
+===========
+
+.. topic:: :doc:`Use APLOSE results `
+
+ Parse `APLOSE `_ results csv files in OSEkit, and use the detections to filter the audio or spectrograms from the project.
+
.. toctree ::
:hidden:
@@ -64,3 +70,4 @@ In the ``docs/source/_static/sample_audio/timestamped`` folder, files are just n
example_multiple_spectrograms
example_multiple_spectrograms_id
example_ltas
+ example_aplose_result
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index cf1769f6..68aa8f97 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -27,3 +27,4 @@ The package combines two APIs:
coreapi_home
multiprocessing
jobs
+ aplose
diff --git a/src/osekit/core/detection.py b/src/osekit/core/detection.py
new file mode 100644
index 00000000..4e850529
--- /dev/null
+++ b/src/osekit/core/detection.py
@@ -0,0 +1,413 @@
+"""The Detection class represents a detection made on APLOSE."""
+
+import math
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Literal, Self
+
+import pandas as pd
+from matplotlib.patches import Rectangle
+from pandas import Timestamp
+
+from osekit.core.event import Event
+
+KNOWN_KEYS = {
+ "dataset",
+ "project",
+ "filename",
+ "annotation_id",
+ "is_update_of_id",
+ "start_time",
+ "end_time",
+ "start_frequency",
+ "end_frequency",
+ "min_frequency",
+ "max_frequency",
+ "annotation",
+ "annotator",
+ "annotator_expertise",
+ "start_datetime",
+ "end_datetime",
+ "is_box",
+ "type",
+ "confidence_indicator_label",
+ "confidence_indicator_level",
+ "comments",
+ "signal_quantity",
+ "signal_is_intensity_too_low",
+ "signal_does_overlap_other_signals",
+ "signal_start_frequency",
+ "signal_end_frequency",
+ "signal_relative_min_frequency_count",
+ "signal_relative_max_frequency_count",
+ "signal_steps_count",
+ "signal_has_harmonics",
+ "signal_trend",
+ "signal_sidebands",
+ "signal_subharmonics",
+ "signal_frequency_jumps",
+ "signal_deterministic_chaos",
+ "created_at_phase",
+}
+
+
+@dataclass
+class FrequencyBounds:
+ """Class representing the frequency bounds of a detection.
+
+ Parameters
+ ----------
+ min: int
+ Lower frequency bound.
+ max: int
+ Upper frequency bound.
+
+ """
+
+ min: int
+ max: int
+
+ def __post_init__(self) -> None:
+ """Check the validity of the frequency bounds."""
+ error_msgs = []
+ if self.min < 0:
+ error_msgs.append(
+ f"Min frequency must be greater than or equal to 0, got {self.min}.",
+ )
+ if self.max < 0:
+ error_msgs.append(
+ f"Max frequency must be greater than or equal to 0, got {self.max}.",
+ )
+ if self.min > self.max:
+ error_msgs.append(
+ f"Max frequency must be greater than min frequency, "
+ f"got ({self.min},{self.max}).",
+ )
+ if error_msgs:
+ msg = "\n".join(error_msgs)
+ raise ValueError(msg)
+
+ @property
+ def bandwidth(self) -> int:
+ """Bandwidth of the detection."""
+ return self.max - self.min
+
+
+@dataclass
+class DetectorInfo:
+ """Class representing a detector info."""
+
+ name: str
+ expertise: Literal["NOVICE", "AVERAGE", "EXPERT"] | None = None
+
+ def __hash__(self) -> int:
+ """Return a hash for the detector."""
+ return hash((self.name, self.expertise))
+
+ def __eq__(self, other: Self) -> bool:
+ """Return whether two detectors are equal."""
+ return self.name == other.name and self.expertise == other.expertise
+
+
+@dataclass
+class SignalParameters:
+ """Class representing parameters of detection signal."""
+
+ is_itensity_too_low: bool | None = None
+ does_overlap_other_signals: bool | None = None
+ min_frequency: int | None = None
+ max_frequency: int | None = None
+ nb_relative_mins: int | None = None
+ nb_relative_maxes: int | None = None
+ nb_steps: int | None = None
+ trend: Literal["FLAT", "ASCENDING", "DESCENDING", "MODULATED"] | None = None
+ frequency_jumps: bool | int | None = None
+ has_harmonics: bool | None = None
+ has_sidebands: bool | None = None
+ has_subharmonics: bool | None = None
+ has_deterministic_chaos: bool | None = None
+
+
+@dataclass
+class ConfidenceIndicator:
+ """Class that represents a detection confidence indicator.
+
+ Parameters
+ ----------
+ label: str
+ Name of the level of confidence.
+ level: int
+ Level of confidence of the detection.
+ maximum_level: int
+ Maximum level of confidence authorized in the project.
+
+ """
+
+ label: str
+ level: int
+ maximum_level: int
+
+ def __post_init__(self) -> None:
+ """Check the validity of the level and maximum level values."""
+ if self.level > self.maximum_level:
+ msg = (
+ f"Confidence level {self.level} is higher than "
+ f"maximum level {self.maximum_level} authorized in the project."
+ )
+ raise ValueError(msg)
+
+ @classmethod
+ def from_relative_level_string(cls, label: str, relative_level_string: str) -> Self:
+ """Return a ``ConfidenceIndicator`` from a string representing its level.
+
+ Parameters
+ ----------
+ label: str
+ Name of the level of confidence.
+ relative_level_string: str
+ Level of confidence relative to the maximum level available.
+ Should be formatted as ``n/m``, where ``n`` is the level of confidence
+ of the detection and ``m`` is the maximum level available in the project.
+
+ Returns
+ -------
+ ConfidenceIndicator
+ The confidence indicator parsed from the input string.
+
+ """
+ level, maximum_level = map(int, relative_level_string.split("/"))
+
+ return cls(label=label, level=level, maximum_level=maximum_level)
+
+
+@dataclass
+class DetectionMetaData:
+ """Class that represents the metadata of a detection.
+
+ Parameters
+ ----------
+ project: str
+ Name of the project in which the detection was made.
+ filename: str
+ Name of the file this detection was made on.
+ detection_id: int
+ ID of the detection.
+ base_id: int
+ ID of the base detection.
+ May differ from ``detection_id`` if the detection is an update/correction.
+ comments: str | None
+ Comments left by the annotator.
+ phase: Literal["ANNOTATION", "VERIFICATION"]
+ Phase during which the detection was created.
+
+ """
+
+ project: str
+ filename: str
+ detection_id: int
+ base_id: int | None
+ comments: str | None
+ phase: Literal["ANNOTATION", "VERIFICATION"]
+
+
+@dataclass
+class Verification:
+ """Class that represents a verification of a detection."""
+
+ verificator: str
+ is_validated: bool
+
+ def __hash__(self) -> int:
+ """Return a hash of the verification."""
+ return hash((self.verificator, self.is_validated))
+
+ def __eq__(self, other: Self) -> bool:
+ """Return whether the two verifications are equal."""
+ return (
+ self.verificator == other.verificator
+ and self.is_validated == other.is_validated
+ )
+
+
+class Detection(Event):
+ """Class that represents a detection made on APLOSE."""
+
+ def __init__( # noqa: PLR0913
+ self,
+ metadata: DetectionMetaData,
+ begin: Timestamp,
+ end: Timestamp,
+ frequency_bounds: FrequencyBounds,
+ label: str,
+ detector_info: DetectorInfo,
+ detection_type: Literal["WEAK", "POINT", "BOX"],
+ confidence_indicator: ConfidenceIndicator | None,
+ signal_quantity: Literal["SINGLE", "MULTIPLE"],
+ signal_parameters: SignalParameters | None,
+ verifications: set[Verification],
+ ) -> None:
+ """Initialize a Detection object.
+
+ Parameters
+ ----------
+ metadata: DetectionMetaData
+ Metadata on the detection.
+ begin: Timestamp
+ Begin timestamp of the detection.
+ end: Timestamp
+ End timestamp of the detection.
+ frequency_bounds: FrequencyBounds
+ Frequency bounds of the detection.
+ label: str
+ Label of the detection.
+ detector_info: DetectorInfo
+ Information on the annotator or detector.
+ detection_type: Literal["WEAK", "POINT", "BOX"]
+ Type of the detection.
+ ``WEAK``: Detection made on the whole spectrogram.
+ ``POINT``: Detection made on one pixel of the spectrogram.
+ ``BOX``: Detection made on one box within the spectrogram.
+ confidence_indicator: ConfidenceIndicator | None
+ Indicator of the confidence of the annotator.
+ signal_quantity: Literal["SINGLE","MULTIPLE"]
+ Whether there is only one signal in the detection or more.
+ signal_parameters: SignalParameters | None
+ Parameters of the annotated signal.
+ ```None`` if ``signal_quantity`` is ``MULTIPLE``.
+ verifications: set[Verification]
+ Verifications made on this detection.
+
+ """
+ self.metadata = metadata
+ self.label = label
+ self.detector_info = detector_info
+ self.frequency_bounds = frequency_bounds
+ self.type = detection_type
+ self.confidence_indicator = confidence_indicator
+ self.signal_quantity = signal_quantity
+ self.signal_parameters = signal_parameters
+ self.verifications = verifications
+
+ super().__init__(begin=begin, end=end)
+
+ def __repr__(self) -> str:
+ """Override the string representation of the detection."""
+ return str(self.metadata.detection_id)
+
+ @classmethod
+ def from_dict(cls, row: dict) -> Self:
+ """Deserialize a Detection object."""
+ metadata = DetectionMetaData(
+ project=row["project"] if "project" in row else row["dataset"],
+ filename=str(row["filename"]),
+ detection_id=row["annotation_id"],
+ base_id=row["is_update_of_id"],
+ comments=row["comments"],
+ phase=row["created_at_phase"],
+ )
+ detector_info = DetectorInfo(
+ name=row["annotator"],
+ expertise=row["annotator_expertise"],
+ )
+
+ min_frequency, max_frequency = row["min_frequency"], row["max_frequency"]
+ frequency_bounds = (
+ FrequencyBounds(min=min_frequency, max=max_frequency)
+ if not any(m is None for m in (min_frequency, max_frequency))
+ else None
+ )
+
+ confidence_indicator = (
+ ConfidenceIndicator.from_relative_level_string(
+ label=row["confidence_indicator_label"],
+ relative_level_string=row["confidence_indicator_level"],
+ )
+ if row["confidence_indicator_label"]
+ else None
+ )
+
+ signal_quantity = row["signal_quantity"]
+ signal_parameters = (
+ SignalParameters(
+ does_overlap_other_signals=row["signal_is_intensity_too_low"],
+ frequency_jumps=row["signal_frequency_jumps"],
+ has_deterministic_chaos=row["signal_deterministic_chaos"],
+ has_harmonics=row["signal_has_harmonics"],
+ has_sidebands=row["signal_sidebands"],
+ has_subharmonics=row["signal_subharmonics"],
+ is_itensity_too_low=row["signal_is_intensity_too_low"],
+ max_frequency=row["signal_end_frequency"],
+ min_frequency=row["signal_start_frequency"],
+ nb_relative_maxes=row["signal_relative_max_frequency_count"],
+ nb_relative_mins=row["signal_relative_min_frequency_count"],
+ nb_steps=row["signal_steps_count"],
+ trend=row["signal_trend"],
+ )
+ if signal_quantity == "SINGLE"
+ else None
+ )
+
+ verifications = {
+ Verification(
+ verificator=key,
+ is_validated=value,
+ )
+ for key, value in row.items()
+ if key not in KNOWN_KEYS
+ }
+
+ return cls(
+ metadata=metadata,
+ label=row["annotation"],
+ detector_info=detector_info,
+ begin=Timestamp(row["start_datetime"]),
+ end=Timestamp(row["end_datetime"]),
+ frequency_bounds=frequency_bounds,
+ detection_type=row["type"],
+ confidence_indicator=confidence_indicator,
+ signal_quantity=row["signal_quantity"],
+ signal_parameters=signal_parameters,
+ verifications=verifications,
+ )
+
+ def to_rectangle(self, **kwargs: Any) -> Rectangle:
+ """Return a matplotlib Rectangle representing the detection.
+
+ Parameters
+ ----------
+ kwargs:
+ Additional keyword arguments
+
+ Returns
+ -------
+ matplotlib.patches.Rectangle
+ Rectangle representing the detection.
+ The coordinates of the rectangle are in time x frequency.
+
+
+
+ """
+ return Rectangle(
+ xy=( # type: ignore[arg-type]
+ self.begin,
+ self.frequency_bounds.min,
+ ),
+ width=self.duration, # type: ignore[arg-type]
+ height=self.frequency_bounds.bandwidth,
+ **kwargs,
+ )
+
+ @classmethod
+ def from_csv(cls, csv: Path) -> list[Self]:
+ """Deserialize a list of Detection from a detections csv file."""
+ records = pd.read_csv(filepath_or_buffer=csv).to_dict(
+ orient="records",
+ )
+ records = [
+ {
+ key: None if type(value) is float and math.isnan(value) else value
+ for key, value in record.items()
+ }
+ for record in records
+ ]
+ return [cls.from_dict(record) for record in records]
diff --git a/src/osekit/core/event.py b/src/osekit/core/event.py
index 2897e778..46c8f842 100644
--- a/src/osekit/core/event.py
+++ b/src/osekit/core/event.py
@@ -7,6 +7,8 @@
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, TypeVar
+from osekit.utils.timestamp import localize_timestamp
+
if TYPE_CHECKING:
from pandas import Timedelta, Timestamp
@@ -63,6 +65,23 @@ def __repr__(self) -> str:
"""Overwrite repr."""
return f"{self.begin} - {self.end}"
+ def localize(self, timezone: str | None) -> None:
+ """Localize the event begin and end in a timezone.
+
+ If the event is already tz-aware, it will be converted
+ to the target timezone.
+
+ Parameters
+ ----------
+ timezone: str | None
+ Target timezone
+
+ """
+ # We use the private fields here because we can't compare
+ # naive and aware timestamps in the begin and end setters
+ self._begin = localize_timestamp(timestamp=self._begin, timezone=timezone)
+ self._end = localize_timestamp(timestamp=self._end, timezone=timezone)
+
def overlaps(self, other: type[Event] | Event) -> bool:
"""Return ``True`` if the other event shares time with the current event.
diff --git a/src/osekit/core/spectro_data.py b/src/osekit/core/spectro_data.py
index 127b122e..bf5d8c15 100644
--- a/src/osekit/core/spectro_data.py
+++ b/src/osekit/core/spectro_data.py
@@ -404,12 +404,12 @@ def plot(
ax: plt.Axes | None = None,
sx: np.ndarray | None = None,
scale: Scale | None = None,
- ) -> None:
+ ) -> plt.Axes:
"""Plot the spectrogram on a specific ``Axes``.
Parameters
----------
- ax: plt.axes | None
+ ax: plt.Axes | None
``Axes`` on which the spectrogram should be plotted.
Defaulted to ``osekit.utils.plot.get_default_axes()``.
sx: np.ndarray | None
@@ -417,6 +417,11 @@ def plot(
scale: osekit.core.frequecy_scale.Scale
Custom frequency scale to use for plotting the spectrogram.
+ Returns
+ -------
+ plt.Axes
+ The ``Axes`` on which the spectrogram has been plotted.
+
"""
ax = ax if ax is not None else get_default_axes()
sx = self.get_value() if sx is None else sx
@@ -439,6 +444,7 @@ def plot(
interpolation="none",
extent=(date2num(time[0]), date2num(time[-1]), freq[0], freq[-1]),
)
+ return ax
def get_db_value(self, sx: np.ndarray | None = None) -> np.ndarray:
"""Return the ``Sx`` spectrum of the spectrogram expressed in ``dB``.
diff --git a/src/osekit/utils/timestamp.py b/src/osekit/utils/timestamp.py
index 9eb69ad4..560ee4fc 100644
--- a/src/osekit/utils/timestamp.py
+++ b/src/osekit/utils/timestamp.py
@@ -90,7 +90,7 @@ def normalize_datetime(datetime: tuple[str], template: str) -> tuple[str, str]:
def localize_timestamp(
timestamp: Timestamp,
- timezone: str | pytz.timezone,
+ timezone: str | pytz.timezone | None,
) -> Timestamp:
"""Localize a timestamp in the given timezone.
@@ -98,8 +98,9 @@ def localize_timestamp(
----------
timestamp: pandas.Timestamp
The timestamp to localize.
- timezone: str | pytz.timezone
+ timezone: str | pytz.timezone | None
The timezone in which the timestamp is localized.
+ If None, the output timestamp is naive.
Returns
-------
@@ -109,7 +110,7 @@ def localize_timestamp(
to the new timezone.
"""
- if not timestamp.tz:
+ if not timestamp.tz or timezone is None:
return timestamp.tz_localize(timezone)
if timestamp.utcoffset() != timestamp.tz_convert(timezone).utcoffset():
diff --git a/tests/_static/aplose_result.csv b/tests/_static/aplose_result.csv
new file mode 100644
index 00000000..ecacfa98
--- /dev/null
+++ b/tests/_static/aplose_result.csv
@@ -0,0 +1,10 @@
+dataset,filename,annotation_id,is_update_of_id,start_time,end_time,start_frequency,end_frequency,min_frequency,max_frequency,annotation,annotator,annotator_expertise,start_datetime,end_datetime,is_box,type,confidence_indicator_label,confidence_indicator_level,comments,signal_quantity,signal_is_intensity_too_low,signal_does_overlap_other_signals,signal_start_frequency,signal_end_frequency,signal_relative_min_frequency_count,signal_relative_max_frequency_count,signal_steps_count,signal_has_harmonics,signal_trend,signal_sidebands,signal_subharmonics,signal_frequency_jumps,signal_deterministic_chaos,created_at_phase,lookaftering,bunyan
+great_tit,990694,586654,,0.0,20.0,0.0,24000.0,0.0,24000.0,bird,vashti,NOVICE,2021-01-01T00:00:00.000+00:00,2021-01-01T00:00:20.000+00:00,0,WEAK,Sure,1/1,great tits |- vashti,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,False
+great_tit,990694,586655,,1.412,3.651,2512.0,15661.0,2512.0,15661.0,bird,vashti,NOVICE,2021-01-01T00:00:01.412+00:00,2021-01-01T00:00:03.651+00:00,1,BOX,Not sure,0/1,fluffy-backed tit-babbler |- vashti,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,False,False
+great_tit,990694,586673,,10.0,12.0,12000.0,13000.0,12000.0,13000.0,rain,heartleap,,2021-01-01T00:00:00.000+00:00,2021-01-01T00:00:20.000+00:00,0,BOX,,,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+great_tit,990694,586656,,0.0,20.0,0.0,24000.0,0.0,24000.0,rain,heartleap,,2021-01-01T00:00:00.000+00:00,2021-01-01T00:00:20.000+00:00,0,WEAK,Sure,1/1,,MULTIPLE,,,,,,,,,,,,,,ANNOTATION,True,True
+great_tit,990694,586657,,3.53,4.71,11137.0,13997.0,11137.0,13997.0,rain,heartleap,,2021-01-01T00:00:03.530+00:00,2021-01-01T00:00:04.710+00:00,1,BOX,Sure,1/1,,SINGLE,,True,12000.0,13000.0,3,2,4,True,MOD,True,,True,True,ANNOTATION,True,True
+great_tit,990694,586669,586655,1.412,3.651,2512.0,15660.0,2512.0,15660.0,bird,bunyan,EXPERT,2021-01-01T00:00:01.412+00:00,2021-01-01T00:00:03.651+00:00,1,BOX,Not sure,0/1,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
+great_tit,990694,586710,586655,1.412,3.651,2512.0,15660.0,2512.0,15660.0,bird,lookaftering,EXPERT,2021-01-01T00:00:01.412+00:00,2021-01-01T00:00:03.651+00:00,1,BOX,Not sure,0/1,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
+great_tit,994410,586671,,0.0,20.0,0.0,24000.0,0.0,24000.0,car,bunyan,EXPERT,2021-01-01T00:01:18.218+00:00,2021-01-01T00:01:38.218+00:00,0,WEAK,Sure,1/1,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
+great_tit,994410,586672,,0.0,20.0,0.0,24000.0,0.0,24000.0,bird,bunyan,EXPERT,2021-01-01T00:01:18.218+00:00,2021-01-01T00:01:38.218+00:00,0,WEAK,Sure,1/1,,MULTIPLE,,,,,,,,,,,,,,VERIFICATION,,
diff --git a/tests/test_annotation.py b/tests/test_annotation.py
new file mode 100644
index 00000000..caff5e7d
--- /dev/null
+++ b/tests/test_annotation.py
@@ -0,0 +1,335 @@
+from contextlib import AbstractContextManager, nullcontext
+from pathlib import Path
+
+import numpy as np
+import pytest
+from pandas import Timestamp
+
+from osekit.core.detection import (
+ ConfidenceIndicator,
+ Detection,
+ DetectionMetaData,
+ DetectorInfo,
+ FrequencyBounds,
+ SignalParameters,
+ Verification,
+)
+
+
+@pytest.fixture
+def sample_detection() -> Detection:
+ return Detection(
+ metadata=DetectionMetaData(
+ detection_id=35173,
+ base_id=None,
+ comments="He's a sneaky, sneaky dog friend",
+ filename="its_teasy",
+ phase="ANNOTATION",
+ project="mockasin",
+ ),
+ begin=Timestamp("2013-11-05 00:00:00"),
+ end=Timestamp("2013-11-05 00:00:10"),
+ frequency_bounds=FrequencyBounds(
+ min=1_000,
+ max=3_000,
+ ),
+ label="Connan",
+ detector_info=DetectorInfo(
+ name="Mockasin",
+ expertise="EXPERT",
+ ),
+ detection_type="BOX",
+ confidence_indicator=ConfidenceIndicator(
+ label="Sure",
+ level=2,
+ maximum_level=2,
+ ),
+ signal_quantity="SINGLE",
+ signal_parameters=SignalParameters(
+ does_overlap_other_signals=False,
+ frequency_jumps=True,
+ has_deterministic_chaos=True,
+ has_harmonics=True,
+ has_sidebands=True,
+ has_subharmonics=False,
+ is_itensity_too_low=False,
+ max_frequency=2_800,
+ min_frequency=1_300,
+ nb_relative_maxes=2,
+ nb_relative_mins=3,
+ nb_steps=4,
+ trend="MODULATED",
+ ),
+ verifications={
+ Verification(
+ verificator="soft_hair",
+ is_validated=True,
+ ),
+ },
+ )
+
+
+@pytest.mark.parametrize(
+ ("min_frequency", "max_frequency", "expectation"),
+ [
+ pytest.param(
+ 0,
+ 1000,
+ nullcontext(1000),
+ id="box_from_bottom",
+ ),
+ pytest.param(
+ 300,
+ 1000,
+ nullcontext(700),
+ id="box_bandwidth_from_higher_than_0",
+ ),
+ pytest.param(
+ -10,
+ 1000,
+ pytest.raises(ValueError, match=r"Min frequency.*-10"),
+ id="negative_min_frequency_raises",
+ ),
+ pytest.param(
+ 0,
+ -5,
+ pytest.raises(ValueError, match=r"Max frequency.*-5"),
+ id="negative_max_frequency_raises",
+ ),
+ pytest.param(
+ 80,
+ 50,
+ pytest.raises(
+ ValueError,
+ match=r"Max frequency.*greater.*min frequency.*\(80,50\)",
+ ),
+ id="min_greater_than_max_raises",
+ ),
+ pytest.param(
+ -20,
+ -30,
+ pytest.raises(
+ ValueError,
+ match=r"(?s)" # Activates the DOTALL mode: includes \n in regex .*
+ r"(?=.*Min frequency.*got -20)"
+ r"(?=.*Max frequency.*got -30)"
+ r"(?=.*Max frequency.*greater.*min frequency.*\(-20,-30\))",
+ ),
+ id="errors_concatenation",
+ ),
+ ],
+)
+def test_frequency_bounds(
+ min_frequency: int,
+ max_frequency: int,
+ expectation: AbstractContextManager,
+) -> None:
+ with expectation as e:
+ frequency_bounds = FrequencyBounds(min=min_frequency, max=max_frequency)
+ assert frequency_bounds.bandwidth == e
+
+
+def test_annotator_info() -> None:
+ annotators = [
+ DetectorInfo(name="ruby", expertise="NOVICE"),
+ DetectorInfo(name="ruby", expertise="NOVICE"),
+ DetectorInfo(name="haunt", expertise="EXPERT"),
+ DetectorInfo(name="haunt", expertise="EXPERT"),
+ DetectorInfo(name="nevada", expertise="EXPERT"),
+ DetectorInfo(name="nevada", expertise="EXPERT"),
+ DetectorInfo(name="haunt", expertise=None),
+ ]
+
+ nb_unique_annotators = 4
+
+ assert sum(1 for _ in set(annotators)) == nb_unique_annotators
+
+
+@pytest.mark.parametrize(
+ ("label", "level", "max_level", "expectation"),
+ [
+ pytest.param(
+ "Sure",
+ 1,
+ 1,
+ nullcontext(),
+ id="max_level_is_ok",
+ ),
+ pytest.param(
+ "Not sure",
+ 0,
+ 1,
+ nullcontext(),
+ id="level_0_is_ok",
+ ),
+ pytest.param(
+ "Moderate",
+ 1,
+ 2,
+ nullcontext(),
+ id="between_0_and_max_is_ok",
+ ),
+ pytest.param(
+ "Moderate",
+ 3,
+ 2,
+ pytest.raises(ValueError, match=r"level 3.*higher.*maximum level 2"),
+ id="higher_than_max_raises",
+ ),
+ ],
+)
+def test_confidence_indicator_value_check(
+ label: str,
+ level: int,
+ max_level: int,
+ expectation: AbstractContextManager,
+) -> None:
+ with expectation:
+ ConfidenceIndicator(
+ label=label,
+ level=level,
+ maximum_level=max_level,
+ )
+
+
+@pytest.mark.parametrize(
+ ("label", "relative_level_string", "expectation"),
+ [
+ pytest.param(
+ "cool",
+ "1/6",
+ nullcontext(
+ ConfidenceIndicator(
+ label="cool",
+ level=1,
+ maximum_level=6,
+ ),
+ ),
+ id="correct_levels",
+ ),
+ pytest.param(
+ "cool",
+ "4/2",
+ pytest.raises(ValueError, match=r"level 4.*higher.*maximum level 2"),
+ id="incorrect_levels_should_raise",
+ ),
+ ],
+)
+def test_confidence_indicator_from_relative_level_string(
+ label: str,
+ relative_level_string: str,
+ expectation: AbstractContextManager,
+) -> None:
+ with expectation as e:
+ ci = ConfidenceIndicator.from_relative_level_string(
+ label=label,
+ relative_level_string=relative_level_string,
+ )
+
+ assert ci.label == e.label
+ assert ci.level == e.level
+ assert ci.maximum_level == e.maximum_level
+
+
+def test_detections_from_csv() -> None:
+ detections = Detection.from_csv(
+ csv=Path(__file__).parent / "_static" / "aplose_result.csv",
+ )
+
+ # All records should be loaded
+ assert len(detections) == 9
+ assert all(a.metadata.project == "great_tit" for a in detections)
+
+ # Two distinct annotated files
+ filenames = {a.metadata.filename for a in detections}
+ assert filenames == {"990694", "994410"}
+
+ # Types
+ types = {a.type for a in detections}
+ assert types == {"WEAK", "BOX"}
+
+ # Phases
+ phases = {a.metadata.phase for a in detections}
+ assert phases == {"ANNOTATION", "VERIFICATION"}
+
+ # Single signal parameters
+ single = next(a for a in detections if a.metadata.detection_id == 586657)
+ assert single.signal_quantity == "SINGLE"
+ assert single.signal_parameters is not None
+ assert not single.signal_parameters.is_itensity_too_low
+ assert not single.signal_parameters.does_overlap_other_signals
+ assert single.signal_parameters.min_frequency == 12000
+ assert single.signal_parameters.max_frequency == 13000
+ assert single.signal_parameters.nb_relative_mins == 3
+ assert single.signal_parameters.nb_relative_maxes == 2
+ assert single.signal_parameters.nb_steps == 4
+ assert single.signal_parameters.trend == "MOD"
+ assert single.signal_parameters.frequency_jumps
+ assert single.signal_parameters.has_harmonics
+ assert single.signal_parameters.has_sidebands
+ assert not single.signal_parameters.has_subharmonics
+ assert single.signal_parameters.has_deterministic_chaos
+
+ # Multiple signal quantity: parameters should be None
+ multiple = next(a for a in detections if a.metadata.detection_id == 586654)
+ assert multiple.signal_quantity == "MULTIPLE"
+ assert multiple.signal_parameters is None
+
+ # Detection update
+ update = next(a for a in detections if a.metadata.detection_id == 586669)
+ assert update.metadata.base_id == 586655
+
+ # Detection without base
+ base = next(a for a in detections if a.metadata.detection_id == 586655)
+ assert base.metadata.base_id is None
+
+ # Annotator parsing
+ annotators = {
+ DetectorInfo(name="vashti", expertise="NOVICE"),
+ DetectorInfo(name="heartleap", expertise=None),
+ DetectorInfo(name="bunyan", expertise="EXPERT"),
+ DetectorInfo(name="lookaftering", expertise="EXPERT"),
+ }
+ assert np.array_equal(
+ annotators,
+ {a.detector_info for a in detections},
+ )
+
+ # Verification parsing
+ verificated = next(a for a in detections if a.metadata.detection_id == 586654)
+ verification = {
+ Verification(
+ verificator="lookaftering",
+ is_validated=True,
+ ),
+ Verification(
+ verificator="bunyan",
+ is_validated=False,
+ ),
+ }
+ assert np.array_equal(verification, verificated.verifications)
+
+ # Repr should be the detection ID
+ detection = detections[0]
+ assert str(detection) == str(detection.metadata.detection_id)
+
+ # Non-specified confidence indicator should be None
+ detection = next(d for d in detections if d.metadata.detection_id == 586673)
+ assert detection.confidence_indicator is None
+
+
+def test_detection_to_rectangle(sample_detection: Detection) -> None:
+ rectangle = sample_detection.to_rectangle()
+
+ t1, t2 = sample_detection.begin, sample_detection.end
+
+ f_box = sample_detection.frequency_bounds
+ f1, f2 = f_box.min, f_box.max
+
+ x, y = rectangle.xy
+
+ assert x == t1
+ assert y == f1
+
+ assert x + rectangle.get_width() == t2
+ assert y + rectangle.get_height() == f2
diff --git a/tests/test_event.py b/tests/test_event.py
index bf8d9fc9..5270636e 100644
--- a/tests/test_event.py
+++ b/tests/test_event.py
@@ -483,3 +483,49 @@ def test_repr() -> None:
)
def test_duration(event: Event, expected_duration: Timedelta) -> None:
assert event.duration == expected_duration
+
+
+@pytest.mark.parametrize(
+ ("event", "timezone", "expected"),
+ [
+ pytest.param(
+ Event(
+ begin=Timestamp("18-02-1954 00:00:00"),
+ end=Timestamp("26-05-2022 00:00:00"),
+ ),
+ "UTC+0100",
+ Event(
+ begin=Timestamp("18-02-1954 00:00:00+0100"),
+ end=Timestamp("26-05-2022 00:00:00+0100"),
+ ),
+ id="naive_to_aware",
+ ),
+ pytest.param(
+ Event(
+ begin=Timestamp("18-02-1954 00:00:00+0100"),
+ end=Timestamp("26-05-2022 00:00:00+0100"),
+ ),
+ None,
+ Event(
+ begin=Timestamp("18-02-1954 00:00:00"),
+ end=Timestamp("26-05-2022 00:00:00"),
+ ),
+ id="aware_to_naive",
+ ),
+ pytest.param(
+ Event(
+ begin=Timestamp("18-02-1954 00:00:00+0100"),
+ end=Timestamp("26-05-2022 00:00:00+0100"),
+ ),
+ "UTC+0300",
+ Event(
+ begin=Timestamp("18-02-1954 02:00:00+0300"),
+ end=Timestamp("26-05-2022 02:00:00+0300"),
+ ),
+ id="aware_to_aware_converts_timezones",
+ ),
+ ],
+)
+def test_localize(event: Event, timezone: str | None, expected: Event) -> None:
+ event.localize(timezone)
+ assert event == expected
diff --git a/tests/test_spectro.py b/tests/test_spectro.py
index 9fc38ae3..4e4e309f 100644
--- a/tests/test_spectro.py
+++ b/tests/test_spectro.py
@@ -1425,7 +1425,8 @@ def mock_imshow(
monkeypatch.setattr(plt.Axes, "imshow", mock_imshow)
- sd.plot()
+ _, ax = plt.subplots()
+ sd_ax = sd.plot(ax=ax)
assert (plot_kwargs["vmin"], plot_kwargs["vmax"]) == sd.v_lim
assert plot_kwargs["cmap"] == sd.colormap
@@ -1441,6 +1442,8 @@ def mock_imshow(
assert f1 == sd.fft.f[0]
assert f2 == sd.fft.f[-1]
+ assert sd_ax == ax
+
def test_spectro_default_v_lim(audio_files: pytest.fixture) -> None:
files, _ = audio_files
diff --git a/tests/test_timestamp_utils.py b/tests/test_timestamp_utils.py
index 674ba46f..1ee533b9 100644
--- a/tests/test_timestamp_utils.py
+++ b/tests/test_timestamp_utils.py
@@ -583,6 +583,12 @@ def test_reformat_timestamp(
Timestamp("2024-10-17T10:14:11.000+0000", tz="UTC"),
id="negative_zero_UTC_offset_timezone",
),
+ pytest.param(
+ Timestamp("2024-10-17 10:14:11+0200"),
+ None,
+ Timestamp("2024-10-17T10:14:11"),
+ id="aware_to_naive",
+ ),
],
)
def test_localize_timestamp(