MMI, Lyon
Au fur et à mesure qu'on calcule les points zn d'une orbite, on peut également calculer la dérivée dn de la fonction qui au premier point z0 associe le n+1-ième zn. On a les relations de récurrence $$\begin{align} z_{n+1}&=f(z_n)\\ d_0&=1\\ d_{n+1}&=f'(z_n)d_n \end{align}$$
Le piège est illustré ci-dessous.
(boucle d'itération)
...
z = f(z)
d = f_prime(z)*d # oups...
...
Si z contenait zn et d contenait dn alors maintenant
z contient f(zn) et d contient f'(zn+1)dn au lieu de
f'(zn)dn.
Solution 1 : changer l'ordre des lignes (ça marche ici)
(boucle d'itération) ... d = f_prime(z)*d z = f(z) ...Solution 2 : utiliser des variables temporaires (c'est plus général)
(boucle d'itération) ... new_z = f(z) new_d = f_prime(z)*d z=new_z d=new_d ...
Note : on peut de même calculer les dérivées d'ordre supérieur au fur et à mesure des itérations mais on ne les utilisera pas ici.
L'image d'un disque de rayon r par fn est approximativement un disque de rayon dn×r. Cette approximation linéaire n'est en théorie bonne que pour dn≠0 et r suffisamment petit. L'idée est ici de faire comme si c'était le cas tout le temps, quand r est de l'ordre de la taille d'un pixel.
Remarque : En fait en dynamique holomorphe en dimension 1, on est fréquemment en situation de distorsion bornée, donc cette procédure donne souvent le bon ordre de grandeur pour la taille de l'image d'un tel disque.
Dans l'algo ci-dessous je choisis r = la longueur du côté d'un pixel, ce qui fait que le disque déborde bien du pixel (son aire est ≈ 3× plus grande). J'itère et considère le disque de centre zn et de rayon dn×r. S'il contient un point de l'ensemble de Julia alors je considère le pixel initial est proche du Julia et je le colorie en conséquence. En effet, sauf cas exceptionnels, il y a une préimage de ce disque dont la taille est comparable au disque initial B(z0,r). Celle-ci contient un point de l'ensemble de Julia car, comme souvent avec les ensembles définis dynamiquement :
f-1(J) = J = f(J)
Mais on a mieux :
l'ensemble des préimages itérées de z est un sous-ensemble dense de J.
Ce qui est encourageant : peut-être suffit-il de tester un seul point de J ? On procède donc comme suit:
- On trouve/choisit un point x ∈ J.
- Pour chaque pixel, correspondant à un certain z0, on calcule zn et dn pour n=1,2,3,...
- Si pour un certain x ∈ B(zn,dn×r) alors on colorie le pixel en bleu.
Ci-dessous, on a choisi la pointe de l'ensemble de Julia, autrement dit le point fixe réel répulsif x=2.357... (j'ai calculé cette approximation à part), qui est aussi le point le plus à gauche de J sur l'axe réel. Le programme implémente la méthode décrite ci-dessus avec ce choix, les instructions correspondantes sont en rouge.
Il y a d'autres ajouts, signalés par d'autres couleurs. En bleu, un détecteur d'overflow par le mécanisme "try/catch" de capture d'exceptions. J'ai ajouté un compteur pour connaître le nombre de pixels qui ont subi un overflow et un autre, en vert, pour ceux pour lesquels le nombre max d'itérations est atteint. Enfin en magenta une variable sur pour gérer plus facilement le sur-échantillonnage.
from __future__ import division import png from array import array import math import cmath center = 5 + 0j xw = 7.0 sur = 1 width = sur*600 height = sur*400 max_iter = 50 thick = 1.0 tip=2.35767667 seuil_bassin=1. seuil=500. tau=2*math.pi pix_size = xw/width NONE=0 ; MAX=1 ; BASIN=2 ; SEUIL=3 ; OVERFLOW=4 ; TIP=5 def N2(z) : return z.real**2 + z.imag**2 def compute(j,i) : z = center + (j-width/2+(height/2-i)*1.0j)*pix_size k = 0 reason = NONE r = thick*pix_size r_sq = r*r global nb_overflow,nb_max while True: if k>=max_iter : reason = MAX; break z = z - tau*1j*math.floor(0.5+z.imag/tau) d_sq = N2(z-tip) if d_sq<r_sq : reason = TIP; break if z.real < seuil_bassin : reason = BASIN; break if z.real > seuil : reason = SEUIL; break; try : z = cmath.exp(z-1.5) r_sq = r_sq * N2(z) except OverflowError: reason=OVERFLOW; break k += 1 if reason==BASIN : return [255,255,255] if reason==SEUIL : return [255,127,255] if reason==TIP : return [0,0,255] if reason==OVERFLOW : nb_overflow += 1 ; return [0,255,0] nb_max +=1 ; return [255,0,0] pixels=[] nb_overflow = 0 nb_max = 0 for i in xrange(height) : row = [] for j in xrange(width) : row.extend(compute(j,i)) pixels.append(row) print 'nb_overflow',nb_overflow print 'nb_max',nb_max image = png.from_array(pixels,'RGB') image.save('essai-3.png')Comme on peut le voir j'ai décidé de travailler avec le carré du rayon plutôt que le rayon. Quand j'ai commencé la programmation il y a 30 ans, la fonction racine carrée était coûteuse en temps de calcul, j'ai donc pris le réflexe de l'éviter. J'ignore si c'est encore d'actualité. Notez aussi que j'ai dû abaisser le seuil. Dans ces conditions j'obtiens nb_overflow=0 et nb_max=0 sur tous les exemples.
Voici quelques exemples d'images produites
Dans les images suivantes, j'ai mis le mauve en bleu.
1 éch/pix
4 éch/pix
On voit une transition. Cela s'explique ainsi : nous représentons un épaississement de J. Pas exactement l'ensemble Vε des points situés à epsilon de J, mais un ensemble qui contient un Vε et est contenu dans un Vε' avec ε et ε' pas trop loins. Quand les doigts d'une génération donnée sont suffisamment serrés, leurs ε-voisinnages respectifs se touchent et forment une surface en continuité. A partir de ce moment il n'y a plus de différence visible entre les différent niveaux de resserrement.
La transition n'est pas aussi nette que celle qui était due au seuil dans nos expériences précédentes, ce qui est une bonne chose à mon sens. Mais elle reste rapide. La couleur moyenne dévie du bleu moyen quand l'espacement inter digital devient comparable à ε. Sur la 2e génération de doigts, cet espacement = constante/exp(Re(z)). Il varie d'un facteur 10 pour un déplacement horizontal de ≈ 2.3, à comparer à la hauteur du doigt d'ordre 1, qui vaut ≈ 3.14. La variation s'accélère quand on va vers la droite et cela participe peut-être à la brusquerie. Ci-dessous une simulation de la couleur basée sur la formule ci-dessus, sur une bande de hauteur π :
x = 255*(1-exp(x))/2
x remis à 0 si négatif
C'est en effet un peu brusque, peut-être légèrement moins marqué que sur nos images de Julia.
16 éch/pix
Dans la dernière image on voit apparaître l'effet du seuil. Dans le programme ci-dessous, on traite le problème, voir les lignes en rouge.
[...] NONE=0 ; MAX=1 ; BASIN=2 ; SEUIL=3 ; OVERFLOW=4 ; TIP=5 ; BANDS=6 [...] while True: if k>=max_iter : reason = MAX; break z = z - tau*1j*math.floor(0.5+z.imag/tau) if z.real>tip and math.log(r_sq)/2 > math.log(tau)+1.5-z.real : d = z.imag / tau d = d-math.floor(d) d = math.fabs(d-0.5) d = max(0.0,0.25-d) d = d * tau d_sq = d*d if d_sq<r_sq : reason = BANDS; break else : reason = BASIN; break d_sq = N2(z-tip) if d_sq<r_sq : reason = TIP; break [...] if reason==BASIN : return [255,255,255] if reason==SEUIL : return [255,0,0] if reason==TIP : return [0,0,255] if reason==BANDS : return [0,0,255] if reason==OVERFLOW : nb_overflow += 1 ; return [0,255,0] nb_max +=1 ; return [255,0,0] [...]Pour ce test, on idéalise le Julia comme une alternance de bandes bleues et blanches de hauteur π. On regarde le disque B(zn,dn×r) et on teste s'il rencontre une bande bleue. Si oui, pixel←bleu, sinon pixel←blanc. Mais on ne fait le test que si la taille du disque est suffisamment grande pour que l'idéalisation soit raisonnable : ici j'ai pris que dn×r doit être plus grand que la distance approximative entre les doigts de la génération 2. Résultat :
16 éch/pix
C'est intéressant : la transition se fait plus progressive qu'avec la détection des pré-images de la pointe. Par contre on récupère plus de moirés. Ci-dessous je les cache avec un "dirty trick".
canaux mis à la puissance 3