Skip to content
Snippets Groups Projects
Commit 48ded44d authored by Loïc Lehnhoff's avatar Loïc Lehnhoff
Browse files

Improved user experience

+Added matplotlib.pyplot shortcuts
parent 89f35aab
Branches
No related tags found
No related merge requests found
......@@ -9,7 +9,7 @@ from args import fetch_inputs
##### MAIN #####
if __name__ == '__main__':
# fetching inputs.
dir_explore, max_traj, new_sr, output, modify, initial_basename = fetch_inputs()
dir_explore, max_traj, new_sr, output, modify, initial_basename, parameters = fetch_inputs()
if modify:
with open(os.path.join(output, modify), "r") as f:
......@@ -21,10 +21,11 @@ if __name__ == '__main__':
new_sr,
output,
os.path.join(dir_explore, initial_basename),
coords_to_change)
coords_to_change,
parameters)
else:
# open explorer to select firt file
# open explorer to select first file
groot = Tk()
initial_file = FileExplorer(dir_explore).file
groot.quit()
......@@ -37,4 +38,5 @@ if __name__ == '__main__':
max_traj,
new_sr,
output,
initial_file)
initial_file,
overwrite_parameters=parameters)
......@@ -9,6 +9,7 @@
## Features
- [x] Same tools as matplotlib.pyplot plots.
- [x] Spectrogram contour annotations.
- [x] Spectrogram automatically computed from waveform.
- [x] Choose custom spectrogram resolutions (fft, hop length, clipping dB value and PCEN).
......@@ -37,13 +38,15 @@ Run `$python PyAVA.py --help` for details.
The annotations are saved in [JSON](http://www.json.org/) files. Each file contains a dictionnary with the categories annotated. For each category there is a list of points, each point is defined by a list of two elements : [time (in sec), frequency (in Hz)].
### User actions
- Use the toolbar to interact with the plot (same as with matplotlib.pyplot).
- Use the toolbar to interact with the plot (same as with matplotlib.pyplot)
- Shortcuts "p" and "w" to activate panning, "z" to activate zoom.
- Draw lines :
- User must not have any toolbar item selected in order to annotate the spectrogram.
- Left-click on a name in the listbox to activate annotation with it.
- Left-click to place a point from the selected category.
- Right-click to remove the nearest point from the selected category.
- Mouse wheel click on a point to move it around.
- Right-click on a name in the listbox to rename it.
- Left-click on spectrogram to place a point from the selected category.
- Right-click on spectrogram to remove the nearest point from the selected category.
- Mouse wheel click on a point on spectrogram to move it around.
- Click on `Open file explorer` Button to change Wavefile used as base (will end the annotation of the current file).
- Change resolution of the spectrogram with `FFT`, `Hop length` and `clipping fields`.
- Click on `Update display` button to validate inputs.
......
......@@ -56,13 +56,11 @@ def fetch_inputs():
parser.add_argument(
'-max', '--max_contours',
type=int,
default=15,
default=1,
nargs='?',
required=False,
help=("Number of contours that can be annotated at the same time."
"\nDue to restrictions with the module used,"
"\nonly a fixed number of contours can be annotated at once."
"\nValue cannot exceed 50. Default value is '15'.\n\n")
help=("Default number of contours available at launcg."
"\nDue to restrictions with the module used, max number of contours is 99 per file.\n\n")
)
parser.add_argument(
......@@ -92,11 +90,21 @@ def fetch_inputs():
type=str,
nargs=2,
required=False,
help=("Name of a file generated by a previous annitation,"
help=("Name of a file generated by a previous annotation,"
"\nand name of the associated wavefile."
"\nThis will open the interface with the contours from this file"
"\nand enable modification of these contours."))
parser.add_argument(
'-p', '--parameters',
type=str,
nargs="?",
required=False,
default=None,
help=("Path to a file with spectrogram parameters."
"\nMust come from a previous usage of PyAVA."
"\nParameters loaded from this file will overwrite default parameters."))
# fetching arguments
args = parser.parse_args()
outputs = args.output
......@@ -113,6 +121,9 @@ def fetch_inputs():
try:
assert (os.path.exists(outputs)), (
f"\nInputError: Could not find dir '{outputs}'.")
if isinstance(args.parameters, str):
assert (os.path.exists(args.parameters)), (
f"\nInputError: Could not find dir '{args.parameters}'.")
assert (os.path.exists(explore)), (
f"\nInputError: Could not find dir '{explore}'.")
if modify_file:
......@@ -126,7 +137,7 @@ def fetch_inputs():
print(e)
sys.exit(1)
return (explore, contour, resampl, outputs, modify_file, from_wav)
return (explore, contour, resampl, outputs, modify_file, from_wav, args.parameters)
# if running `$python ARGS.py -h` for help.
if __name__ == '__main__':
......
##### IMPORTATIONS #####
import os
import json
import numpy as np
from tkinter import *
from tkinter import simpledialog as sd
from tkinter import filedialog as fd
from tkinter import ttk
from matplotlib.backends.backend_tkagg import (NavigationToolbar2Tk,
......@@ -153,6 +155,8 @@ class App(object):
coords_to_modify : dict
Coordinates of points (from a previous annotation) that can be used
as input to add modifications.
overwrite_parameters : str
Path to a file containing parameters to overwrite before launch
Attributes
----------
......@@ -236,8 +240,10 @@ class App(object):
Loads default variables to local variables.
submit():
Loads user inputs to local variables.
switch(self)
switch()
Updates spectrogram displayed to PCEN (and conversely).
_rename_label(event):
A function to manually rename an item in listbox
_frame_listbox_scroll():
Just a callable part of layout()
_quit():
......@@ -255,7 +261,8 @@ class App(object):
NEW_SR,
DIR_OUT,
WAVEFILE,
coords_to_modify={}):
coords_to_modify={},
overwrite_parameters=None):
# init variables
self.DIR = DIR
......@@ -265,12 +272,27 @@ class App(object):
self.WAVEFILE = WAVEFILE
self.NAME0 = 0
self.NAME1 = MAX_C
self.setup()
self._default_pcen = False
# load audio data
self.load_audio()
# check if parameters should be overwritten
if isinstance(overwrite_parameters, str):
with open(os.path.join(self.DIR_OUT, "..","last-parameters-used.json"), "r") as f:
parameter_dict = json.load(f)
self._default_hop_length = parameter_dict["HOP_LENGTH"]
self._default_nfft = parameter_dict["NFFT"]
self._default_clipping = parameter_dict["CLIPPING"]
self.NEW_SR = parameter_dict["SR"]
self._default_pcen = parameter_dict["PCEN"]
if self._default_pcen:
self.initial_text_pcen = "Switch to PCEN"
else:
self.initial_text_pcen = "Switch to Spectrogram"
# init interface
self.setup()
self.load_audio()
self.root = Tk()
self.root.style = ttk.Style()
self.root.style.theme_use('clam')
......@@ -288,8 +310,7 @@ class App(object):
self.layout()
self.axis.set_position(self._default_bounds)
# To avoid probles, disconnect matplotlib keypress
# and replace it with tkinter keypress.
# To avoid problems, disconnect matplotlib keypress
self.figure.canvas.mpl_disconnect(self.klicker.key_press)
self.root.bind('<Key>', self.get_key_pressed)
......@@ -390,15 +411,17 @@ class App(object):
"""
class EmptyObject(object):
"""
Empty class that is just a hacky of creating an object that
can be used in matplotlib.
Empty class that is just a hacky way of creating an object
that can be used in matplotlib.
"""
pass
# create key attribute and use it
dummy_event = EmptyObject()
dummy_event.key = event.char
self.klicker.get_key_event(dummy_event, show=False)
if dummy_event.key=="A":
self.klicker.add_category(False)
# if a category is added. Update listbox and canvas.
if len(self.klicker.legend_labels) > self.listbox.size():
......@@ -423,6 +446,12 @@ class App(object):
self.axis.set_position(self._default_bounds)
self.figure.canvas.draw()
elif dummy_event.key=="p" or dummy_event.key=="w":
self.toolbar.pan()
elif dummy_event.key=="z":
self.toolbar.zoom()
def layout(self):
"""
This *long* function lays the structure of the tkinter interface
......@@ -450,7 +479,7 @@ class App(object):
self.list_label = Label(
self.root,
width=self._default_left_panel_width,
text='Pick a line to draw.\n(Shift+a adds a new line)',
text='Pick a line to draw.\n(Shift+a adds a new line\nRight-click to rename item)',
font=('calibre',10,'bold'))
self.list_label.grid(row=2, column=0)
......@@ -522,7 +551,7 @@ class App(object):
self.switch_view_button = Button(
self.root,
text="Switch to PCEN",
text=self.initial_text_pcen,
width=self._default_left_panel_width,
command=self.switch)
self.switch_view_button.grid(row=13, column=0)
......@@ -559,8 +588,6 @@ class App(object):
Changes the focus to be on a new category, corresponding to
the selected item in listbox widget.
...
Parameters
----------
event : tkinter object
......@@ -720,6 +747,85 @@ class App(object):
vmax=np.nanmax(self.spectrogram))
self.canvas.draw()
def _rename_label(self, event):
"""
A function that allows the user to rename a category
Parameters
----------
event : tkinter object
event containing the item clicked in listbox widget.
Returns
-------
None : Shows a popup that ask for the name to give to the contour.
"""
self.listbox.selection_clear(0,END)
index_item = self.listbox.nearest(event.y)
save_config = self.listbox.itemconfig(index_item)
save_config = {
'bg':save_config['background'][-1],
'selectbackground':save_config['selectbackground'][-1],
'selectforeground':save_config['selectforeground'][-1],
}
# update selection
self.klicker.current_line = index_item
self.listbox.select_clear(0, END)
self.listbox.select_set(index_item)
self.listbox.see(index_item)
self.listbox.activate(index_item)
self.listbox.selection_anchor(index_item)
# get new name from user
new_name = sd.askstring(
"Rename window",
f"Insert new name for '{self.klicker.legend_labels[index_item]}':"
)
if isinstance(new_name, str):
if new_name in self.klicker.legend_labels:
count=0
for label in self.klicker.legend_labels:
if (label==new_name) or ("_".join(label.split("_")[:-1])==new_name):
count+=1
new_name = new_name + f"_{count}"
# replace spaces
new_name = new_name.replace(" ", "_")
# destroy item
self.listbox.delete(
index_item)
old_name = self.klicker.legend_labels.pop(index_item)
# handle klicker
self.klicker.legend_labels.insert(index_item, new_name)
self.klicker.set_legend()
self.klicker.current_line = index_item
if old_name in self.klicker.coords.keys():
self.klicker.coords[new_name] = self.klicker.coords.pop(old_name)
self.klicker.update_lines()
# insert item at the same index in listbox, with new name
self.listbox.insert(
index_item,
new_name)
self.listbox.itemconfig(
index_item,
save_config
)
# update selection
self.listbox.select_clear(0, END)
self.listbox.select_set(index_item)
self.listbox.see(index_item)
self.listbox.activate(index_item)
self.listbox.selection_anchor(index_item)
self.axis.set_position(self._default_bounds)
self.figure.canvas.draw()
def _frame_listbox_scroll(self):
"""
Just a callable part of "layout"
......@@ -746,6 +852,7 @@ class App(object):
'selectforeground': 'white'})
self.listbox.pack(side="left", fill="y")
self.listbox.bind("<<ListboxSelect>>", self.link_select)
self.listbox.bind("<Button-3>", self._rename_label)
self.listbox.select_set(0)
self.scrollbar = Scrollbar(self.frame_list, orient="vertical")
......@@ -756,6 +863,7 @@ class App(object):
def _quit(self):
"""
A function that saves coordinates of lines before closing app.
Also saves the last parameters in memory (for further use).
...
......@@ -778,8 +886,8 @@ class App(object):
"HOP_LENGTH":self.HOP_LENGTH,
"CLIPPING":self.CLIPPING
},
os.path.join(self.DIR_OUT, "parameters"),
os.path.basename(self.WAVEFILE)[:-4]+"-params.json"
os.path.join(self.DIR_OUT, ".."),
"last-parameters-used.json"
)
# quit window
......
{
"PCEN": false,
"SR": 96000,
"NFFT": 2048,
"HOP_LENGTH": 512,
"CLIPPING": -80
}
\ No newline at end of file
No preview for this file type
This diff is collapsed.
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment