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(