#!/usr/bin/env python
"""
Ensemble of functions that facilitate data visualization.
"""
# Import external modules
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
import matplotlib.patches as mpatches
from matplotlib.axes import Axes
import pandas as pd
from skimage.io import imsave
import colorsys
from ast import literal_eval
from datetime import datetime
# min value
import sys
_MIN_ = sys.float_info.min
# Importation from internal modules
from maad.util import linear_scale, power2dB
#%%
def _check_axes(axes):
"""Check if "axes" is an instance of an axis object. If not, use `gca`."""
if axes is None:
import matplotlib.pyplot as plt
axes = plt.gca()
elif not isinstance(axes, Axes):
raise ValueError(
"`axes` must be an instance of matplotlib.axes.Axes. "
"Found type(axes)={}".format(type(axes))
)
return axes
#%%
def _is_single_value(value):
"""
Check if a value is a single numeric value or a single character.
Parameters:
-----------
value (int, float, str): The value to be checked.
Returns:
--------
bool: True if the value is a single numeric value or a single character, False otherwise.
"""
if isinstance(value, (int, float, str)):
if isinstance(value, (int, float)):
return True # It's a numeric value
elif isinstance(value, str):
# Check if it's a single numeric character (0-9)
if len(value) == 1 and value.isdigit():
return True # It's a single numeric character
else:
return False # It's a string, but not a single character
return False # It's not a single value
#%%
MIN_V = 1 # min week number possible
MIN_m = 1 # min month number possible
MIN_d = 1 # min day number possible
MIN_H = 0 # min hour number possible
MIN_M = 0 # min minute number possible
MAX_V = 53 # max week number possible
MAX_m = 12 # max month number possible
MAX_d = 31 # max day number possible
MAX_H = 23 # max hour number possible
MAX_M = 59 # max minute number possible
def _filter_dataframe_by_datetime(df, date_format, date_range):
"""
Filter a DataFrame based on a specified date format and date range.
Parameters:
-----------
df : pandas.DataFrame
The DataFrame to be filtered.
date_format :str
The format of the date to be extracted from the DataFrame's index.
date_range : int, str, list
The range of dates to filter the DataFrame by.
Returns:
--------
pandas.DataFrame: The filtered DataFrame.
Raises:
-------
ValueError: If the date_format is not allowed or if the date_range is not a single value or a list.
Note:
-----
The date_format parameter can be one of the following:
- "%V": Week number
- "%Y": Year
- "%m-%d": Month and day
- "%a": Weekday
- "%m": Month
- "%d": Day
- "%y-%m-%d": Year, Month, and Day
- "%H:%M": Hour and minute (e.g., "13:42")
The date_range parameter can be one of the following:
- A single value (int or str) representing a specific date or time.
- A list of values (int or str) representing a range of dates or times.
For example, ["01-01", "01-31"] represents a range of dates from January 1st to January 31st.
"""
# Extract the date portion from the DatetimeIndex based on the specified date format
try:
df['date_type'] = df.index.strftime(date_format)
except :
raise ValueError('Error in the date_format. Check the date_format parameter')
if _is_single_value(date_range) == True :
if (date_format == "%V") or (date_format == "%Y") or (date_format == "%m") or (date_format == "%d"):
# Filter by week number or by year or by month
df_filtered = df[df['date_type'].astype(int) == date_range]
elif (date_format == "%m-%d") or (date_format == "%H:%M") or (date_format == "%a") or (date_format == "%y-%m-%d"):
# Filter by time (hour:minutes)
# Filter by weekday (e.g., "Mon" for Monday)
# Filter by year-month-day (e.g., "23-01-01" for January 1, 2023)
# Filter by month and day (e.g., "01-01" for January 1)
df_filtered = df[df['date_type'] == date_range]
else :
print("Filtering was no possible. date_format {} is not allowed".format(date_format))
df_filtered = df
elif (len(date_range)==1) or(len(date_range)>2):
if (date_format == "%V") or (date_format == "%Y") or (date_format == "%m") or (date_format == "%d"):
# Filter by week number or by year or by month
df_filtered = df[df['date_type'].astype(int).isin(date_range)]
elif (date_format == "%m-%d") or (date_format == "%H:%M") or (date_format == "%a") or (date_format == "%y-%m-%d"):
# Filter by time (hour:minutes)
# Filter by weekday (e.g., "Mon" for Monday)
# Filter by year-month-day (e.g., "23-01-01" for January 1, 2023)
# Filter by month and day (e.g., "01-01" for January 1)
df_filtered = df[df['date_type'].isin(date_range)]
else :
print("Filtering was no possible. date_format {} is not allowed".format(date_format))
df_filtered = df
else:
if isinstance(date_range, list) :
if (date_format == "%V") or (date_format == "%Y") or (date_format == "%m") or (date_format == "%d"):
# Filter by week number or by year or by month
df_filtered = df[df['date_type'].astype(int).between(date_range[0], date_range[1])]
elif (date_format == "%m-%d") or (date_format == "%H:%M") or (date_format == "%a") or (date_format == "%y-%m-%d"):
# Filter by time (hour:minutes)
# Filter by weekday (e.g., "Mon" for Monday)
# Filter by year-month-day (e.g., "23-01-01" for January 1, 2023)
# Filter by month and day (e.g., "01-01" for January 1)
if date_range[0] <= date_range[1] :
df_filtered = df[df['date_type'].between(str(date_range[0]), str(date_range[1]))]
else :
if date_format == "%H:%M" :
MAX = f'{MAX_H:02}:{MAX_M:02}'
df_filtered_part1 = df[df['date_type'].between(str(date_range[0]), MAX)]
MIN = f'{MIN_H:02}:{MIN_M:02}'
df_filtered_part2 = df[df['date_type'].between(MIN, str(date_range[1]))]
df_filtered = pd.concat([df_filtered_part1, df_filtered_part2], axis = 0)
else :
print("Filtering was no possible. The range must increase")
df_filtered = df
else :
print("Filtering was no possible. date_format {} is not allowed".format(date_format))
df_filtered = df
else :
print("Filtering was no possible. date_range must be a single value or a list")
df_filtered = df
# Check if 'data_type' is in the DataFrame's columns
if 'date_type' in df_filtered.columns:
# If it exists, drop the column
df_filtered = df_filtered.drop('date_type', axis=1)
return df_filtered
#%%
[docs]
def plot_shape(shape, params, row=0, ax=None, display_values=False):
"""
Plot shape features in a bidimensional plot.
Parameters
----------
shape: 1D array, pd.Series or pd.DataFrame
Shape features computed with shape_features function.
params: pd.DataFrame
Pandas dataframe returned by maad.features_rois.shape_features
row: int
Observation to be visualized
display_values: bool
Set to True to display the coefficient values. Default is False.
Returns
-------
ax: matplotlib.axes
Axes of the figure
Examples
--------
>>> from maad.sound import load, spectrogram
>>> from maad.features import shape_features, plot_shape
>>> import numpy as np
>>> s, fs = load('../data/spinetail.wav')
>>> Sxx, ts, f, ext = spectrogram(s, fs)
>>> shape, params = shape_features(np.log10(Sxx), resolution='high')
>>> plot_shape(shape, params)
"""
# compute shape of matrix
dirs_size = params.theta.unique().shape[0]
scale_size = np.unique(params.freq).size * np.unique(params.pyr_level).size
# reshape feature vector
idx = params.sort_values(["theta", "pyr_level", "scale"]).index
if isinstance(shape, pd.DataFrame):
shape_plt = shape.iloc[:, shape.columns.str.startswith("shp")]
shape_plt = np.reshape(shape_plt.iloc[row, idx].values, (dirs_size, scale_size))
elif isinstance(shape, pd.Series):
shape_plt = shape.iloc[shape.index.str.startswith("shp")]
shape_plt = np.reshape(shape_plt.iloc[idx].values, (dirs_size, scale_size))
elif isinstance(shape, np.ndarray):
shape_plt = np.reshape(shape_plt[idx], (dirs_size, scale_size))
unique_scale = params.scale[idx] * 2 ** params.pyr_level[idx]
# get textlab
textlab = shape_plt
textlab = np.round(textlab, 2)
# plot figure
if ax is None:
fig = plt.figure(figsize=(8, 6))
ax = fig.add_subplot(111)
else:
pass
ax.imshow(
shape_plt, aspect="auto", origin="lower", interpolation="None", cmap="viridis"
)
if display_values:
for (j, i), label in np.ndenumerate(textlab):
ax.text(i, j, label, ha="center", va="center")
yticklab = np.round(params.theta.unique(), 1)
xticklab = np.reshape(unique_scale.values, (dirs_size, scale_size))
ax.set_xticks(np.arange(scale_size))
ax.set_xticklabels(np.round(xticklab, 1)[0, :])
ax.set_yticks(np.arange(dirs_size))
ax.set_yticklabels(yticklab)
ax.set_xlabel("Scale")
ax.set_ylabel("Theta")
plt.show()
return ax
#%%
[docs]
def overlay_centroid(im_ref, centroid, savefig=None, **kwargs):
"""
Overlay centroids on the original spectrogram
Parameters
----------
Sxx : 2D array
Spectrogram
centroid: pandas DataFrame
DataFrame with centroid descriptors (centroid_f, centroid_t)
Do format_features(rois,tn,fn) before using overlay_centroid to be sure that
the format of the DataFrame is correct
savefig : string, optional, default is None
Root filename (with full path) is required to save the figures. Postfix
is added to the root filename.
kwargs, optional. This parameter is used by plt.plot and savefig functions
- savefilename : str, optional, default :'_spectro_overlaycentroid.png'
Postfix of the figure filename
- extent : list of scalars [left, right, bottom, top], optional, default: None
The location, in data-coordinates, of the lower-left and
upper-right corners. If `None`, the image is positioned such that
the pixel centers fall on zero-based (row, column) indices.
- title : string, optional, default : 'Spectrogram'
title of the figure
- xlabel : string, optional, default : 'Time [s]'
label of the horizontal axis
- ylabel : string, optional, default : 'Amplitude [AU]'
label of the vertical axis
- cmap : string or Colormap object, optional, default is 'gray'
See https://matplotlib.org/examples/color/colormaps_reference.html
in order to get all the existing colormaps
examples: 'hsv', 'hot', 'bone', 'tab20c', 'jet', 'seismic',
'viridis'...
- vmin, vmax : scalar, optional, default: None
`vmin` and `vmax` are used in conjunction with norm to normalize
luminance data. Note if you pass a `norm` instance, your
settings for `vmin` and `vmax` will be ignored.
- dpi : integer, optional, default is 96
Dot per inch.
For printed version, choose high dpi (i.e. dpi=300) => slow
For screen version, choose low dpi (i.e. dpi=96) => fast
- format : string, optional, default is 'png'
Format to save the figure
... and more, see matplotlib
Returns
-------
ax
axis object (see matplotlib)
fig
figure object (see matplotlib)
Examples
--------
Get centroid from the whole power spectrogram
>>> from maad.sound import load, spectrogram
>>> from maad.features import centroid_features
>>> from maad.util import (power2dB, format_features, overlay_rois, plot2d,
overlay_centroid)
Load audio and compute spectrogram
>>> s, fs = load('../data/spinetail.wav')
>>> Sxx,tn,fn,ext = spectrogram(s, fs, db_range=80)
>>> Sxx = power2dB(Sxx, db_range=80)
Load annotations and plot
>>> from maad.util import read_audacity_annot
>>> rois = read_audacity_annot('../data/spinetail.txt')
>>> rois = format_features(rois, tn, fn)
>>> ax, fig = plot2d (Sxx, extent=ext)
>>> ax, fig = overlay_rois(Sxx,rois, extent=ext, ax=ax, fig=fig)
Compute the centroid of each rois, format to get results in the
temporal and spectral domain and overlay the centroids.
>>> centroid = centroid_features(Sxx, rois)
>>> centroid = format_features(centroid, tn, fn)
>>> ax, fig = overlay_centroid(Sxx,centroid, extent=ext, ax=ax, fig=fig)
"""
# Check format of the input data
if type(centroid) is not pd.core.frame.DataFrame:
raise TypeError("Rois must be of type pandas DataFrame")
if not (("centroid_t" and "centroid_f") in centroid):
raise TypeError(
"Array must be a Pandas DataFrame with column names:(centroid_t, centroid_f). Check example in documentation."
)
ylabel = kwargs.pop("ylabel", "Frequency [Hz]")
xlabel = kwargs.pop("xlabel", "Time [s]")
title = kwargs.pop("title", "ROIs Overlay")
cmap = kwargs.pop("cmap", "gray")
extent = kwargs.pop("ext", None)
if extent is None:
ylabel = "pseudofrequency [points]"
xlabel = "pseudotime [points]"
vmin = kwargs.pop("vmin", 0)
vmax = kwargs.pop("vmax", 1)
ax = kwargs.pop("ax", None)
fig = kwargs.pop("fig", None)
color = kwargs.pop("color", "firebrick")
ms = kwargs.pop("ms", 2)
marker = kwargs.pop("marker", "o")
if (ax is None) and (fig is None):
ax, fig = plot2d(
im_ref,
extent = extent,
now = False,
title = title,
ylabel = ylabel,
xlabel = xlabel,
vmin = vmin,
vmax = vmax,
cmap = cmap,
**kwargs
)
ax.plot(centroid.centroid_t, centroid.centroid_f, marker, ms=ms, color=color)
fig.canvas.draw()
# SAVE FIGURE
if savefig is not None:
dpi = kwargs.pop("dpi", 96)
format = kwargs.pop("format", "png")
filename = kwargs.pop("filename", "_spectro_overlaycentroid")
filename = savefig + filename + "." + format
fig.savefig(filename, bbox_inches="tight", dpi=dpi, format=format, **kwargs)
return ax, fig
#%%
[docs]
def overlay_rois(im_ref, rois, edge_color=None, unique_labels= None,
textbox_label=False, savefig=None, **kwargs):
"""
Display bounding boxes with time-frequency regions of interest over a spectrogram.
Regions of interest (ROIs) must be provided. They can be loaded as manual annotations or computed using automated methods (see the example section).
Parameters
----------
im_ref : 2d ndarray of scalars
Spectrogram (or image)
rois_bbox : pandas.DataFrame
Contains the bounding box of each ROI. The pandas.DataFrame must have columns
['min_x', 'max_x', 'min_y', 'max_y']. If your data is in the time-frequency
format ['min_t', 'max_t', 'min_f', 'max_f'], use the function
maad.util.format_features to get the cooresponding
x, y coordinates.
edge_color : {None, string, tuple, list of tuples}, default is None
Define the color of the bounding-box to display.
if it's a string, select a color name
if it's a tuple, give a tuple of floats [0,1] in the form (r,g,b,a),
with r for red, g for green, b for blue and a for alpha (i.e transparency)
for example
- pure red : (1,0,0,0)
- pure yellow : (1,1,0,0)
- pure blue with transparency(0,0,1,0.5)
if it's a list, give a list of tuple or string. The number of element
in the list should be equal to the number of elements in unique_labels.
if it's set to None, a random list of colors is used.
unique_labels : list, default is None
list of unique labels that could be either strings or numbers. When
a list is given as argument, the number element in the list should
be the same as the number of edge_color
textbox_label: bool
Display a text box above the bounding box indicating the name of the label.
The rois_bbox pandas.DataFrame must have a column called 'label' with names
assigned to each ROI.
savefig : string, optional, default is None
Root filename (with full path) is required to save the figures. Postfix
is added to the root filename.
kwargs, optional. This parameter is used by plt.plot and savefig functions
- savefilename : str, optional, default :'_spectro_overlayrois.png'
Postfix of the figure filename
- extent : list of scalars [left, right, bottom, top], optional, default: None
The location, in data-coordinates, of the lower-left and
upper-right corners. If `None`, the image is positioned such that
the pixel centers fall on zero-based (row, column) indices.
- title : string, optional, default : 'Spectrogram'
title of the figure
- xlabel : string, optional, default : 'Time [s]'
label of the horizontal axis
- ylabel : string, optional, default : 'Amplitude [AU]'
label of the vertical axis
- cmap : string or Colormap object, optional, default is 'gray'
See https://matplotlib.org/examples/color/colormaps_reference.html
in order to get all the existing colormaps
examples: 'hsv', 'hot', 'bone', 'tab20c', 'jet', 'seismic',
'viridis'...
- vmin, vmax : scalar, optional, default: None
`vmin` and `vmax` are used in conjunction with norm to normalize
luminance data. Note if you pass a `norm` instance, your
settings for `vmin` and `vmax` will be ignored.
- dpi : integer, optional, default is 96
Dot per inch.
For printed version, choose high dpi (i.e. dpi=300) => slow
For screen version, choose low dpi (i.e. dpi=96) => fast
- format : string, optional, default is 'png'
Format to save the figure
... and more, see matplotlib
Returns
-------
ax : axis object (see matplotlib)
fig : figure object (see matplotlib)
Examples
--------
Simple display of bounding boxes over the spectrogram.
>>> from maad import sound, util
>>> s, fs = sound.load('./data/spinetail.wav')
>>> rois = util.read_audacity_annot('./data/spinetail.txt')
>>> Sxx, tn, fn, ext = sound.spectrogram(s, fs, nperseg=512, noverlap=256)
>>> rois = util.format_features(rois, tn, fn)
>>> fig, ax = plt.subplots(figsize=(10,5))
>>> util.plot_spectrogram(Sxx,ext, db_range=70, ax=ax)
>>> util.overlay_rois(Sxx, rois, fig=fig, ax=ax, textbox_label=True)
Detect regions of interest and display them over the spectrogram.
Load audio recording and compute the spectrogram.
>>> s, fs = maad.sound.load('../data/cold_forest_daylight.wav')
>>> Sxx,tn,fn,ext = maad.sound.spectrogram (s, fs, fcrop=(0,10000))
Subtract the background noise before finding ROIs.
>>> Sxx_noNoise = maad.sound.median_equalizer(Sxx)
Convert linear spectrogram into dB and smooth.
>>> Sxx_noNoise_dB = maad.util.power2dB(Sxx_noNoise)
>>> Sxx_noNoise_dB_blurred = maad.sound.smooth(Sxx_noNoise_dB)
Detection of the acoustic signature => creation of a mask
>>> im_bin = maad.rois.create_mask(Sxx_noNoise_dB_blurred, bin_std=6, bin_per=0.5, mode='relative')
Select rois from the mask and display bounding box over the spectrogram without noise.
>>> import numpy as np
>>> im_rois, df_rois = maad.rois.select_rois(im_bin, min_roi=100)
>>> maad.util.overlay_rois (Sxx_noNoise_dB, df_rois, extent=ext,vmin=np.median(Sxx_noNoise_dB), vmax=np.median(Sxx_noNoise_dB)+60)
"""
# Check format of the input data
if type(rois) is not pd.core.frame.DataFrame:
raise TypeError("Rois must be of type pandas DataFrame.")
if not (("min_y" and "min_x" and "max_y" and "max_x") in rois):
raise TypeError(
"Array must be a Pandas DataFrame with column names: min_y, min_x, max_y, max_x. Check example in documentation."
)
ylabel = kwargs.pop("ylabel", "Frequency [Hz]")
xlabel = kwargs.pop("xlabel", "Time [s]")
title = kwargs.pop("title", "ROIs Overlay")
cmap = kwargs.pop("cmap", "gray")
vmin = kwargs.pop("vmin", np.percentile(im_ref, 0.05))
vmax = kwargs.pop("vmax", np.percentile(im_ref, 0.95))
ax = kwargs.pop("ax", None)
fig = kwargs.pop("fig", None)
extent = kwargs.pop("extent", None)
if extent is None:
xlabel = "pseudoTime [points]"
ylabel = "pseudoFrequency [points]"
if (ax is None) and (fig is None):
ax, fig = plot2d(
im_ref,
extent = extent,
now = False,
title = title,
ylabel = ylabel,
xlabel = xlabel,
vmin = vmin,
vmax = vmax,
cmap = cmap,
**kwargs
)
# Convert pixels into time and frequency values
y_len, x_len = im_ref.shape
xmin, xmax = ax.get_xlim()
ymin, ymax = ax.get_ylim()
x_scaling = (xmax - xmin) / x_len
y_scaling = (ymax - ymin) / y_len
# if no list of unique labels is provided, try to find a "label" column
if unique_labels is None :
# test if rois has a label column
if "label" in rois:
# select the label column
rois_label = rois.label.values
uniqueLabels = list(np.unique(np.array(rois_label)))
else:
uniqueLabels = [0]
else:
if isinstance(unique_labels,list) :
uniqueLabels = unique_labels
elif isinstance(unique_labels,np.ndarray) :
uniqueLabels = list(unique_labels)
elif isinstance(unique_labels, str) :
uniqueLabels = [unique_labels]
else:
# test if unique_labels is a number
try:
int(unique_labels)
uniqueLabels = [unique_labels]
except:
raise TypeError(
"unique_labels must be a list or a string or a number"
)
# Colormap
if edge_color is None :
# in order to still have the color yellow by default in case of no label
# or a unique label
if len(uniqueLabels) == 1:
color = tuple(["yellow"])
else:
color_func = rand_cmap(len(uniqueLabels) + 1, first_color_black=False)
color = color_func(np.arange(len(uniqueLabels)))
# if a list of colors is defined, test if the length of the list is
# enough compared to the number of unique labels
# convert into tuple
elif isinstance(edge_color, str) :
color = tuple([edge_color]) * len(uniqueLabels)
elif isinstance(edge_color, list) :
nn = int(len(uniqueLabels)/len(edge_color)) +1
color = tuple(edge_color) * nn
cc = 0
for index, row in rois.iterrows():
cc = cc + 1
y0 = row["min_y"]
x0 = row["min_x"]
y1 = row["max_y"]
x1 = row["max_x"]
# find the position of the label in uniqueLabels
if "label" in row :
ii = uniqueLabels.index(row["label"])
else:
ii = 0
rect = mpatches.Rectangle(
(x0 * x_scaling + xmin, y0 * y_scaling + ymin),
(x1 - x0) * x_scaling,
(y1 - y0) * y_scaling,
fill=False,
edgecolor=color[ii],
linewidth=1,
)
# draw the rectangle
ax.add_patch(rect)
if textbox_label:
textbox = dict(boxstyle='square', fc=color[ii],
ec=color[ii], alpha=1, pad=0, linewidth=2)
ax.text(x0*x_scaling + xmin, (y1+2)*y_scaling + ymin,
str(row.label),
color='white',
fontweight='semibold',
fontsize='small',
fontfamily='sans-serif',
fontvariant='small-caps',
bbox=textbox
)
if fig is not None:
fig.canvas.draw()
# SAVE FIGURE
if savefig is not None and fig is not None:
dpi = kwargs.pop("dpi", 96)
format = kwargs.pop("format", "png")
filename = kwargs.pop("filename", "_spectro_overlayrois")
filename = savefig + filename + "." + format
fig.savefig(filename, bbox_inches="tight", dpi=dpi, format=format, **kwargs)
return ax, fig
#%%
[docs]
def plot1d(x, y, ax=None, **kwargs):
"""
Plot the waveform or spectrum of an audio signal.
Parameters
----------
x : 1d ndarray of integer
Vector containing the abscissa values (horizontal axis)
y : 1d ndarray of scalar
Vector containing the ordinate values (vertical axis)
ax : axis, optional, default is None
Draw the signal on this specific axis. Allow multiple plots on the same
axis.
kwargs, optional
- figsize : tuple of integers, optional, default: (4,10)
width, height in inches.
- facecolor : matplotlib color, optional, default: 'w' (white)
the background color.
- edgecolor : matplotlib color, optional, default: 'k' (black)
the border color.
- color : matplotlib color, optional, default: 'k' (black)
the line color
The following color abbreviations are supported:
========== ========
character color
========== ========
'b' blue
'g' green
'r' red
'c' cyan
'm' magenta
'y' yellow
'k' black
'w' white
========== ========
In addition, you can specify colors in many ways, including RGB
tuples (0.2,1,0.5). See matplotlib color
- linewidth : scalar, optional, default: 0.5
width in pixels
- figtitle: string, optional, default: 'Audiogram'
Title of the plot
- xlabel : string, optional, default : 'Time [s]'
label of the horizontal axis
- ylabel : string, optional, default : 'Amplitude [AU]'
label of the vertical axis
- legend : string, optional, default : None
Legend for the plot
- now : boolean, optional, default : True
if True, display now. Cannot display multiple plots.
To display mutliple plots, set now=False until the last call for
the last plot
...and more, see matplotlib
Returns
-------
fig : Figure
The Figure instance
ax : Axis
The Axis instance
Examples
--------
>>> import numpy as np
>>> s, fs = maad.sound.load('../data/cold_forest_daylight.wav')
Plot the audiogram
>>> tn = np.arange(0,len(s))/fs
>>> fig, ax = maad.util.plot1d(tn,s)
>>> Sxx_power,tn,fn,_ = maad.sound.spectrogram(s,fs)
Convert spectrogram into dB SPL
>>> Lxx = maad.spl.power2dBSPL(Sxx_power, gain=42)
Plot the spectrum at t = 7s
>>> index = maad.util.nearest_idx(tn,7)
>>> fig_kwargs = {'figtitle':'Spectrum (PSD)',
'xlabel':'Frequency [Hz]',
'ylabel':'Power [dB]',
'linewidth': 0.5
}
>>> fig, ax = maad.util.plot1d(fn, Lxx[:,index], **fig_kwargs)
"""
figsize = kwargs.pop("figsize", (4, 10))
facecolor = kwargs.pop("facecolor", "w")
edgecolor = kwargs.pop("edgecolor", "k")
linewidth = kwargs.pop("linewidth", 0.5)
color = kwargs.pop("color", "k")
title = kwargs.pop("figtitle", "")
xlabel = kwargs.pop("xlabel", "Time [s]")
ylabel = kwargs.pop("ylabel", "Amplitude [AU]")
legend = kwargs.pop("legend", None)
now = kwargs.pop("now", False)
# if no ax, create a figure and a subplot associated a figure otherwise
# find the figure that belongs to ax
if ax is None:
fig, ax = plt.subplots()
# set the paramters of the figure
fig.set_figheight(figsize[0])
fig.set_figwidth(figsize[1])
fig.set_facecolor(facecolor)
fig.set_edgecolor(edgecolor)
else:
fig = ax.get_figure()
# plot the data on the subplot
line = ax.plot(x, y, color=color, linewidth=linewidth, **kwargs)
# set legend to the line
line[0].set_label(legend)
# set the parameters of the subplot
ax.set_title(title)
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
#ax.axis("tight")
ax.grid(True, linewidth=0.3)
ax.margins(x=0)
if legend is not None:
ax.legend()
# Display the figure now
if now:
plt.show()
return ax, fig
#%%
[docs]
def plot_wave(s, fs, tlims=None, ax=None, **kwargs):
"""
Plot audio waveform.
Parameters
----------
s : 1d ndarray
Audio signal.
fs : int
Sampling rate of audio signal.
tlims : tuple, optional
Minimum and maximum temporal limits for the display (min_t, max_t).
The default is None.
ax : matplotlib.axes, optional
Pre-existing axes for the plot. The default is None.
**kwargs : matplotlib figure properties
Other keyword arguments that are passed down to matplotlib.axes.
Returns
-------
ax : matplotlib.axes
The matplotlib axes associated to plot.
See also
--------
maad.sound.plot_spectrum
Examples
--------
Plot a waveform of an audio signal.
>>> from maad import sound
>>> from maad.util import plot_wave
>>> s, fs = sound.load('../data/spinetail.wav')
>>> ax = plot_wave(s, fs)
Use `plot_wave` with predifined matplotlib axes.
>>> import matplotlib.pyplot as plt
>>> s, fs = sound.load('../data/spinetail.wav')
>>> fig, ax = plt.subplots(2,1)
>>> plot_wave(s, fs, ax=ax[0], xlabel='', figtitle='Spinetail')
>>> plot_wave(s, fs, tlims=(5,8), ax=ax[1])
"""
if tlims is not None:
s = s[int(tlims[0] * fs) : int(tlims[1] * fs)]
t = np.linspace(0, s.shape[0] / fs, s.shape[0])
t = t + tlims[0] # add minimum value
else:
t = np.linspace(0, s.shape[0] / fs, s.shape[0])
ax, fig = plot1d(t, s, ax, **kwargs)
return ax
#%%
[docs]
def plot_spectrum(pxx, f_idx, ax=None, flims=None, log_scale=False, fill=True, **kwargs):
"""
Plot power spectral density estimate (PSD).
Parameters
----------
pxx : 1d ndarray
Power spectral density estimate computed with `maad.sound.spectrum`.
f_idx : 1d ndarray
Index of frequencies associated with the PSD.
ax : matplotlib.axes, optional
Pre-existing axes for the plot. The default is None.
flims : tuple, optional
Minimum and maximum spectral limits for the display (min_f, max_f).
Default is None.
log_scale : bool, optional
Use a logarithmic scale to display amplitude values. The default is False.
fill : bool, optional
Fill the area between the curve and the minimum value.
**kwargs : matplotlib figure properties
Other keyword arguments that are passed down to matplotlib.axes.
Returns
-------
ax : matplotlib.axes
The matplotlib axes associated to plot.
See also
--------
maad.sound.spectrum, maad.util.plot_wave
Examples
--------
Plot a spectrum of an audio signal.
>>> from maad import sound, util
>>> s, fs = sound.load('../data/spinetail.wav')
>>> pxx, f_idx = sound.spectrum(s, fs, nperseg=1024)
>>> util.plot_spectrum(pxx, f_idx)
Use `plot_spectrum` with predifined matplotlib axes.
>>> import matplotlib.pyplot as plt
>>> s, fs = sound.load('../data/spinetail.wav')
>>> s_slice = sound.trim(s, fs, 5, 8)
>>> pxx, f_idx = sound.spectrum(s_slice, fs, nperseg=1024)
>>> fig, ax = plt.subplots(2,1, figsize=(10,6))
>>> util.plot_wave(s_slice, fs, ax=ax[0])
>>> util.plot_spectrum(pxx, f_idx, ax=ax[1], log_scale=True)
"""
ylabel = kwargs.pop("ylabel", "Amplitude [AU]")
xlabel = kwargs.pop("xlabel", "Frequency [Hz]")
if log_scale == True:
pxx = power2dB(pxx)
ylabel = 'Amplitude [dB]'
if flims is not None:
pxx = pxx[(f_idx >= flims[0]) & (f_idx <= flims[1])]
f_idx = f_idx[(f_idx >= flims[0]) & (f_idx <= flims[1])]
amp_min = pxx.min()
ax, fig = plot1d(f_idx, pxx, ax=ax, ylabel=ylabel, xlabel=xlabel, **kwargs)
if fill:
ax.fill_between(f_idx, amp_min, pxx, alpha=0.3, fc="grey")
return ax
#%%
[docs]
def plot2d(im, ax=None, colorbar=True, **kwargs):
"""
Display the spectrogram of an audio signal.
The spectrogram should be previously computed using the function
``maad.sound.spectrogram``.
Parameters
----------
im : 2D numpy array
Image or Spectrogram
ax : axis, optional, default is None
Draw the image on this specific axis. Allow multiple plots on the same
figure.
kwargs, optional
- figsize : tuple of integers, optional, default: (4,13)
width, height in inches.
- title : string, optional, default : 'Spectrogram'
title of the figure
- xlabel : string, optional, default : 'Time [s]'
label of the horizontal axis
- ylabel : string, optional, default : 'Frequency [Hz]'
label of the vertical axis
- xticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- yticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- cmap : string or Colormap object, optional, default is 'gray'
See https://matplotlib.org/examples/color/colormaps_reference.html
in order to get all the existing colormaps
examples: 'hsv', 'hot', 'bone', 'tab20c', 'jet', 'seismic',
'viridis'...
- vmin, vmax : scalar, optional, default: None
`vmin` and `vmax` are used in conjunction with norm to normalize
luminance data. Note if you pass a `norm` instance, your
settings for `vmin` and `vmax` will be ignored.
- extent : list of scalars [left, right, bottom, top], optional, default: None
The location, in data-coordinates, of the lower-left and
upper-right corners. If `None`, the image is positioned such that
the pixel centers fall on zero-based (row, column) indices.
- now : boolean, optional, default : True
if True, display now. Cannot display multiple images.
To display mutliple images, set now=False until the last call for
the last image
- interpolation : list of string [None, 'none', 'nearest', 'bilinear',
'bicubic', 'spline16','spline36', 'hanning', 'hamming', 'hermite',
'kaiser', 'quadric','catrom', 'gaussian', 'bessel', 'mitchell',
'sinc', 'lanczos'], optional, default : None
... and more, see matplotlib
Returns
-------
fig : Figure
The Figure instance
ax : Axis
The Axis instance
Examples
--------
>>> w, fs = maad.sound.load('../data/cold_forest_daylight.wav')
>>> Sxx_power,tn,fn,_ = maad.sound.spectrogram(w,fs)
>>> Lxx = maad.spl.power2dBSPL(Sxx_power, gain=42) # convert into dB SPL
>>> fig_kwargs = {'vmax': Lxx.max(),
'vmin':0,
'extent':(tn[0], tn[-1], fn[0], fn[-1]),
'title':'Power spectrogram density (PSD) in dB SPL',
'xlabel':'Time [s]',
'ylabel':'Frequency [Hz]',
}
>>> fig, ax = maad.util.plot2d(Lxx,interpolation=None,**fig_kwargs)
"""
# matplotlib parameters
title = kwargs.pop("title", "")
ylabel = kwargs.pop("ylabel", "Frequency [Hz]")
xlabel = kwargs.pop("xlabel", "Time [s]")
xticks = kwargs.pop("xticks", None)
yticks = kwargs.pop("yticks", None)
cmap = kwargs.pop("cmap", "gray")
vmin = kwargs.pop("vmin", None)
vmax = kwargs.pop("vmax", None)
extent = kwargs.pop("extent", None)
now = kwargs.pop("now", False)
if extent is not None:
figsize = kwargs.pop(
"figsize",
(0.20 * (extent[3] - extent[2]) / 1000, 0.33 * (extent[1] - extent[0])),
)
else:
figsize = kwargs.pop("figsize", (4, 13))
# if no ax, create a figure and a subplot associated a figure otherwise
# find the figure that belongs to ax
if ax is None:
fig, ax = plt.subplots()
# set the paramters of the figure
fig.set_facecolor("w")
fig.set_edgecolor("k")
fig.set_figheight(figsize[0] + 1)
fig.set_figwidth(figsize[1])
else:
fig = ax.get_figure()
# display image
_im = ax.imshow(im, extent=extent, origin="lower", cmap=cmap,
vmin=vmin, vmax=vmax, **kwargs)
if colorbar==True:
plt.colorbar(_im, ax=ax)
# set the parameters of the subplot
ax.set_title(title)
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
if xticks is not None:
ax.set_xticks(ticks=xticks[0])
if len(xticks) == 2:
ax.set_xticklabels(labels=xticks[1])
if yticks is not None:
ax.set_yticks(ticks=yticks[0])
if len(yticks) == 2:
ax.set_yticklabels(labels=yticks[1])
ax.axis("tight")
fig.tight_layout()
# Display the figure now
if now:
plt.show()
return ax, fig
#%%
[docs]
def plot_spectrogram(Sxx, extent, db_range=96, gain=0, log_scale=True,
colorbar=True, interpolation='bilinear', **kwargs):
"""
Plot spectrogram represenation.
Parameters
----------
Sxx : 2d ndarray
Spectrogram computed using `maad.sound.spectrogram`.
extent : list of scalars [left, right, bottom, top]
Location, in data-coordinates, of the lower-left and upper-right corners.
db_range : int or float, optional
Range of values to display the spectrogram. The default is 96.
gain : int or float, optional
Gain in decibels added to the signal for display. The default is 0.
log_scale : bool, optional
Logarithmic transformation of spectrogram coefficients to enhace visual
representation. The default is True.
colorbar : bool, optional
Plot the colorbar next to the spectrogram. The default is True.
interpolation : bool, optional
Interpolate spectrogram values to enhance visual representation.
The default is 'bilinear'.
**kwargs : matplotlib figure properties
Other keyword arguments that are passed down to matplotlib.axes.
Returns
-------
ax : matplotlib.axes
The matplotlib axes associated to plot.
See also
--------
maad.sound.plot_spectrum, maad.util.plot_wave
Examples
--------
Plot the spectrogram of an audio.
>>> from maad import sound, util
>>> s, fs = sound.load('../data/spinetail.wav')
>>> Sxx, tn, fn, ext = sound.spectrogram(s,fs)
>>> util.plot_spectrogram(Sxx, ext, db_range=50, gain=30, figsize=(4,10))
Use `plot_spectrogram` with predifined matplotlib axes.
>>> import matplotlib.pyplot as plt
>>> fig, ax = plt.subplots(2,1, figsize=(10,6))
>>> util.plot_wave(s, fs, ax=ax[0])
>>> util.plot_spectrogram(Sxx, ext, db_range=50, gain=30, colorbar=False, ax=ax[1])
Plot a single frase of the spinetail.
>>> Sxx, tn, fn, ext = sound.spectrogram(s,fs, flims=(2000,15000), tlims=(5,8))
>>> util.plot_spectrogram(Sxx, ext, db_range=50, gain=30, figsize=(4,6))
"""
if log_scale:
Sxx_db = power2dB(Sxx, db_range, gain)
else :
Sxx_db = Sxx
ax, fig = plot2d(Sxx_db, extent=extent, colorbar=colorbar,
interpolation=interpolation, **kwargs)
return ax
#%%
[docs]
def rand_cmap(
nlabels,
type="bright",
first_color_black=True,
last_color_black=False,
seed=321,
verbose=False):
"""
Creates a random colormap to be used together with matplotlib. Useful for segmentation tasks
Parameters
----------
nlabels : int
Number of labels (size of colormap)
type : string
'bright' for strong colors, 'soft' for pastel colors. Default is 'bright'
first_color_black : bool, optional
Option to use first color as black. Default is True
last_color_black : bool, optional
Option to use last color as black. Default is False
seed : int, optional
Fix the seed of the random engine. Default is 321
verbose : bool, optional
Prints the number of labels and shows the colormap. Default is False
Returns
-------
random_colormap : Colormap
Colormap type used by matplotlib
References
----------
adapted from https://github.com/delestro/rand_cmap author : delestro
"""
# initialize the random seed in order to get always the same random order
np.random.seed(seed=seed)
if verbose:
print("Number of labels: " + str(nlabels))
# Generate color map for bright colors, based on hsv
if type == "bright":
randHSVcolors = [
(
np.random.uniform(low=0.1, high=1),
np.random.uniform(low=0.2, high=1),
np.random.uniform(low=0.5, high=1),
)
for i in range(nlabels)
]
# Convert HSV list to RGB
randRGBcolors = []
for HSVcolor in randHSVcolors:
randRGBcolors.append(
colorsys.hsv_to_rgb(HSVcolor[0], HSVcolor[1], HSVcolor[2])
)
# Generate soft pastel colors, by limiting the RGB spectrum
if type == "soft":
low = 0.6
high = 0.95
randRGBcolors = [
(
np.random.uniform(low=low, high=high),
np.random.uniform(low=low, high=high),
np.random.uniform(low=low, high=high),
)
for i in range(nlabels)
]
if first_color_black:
randRGBcolors[0] = [0, 0, 0]
if last_color_black:
randRGBcolors[-1] = [0, 0, 0]
random_colormap = LinearSegmentedColormap.from_list(
"new_map", randRGBcolors, N=nlabels
)
# Display colorbar
if verbose:
from matplotlib import colors, colorbar
from matplotlib import pyplot as plt
fig, ax = plt.subplots(1, 1, figsize=(15, 0.5))
bounds = np.linspace(0, nlabels, nlabels + 1)
norm = colors.BoundaryNorm(bounds, nlabels)
colorbar.ColorbarBase(
ax,
cmap=random_colormap,
norm=norm,
spacing="proportional",
ticks=None,
boundaries=bounds,
format="%1i",
orientation=u"horizontal",
)
return random_colormap
#%%
[docs]
def crop_image(im, tn, fn, fcrop=None, tcrop=None):
"""
Crop a spectrogram (or an image) in time (horizontal X axis) and frequency
(vertical y axis)
Parameters
----------
im : 2d ndarray
image to be cropped
tn, fn : 1d ndarray
tn is the time vector. fn is the frequency vector. They are required
in order to know the correspondance between pixels and (time,frequency)
fcrop, tcrop : list of 2 scalars [min, max], optional, default is None
fcrop corresponds to the min and max boundary frequency values
tcrop corresponds to the min and max boundary time values
Returns
-------
im : 2d ndarray
image cropped
tn, fn, 1d ndarray
new time and frequency vectors
Examples
--------
>>> w, fs = maad.sound.load('../data/cold_forest_daylight.wav')
>>> Sxx_power,tn,fn,_ = maad.sound.spectrogram(w,fs)
>>> Lxx = maad.spl.power2dBSPL(Sxx_power, gain=42) # convert into dB SPL
>>> fig_kwargs = {'vmax': Lxx.max(),
'vmin':0,
'extent':(tn[0], tn[-1], fn[0], fn[-1]),
'title':'Power spectrogram density (PSD)',
'xlabel':'Time [s]',
'ylabel':'Frequency [Hz]',
}
>>> fig, ax = maad.util.plot2d(Lxx,**fig_kwargs)
>>> Lxx_crop, tn_crop, fn_crop = maad.util.crop_image(Lxx, tn, fn, fcrop=(2000,10000), tcrop=(0,30))
>>> fig_kwargs = {'vmax': Lxx.max(),
'vmin':0,
'extent':(tn_crop[0], tn_crop[-1], fn_crop[0], fn_crop[-1]),
'title':'Crop of the power spectrogram density (PSD)',
'xlabel':'Time [s]',
'ylabel':'Frequency [Hz]',
}
>>> fig, ax = maad.util.plot2d(Lxx_crop,**fig_kwargs)
"""
if tcrop is not None:
indt = (tn >= tcrop[0]) * (tn <= tcrop[1])
im = im[:, indt]
# redefine tn
tn = tn[np.where(indt > 0)]
if fcrop is not None:
indf = (fn >= fcrop[0]) * (fn <= fcrop[1])
im = im[
indf,
]
# redefine fn
fn = fn[np.where(indf > 0)]
return im, tn, fn
#%%
[docs]
def save_figlist(fname, figlist):
"""
Save a list of figures or spectrograms to disk.
Parameters
----------
fname: string
Suffix string add to the filename. The extension should be specified since it
indicates the image format.
Notes
-----
This function does not return any variable.
"""
for i, fig in enumerate(figlist):
fname_save = "%d_%s" % (i, fname)
imsave(fname_save, fig)
#%%
[docs]
def plot_features_map(df, norm=True, mode="24h", **kwargs):
"""
Plot features values on a heatmap.
The plot has the features the vertical axis and the time on the horizontal axis.
Parameters
----------
df : Panda DataFrame
DataFrame with features (ie. indices).
norm : boolean, default is True
if True, the features are scaled between 0 to 1
mode : string in {'24h'}, default is '24h'
Select if the timeline of the phenology:
-'24h' : average of the results over a day
- otherwise, the timeline is the timeline of the dataframe
**kwargs
Specific to this function:
- ftime : Time format to display as x label by default '%Y-%m-%d'(see https://docs.python.org/fr/3.6/library/datetime.html?highlight=strftime#strftime-strptime-behavior)
Specific to matplotlib:
- figsize : tuple of integers, optional, default: (4,10) width, height in inches.
- title : string, optional, default : 'Spectrogram' title of the figure
- xlabel : string, optional, default : 'Time [s]' label of the horizontal axis
- ylabel : string, optional, default : 'Amplitude [AU]' label of the vertical axis
- xticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- yticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- cmap : string or Colormap object, optional, default is 'gray'
See https://matplotlib.org/examples/color/colormaps_reference.html
in order to get all the existing colormaps
examples: 'hsv', 'hot', 'bone', 'tab20c', 'jet', 'seismic',
'viridis'...
- vmin, vmax : scalar, optional, default: None
`vmin` and `vmax` are used in conjunction with norm to normalize
luminance data. Note if you pass a `norm` instance, your
settings for `vmin` and `vmax` will be ignored.
- extent : list of scalars [left, right, bottom, top], optional, default: None
The location, in data-coordinates, of the lower-left and
upper-right corners. If `None`, the image is positioned such that
the pixel centers fall on zero-based (row, column) indices.
- now : boolean, optional, default : True
if True, display now. Cannot display multiple images.
To display mutliple images, set now=False until the last call for
the last image
... and more, see matplotlib
Returns
-------
fig : Figure
The Figure instance
ax : Axis
The Axis instance
Examples
--------
see plot_extract_alpha_indices.py advanced example for a complete example
>>> import numpy as np
>>> import pandas as pd
>>> np.random.seed(2021)
>>> M = np.random.rand(24, 7)
>>> df = pd.DataFrame(M)
>>> indices = ['A','B','C','D','E','F','G']
>>> df.columns = indices
>>> df.index =pd.date_range(start=pd.Timestamp('00:00:00'), end=pd.Timestamp('23:00:00'), freq='1H')
>>> maad.util.plot_features_map(df)
"""
if isinstance(df, pd.DataFrame) == False:
raise TypeError("df must be a Pandas Dataframe")
elif isinstance(df.index, pd.DatetimeIndex) == False:
raise TypeError("df must have an index of type DateTimeIndex")
if mode == "24h":
# Mean values by hour
df = df.groupby(df.index.hour).mean()
# Get the list of unique index of type 'hour'
x_label = [i + j for i, j in zip(map(str, df.index.values), ["h"] * len(df))]
else:
# Get the list of unique index of type 'hour'
ftime = kwargs.pop("ftime", "%Y-%m-%d")
x_value = df.index.strftime(ftime)
x_label = x_value.tolist()
if norm:
df = linear_scale(df)
# kwargs
cmap = kwargs.pop("cmap", "RdBu_r")
figsize = kwargs.pop("figsize", None)
# plot
if figsize is None :
fig = plt.figure(figsize=(len(df)*0.33, len(list(df))*0.27))
ax = fig.add_subplot(111)
caxes = ax.matshow(df.transpose(), cmap=cmap, aspect="auto", **kwargs)
if norm :
fig.colorbar(caxes, shrink=0.75, label="Normalized value")
else :
fig.colorbar(caxes, shrink=0.75, label="Value")
# Set ticks on both sides of axes on
ax.tick_params(axis="x", bottom=True, top=False, labelbottom=True, labeltop=False)
# We want to show all ticks...
ax.set_yticks(np.arange(len(df.columns)))
ax.set_xticks(np.arange(len(x_label)))
# ... and label them with the respective list entries
ax.set_yticklabels(df.columns)
ax.set_xticklabels(x_label)
# Rotate the tick labels and set their alignment.
plt.setp(ax.get_yticklabels(), rotation=0, ha="right", fontsize=10)
plt.setp(ax.get_xticklabels(), rotation=90, ha="center", fontsize=10)
fig.tight_layout()
plt.show()
return fig, ax
#%%
[docs]
def heatmap_by_date_and_time (
dataframe,
disp_column,
date_format = "%V", # see https://docs.python.org/3/library/datetime.html
date_range = [1,53],
time_resolution = "30T",
time_range = ["00:00", "23:59"],
start_hour = "00:00",
full_display = False,
date_min_to_disp = 1,
date_max_to_disp = 53,
cb_legend = "",
display = True,
verbose = False,
**kwargs,
) :
"""
Plot a heatmap of a features by time (x-axis) and date (y-axis).
Parameters
----------
dataframe : pandas.DataFrame
The input DataFrame containing the data.
Must contain a column (or index) date in the format %Y-%m-%d %H:%M%S
disp_column : str
The name of the column to be displayed.
date_format : str, optional
The format of the date. The default is "%V".
(See https://docs.python.org/3/library/datetime.html for format codes)
Possible format are :
- "%V" for week number (from 1 to 53)
- "%m" for Month (from 1 to 12)
- "%d" for Day (from 1 to 31 depending on the month)
- "%m-%d" for Date without year (from 01-01 to 12-31)
- "%y-%m-%d" for Date with year
date_range : list of int, optional
The range of date types to include. The default is [1, 53].
The format of the range depends on date_format.
For instance, to get all the samples in the March, date_range would be
[03-01, 03-31] and date_format would be "%m-%d"
time_resolution : str, optional
The time resolution. The default is "30T".
T is for minute. 30T means a time resolution of 30 min.
Everything within this intervale is averaged
Other time formats are H for hour and D for day.
time_range : list of str, optional
The time range to consider. The default is ["00:00", "23:59"].
start_hour : str, optional
The start hour. The default is "12:00".
full_display : bool, optional
Whether to display the full date range. The default is False.
date_min_to_disp : int, optional
The minimum date on the y-axis. The default is 1.
The value depends on the date_format.
See date_format to know the possible formats and the range of possible values
date_max_to_disp : int, optional
The maximum date on the y-axis. The default is 53.
The value depends on the date_format.
See date_format to know the possible formats and the range of possible values
cb_legend : str, optional
The colorbar legend label. The default is "".
verbose : bool, optional
If True, display verbose information. The default is False.
**kwargs
Specific to this function:
- ftime : Time format to display as x label by default '%Y-%m-%d'(see https://docs.python.org/fr/3.6/library/datetime.html?highlight=strftime#strftime-strptime-behavior)
Specific to matplotlib:
- figsize : tuple of integers, optional, default: (4,10) width, height in inches.
- title : string, optional, default : 'Spectrogram' title of the figure
- xlabel : string, optional, default : 'Time [s]' label of the horizontal axis
- ylabel : string, optional, default : 'Amplitude [AU]' label of the vertical axis
- xticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- yticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- cmap : string or Colormap object, optional, default is 'gray'
See https://matplotlib.org/examples/color/colormaps_reference.html
in order to get all the existing colormaps
examples: 'hsv', 'hot', 'bone', 'tab20c', 'jet', 'seismic',
'viridis'...
- vmin, vmax : scalar, optional, default: None
`vmin` and `vmax` are used in conjunction with norm to normalize
luminance data. Note if you pass a `norm` instance, your
settings for `vmin` and `vmax` will be ignored.
- extent : list of scalars [left, right, bottom, top], optional, default: None
The location, in data-coordinates, of the lower-left and
upper-right corners. If `None`, the image is positioned such that
the pixel centers fall on zero-based (row, column) indices.
- now : boolean, optional, default : True
if True, display now. Cannot display multiple images.
To display mutliple images, set now=False until the last call for
the last image
... and more, see matplotlib
Returns
-------
df_mean : pd.DataFrame
The heatmap with mean values.
df_std :pd.DataFrame
The heatmap with standard deviation values.
fig : matplotlib.figure.Figure
The generated matplotlib figure.
ax : matplotlib.axes.Axes
The generated matplotlib axis.
"""
# test if dataframe is not a pd.DataFrame
if isinstance(dataframe, pd.DataFrame) == False:
if verbose :
print("***WARNING*** dataframe must be a valid pandas dataframe")
return
# copy the dataframe
df = dataframe.copy()
# keep some columns
df = df.filter([disp_column,'date'])
try :
if not('date' in df) :
df = df.reset_index(['date'])
# convert date to datetime
df['date'] = pd.to_datetime(df['date'])
# set the index to date
df.set_index(['date'], inplace=True)
# convert index to datetime
df.index = pd.DatetimeIndex(df.index)
# sort dataframe by date
df = df.sort_index(axis=0)
except :
raise Exception("***WARNING*** df must have a valid date index that could be converted in Datetime type")
# filter by time
df = _filter_dataframe_by_datetime(df, "%H:%M", time_range)
# filter by date_type
df = _filter_dataframe_by_datetime(df, date_format, date_range)
# # Extract hour and minute
df['time'] = df.index.strftime("%H:%M")
# # Extract week, year, weekday or date
df['date_type'] = df.index.strftime(date_format)
# create a matrix with date_type (week, year, weekday, date) as columns and time (hour) as rows
df_mean = df.groupby(['time', 'date_type'])[disp_column].aggregate('mean').unstack()
# Resample the timeline. Need to reformat the time in datetime before resampling
df_mean = df_mean.reset_index()
df_mean.index = pd.to_datetime(df_mean['time'], format='%H:%M')
df_mean.drop(columns=['time'], inplace = True)
df_mean = df_mean.resample(time_resolution).mean()
df_std = df_mean.resample(time_resolution).std()
df_mean.index = df_mean.index.strftime("%H:%M")
df_std.index = df_std.index.strftime("%H:%M")
if display == True :
# try to use seaborn if installed
try:
import seaborn as sns
sns.set_theme("paper")
except ImportError as e:
if verbose :
print("seaborn is not installed, use default settings")
if full_display == True :
# Create a list of unique index of type 'hour' for x axis
time_min = datetime.strptime("00:00", "%H:%M")
time_max = datetime.strptime("23:59", "%H:%M")
x_label = pd.date_range(time_min, time_max, freq=time_resolution).strftime("%H:%M")
x_label = x_label.values
# Create a vector for y axis depending on date_format
if date_format == "%V" :
y_label = [f'{x:02d}' for x in np.arange(date_min_to_disp,date_max_to_disp+1)]
elif date_format == "%m" :
y_label = [f'{x:02d}' for x in np.arange(date_min_to_disp,date_max_to_disp+1)]
elif (date_format == "%y-%m-%d") or (date_format == "%m-%d") or (date_format == "%d"):
date_min_to_disp = datetime.strptime(str(date_min_to_disp), date_format)
date_max_to_disp = datetime.strptime(str(date_max_to_disp), date_format)
y_label = pd.date_range(date_min_to_disp, date_max_to_disp, freq='D').strftime(date_format)
y_label = y_label.values
else:
# by default per week
y_label = [f'{x:02d}' for x in np.arange(1,53)]
# create an prefilled dataframe with all weeks and time
df2 = pd.DataFrame(index=x_label,
columns=y_label,
dtype = 'float')
df2.reset_index(inplace=True)
df2 = df2.rename(columns={"index":"time"})
df2.set_index("time", inplace=True)
for idx, row in df_mean.iterrows() :
df2.loc[idx] = row
df_mean=df2
# rename the unique column with the corresponding name
if date_format == "%V" : df_mean.columns.name='Week number'
if date_format == "%m" : df_mean.columns.name='Month'
if date_format == "%d" : df_mean.columns.name='Day'
if date_format == "%m-%d" : df_mean.columns.name='Date'
if date_format == "%y-%m-%d" : df_mean.columns.name='Date'
# shift the time to start at start_hour and finish at start_hour, in order to center
# the graph at start_hour + 12h
df_part1 = df_mean[df_mean.index >= start_hour]
df_part2 = df_mean[df_mean.index < start_hour]
df_part2.sort_index(inplace=True)
df = pd.concat([df_part1,df_part2])
# Get the list of unique index of type 'hour'
x_label = [i + j for i, j in zip(map(str, df.index.values), [" "] * len(df))]
# plot
# matplotlib parameters
fig = kwargs.pop("fig", None)
ax = kwargs.pop("ax", None)
xlabel = kwargs.pop("xlabel", "Hours")
ylabel = kwargs.pop("ylabel", df_mean.columns.name)
now = kwargs.pop("now", True)
vmin = kwargs.pop("vmin", np.nanpercentile(df,1))
vmax = kwargs.pop("vmax", np.nanpercentile(df,99))
cmap = kwargs.pop("cmap", "RdPu")
title = kwargs.pop("title", None)
figsize = kwargs.pop("figsize", (len(df)*0.33*0.75, len(list(df))*0.26*0.75 +0.75))
# Create a figure and a subplot associated
if (fig is None) or (ax is None) :
fig = plt.figure(figsize=figsize)
ax = fig.add_subplot(111)
# display image
caxes = ax.matshow(df.transpose(),
interpolation = "None",
aspect="auto",
cmap = cmap,
vmin = vmin,
vmax = vmax,
**kwargs
)
if cb_legend =="" :
fig.colorbar(caxes, shrink=0.75, label=disp_column)
else:
fig.colorbar(caxes, shrink=0.75, label=cb_legend)
# Set ticks on both sides of axes on
ax.tick_params(which='major', axis="x", bottom=False, top=False,
labelbottom=True, labeltop=False)
ax.tick_params(which='minor', axis="x", length = 0)
# We want to show all ticks...
ax.set_yticks(np.arange(len(df.columns)))
ax.set_xticks(np.arange(len(x_label)))
# ... and label them with the respective list entries
ax.set_yticklabels(df.columns)
ax.set_xticklabels(x_label)
# Minor ticks
ax.set_xticks(np.arange(-0.5, len(df.index), 1), minor=True)
ax.set_yticks(np.arange(-0.5, len(list(df)), 1), minor=True)
# Gridlines based on minor ticks
ax.grid(which='major', color='w', linestyle='-', linewidth=0)
ax.grid(which='minor', color='w', linestyle='-', linewidth=1)
# add title to the axis
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
# Rotate the tick labels and set their alignment.
plt.setp(ax.get_yticklabels(), rotation=0, ha="right", fontsize=10)
plt.setp(ax.get_xticklabels(), rotation=90, ha="center", fontsize=10)
plt.tight_layout()
# Adding a title to the figure
if title is not None:
fig.suptitle(title)
if now :
plt.show()
else :
fig = None
ax = None
return df_mean, df_std, fig, ax
# =============================================================================
[docs]
def plot_features(df, ax=None, norm=True, mode="24h", **kwargs):
"""
Plot the variation of features values (ie. indices) in the DataFrame obtained
with ``maad.features``.
Parameters
----------
df : Panda DataFrame
DataFrame with features (ie. indices).
norm : boolean, default is True
if True, the features are normalized by the max
mode : string in {'24h'}, default is '24h'
Select if the timeline of the phenology :
-'24h' : average of the results over a day
- otherwise, the timeline is the timeline of the dataframe
**kwargs
- figsize : tuple of integers, optional, default: (4,10)
width, height in inches.
- figtitle : string, optional, default : ''
title of the figure
- xlabel : string, optional, default : 'Time [s]'
label of the horizontal axis
- ylabel : string, optional, default : 'Amplitude [AU]'
label of the vertical axis
- xticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- yticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- now : boolean, optional, default : True
if True, display now. Cannot display multiple images.
To display mutliple images, set now=False until the last call for
the last image
... and more, see matplotlib
Returns
-------
fig : Figure
The Figure instance
ax : Axis
The Axis instance
Examples
--------
see plot_extract_alpha_indices.py advanced example for a complete example
>>> import numpy as np
>>> import pandas as pd
>>> np.random.seed(2021)
>>> M = np.random.rand(24, 2)
>>> df = pd.DataFrame(M)
>>> indices = ['A','B']
>>> df.columns = indices
>>> df.index =pd.date_range(start=pd.Timestamp('00:00:00'), end=pd.Timestamp('23:00:00'), freq='1H')
>>> maad.util.plot_features(df)
"""
if isinstance(df, pd.DataFrame) == False:
raise TypeError("df must be a Pandas Dataframe")
elif isinstance(df.index, pd.DatetimeIndex) == False:
raise TypeError("df must have an index of type DateTimeIndex")
if mode == "24h":
# Mean values by hour
df = df.groupby(df.index.hour).mean()
if norm:
df = linear_scale(df)
# plot
import itertools
from matplotlib.lines import Line2D
list_markers = tuple(list(Line2D.markers.keys())[0:-4])
markers = itertools.cycle(list_markers)
prop_cycle = plt.rcParams["axes.prop_cycle"]
colors = itertools.cycle(prop_cycle.by_key()["color"])
figsize = kwargs.pop("figsize", (5, 5))
kwargs.pop("label", None)
now = kwargs.pop("now", True)
# if no ax, create a figure and a subplot associated a figure otherwise
# find the figure that belongs to ax
if ax is None:
fig, ax = plt.subplots(**kwargs)
fig.set_size_inches(figsize)
else:
fig = ax.get_figure()
for indice in list(df):
ax.plot(
df.index,
df[indice],
marker=next(markers),
color=next(colors),
linestyle="-",
label=indice,
**kwargs
)
if mode == "24h":
ax.set_xlabel("Day time (Hour)")
ax.grid()
ax.legend()
fig.tight_layout()
# Display the figure now
if now:
plt.show()
return fig, ax
# =============================================================================
[docs]
def plot_correlation_map(df, R_threshold=0.75, method="spearman", **kwargs):
"""
Plot the correlation map between indices in the DataFrame obtained
with ``maad``.
Parameters
----------
df : Panda DataFrame
DataFrame with features (ie. indices).
R_threshold : scalar between 0 to 1, default is 0.75
Show correlations with R higher than R_threshold
method : string in {'spearman', 'pearson'}, default is 'spearman'
Choose the correlation type
**kwargs
- figsize : tuple of integers, optional, default: (4,10)
width, height in inches.
- title : string, optional, default : 'Spectrogram'
title of the figure
- xlabel : string, optional, default : 'Time [s]'
label of the horizontal axis
- ylabel : string, optional, default : 'Amplitude [AU]'
label of the vertical axis
- xticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- yticks : tuple of ndarrays, optional, default : none
* ticks : array_like => A list of positions at which ticks should be placed. You can pass an empty list to disable yticks.
* labels : array_like, optional => A list of explicit labels to place at the given locs.
- cmap : string or Colormap object, optional, default is 'gray'
See https://matplotlib.org/examples/color/colormaps_reference.html
in order to get all the existing colormaps
examples: 'hsv', 'hot', 'bone', 'tab20c', 'jet', 'seismic',
'viridis'...
- vmin, vmax : scalar, optional, default: None
`vmin` and `vmax` are used in conjunction with norm to normalize
luminance data. Note if you pass a `norm` instance, your
settings for `vmin` and `vmax` will be ignored.
- extent : list of scalars [left, right, bottom, top], optional, default: None
The location, in data-coordinates, of the lower-left and
upper-right corners. If `None`, the image is positioned such that
the pixel centers fall on zero-based (row, column) indices.
- now : boolean, optional, default : True
if True, display now. Cannot display multiple images.
To display mutliple images, set now=False until the last call for
the last image
... and more, see matplotlib
Returns
-------
fig : Figure
The Figure instance
ax : Axis
The Axis instance
Examples
--------
see plot_extract_alpha_indices.py advanced example for a complete example
>>> import numpy as np
>>> import pandas as pd
>>> np.random.seed(2021)
>>> M = np.random.rand(10, 10)
>>> df = pd.DataFrame(M)
>>> indices = ['A','B','C','D','E','F','G','H','I','J']
>>> df.columns = indices
>>> maad.util.plot_correlation_map(df, R_threshold=0)
"""
# Correlation matrix with only the columns that contain numeric values
numeric_columns = df.select_dtypes(include='number')
corr_matrix = numeric_columns.corr()
# pop kwargs
figsize = kwargs.pop("figsize", (10, 8))
cmap = kwargs.pop("cmap", "RdBu_r")
label_colorbar = kwargs.pop("label_colorbar", "R")
fig = plt.figure()
fig.set_size_inches(figsize)
ax = fig.add_subplot(111)
caxes = ax.matshow(corr_matrix[abs(corr_matrix) ** 2 > R_threshold ** 2], cmap=cmap)
fig.colorbar(caxes, shrink=0.75, label=label_colorbar)
# We want to show all ticks...
ax.set_xticks(np.arange(len(corr_matrix.columns)))
ax.set_yticks(np.arange(len(corr_matrix.columns)))
# ... and label them with the respective list entries
ax.set_xticklabels(corr_matrix.columns)
ax.set_yticklabels(corr_matrix.columns)
# Rotate the tick labels, set their alignment and fontsize
plt.setp(ax.get_xticklabels(), rotation=90, ha="center", fontsize=7)
# Rotate the tick labels and set their alignment.
plt.setp(ax.get_yticklabels(), rotation=0, ha="right", fontsize=8)
fig.tight_layout()
plt.show()
return fig, ax
# =============================================================================
[docs]
def false_Color_Spectro(
df,
indices=None,
plim=(1, 99),
reverseLUT=False,
permut=False,
unit="minutes",
verbose=False,
display=False,
savefig=None,
**kwargs
):
"""
Create False Color Spectrogram from indices obtained by MAAD.
Only indices than can be computed bin per bin (ie frequency per frequency)
are used to create False Color Spectro. They are called xxx_per_bin.
Parameters
----------
df : Panda DataFrame
DataFrame with indices per frequency bin.
indices : list, default is None
List of indices.
If permut is False (see permut), if indices is None : 1st indices is red (R), 2nd indice is green (G)
and the 3rd indices is blue (B).
if indices is a list of 3 indices or more, only the 3 first indices (triplet) are used to create the false color spectro.
if permut is True, if indices is None : All indices in df are used
if indices is a list of 3 indices or more, all the indices in the
list are used to create the false color spectro with all the possibile permutations.
plim : list of 2 scalars, default is (1,99)
Set the minimum and maximum percentile to define the min and max value
of each indice. These values are then used to clip the values of each
indices between these limits.
reverseLUT: boolean, default is False
LUT : look-up table.
if False, the highest indice value is the brigthest color (255)
and the lowest indice value is the darkest (0)
if True, it's the reverse order, the highest indice value is the
darkest color (0) and the lowest is the brighest (255)
permut : boolean, default is False
if True, all the permutations possible for red, green, blue are computed
among the list of indices (see indices)
unit : string in {'minutes', 'hours', 'days', 'weeks'}, default is 'minutes'
Select the unit base for x axis
verbose : boolean, default is False
print indices on the default terminal
display : boolean, default is False
Display False Color Spectro
savefig : string, optional, default is None
if not None, figures will be safe. Savefig is the prefix of the save
filename.
kwargs, optional
- dpi : scalar, optional, default 96
- format : str, optional, default .png
Returns
-------
false_color_image : ndarray of scalars
Matrix with ndim = 4 if multiple false color spectro or ndim = 3, if
single false color spectro with 3 colors : R, G, B
triplet : list
List of triplet of indices corresponding to each false color spectro
Examples
--------
see plot_extract_alpha_indices.py advanced example for a complete example
>>> import numpy as np
>>> import pandas as pd
>>> A_per_bin = [[0.5,0.4,0.3,0.2,0.1],
[0.3,0.3,0.3,0.3,0.3],
[0.5,0.5,0.3,0.0,0.0],
[0.2,0.2,0.3,0.0,0.0],
[0.0,0.0,0.3,0.0,0.0]]
>>> B_per_bin = [[0.5,0.5,0.5,0.5,0.5],
[0.5,0.5,0.5,0.5,0.5],
[0.5,0.5,0.2,0.0,0.0],
[0.5,0.2,0.1,0.0,0.0],
[0.5,0.2,0.0,0.0,0.0]]
>>> C_per_bin = [[0.0,0.7,0.0,0.5,0.5],
[0.7,0.2,0.0,0.5,0.5],
[0.5,0.5,0.7,0.2,0.2],
[0.0,0.7,0.0,0.2,0.2],
[0.7,0.2,0.0,0.2,0.2]]
>>> df = pd.DataFrame([A_per_bin,B_per_bin,C_per_bin])
>>> df = df.T
>>> df.columns = ['A_per_bin','B_per_bin','C_per_bin']
>>> df.index = pd.date_range('20200101', periods=len(df))
>>> maad.util.false_Color_Spectro (df, display=True ,unit='days', figsize=[3,3])
"""
# sort dataframe by date
df = df.sort_index(axis=0)
# set index as DatetimeIndex
df = df.set_index(pd.DatetimeIndex(df.index))
# remove column file
if "file" in df.columns:
df = df.drop(columns="file")
# test if frequencies is in columns
if "frequencies" in df.columns:
# get frequencies and remove the column
# test if type of df['frequencies'] is number or str
if isinstance(df.frequencies.iloc[0], str):
fn = df["frequencies"].apply(literal_eval).iloc[0]
else:
fn = df["frequencies"].iloc[0]
# drop frequencies
df = df.drop(columns="frequencies")
else:
fn = np.arange(0, len(df))
# Set the list of indices if indices is None
if indices is None:
indices = list(df)
# for each indice, check if values type is string and convert into scalars
# Values are vectors of strings when data are loading from csv
for indice in indices:
# test if type of df[indice] is string
if isinstance(df[indice].iloc[0], str):
# convert string into scalars
df[indice] = df[indice].apply(literal_eval)
# create a dataframe with normalized values for each indices
df_z = pd.DataFrame()
for indice in indices:
z = []
if verbose:
print(indice)
for v in df[indice]:
z.append(v)
z = np.asarray(z).T
# Select the min and max value for each indice
z_min = np.percentile(z, plim[0])
z_max = np.percentile(z, plim[1])
# clip the value to the min and max found
z = np.clip(z, z_min, z_max)
# linear conversion
if reverseLUT == True:
# between 1 to 0
z = linear_scale(z, 1, 0)
else:
# between 0 to 1
z = linear_scale(z, 0, 1)
df_z[indice] = [z * 255]
# find all permutation of 3 indices among all indices
if permut == True:
import itertools
per = itertools.permutations(list(df_z), 3)
triplet = []
for val in per:
triplet.append([*val])
else:
triplet = []
triplet.append(indices[0])
triplet.append(indices[1])
triplet.append(indices[2])
triplet = [triplet]
#####################
# get the number of pixels along frequency (Nf) and time (Nt)
Nf, Nt = df_z[triplet[0][0]].values.tolist()[0].shape
# test if figsize is in kwargs
figsize = kwargs.pop("figsize", None)
# if figsize is not in kwargs
if figsize is None:
figsize = (6 * Nt / 250, 10 * Nf / 512)
fig_kwargs = {"figsize": figsize, "tight_layout": "tight_layout"}
# number of days in the period
deltaT = df.index.max() - df.index.min()
# unit
if unit == "minutes":
normT = 60e9
xlabel = "Minutes"
elif unit == "hours":
normT = 60e9 * 60
xlabel = "Hours"
elif unit == "days":
normT = 60e9 * 60 * 24
xlabel = "Days"
elif unit == "weeks":
normT = 60e9 * 60 * 24 * 7
xlabel = "Weeks"
else:
normT = 60e9
xlabel = "Minutes"
false_color_image = []
for tt in np.arange(len(triplet)):
# create the false color image (R,G,B)
z0 = df_z[triplet[tt][0]].values.tolist()[0]
z1 = df_z[triplet[tt][1]].values.tolist()[0]
z2 = df_z[triplet[tt][2]].values.tolist()[0]
false_color_image.append((np.dstack((z0, z1, z2))).astype(np.uint8))
# Display the False Color Spectro
if display:
plt.rcParams.update({"font.size": 10})
plt.rcParams.update({"font.family": "serif"})
fig = plt.figure(facecolor="white", **fig_kwargs)
plt.imshow(
false_color_image[tt],
aspect="auto",
origin="lower",
extent=(fn[0], deltaT.value / normT, 0, fn[-1]),
**kwargs
)
plt.xlabel(xlabel)
plt.ylabel("Frequency (Hz)")
plt.title(
"False Color Spectro "
+ "\n"
+ " [R:"
+ triplet[tt][0][:-8]
+ "; "
+ "G:"
+ triplet[tt][1][:-8]
+ "; "
+ "B:"
+ triplet[tt][2][:-8]
+ "]",
size=12,
)
plt.xticks(fontsize=10)
plt.yticks(fontsize=10)
plt.tight_layout()
# SAVE FIGURE
if savefig is not None:
dpi = kwargs.pop("dpi", 96)
bbox_inches = "tight"
format = kwargs.pop("format", "png")
filename = (
"_fcs_"
+ triplet[tt][0][:-8]
+ "_"
+ triplet[tt][1][:-8]
+ "_"
+ triplet[tt][2][:-8]
)
full_filename = savefig + filename + "." + format
if verbose:
print("\n" "save figure : %s" % full_filename)
# save fig
fig.savefig(
fname=full_filename,
dpi=dpi,
bbox_inches=bbox_inches,
format=format,
**kwargs
)
# close fig
plt.close(fig)
# convert into ndarray
false_color_image = np.asarray(false_color_image)
# test if there is only one False Color Spectro, then, remove the 1st dim
if false_color_image.shape[0] == 1:
false_color_image = false_color_image[0]
triplet = triplet[0]
return false_color_image, triplet