jeudi 16 février 2012

Dessiner un tas de sable (plot)

Pour afficher un tas de sable avec Sage il faut définir une méthode plot(), comme dans le module standard de David Perkinson. Ici je ne m'intéresse qu'aux tas de sable sur une grille rectangulaire, et je vais donc écrire une méthode pour la classe ConfigGrid :

from sage.plot.colors import Color
from sage.plot.plot import Graphics
from sage.plot.line import line
from sage.plot.text import text
from sage.plot.scatter_plot import scatter_plot
[...]
class ConfigGrid (Configuration):
    def __init__ (self, m, n, sable = 0):
        g = SandpileGrid (m, n)
        Configuration.__init__ (self, g, sable)
        
    def stabilize(self):
        [...]

    def plot (self, palette=0, cell=None, composition='', **kwds):

Le premier argument, palette, est probablement inattendu, mais j'aime bien pouvoir varier les palettes de couleurs, j'en reparlerai. Je souhaite afficher une configuration comme un ensemble de cellules, rondes a priori, avec pour chacune le nombre de grains de sable, indiqué à la fois par une couleur et par un chiffre (ou plusieurs). Le second argument, cell, désigne la taille de chaque cellule, et le troisième, composition, sert à spécifier des options, j'en reparlerai. Enfin la méthode admet des arguments complémentaires en nombre indéfini, passés sous la forme keyword=value, selon un protocole Python simple, agréable, et classique.

La méthode plot() retourne un objet graphique, appelé traditionnellement G, et constitué de primitives graphiques, qu'on ajoute une à une à G. Ici la primitive essentielle est fournie par la fonction scatter_plot() :

    def plot (self, palette=0, cell=None, composition='', **kwds):
        m = self.sandpile.nbrows
        n = self.sandpile.nbcols
        
        # ms = marker size
        dmax = max (m, n)
        if dmax < 10: ms = 500
        else:  ms = 4000 // dmax
        # l'argument l'emporte, s'il est fourni
        if cell != None: ms = cell
            
        G = Graphics()
        p = [[x, m-y-1] for y in range(m) for x in range(n)]
        c = [couleur(self[i+1, j+1]) for i in range(m) for j in range(n)]
        ec = 'black'    # edgecolor
        # scatter_plot : zorder = 5 par défaut
        # on peut modifier les options 'marker' et 'alpha'
        # exemple: plot (palette=3, marker='s')
        s = scatter_plot (p, markersize = ms,
            facecolor = c, edgecolor = ec, **kwds)
        G += s

La fonction scatter_plot() prend en argument une liste de points, qui seront représentés par des marques (disque, carré, croix, etc); il faut prendre garde au piège habituel : la ligne i correspond à l'ordonnée y renversée (la ligne 1 est en haut, etc), tandis que la colonne j correspond à l'abscisse x. La fonction admet ensuite des arguments optionnels : taille des marques, couleur, et couleur de leur bord. La spécification des couleurs est très souple, et peut être une liste : ici chaque couleur dépend du nombre de grains de sable et de la palette, je ne donnerai pas le code de la fonction couleur (il est très simple, il faut juste faire attention à utiliser les palettes de manière circulaire).

La fonction scatter_plot() admet deux autres arguments optionnels, indiqués dans le code ci-dessus, et récupérés automatiquement s'ils sont fournis lors de l'appel à la méthode plot(), grâce à **kwds. De son côté elle transmet d'éventuels arguments inconnus d'elle à la méthode show(), qui affiche un objet graphique.

Tracer les lignes de la grille est très simple, on utilise la primitive graphique line() :

        for i in range (m):
            G += line ([(-0.5, i),(n-0.5, i)])
        for j in range (n):
            G += line ([(j, -0.5),(j, m-0.5)])

Ces lignes passent sous les cellules dessinées par scatter_plot(), comme par magie : en fait l'ordre de superposition des composantes d'un objet graphique est déterminé par le paramètre zorder, qui vaut 0 par défaut pour line(), et 5 pour scatter_plot().

Il ne reste plus qu'à ajouter la représentation décimale de chaque altitude, si on le souhaite, en utilisant la primitive graphique text() :

        fs = 8 + ms // 50
        G += sum (text (str(self[i+1, j+1]), (j, m-i-1),
                        fontsize = fs, zorder = 10)
                  for i in range (m) for j in range (n))

Comme d'habitude il faut écrire soigneusement la correspondance entre le couple (ligne, colonne) de la configuration, et le couple (x, y) sur le dessin. Le paramètre zorder spécifie que le texte est visible, car placé au-dessus des marques dessinées par scatter_plot().

La fin du code de plot() se comprend d'elle-même :

        G.xmin(-1)
        G.xmax(n)
        G.ymin(-1)
        G.ymax(m)
        G.axes(False)
        G.set_aspect_ratio(1)
        return G

Le code de la méthode ConfigGrid.plot() est maintenant presque complet. Je ne donne pas les palettes, ce sont de simples listes de couleurs, que je suis allé chercher sur le site Color Brewer — le graphisme est un métier.

Quand on utilise cette méthode, on souhaite rapidement disposer de pas mal d'options : afficher ou non les lignes, les chiffres, etc. Le premier réflexe est d'ajouter des paramètres un à un, mais on est rapidement débordé. J'ai choisi d'ajouter un seul paramètre composition, qui est une chaîne de caractères, chaque option étant repérée par une lettre, d'où par exemple le fragment de code suivant :

        digits = dmax < 80
        if 'd' in composition:
            digits = not digits

et le code donné un peu plus haut pour afficher les altitudes avec la primitive text() est gardé par :

        if digits:

Je peux ainsi basculer le choix par défaut (chiffres affichés pour les grilles de côtés inférieurs à 80). Voici quelques exemples de ce que je peux obtenir :

c=ConfigGrid (6, 8, 4); c.stabilize(); c.plot()
c=ConfigGrid (100, 100, 12)
c.stabilize()
c.plot(palette=2, figsize=12)

L'option 'q' permet d'afficher seulement le quart nord-ouest de la grille :

c.plot(palette=2, composition='q', figsize=12)

L'option 'b' supprime le bord de chaque cellule, l'option 'd' supprime l'affichage des chiffres, l'option 'l' supprime l'affichage des lignes de la grille, et 's' désigne une marque carrée (square) :

c.plot(palette=4, cell=20, composition='bdlq', marker='s')