From 8ec72e5c6b4891a4680fdbd266624e0a177408b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Lehnhoff?= <loic.lehnhoff@gmail.com> Date: Thu, 16 Nov 2023 13:47:09 +0100 Subject: [PATCH] Optimization of display + visibility depends on visible frame ~ changed canvas.draw() to canvas.draw_idle() + huge re-organisation packed in lite_line_clicker.py --- .gitignore | 6 +- functions.py | 14 +- interface.py | 146 ++-- .../__pycache__/__init__.cpython-39.pyc | Bin 164 -> 0 bytes .../__pycache__/line_clicker.cpython-39.pyc | Bin 19273 -> 0 bytes line_clicker/line_clicker.py | 344 ++++---- line_clicker/lite_line_clicker.py | 787 ++++++++++++++++++ 7 files changed, 1076 insertions(+), 221 deletions(-) delete mode 100644 line_clicker/__pycache__/__init__.cpython-39.pyc delete mode 100644 line_clicker/__pycache__/line_clicker.cpython-39.pyc create mode 100644 line_clicker/lite_line_clicker.py diff --git a/.gitignore b/.gitignore index ba372b7..accacbb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ - +audio_examples/SCW1807_20200711_114100.wav audio_examples/SCW1807_20200713_064545.wav +outputs/SCW1807_20200711_114100-contours.json + line_clicker/__pycache__ -__pycache__ +__pycache__ \ No newline at end of file diff --git a/functions.py b/functions.py index 80d4566..202bbce 100644 --- a/functions.py +++ b/functions.py @@ -7,7 +7,7 @@ from librosa import load, amplitude_to_db, stft, pcen from scipy.signal import resample ##### FUNCTIONS ##### -def save_dict(dictionary, folder, name): +def save_dict(dictionary, folder, name, contours=True): """ A function that saves a dictionary to a given path. @@ -28,6 +28,18 @@ def save_dict(dictionary, folder, name): None : save dict to json file. """ if len(dictionary) > 0: + + if contours: + # delete labels with empty labels + for key in dictionary.keys(): + if len(dictionary[key])<=1: + del dictionary[key] + + # sort contouts by starting time. + dictionary = dict( + sorted(dictionary.items(), + key=lambda item: (np.min(np.array(item[1])[:,0])))) + with open(os.path.join(folder, name), "w") as f: json.dump(dictionary, f, indent=4) diff --git a/interface.py b/interface.py index 9ebcfa6..1921c60 100644 --- a/interface.py +++ b/interface.py @@ -1,19 +1,21 @@ ##### IMPORTATIONS ##### -import os import json -import numpy as np +import os from tkinter import * -from tkinter import simpledialog as sd from tkinter import filedialog as fd +from tkinter import simpledialog as sd from tkinter import ttk -from matplotlib.backends.backend_tkagg import (NavigationToolbar2Tk, - FigureCanvasTkAgg) -from matplotlib.figure import Figure + import matplotlib.colors as mc -from line_clicker.line_clicker import clicker +import numpy as np + +from functions import load_waveform, save_dict, wave_to_spectrogram +from line_clicker.lite_line_clicker import clicker -# Import external functions -from functions import load_waveform, wave_to_spectrogram, save_dict +from matplotlib.backends.backend_tkagg import (FigureCanvasTkAgg, + NavigationToolbar2Tk) +from matplotlib.figure import Figure +from matplotlib.patches import Rectangle ##### CLASSES ##### @@ -250,9 +252,10 @@ class App(object): Saves contours and closes the app. """ - from parameters import (_default_width, _default_height, _default_hop_length, - _default_nfft, _default_clipping, _default_cmap, _default_bounds, - _default_left_panel_width) + from parameters import (_default_bounds, _default_clipping, _default_cmap, + _default_height, _default_hop_length, + _default_left_panel_width, _default_nfft, + _default_width) def __init__( self, @@ -298,7 +301,8 @@ class App(object): self.root.style.theme_use('clam') self.create_canvas() - # addon + # addons + self.figure_bboxes = [] self.klicker = clicker( axis=self.axis, names=["Line" + str(i+1) for i in range(self.NAME0, self.NAME1)], @@ -313,10 +317,11 @@ class App(object): # To avoid problems, disconnect matplotlib keypress self.figure.canvas.mpl_disconnect(self.klicker.key_press) self.root.bind('<Key>', self.get_key_pressed) + self.figure.canvas.mpl_connect('button_press_event', self._draw_bbox) # just to be sure self.root.protocol("WM_DELETE_WINDOW", self.on_close) - + self.root.resizable(True, True) self.root.mainloop() def on_close(self): @@ -341,11 +346,10 @@ class App(object): (It uses the "wait" parameter to force straigth lines). """ if self.CHECK_bspline.get(): - self.klicker.wait = 2 + self.klicker.wait_before_interpolation = 2 else: - self.klicker.wait = np.inf + self.klicker.wait_before_interpolation = np.inf self.klicker.update_lines() - self.klicker.figure.canvas.draw() def create_canvas(self): """ @@ -389,9 +393,10 @@ class App(object): """ self.FFT_IN = IntVar(value=self._default_nfft) self.HOP_IN = IntVar(value=self._default_hop_length) - self.CHECK_bspline = IntVar(value=1) + self.CHECK_bspline = BooleanVar(value=True) + self.CHECK_bbox = BooleanVar(value=False) self.CLIP_IN = DoubleVar(value=self._default_clipping) - self.OPTIONS = Variable(value=self.klicker.legend_labels) + self.OPTIONS = Variable(value=list(self.klicker.coords.keys())) def get_key_pressed(self, event): """ @@ -424,10 +429,10 @@ class App(object): self.klicker.add_category(False) # if a category is added. Update listbox and canvas. - if len(self.klicker.legend_labels) > self.listbox.size(): + if len(self.klicker.coords) > self.listbox.size(): self.listbox.insert( self.listbox.size(), - self.klicker.legend_labels[-1]) + list(self.klicker.coords.keys())[-1]) self.listbox.itemconfig( self.listbox.size()-1, { @@ -472,7 +477,7 @@ class App(object): self.root.geometry( f"{str(self._default_width)}x{str(self._default_height)}") self.root.rowconfigure(1, weight=1) - self.root.rowconfigure(14, weight=1) + self.root.rowconfigure(15, weight=1) self.root.configure(bg='gainsboro') # Add Panel for line selection on Left side @@ -492,12 +497,19 @@ class App(object): command=self.bspline_activation) self.activate_bspline.grid(row=4, column=0) + self.show_bbox = Checkbutton( + self.root, + text='Show Bounding Boxes', + variable=self.CHECK_bbox, + command=self._draw_bbox) + self.show_bbox.grid(row=5, column=0) + # Add space between panels self.empty_frame = Label( self.root, width=self._default_left_panel_width, height=30) - self.empty_frame.grid(row=5, column=0) + self.empty_frame.grid(row=6, column=0) # Add panel for spectrogram personalisation on Left side. self.fft_label = Label( @@ -505,69 +517,69 @@ class App(object): width=self._default_left_panel_width, text='FFT window size:', font=('calibre',10, 'bold')) - self.fft_label.grid(row=6, column=0) + self.fft_label.grid(row=7, column=0) self.fft_entry = Entry( self.root, width=self._default_left_panel_width, textvariable=self.FFT_IN, font=('calibre',10,'normal')) - self.fft_entry.grid(row=7, column=0) + self.fft_entry.grid(row=8, column=0) self.win_label = Label( self.root, width=self._default_left_panel_width, text='Hop length:', font=('calibre',10,'bold')) - self.win_label.grid(row=8, column=0) + self.win_label.grid(row=9, column=0) self.win_entry = Entry( self.root, width=self._default_left_panel_width, textvariable=self.HOP_IN, font=('calibre',10,'normal')) - self.win_entry.grid(row=9, column=0) + self.win_entry.grid(row=10, column=0) self.clip_label = Label( self.root, width=self._default_left_panel_width, text='Clipping (dB):', font=('calibre',10,'bold')) - self.clip_label.grid(row=10, column=0) + self.clip_label.grid(row=11, column=0) self.clip_entry = Entry( self.root, width=self._default_left_panel_width, textvariable=self.CLIP_IN, font=('calibre',10,'normal')) - self.clip_entry.grid(row=11, column=0) + self.clip_entry.grid(row=12, column=0) self.submit_button = Button( self.root, text='Update display', width=self._default_left_panel_width, command=self.submit) - self.submit_button.grid(row=12, column=0) + self.submit_button.grid(row=13, column=0) self.switch_view_button = Button( self.root, text=self.initial_text_pcen, width=self._default_left_panel_width, command=self.switch) - self.switch_view_button.grid(row=13, column=0) + self.switch_view_button.grid(row=14, column=0) # Add buttons at the bottom of the interface self.quit_button = Button( self.root, text="Save & Quit", command=self._quit) - self.quit_button.grid(row=15, column=0) + self.quit_button.grid(row=16, column=0) self.explore_button = Button( self.root, text="Open file explorer", command=self.select_file) - self.explore_button.grid(row=15, column=1) + self.explore_button.grid(row=16, column=1) # Add matplotlib tools at the top of the interface self.toolbarFrame = Frame(self.root) @@ -747,6 +759,37 @@ class App(object): vmax=np.nanmax(self.spectrogram)) self.canvas.draw() + def _draw_bbox(self, *args): + # first, fetch coords + self.bboxes = [] + for line in self.klicker.lines: + x, y = line.get_data() + if len(x)>0: + self.bboxes += [[[min(x), min(y)], [max(x)-min(x), max(y)-min(y)], line.get_color()]] + + # remove current bbox if there are + if len(self.figure_bboxes)>0: + for rectangle in self.figure_bboxes: + rectangle.remove() + + # add new bbox if needed + self.figure_bboxes = [] + if self.CHECK_bbox.get(): + for bbox in self.bboxes: + self.figure_bboxes += [ + Rectangle( + xy=(bbox[0][0], bbox[0][1]), + width=bbox[1][0], height=bbox[1][1], + edgecolor = bbox[2], + facecolor='none', + fill=False, + lw=2)] + + for rectangle in self.figure_bboxes: + self.axis.add_patch(rectangle) + + self.klicker.figure.canvas.draw() + def _rename_label(self, event): """ A function that allows the user to rename a category @@ -780,13 +823,13 @@ class App(object): # get new name from user new_name = sd.askstring( "Rename window", - f"Insert new name for '{self.klicker.legend_labels[index_item]}':" + f"Insert new name for '{list(self.klicker.coords.keys())[index_item]}':" ) if isinstance(new_name, str): - if new_name in self.klicker.legend_labels: + if new_name in self.klicker.coords.keys(): count=0 - for label in self.klicker.legend_labels: + for label in self.klicker.coords.keys(): if (label==new_name) or ("_".join(label.split("_")[:-1])==new_name): count+=1 new_name = new_name + f"_{count}" @@ -797,15 +840,16 @@ class App(object): # destroy item self.listbox.delete( index_item) - old_name = self.klicker.legend_labels.pop(index_item) + old_name = list(self.klicker.coords.keys())[index_item] + + # new coordinates + self.klicker.coords = { + new_name if key==old_name else key:value + for key,value in self.klicker.coords.items()} - # handle klicker - self.klicker.legend_labels.insert(index_item, new_name) - self.klicker.set_legend() + 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() + self.klicker.update_lines() # insert item at the same index in listbox, with new name self.listbox.insert( @@ -842,7 +886,7 @@ class App(object): width=self._default_left_panel_width, selectmode=SINGLE, listvariable=self.OPTIONS) - for idx in range(len(self.klicker.legend_labels)): + for idx in range(len(self.klicker.coords)): self.listbox.itemconfig(idx, { 'bg': self.klicker.colors[idx%len(self.klicker.colors)], @@ -875,19 +919,21 @@ class App(object): save_dict( self.klicker.coords, self.DIR_OUT, - os.path.basename(self.WAVEFILE)[:-4]+"-contours.json") + os.path.basename(self.WAVEFILE)[:-4]+"-contours.json", + contours=True) # save parameters save_dict( { "PCEN": (self.switch_view_button['text'] == "Switch to PCEN"), - "SR": self.NEW_SR, - "NFFT":self.NFFT, - "HOP_LENGTH":self.HOP_LENGTH, - "CLIPPING":self.CLIPPING + "SR": int(self.NEW_SR), + "NFFT": int(self.NFFT), + "HOP_LENGTH": int(self.HOP_LENGTH), + "CLIPPING": int(self.CLIPPING) }, os.path.join(self.DIR_OUT, ".."), - "last-parameters-used.json" + "last-parameters-used.json", + contours=False ) # quit window diff --git a/line_clicker/__pycache__/__init__.cpython-39.pyc b/line_clicker/__pycache__/__init__.cpython-39.pyc deleted file mode 100644 index d7f4827996913926ebbfe4cdca728bac46115bac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 164 zcmYe~<>g{vU|{$lx;z;~KL!!Vn2~{j!GVE+p_qk%fgyz<m_d`#ZzV$!NEku<^3>1B z&rQ|O$<IvIcS$Ts)OYds3Gndra|?2H)ek65%E?StNXySjNi8bY52$nubJWkt%u9_= m&dE&9PA$@pkI&4@EQycTE2zB1VUwGmQks)$2eRTb$SD8<?I$Jx diff --git a/line_clicker/__pycache__/line_clicker.cpython-39.pyc b/line_clicker/__pycache__/line_clicker.cpython-39.pyc deleted file mode 100644 index f63f3cb63553a38118e9935f9ef90867132fe0e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19273 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 diff --git a/line_clicker/line_clicker.py b/line_clicker/line_clicker.py index 687ed06..a5d5543 100644 --- a/line_clicker/line_clicker.py +++ b/line_clicker/line_clicker.py @@ -12,30 +12,31 @@ import matplotlib.lines as mlines from scipy.interpolate import interp1d ##### FUNCTIONS ##### -def to_curve(x, y, kind="quadratic"): +def to_curve(x, y, kind="quadratic", precision=10): """ - A function to compute a curvy line between two points. - It interpolates a line with 100 points. - - ... - - Parameters - ---------- - x : list or numpy array - List of coordinates on x axis. - y : list or numpy array - List of coordinates on y axis. - kind : string - The interpolation method to use. Can be any method in : ‘linear’, - ‘nearest’, ‘nearest-up’, ‘zero’, ‘slinear’, ‘quadratic’, ‘cubic’, - ‘previous’, or ‘next’. - - Returns - ------- - xi : numpy array - List of the coordinates of the curve on x-axis. - yi : numpy array - List of the coordinates of the curve on y-axis. + A function to compute a curvy line between two points. + It interpolates a line with 100 points. + + ... + + Parameters + ---------- + x : list or numpy array + List of coordinates on x axis. + y : list or numpy array + List of coordinates on y axis. + kind : string + The interpolation method to use. Can be any method in : ‘linear’, + ‘nearest’, ‘nearest-up’, ‘zero’, ‘slinear’, ‘quadratic’, ‘cubic’, + ‘previous’, or ‘next’. + precision : number of segments between each point. + + Returns + ------- + xi : numpy array + List of the coordinates of the curve on x-axis. + yi : numpy array + List of the coordinates of the curve on y-axis. """ y = y[np.argsort(x)] @@ -43,7 +44,7 @@ def to_curve(x, y, kind="quadratic"): f = interp1d(x,y, kind=kind) - xi = np.linspace(x.min(), x.max(), 100 * (len(x)-1)) + xi = np.linspace(x.min(), x.max(), precision * (len(x)-1)) yi = f(xi) return xi, yi @@ -51,124 +52,124 @@ def to_curve(x, y, kind="quadratic"): ##### CLASSES ##### class clicker(object): """ - A Class that is an "add-on" for an existing matplotlib figure axis. - Added to an axis, it allows to draw lines from mouse clicks. - Points can be removed using the right click. - - Different categories can be selected using the generated legend : - by clicking on a line or by using up/down arrows. - It is also possible to add more categories using with 'ctrl+a' shortcut - - ... - - Params - ---------- - axis : matplotlib object - Axis instance. - bspline : string or boolean, optional. - If False, app will plot points linked by lines. - If string, app will plot points linked by curve. - For curves, use 'cubic' or 'quadratic' - colors : list, optional. - List of color (hexadecimal) to cycle over when plotting lines. - Default is the default list of colors from matplotlib. - legend_bbox : tuple, optional. - The position of the legend (x-axis, y-axis) relative to the figure. - marker : dict, optional. - Additional parameters to change the look of the lines. - Default is None, lines will be solid with points as white dots. - maxlines : int, optional. - Maximum number of labels in legend. - Default is 30. - Default is False. - names : str or list, optional. - Names for legend labels. - If string, names will be name+{line number} - If list, when a category is added, it will have the name "DefaultNameX". - Default is "Line". - n_names : int, optional. - Number of categories at launch when name is a string. - Default is 10. - pick_dist : int, optional. - Distance to legend category for picking mouse. - Default is 10. - wraplines : int, optional. - Maximum number of labels in legend before creating a new column. - Default is 15. - Default is (1, 0.5) - coords : dict, optional. - Coordinates of each point for each category (line, key of the dict). - Coordinates are expressed in (time, frequency) - or (pixel, pixel) if no extent given. - Default is {}. If given, initates figure with annotations. - - - Attributes - ---------- - current_line : int - Index of the ctegory (line) currently selected - figure : matplotlib object - Abbreviation for axis.figure. - figure_bsplines : list of matplotlib 2D lines - Secondary lines, in use if bspline mode is selected. - Draws curves instead of straight lines. - figure_lines : list of matplotlib 2D lines - Main lines, displayed on image as straight lines if bspline is False. - If bspline is true, linestyle is removed and they are displayed as dots. - key_press : matplotlib event - Information on the last key_press event. - legend : matplotlib object - legend of the plot - legend_labels : list of str - labels of the categories in legend. - linestyle : str - If bspline is True, saves linestyle. - mouse_press : matplotlib event - Information on the last mouse click in matplotlib canvas. - pick_press : matplotlib event - Information on the last mouse click event in matplotlib legend. - pressed : bool - Is used to check if a specific mouse button is pressed or not. - - Other attributes have self explenatory names. - - Methods - ------- - add_category(show): - Adds a category to the list in legend. - add_point(x,y): - Adds a point to the category in focus at the given coordinates. - Focus is updated to be on this new category. - clear_category(): - Clears all points from the category in focus. - double_lines(): - Creates a copy of the current set of lines from figure_lines. - It is used to interpolate curves between figure_lines points. - distance_mouse(points, mouse_x, mouse_y): - Gets the distance bewteen a mouse position and the currently - selected category (points). - get_focus(event): - Puts focus on a category in legend. Allows the user to interact with it. - get_key_event(event): - Activates functions when specific keyboard keys are pressed. - get_mouse_press(event, show=True): - Activates functions when specific mouse buttons are pressed. - move_point(event): - Moves a selected point of the currently selected category to a new - position (event). Updates figure accordingly. - rm_point(x,y): - Removes a point from the category in focus at the given coordinates. - set_legend(): - Adds legend to axis given. - switch_line(arrow): - Puts focus on previous/next category in legend based on arrow input. - update_lines(): - Updates data in lines. Called when coords is changed. + A Class that is an "add-on" for an existing matplotlib figure axis. + Added to an axis, it allows to draw lines from mouse clicks. + Points can be removed using the right click. + + Different categories can be selected using the generated legend : + by clicking on a line or by using up/down arrows. + It is also possible to add more categories using with 'ctrl+a' shortcut + + ... - References - ---------- - Heavily inspired from mpl_point_clicker : - mpl-point-clicker.readthedocs.io/ + Params + ---------- + axis : matplotlib object + Axis instance. + bspline : string or boolean, optional. + If False, app will plot points linked by lines. + If string, app will plot points linked by curve. + For curves, use 'cubic' or 'quadratic' + colors : list, optional. + List of color (hexadecimal) to cycle over when plotting lines. + Default is the default list of colors from matplotlib. + legend_bbox : tuple, optional. + The position of the legend (x-axis, y-axis) relative to the figure. + marker : dict, optional. + Additional parameters to change the look of the lines. + Default is None, lines will be solid with points as white dots. + maxlines : int, optional. + Maximum number of labels in legend. + Default is 30. + Default is False. + names : str or list, optional. + Names for legend labels. + If string, names will be name+{line number} + If list, when a category is added, it will have the name "DefaultNameX". + Default is "Line". + n_names : int, optional. + Number of categories at launch when name is a string. + Default is 10. + pick_dist : int, optional. + Distance to legend category for picking mouse. + Default is 10. + wraplines : int, optional. + Maximum number of labels in legend before creating a new column. + Default is 15. + Default is (1, 0.5) + coords : dict, optional. + Coordinates of each point for each category (line, key of the dict). + Coordinates are expressed in (time, frequency) + or (pixel, pixel) if no extent given. + Default is {}. If given, initates figure with annotations. + + + Attributes + ---------- + current_line : int + Index of the ctegory (line) currently selected + figure : matplotlib object + Abbreviation for axis.figure. + figure_bsplines : list of matplotlib 2D lines + Secondary lines, in use if bspline mode is selected. + Draws curves instead of straight lines. + figure_lines : list of matplotlib 2D lines + Main lines, displayed on image as straight lines if bspline is False. + If bspline is true, linestyle is removed and they are displayed as dots. + key_press : matplotlib event + Information on the last key_press event. + legend : matplotlib object + legend of the plot + legend_labels : list of str + labels of the categories in legend. + linestyle : str + If bspline is True, saves linestyle. + mouse_press : matplotlib event + Information on the last mouse click in matplotlib canvas. + pick_press : matplotlib event + Information on the last mouse click event in matplotlib legend. + pressed : bool + Is used to check if a specific mouse button is pressed or not. + + Other attributes have self explenatory names. + + Methods + ------- + add_category(show): + Adds a category to the list in legend. + add_point(x,y): + Adds a point to the category in focus at the given coordinates. + Focus is updated to be on this new category. + clear_category(): + Clears all points from the category in focus. + double_lines(): + Creates a copy of the current set of lines from figure_lines. + It is used to interpolate curves between figure_lines points. + distance_mouse(points, mouse_x, mouse_y): + Gets the distance bewteen a mouse position and the currently + selected category (points). + get_focus(event): + Puts focus on a category in legend. Allows the user to interact with it. + get_key_event(event): + Activates functions when specific keyboard keys are pressed. + get_mouse_press(event, show=True): + Activates functions when specific mouse buttons are pressed. + move_point(event): + Moves a selected point of the currently selected category to a new + position (event). Updates figure accordingly. + rm_point(x,y): + Removes a point from the category in focus at the given coordinates. + set_legend(): + Adds legend to axis given. + switch_line(arrow): + Puts focus on previous/next category in legend based on arrow input. + update_lines(): + Updates data in lines. Called when coords is changed. + + References + ---------- + Heavily inspired from mpl_point_clicker : + mpl-point-clicker.readthedocs.io/ """ DEFAULT_marker = { # Markerstyle for categories. @@ -300,8 +301,6 @@ class clicker(object): 'key_press_event', self.get_key_event) - - def add_category(self, show): """ A method to add a line (therefore a new category) to the plot. @@ -401,14 +400,23 @@ class clicker(object): else: self.figure_bsplines = [] for idx, legend_label in enumerate(self.legend_labels): - curves = to_curve( - np.array(self.coords[legend_label])[:,0], - np.array(self.coords[legend_label])[:,1], - kind=self.bspline) - self.figure_bsplines += [mlines.Line2D(curves[0], curves[1], - label=legend_label, - color=self.colors[idx%len(self.colors)], - **{'linestyle':self.linestyle})] + if np.array(self.coords[legend_label]).shape[0] > self.wait: + curves = to_curve( + np.array(self.coords[legend_label])[:,0], + np.array(self.coords[legend_label])[:,1], + kind=self.bspline) + + self.figure_bsplines += [mlines.Line2D(curves[0], curves[1], + label=legend_label, + color=self.colors[idx%len(self.colors)], + **{'linestyle':self.linestyle})] + else: + self.figure_bsplines += [mlines.Line2D( + np.array(self.coords[legend_label])[:,0], + np.array(self.coords[legend_label])[:,1], + label=legend_label, + color=self.colors[idx%len(self.colors)], + **{'linestyle':self.linestyle})] def clear_category(self): """ @@ -504,7 +512,7 @@ class clicker(object): Returns ------- - None : is used to trigger methods add_points and rm_points(). + None : is used to trigger methods add_points and rm_points. """ if self.figure.canvas.widgetlock.available(self): pressed, x, y = event.button, event.xdata, event.ydata @@ -528,7 +536,7 @@ class clicker(object): np.array(self.coords[self.legend_labels[self.current_line]]), event.xdata, event.ydata) self.index, dist = np.argmin(distances), np.min(distances) - if dist < 0.1: + if dist < 0.25: self.currently_pressed = True elif ((pressed is self.param["move_point"]) and @@ -537,7 +545,7 @@ class clicker(object): event.name == 'button_release_event'): self.currently_pressed = False self.index = False - + def get_key_event(self, event, show=True): """ A method that retrieve key interactions with matplotlib plot. @@ -574,25 +582,25 @@ class clicker(object): def move_point(self, event): """ - Moves a selected point of the currently selected category to a new - position (event). Updates figure accordingly. + Moves a selected point of the currently selected category to a new + position (event). Updates figure accordingly. - ... + ... - Parameters - ---------- - event : matplotlib object - Matplotlib event containig information on positions. + Parameters + ---------- + event : matplotlib object + Matplotlib event containig information on positions. - Returns - ------- - None : it updates the coordinates of a point in a category - and re-draws the figure. + Returns + ------- + None : it updates the coordinates of a point in a category + and re-draws the figure. """ if (self.currently_pressed and event.xdata != None and event.ydata != None): - + # does this point already exists ? if event.xdata in np.array(self.coords[self.legend_labels[self.current_line]])[:,0]: warnings.warn("Cannot place two points at the same timestamp!", UserWarning, stacklevel=2) @@ -744,9 +752,9 @@ class clicker(object): ##### MAIN ##### if __name__ == '__main__': # dummy example - img = np.array([[0,1,0],[1,0,1],[0,1,0]]) + img = np.tile([[0, 0],[0, 0]], (25,25)) fig, ax = plt.subplots(figsize=(16, 9)) - ax.imshow(img, cmap="viridis") + ax.imshow(img, cmap="gray") base = clicker(axis=ax, bspline="quadratic") plt.show(block=True) \ No newline at end of file diff --git a/line_clicker/lite_line_clicker.py b/line_clicker/lite_line_clicker.py new file mode 100644 index 0000000..ca0271b --- /dev/null +++ b/line_clicker/lite_line_clicker.py @@ -0,0 +1,787 @@ +""" +Add default parameters for keys and mousebutton so it can be customized +""" + +##### IMPORTATIONS ###### +import warnings +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.backend_bases import MouseButton +import matplotlib.lines as mlines + +from scipy.interpolate import interp1d + +##### FUNCTIONS ##### +def to_curve(x, y, kind="quadratic", precision=10): + """ + A function to compute a curvy line between two points. + It interpolates a line with 100 points. + + ... + + Parameters + ---------- + x : list or numpy array + List of coordinates on x axis. + y : list or numpy array + List of coordinates on y axis. + kind : string + The interpolation method to use. Can be any method in : ‘linear’, + ‘nearest’, ‘nearest-up’, ‘zero’, ‘slinear’, ‘quadratic’, ‘cubic’, + ‘previous’, or ‘next’. + precision : number of segments between each point. + + Returns + ------- + xi : numpy array + List of the coordinates of the curve on x-axis. + yi : numpy array + List of the coordinates of the curve on y-axis. + """ + + y = y[np.argsort(x)] + x = np.sort(x) + + f = interp1d(x,y, kind=kind) + + xi = np.linspace(x.min(), x.max(), precision * (len(x)-1)) + yi = f(xi) + + return xi, yi + +##### CLASSES ##### +class clicker(object): + """ + A Class that is an "add-on" for an existing matplotlib figure axis. + Added to an axis, it allows to draw lines from mouse clicks. + Points can be removed using the right click. + + Different categories can be selected using the generated legend : + by clicking on a line or by using up/down arrows. + It is also possible to add more categories using with 'ctrl+a' shortcut + + ... + + Params + ---------- + axis : matplotlib object + Axis instance. + bspline : string or boolean, optional. + If False, app will plot points linked by lines. + If string, app will plot points linked by curve. + For curves, use 'cubic' or 'quadratic' + colors : list, optional. + List of color (hexadecimal) to cycle over when plotting lines. + Default is the default list of colors from matplotlib. + legend_bbox : tuple, optional. + The position of the legend (x-axis, y-axis) relative to the figure. + marker : dict, optional. + Additional parameters to change the look of the lines. + Default is None, lines will be solid with points as white dots. + maxlines : int, optional. + Maximum number of labels in legend. + Default is 30. + Default is False. + names : str or list, optional. + Names for legend labels. + If string, names will be name+{line number} + If list, when a category is added, it will have the name "DefaultNameX". + Default is "Line". + n_names : int, optional. + Number of categories at launch when name is a string. + Default is 10. + pick_dist : int, optional. + Distance to legend category for picking mouse. + Default is 10. + wraplines : int, optional. + Maximum number of labels in legend before creating a new column. + Default is 15. + Default is (1, 0.5) + coords : dict, optional. + Coordinates of each point for each category (line, key of the dict). + Coordinates are expressed in (time, frequency) + or (pixel, pixel) if no extent given. + Default is {}. If given, initates figure with annotations. + + + Attributes + ---------- + current_line : int + Index of the ctegory (line) currently selected + figure : matplotlib object + Abbreviation for axis.figure. + figure_bsplines : list of matplotlib 2D lines + Secondary lines, in use if bspline mode is selected. + Draws curves instead of straight lines. + figure_lines : list of matplotlib 2D lines + Main lines, displayed on image as straight lines if bspline is False. + If bspline is true, linestyle is removed and they are displayed as dots. + key_press : matplotlib event + Information on the last key_press event. + legend : matplotlib object + legend of the plot + legend_labels : list of str + labels of the categories in legend. + linestyle : str + If bspline is True, saves linestyle. + mouse_press : matplotlib event + Information on the last mouse click in matplotlib canvas. + pick_press : matplotlib event + Information on the last mouse click event in matplotlib legend. + pressed : bool + Is used to check if a specific mouse button is pressed or not. + + Other attributes have self explenatory names. + + Methods + ------- + add_category(show): + Adds a category to the list in legend. + add_point(x,y): + Adds a point to the category in focus at the given coordinates. + Focus is updated to be on this new category. + clear_category(): + Clears all points from the category in focus. + double_lines(): + Creates a copy of the current set of lines from figure_lines. + It is used to interpolate curves between figure_lines points. + distance_mouse(points, mouse_x, mouse_y): + Gets the distance bewteen a mouse position and the currently + selected category (points). + get_focus(event): + Puts focus on a category in legend. Allows the user to interact with it. + get_key_event(event): + Activates functions when specific keyboard keys are pressed. + get_mouse_press(event, show=True): + Activates functions when specific mouse buttons are pressed. + move_point(event): + Moves a selected point of the currently selected category to a new + position (event). Updates figure accordingly. + rm_point(x,y): + Removes a point from the category in focus at the given coordinates. + set_legend(): + Adds legend to axis given. + switch_line(arrow): + Puts focus on previous/next category in legend based on arrow input. + update_lines(): + Updates data in lines. Called when coords is changed. + + References + ---------- + Heavily inspired from mpl_point_clicker : + mpl-point-clicker.readthedocs.io/ + """ + + DEFAULT_marker = { # Markerstyle for categories. + "marker":"o", + "mfc":"white", + "linestyle":"-", + } + + DEFAULT_colors = [ # Colors for categories. Will cycle through them. + '#1f77b4', # List can be appened or reduced. + '#ff7f0e', + '#2ca02c', + '#d62728', + '#9467bd', + '#8c564b', + '#e377c2', + '#7f7f7f', + '#bcbd22', + '#17becf' + ] + + DEFAULT_param = { + "add_point" : MouseButton.LEFT, + "rm_point" : MouseButton.RIGHT, + "move_point" : MouseButton.MIDDLE, + "add_category" : "A", # shift+a + "clear_category" : "R", # shift+r + "focus_up" : "up", + "focus_down" : "down", + } + + + def __init__( + self, + axis, + marker=None, + bspline=False, + colors=None, + names="Line", + maxlines=30, + wraplines=15, + legend_bbox=(1,0.5), + pick_dist=10, + n_names=10, + coords={}): + + # Variable assignement + self.axis = axis + self.legend_bbox = legend_bbox + self.pick_dist = pick_dist + self.param = self.DEFAULT_param.copy() + self.wait_before_interpolation = 2 + self.bspline = bspline + + if isinstance(marker, dict): + self.marker = marker + else: + self.marker = self.DEFAULT_marker.copy() + + if isinstance(colors, list): + self.colors = colors + else: + self.colors = self.DEFAULT_colors.copy() + + if isinstance(names, str): + self.names = names + coord_keys = [self.names + str(i+1) for i in range(n_names)] + elif isinstance(names, list): + coord_keys = names.copy() + self.names = "DefaultName" + else: + self.names = self.DEFAULT_name + coord_keys = [self.names + str(i+1) for i in range(n_names)] + + if isinstance(coords, dict) and coords != {}: + self.coords = coords + else: + self.coords = {key: [] for key in coord_keys} + + if isinstance(maxlines, int): + self.maxlines = maxlines + else: + self.maxlines = self.DEFAULT_maxlines + + if isinstance(wraplines, int): + self.wraplines = wraplines + else: + self.wraplines = self.DEFAULT_wraplines + + # Line creation + self._create_lines_and_points() + + # Drawing lines + self.figure = self.axis.figure + self.current_line = 0 + self._set_plot() + + # Linking actions in matplotlib canvas + self.mouse_press_event = self.figure.canvas.mpl_connect( + 'button_press_event', + self.get_mouse_press) + self.mouse_release_event = self.figure.canvas.mpl_connect( + 'button_release_event', + self.get_mouse_press) + self.activate_move = False + + self.pick_event = self.figure.canvas.mpl_connect( + 'pick_event', + self.get_focus) + self.key_press = self.figure.canvas.mpl_connect( + 'key_press_event', + self.get_key_event) + + # callbacks + self.xlims = self.axis.get_xlim() + self.ylims = self.axis.get_ylim() + self.axis.callbacks.connect('xlim_changed', self._on_xlims_change) + self.axis.callbacks.connect('ylim_changed', self._on_ylims_change) + + def _on_xlims_change(self, event_ax): + self.xlims = event_ax.get_xlim() + self.update_lines() + + def _on_ylims_change(self, event_ax): + self.ylims = event_ax.get_ylim() + + + def _create_lines_and_points(self): + self.lines = [] + self.points = [] + for idx, legend_label in enumerate(self.coords.keys()): + if len(self.coords[legend_label]) > 0: + self.points += [ + mlines.Line2D( + np.array(self.coords[legend_label])[:,0], + np.array(self.coords[legend_label])[:,1], + label=legend_label, + color=self.colors[idx%len(self.colors)], + zorder=3, + linestyle="", + **{x: self.marker[x] for x in self.marker if x not in {"linestyle"}}) + ] + self.lines += [ + mlines.Line2D( + np.array(self.coords[legend_label])[:,0], + np.array(self.coords[legend_label])[:,1], + label=legend_label, + zorder=2, + color=self.colors[idx%len(self.colors)]) + ] + else: + self.points += [ + mlines.Line2D( + [], + [], + label=legend_label, + color=self.colors[idx%len(self.colors)], + zorder=3, + linestyle="", + **{x: self.marker[x] for x in self.marker if x not in {"linestyle"}}) + ] + self.lines += [ + mlines.Line2D( + [], + [], + label=legend_label, + zorder=2, + color=self.colors[idx%len(self.colors)]) + ] + + def _set_plot(self): + """ + A method to create matplotlib.pyplot legend and draw ut. + Legend contains empty lines and can be clicked. + + ... + + Returns + ------- + None : updates axis, creates legend. + """ + # Make some space to include legend + scale = (19-((len(self.coords.keys())//self.wraplines)+1))/19 + self.axis.set_position([0, 0, scale, 1]) + + # Add legend to plot + self.legend = self.axis.legend( + loc="center left", + bbox_to_anchor=self.legend_bbox, + ncol=(len(self.coords.keys())//self.wraplines)+1, + title="Selection of lines", + handles=self.lines) + + # Add lines and points to plot + for line, point in zip(self.lines, self.points): + self.axis.add_line(point) + self.axis.add_line(line) + + for legend_l, line in zip(self.legend.get_lines(), self.lines): + legend_l.set_picker(True) + legend_l.set_pickradius(self.pick_dist) + legend_l.set_alpha(0.2) + self.axis.add_line(line) + + # Focus on selected line + self.legend.get_lines()[self.current_line].set_alpha(1) + + def get_mouse_press(self, event): + """ + A Method that retrieves mouse interactions with matplotlib plot. + + ... + + Parameters + ---------- + event : matplotlib object + Contains 3 attributes : button pressed, x and y at that time. + + Returns + ------- + None : is used to trigger methods add_points and rm_points. + """ + if self.figure.canvas.widgetlock.available(self): + pressed, x, y = event.button, event.xdata, event.ydata + if ((pressed is self.param["add_point"]) and + (isinstance(x, float)) and + (isinstance(y, float)) and + event.name == 'button_press_event'): + self.add_point(x, y) + + elif ((pressed is self.param["rm_point"]) and + (isinstance(x, float)) and + (isinstance(y, float)) and + event.name == 'button_press_event'): + self.rm_point(x, y) + + elif ((pressed is self.param["move_point"]) and + (isinstance(x, float)) and + (isinstance(y, float)) and + event.name == 'button_press_event'): + distances = self.distance_mouse( + np.array(self.coords[list(self.coords.keys())[self.current_line]]), + event.xdata, + event.ydata) + self.point_to_move = np.argmin(distances) + dist = np.min(distances) + if dist < 0.5: + self.activate_move = True + self.move_point(event) + self.motion_event = self.figure.canvas.mpl_connect( + 'motion_notify_event', + self.move_point) + + elif ((pressed is self.param["move_point"]) and + (isinstance(x, float)) and + (isinstance(y, float)) and + event.name == 'button_release_event'): + if self.activate_move: + self.figure.canvas.mpl_disconnect(self.motion_event) + self.activate_move = False + self.point_to_move = False + + def update_lines(self): + """ + Updates data in lines. Called when coords is changed. + + ... + + Returns + ------- + None : Updates figure lines + """ + for i_line in range(len(self.coords)): + line_coords = np.array(self.coords[list(self.coords.keys())[i_line]]) + + if (line_coords.shape[0] > 0): + # [ ] MAIN OPTIMIZATION : check if line is in visible frame + in_xframe = ( + np.any(line_coords[:,0] >= min(self.xlims)) and + np.any(line_coords[:,0] <= max(self.xlims))) + in_yframe = ( + np.any(line_coords[:,1] >= min(self.ylims)) and + np.any(line_coords[:,1] <= max(self.ylims))) + + if in_xframe and in_yframe: + self.points[i_line].set_data( + np.array(line_coords)[:,0], + np.array(line_coords)[:,1]) + + if (self.bspline) and (len(self.coords[list(self.coords.keys())[i_line]]) > self.wait_before_interpolation): + curvex, curvey = to_curve( + line_coords[:,0], + line_coords[:,1], + kind="quadratic") + self.lines[i_line].set_data(curvex, curvey) + else: + self.lines[i_line].set_data( + np.array(line_coords)[:,0], + np.array(line_coords)[:,1]) + else: + self.points[i_line].set_data([], []) + self.lines[i_line].set_data([], []) + + else: + self.points[i_line].set_data([], []) + self.lines[i_line].set_data([], []) + + self.figure.canvas.draw_idle() + + def distance_mouse(self, points, mouse_x, mouse_y): + """ + Gets the distance bewteen a mouse position + and the currently selected category (points). + + ... + Parameters + ---------- + points : numpy array + A list of coordinates with shape (2,n). + mouse_x : int or float + Coordinate of the mouse on x-axis. + mouse_y : int or float + Coordinate of the mouse on y-axis. + Returns + ------- + distances : numpy array + Distances of each point to mous coordinates. + """ + size_x, size_y = self.figure.get_size_inches() + max_x, max_y=self.axis.get_xbound()[1],self.axis.get_ybound()[1] + + # look for closest coordinates of the category currently selected. + distances = [np.linalg.norm([(point[0]/max_x)*size_x, + (point[1]/max_y)*size_y] - np.array([(mouse_x/max_x)*size_x, + (mouse_y/max_y)*size_y])) + for point in points] + + return distances + + def add_point(self, x, y): + """ + A method to add a point on plot at given coordinates. + + ... + + Parameters + ---------- + x : float + x-axis coordinate. + y : float + y-axis coordinate. + + Returns + ------- + None : updates coords, figure_lines, figure. + """ + + if len(self.coords[list(self.coords.keys())[self.current_line]])==0: + self.coords[list(self.coords.keys())[self.current_line]] += [[x, y]] + else: + if x in np.array(self.coords[list(self.coords.keys())[self.current_line]])[:,0]: + warnings.warn("Cannot place two points at the same timestamp!", UserWarning, stacklevel=2) + else: + # where should it be inserted ? + here = np.where( + np.array( + self.coords[ + list(self.coords.keys())[self.current_line] + ] + )[:,0] > x + )[0] + + if len(here) != 0: + self.coords[list(self.coords.keys())[self.current_line]].insert(here[0] ,[x, y]) + else: + self.coords[list(self.coords.keys())[self.current_line]] += [[x, y]] + + self.update_lines() + + def rm_point(self, x, y): + """ + A method to remove closest point in plot to given coordinates. + + ... + + Parameters + ---------- + x : float + x-axis coordinate. + y : float + y-axis coordinate. + + Returns + ------- + None : updates coords, figure_lines, figure. + """ + list_coords = np.array( + self.coords[ + list(self.coords.keys())[self.current_line] + ] + ) + + distances = self.distance_mouse(list_coords, x, y) + + if len(distances) > 0: + if min(distances) < 1: + if len(list_coords) > 1: + # remove closest point of selected + list_coords = np.delete(list_coords, np.argmin(distances), axis=0) + self.coords[list(self.coords.keys())[self.current_line]] = list_coords.tolist() + else: + self.coords[list(self.coords.keys())[self.current_line]] = [] + + self.update_lines() + + def move_point(self, event): + """ + Moves a selected point of the currently selected category to a new + position (event). Updates figure accordingly. + + ... + + Parameters + ---------- + event : matplotlib object + Matplotlib event containig information on positions. + + Returns + ------- + None : it updates the coordinates of a point in a category + and re-draws the figure. + """ + if (self.activate_move and + event.xdata != None and + event.ydata != None): + + # does this point already exists ? + if event.xdata in np.array(self.coords[list(self.coords.keys())[self.current_line]])[:,0]: + warnings.warn("Cannot place two points at the same timestamp!", UserWarning, stacklevel=2) + else: + # update coords + self.coords[list(self.coords.keys())[self.current_line]][self.point_to_move] = [event.xdata, event.ydata] + self.update_lines() + + def get_key_event(self, event, show=True): + """ + A method that retrieve key interactions with matplotlib plot. + + ... + + Parameters + ---------- + event : matplotlib object + Contains 3 attributes : button pressed, x and y at that time. + show : boolean + Parameter passed to add_category() + + Returns + ------- + None : is used to trigger other functions. + """ + key = event.key + if ((key == self.param["add_category"]) and + (len(self.coords) < self.maxlines)): + self.add_category(show) + + elif ((key == self.param["clear_category"]) and + (len(self.coords) > 1)): + self.clear_category() + + elif ((key == self.param["focus_up"]) and + (self.current_line != 0)): + self.switch_line(-1) + + elif ((key == self.param["focus_down"]) and + (self.current_line != len(self.coords)-1)): + self.switch_line(1) + + def add_category(self, show): + """ + A method to add a line (therefore a new category) to the plot. + Also change current focus to be on the newly created category. + + ... + + Parameters + ---------- + show : boolean + Shows change in legend. + + Returns + ------- + None : updates legend_labels, figure_lines, legend, figure + and current_line. + """ + # make new name + self.coords[self.names + str(len(self.coords)+1)] = [] + + # add to points and lines + self.points += [ + mlines.Line2D( + [], + [], + label=list(self.coords.keys())[-1], + color=self.colors[(len(self.coords)-1)%len(self.colors)], + zorder=3, + linestyle="", + **{x: self.marker[x] for x in self.marker if x not in {"linestyle"}}) + ] + self.lines += [ + mlines.Line2D( + [], + [], + label=list(self.coords.keys())[-1], + zorder=2, + color=self.colors[(len(self.coords)-1)%len(self.colors)]) + ] + + self._set_plot() + self.update_lines() + + # focus auto on new category + self.current_line = len(self.coords)-1 + + for legend_line in self.legend.get_lines(): + legend_line.set_alpha(0.2) + self.legend.get_lines()[self.current_line].set_alpha(1) + if show: + self.figure.canvas.draw_idle() + + def clear_category(self): + """ + A method to remove a line (therefore a whole category) from the plot. + Removes the category that is in focus. + + ... + + Returns + ------- + None : updates legend_labels, figure_lines, legend, figure. + """ + + # we only need to remove data from coords + self.coords[list(self.coords.keys())[self.current_line]] = [] + self.points[self.current_line].set_data([], []) + self.lines[self.current_line].set_data([], []) + + # update plot + self.update_lines() + + def switch_line(self, arrow): + """ + Puts focus on previous/next category in legend based on arrow input. + + ... + + Parameters + ---------- + arrow : int + Can be 1 or -1. Change the index of the current category in focus. + + Returns + ------- + None : updates currentline, legend and figures attributes. + """ + # new focus + self.current_line += arrow + + # adapt alpha + for legend_line in self.legend.get_lines(): + legend_line.set_alpha(0.2) + self.legend.get_lines()[self.current_line].set_alpha(1) + self.figure.canvas.draw_idle() + + def get_focus(self, event): + """ + A method to highlight a given marker in legend. + Points can be added/removed only to the category in focus. + + ... + + Parameters + ---------- + event : matplotlib object + Is used to acces event.artist attribute. + + Returns + ------- + None : updates figure, current_line and legend attributes. + """ + # set all legend lines to alpha = 0.2 + for legend_line in self.legend.get_lines(): + legend_line.set_alpha(0.2) + # set legend line in focus to alpha = 1 + selected_legend = event.artist + current_alpha = selected_legend._alpha + selected_legend.set_alpha(1.0 if (current_alpha==0.2) else 0.2) + self.figure.canvas.draw_idle() + + # new focus + self.current_line = int(np.where( + np.array(self.legend.get_lines()) == event.artist)[0]) + + +##### MAIN ##### +if __name__ == '__main__': + # dummy example + img = np.tile([[0.45, 0.55],[0.55, 0.45]], (25,25)) + img[0][0] = 0 + img[-1][-1] = 1 + + fig, ax = plt.subplots(figsize=(16, 9)) + ax.imshow(img, cmap="gray") + base = clicker(axis=ax, bspline="quadratic") + plt.show(block=True) \ No newline at end of file -- GitLab