|
|
|
**But de l'atelier** : manipuler les notions d' **événement** et de **fonction associée** pour rendre un graphique interactif.
|
|
|
|
|
|
|
|
**Application** : sélectionner sur une courbe le point le plus proche d'un clic de souris
|
|
|
|
|
|
|
|
Il est possible de rendre certains objets d'une fenêtre matplotlib "réactifs" afin de un utilisateur de modifier , notamment avec la souris (cliquer sur un bouton ou un graphe, par exemple). On va donc ici présenter la notion d'événement, associée à une fonction qu'il va déclencher.
|
|
|
|
|
|
|
|
On propose de commencer par un événement simple, un clic de souris qui va juste renvoyer ses coordonnées, et déclencher la fonction avec laquelle il est associée, qui est définie par l'utilisateur. A la suite vous trouverez les tests effectués pour "Picker", qui permet de sélectionner les points les plus proches du clic (code test_picker, C. Poulard, S. Coulibaly). A priori, ses fonctionnalités permettent de sélectionner le point le plus proche de manière plus performante. Dans les faits, nous avons rencontré des problèmes de version (ça marche avec Matplotlib 3.4.2) et eu du mal à trouver des explications claires, d'où le besoin de faire des tests. On garde cependant une trace, en espérant que les prochaines versions soient plus pratiques à mettre en oeuvre. Les tests ont été l'occasion de découvrir un nouveau widget, **textbox**, une fenêtre de texte, qui est préférable au slider quand on veut rentrer des valeurs précises.
|
|
|
|
|
|
|
|
Exemple [d'événements ](https://matplotlib.org/stable/users/event_handling.html)à qui on peut associer une fonction :
|
|
|
|
| nom de l'événement | type | description |
|
|
|
|
|--------------------|------|-------------|
|
|
|
|
| 'button_press_event' | MouseEvent | mouse button is pressed |
|
|
|
|
| 'close_event' | CloseEvent | figure is closed |
|
|
|
|
| 'key_press_event' | KeyEvent | key is pressed |
|
|
|
|
| 'motion_notify_event' | MouseEvent | mouse moves |
|
|
|
|
| 'pick_event' | PickEvent | artist in the canvas is selected |
|
|
|
|
| 'resize_event' | ResizeEvent | figure canvas is resized |
|
|
|
|
|
|
|
|
## Avec un "MouseEvent"
|
|
|
|
|
|
|
|
```
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
|
|
|
def onclick(event):
|
|
|
|
print(event.xdata, event.ydata)
|
|
|
|
|
|
|
|
fig,ax = plt.subplots()
|
|
|
|
ax.plot(range(10))
|
|
|
|
fig.canvas.mpl_connect('button_press_event', onclick)
|
|
|
|
plt.show()
|
|
|
|
```
|
|
|
|
|
|
|
|
Prenons un code \[proposé pour expliquer le principe\]([https://stackoverflow.com/questions/37363755/python-mouse-click-coordinates-as-simply-as-possible](https://stackoverflow.com/questions/37363755/python-mouse-click-coordinates-as-simply-as-possible)) :
|
|
|
|
|
|
|
|
* sur la figure, on connecte l'événement "button_press_event" avec la fonction onclick : concrètement, si on clique quelque part dans la figure, on déclenche la fonction en lui passant comme argument l'événement
|
|
|
|
* les attributs **xdata** et **ydata** de l'événement sont les coordonnées de la souris dans l'unité de la courbe, si la souris est sur un "Axes" ; l'axe sera alors retrouvé avec l'attributs **inaxes**. A ne pas confondre avec **x** et **y** qui sont les coordonnées en pixels.
|
|
|
|
* dans la fonction onclick, on se contente d'écrire les valeurs dans la console.
|
|
|
|
|
|
|
|
On commence par une figure ne comportant qu'une seule courbe.
|
|
|
|
|
|
|
|
Pour manipuler, on va changer la fonction onclick, elle va maintenant ajouter (event.xdata, event.ydata) à la liste des points de la courbe, initialement dix points alignés.
|
|
|
|
|
|
|
|
Pour cela, il faut donner un nom à la courbe, ici donnees, et on récupère les valeurs en x avec donnees. get_xdata( ). Comme on récupère un numpy.array, il faut utiliser numpy.append pour concaténer le vecteur existant avec le nouveau point., et on le passe comme nouveau vercteur avec donnees.set_xdata(). On rafraîchit la courbe avec draw_idle.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
import matplotlib.pyplot as plt
|
|
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
|
|
fig = None
|
|
|
|
|
|
|
|
def onclick(event):
|
|
|
|
|
|
|
|
donnees.set_xdata(np.append(donnees.get_xdata(), event.xdata))
|
|
|
|
|
|
|
|
donnees.set_ydata(np.append(donnees.get_ydata(),event.ydata))
|
|
|
|
|
|
|
|
fig.canvas.draw_idle()
|
|
|
|
|
|
|
|
fig,ax = plt.subplots()
|
|
|
|
|
|
|
|
donnees, = ax.plot(range(10), marker='o')
|
|
|
|
|
|
|
|
fig.canvas.mpl_connect('button_press_event', onclick)
|
|
|
|
|
|
|
|
plt.show()
|
|
|
|
|
|
|
|
![Figure_on_ajoute_des_points](uploads/f7559a93cb64796ce97dca1714d8c0f3/Figure_on_ajoute_des_points.png)
|
|
|
|
|
|
|
|
## Avec un "PickEvent" :
|
|
|
|
|
|
|
|
On va utiliser la notion de _picker_, ici sur une figure ne comportant qu'une seule courbe. Si on a plusieurs courbes, on peut ne lier le picker qu'à certaines d'entre elles. Si plusieurs sont réceptives au picker, la fonction liée sera déclenchée une fois pour chacune.
|
|
|
|
|
|
|
|
![sélection picker](uploads/510c61180d0c1d51a059871fed23ab1c/selection_picker.png)
|
|
|
|
|
|
|
|
_comparaison des points sélectionnés avec chacune des 2 distances par rapport au clic de souris, matérialisé ici par une croix bleue_
|
|
|
|
|
|
|
|
### 1. il faut rendre l'objet attentif au clic de souris...
|
|
|
|
|
|
|
|
```python
|
|
|
|
fig.canvas.mpl_connect('pick_event', onpick_cp)
|
|
|
|
```
|
|
|
|
|
|
|
|
Toute la figure va maintenant être "attentive" à un éventuel clic de souris, qui déclenchera ici la fonction "onpick_cp", qu'il faut écrire.
|
|
|
|
|
|
|
|
### 2) def onpick_cp : instructions quand on clique au-dessus de cet objet La fonction on_pick_cp a comme argument l'événement, _event_
|
|
|
|
|
|
|
|
##### But : un clic de souris compare sa position avec les points d'un objet matplotlib ; le plus proche est sélectionné et des informations s'affichent.
|
|
|
|
|
|
|
|
##### Principe : cette fonction sera appelée pour chaque "artist" pour lequel le picker a été activé, ici toute la figure, qui ne contient qu'une courbe (de type "Line2D").
|
|
|
|
|
|
|
|
L'événement déclencheur a des attributs :
|
|
|
|
|
|
|
|
* event.mouseevent : les caractéristiques du clic de souris, avec comme attributs la position en coorodnnées des données (xdata , ydata)
|
|
|
|
* event.artist : la ligne courante, parmi celles pour qui le pickler est activé
|
|
|
|
* event.ind : les indices des points de la ligne compris dans la portée du pickler
|
|
|
|
|
|
|
|
Au moyen de ces informations, on va comparer les coordonnées de la souris avec celle des points. L'attribut event.ind contient les indices des points suffisamment proches du clic. Numpy permet de traiter tout le vecteur des coordoonnées en une seule fois. On pourrait utiliser seulement les coordonnées de la souris, mais l'événement "on pick" a parmi ses attributs la liste des indices des points suffisamment proches, ce qui constitue déjà un premier tri.
|
|
|
|
|
|
|
|
```python
|
|
|
|
# indices des points de l'artist sélectionnés
|
|
|
|
ind = event.ind
|
|
|
|
print("indices des points de la ligne sélectionnés par le clic: ", ind)
|
|
|
|
# on en déduit la liste des coordonnées (x,y) des points sélectionnés
|
|
|
|
points = np.array([(xdata[i], ydata[i]) for i in ind])
|
|
|
|
```
|
|
|
|
|
|
|
|
Il reste à calculer les distances et à trouver la plus petite. Cependant, on peut être surpris : le point sélectionné n'est pas forcément le plus proche à première vue. En fait, la distorsion entre les coordonnées "en unités réelles" et "en pixels" fait que la distance perçue n'est pas nécessairement la même que la distance calculée. Selon l'usage, on peut donc modifier la définition de la distance, en normant par la largeur et la hauteur de la vignette en unités des données, puis en renormant par la largeur et la hauteur en pixels. La largeur et la hauteur de la vignette en unités des données sont calculables grâce à ax.get_xlim() et ax.get_ylim(), et ax.get_window_extent() donne les dimensions de la vignette en pixels ou en unités proportionnelles à des pixels.
|
|
|
|
|
|
|
|
```python
|
|
|
|
bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
|
|
|
|
largeur, hauteur = bbox.width , bbox.height
|
|
|
|
largeur_en_pixels, hauteur_en_pixels = bbox.width* fig.dpi , bbox.height * fig.dpi
|
|
|
|
```
|
|
|
|
|
|
|
|
### 3) mise à jour d'objets matplotlib : méthodes _set__
|
|
|
|
|
|
|
|
Dans ce code, on commence par tracer une courbe, en la nommant, et des objets supplémentaire comme des courbes réduites à un point et des annotations, toujours en les nommant, mais sans les afficher dans un premier temps grâce à set_visible(False). On rappelle que dans une fonction, on ne pourrait pas redéfinir ces objets (courbe = plt.plot(nouveaux_x, nouveaux_y)) mais par contre on peut parfaitement appliquer des méthodes sur des objets définis dans le programme principal, ou en changer des attributs : courbe = set_data(nouveaux_x, nouveaux_y). Ainsi, dans la fonction on_pick_cp on va pouvoir modifier certains arguments de ces objets. On peut donc déplacer la croix bleue vers les coordonnées de la souris, et déplacer également les annotations en mettant à jour leur texte, et bien sûr changer leur statut avec set_visible(True). On pourrait également modifier le titre de la figure, avec ax.set_text()
|
|
|
|
|
|
|
|
![selection_picker2](uploads/36f24ebb707baac8a9af0c29c562d76c/selection_picker2.png)
|
|
|
|
|
|
|
|
## Exemple 2: comprendre l'argument pickradius
|
|
|
|
|
|
|
|
Le "Picker" sélectionne plus ou moins de points "autour" du clic de souris, en fonction d'un argument correspondant à un rayon.
|
|
|
|
|
|
|
|
Pour mieux comprendre à quoi les valeurs correspondent, on propose dans le code test_picker_semis_de_points de tracer un graphique avec un grand nombre de points, définis aléatoirement, et de surligner les points sélectionnés. En pratique, on définit le graphique avec plot et l'on nomme la courbe retournée par la fonction **nuage**.
|
|
|
|
|
|
|
|
On définit une autre courbe, **selection**, qui sera mise à jour avec selection.set_data(\[xdata\[i\] for i in ind\], \[ydata\[i\] for i in ind\]), soit les points sélectionnés.
|
|
|
|
|
|
|
|
On définit également une annotation, avec une flèche qui pointe vers le point où on a cliqué, et dont le texte indiquera le nombre de points sélectionnés.
|
|
|
|
|
|
|
|
### définition du rayon par picker=flottant
|
|
|
|
|
|
|
|
Dans un premier temps, on va définir ce rayon par un entier passé à l'argument picker.
|
|
|
|
|
|
|
|
On définit un premier cadre de texte (textbox) avec un fond jaune, où l'utilisateur peut changer le rayon. On associe à l'action "saisir une valeur" la fonction "submit_radius" que l'on écrit de manière à ce qu'elle change la valeur de l'argument picker.
|
|
|
|
|
|
|
|
```python
|
|
|
|
def submit_radius(val):
|
|
|
|
"""
|
|
|
|
si possible, la valeur écrite dans le textbox est convertie en entier
|
|
|
|
on affecte cette valeur comme nouveau rayon avec set_picker
|
|
|
|
"""
|
|
|
|
try:
|
|
|
|
radius = int(val)
|
|
|
|
nuage.set_picker(abs(radius))
|
|
|
|
textbox_radius.text_disp.set_color("blue")
|
|
|
|
except:
|
|
|
|
textbox_radius.text_disp.set_color("orange")
|
|
|
|
textbox_radius.set_val(nuage.get_picker())
|
|
|
|
|
|
|
|
nuage, = ax.plot(x, y, '*', c='blue', label="nuage", picker=init_radius, ls='None')
|
|
|
|
textbox_radius.on_submit(submit_radius)
|
|
|
|
```
|
|
|
|
| nuage.set_picker(10) | nuage.set_picker(50) |
|
|
|
|
|----------------------|----------------------|
|
|
|
|
| ![pickradius10_tout](uploads/0821128d553fe5339ad7def207fc30cf/pickradius10_tout.png) | ![pickradius50_tout](uploads/19029398896946216efe286eac79783e/pickradius50_tout.png) |
|
|
|
|
| nuage.set_picker(10), image complète | nuage.set_picker(abs(50)), image complète |
|
|
|
|
| ![pickradius10](uploads/df7e677242aaed3483d3ccf1df7e4823/pickradius10.png) | ![pickradius](uploads/92c91c7d3faf298fae3e820377a00d6a/pickradius.png) |
|
|
|
|
| nuage.set_picker(10), après zoom | nuage.set_picker(abs(50)), après même zoom |
|
|
|
|
|
|
|
|
On remarque que :
|
|
|
|
|
|
|
|
* les points sélectionnés apparaissent former un cercle : ils sont donc sélectionnés dans un cercle "en coordonnées écran". Les distances en x et en y étant distordues, si le rayon était défini en "coordonnées des données" on verrait une ellipse ;
|
|
|
|
* pour une valeur donnée, le rayon "apparent" est le même quel que soit le zoom ou la taille de la vignette. Le rayon correspond donc à une distance "écran", qui dépend uniquement des dimensions de la vignette. Dit autrement, avec les mêmes données de départ et pour un même rayon, on captera moins de points en zoomant ou en agrandissant la taille de la vignette, les deux actions ayant pour effet de diminuer la densité des points tracés.
|
|
|
|
|
|
|
|
## Exemple 3: comprendre les arguments pickradius des axes
|
|
|
|
|
|
|
|
```python
|
|
|
|
def submit_radius(val):
|
|
|
|
"""
|
|
|
|
si possible, la valeur écrite dans le textbox est convertie en entier
|
|
|
|
on affecte cette valeur comme nouveau rayon avec set_picker
|
|
|
|
"""
|
|
|
|
try:
|
|
|
|
radius = int(val)
|
|
|
|
nuage.set_picker(abs(radius))
|
|
|
|
textbox_radius.text_disp.set_color("blue")
|
|
|
|
except:
|
|
|
|
textbox_radius.text_disp.set_color("orange")
|
|
|
|
textbox_radius.set_val(nuage.get_picker())
|
|
|
|
|
|
|
|
nuage, = ax.plot(x, y, '*', c='blue', label="nuage", picker=init_radius, ls='None')
|
|
|
|
textbox_radius.on_submit(submit_radius)
|
|
|
|
``` |
|
|
|
\ No newline at end of file |