Forums Développement Multimédia

Les formations Mediabox
Les formations Mediabox

Grignoter une image par le script

Compatible ActionScript 3. Cliquer pour en savoir plus sur les compatibilités.Par Billyben, le 05 juillet 2010

Bonjour !
Aujourd'hui nous allons voir comment grignoter une image petit à petit à partir d'un ou de plusieurs point(s) de départ, qui vont s'étendre petit à petit, le tout étant contrôlé par un Timer, et autres petites filouteries….

Prérequis :


Si vous voulez utiliser ces scripts avec des versions antérieures du Flash Player (avant la 10), qui ne connaissent pas Vector, vous pouvez remplacer tous les Vector.<machin> par Array.


Voilà ce que nous allons faire :

L"extension Adobe Flash Plugin est nécessaire pour afficher ce contenu.

(cliquez en plusieurs endroits sur l'image, et vous pouvez vous amuser avec les sliders)

Je vous conseille d'aller voir l'excellentissime travail de tlecoz sur son ProgressiveFill (et pour le plaisir des yeux, la partie II, époustouflante) à qui j'ai piqué quelques idées (vous aurez même accès à ses sources, que demande le peuple!). Vous aurez peut-être l'impression qu'il s'agit de quelque chose de différent (hormis le fait qu'ici c'est ach'ment moins powerfull), mais ce que nous allons voir par la suite n'est autre qu'un remplissage avec du vide!



Le Principe

Nous allons créer une classe qui étend la classe Bitmap, afin d'obtenir un objet d'affichage, à laquelle nous allons fournir :

  • une image (la bonne blague), qui sera un objet BitmapData
  • un point de départ, dans cette image

A partir de ce point de départ, nous allons tester les pixels immédiatement voisins de celui-ci. S'ils sont “colorés”, nous allons les invisibiliser, puis faire en sorte qu'ils deviennent le nouveau front de disparition. Une fois ceci fait, nous allons tester sur chacun de ces pixels du “front” les pixels immédiatement voisins, et les invisibiliser le cas échéant, et en faire le nouveau front d'invisibilisation, et ainsi de suite jusqu'à ce que l'image entière ne contienne plus de couleur !



Le test des pixels

la couleur

Nous pouvons facilement récupérer la couleur d'un pixel grâce aux méthodes de la classe BitmapData. Il nous suffit juste d'avoir les coordonnées du pixel à tester.

var pixelValue:int = monBitmapData.getPixel32(coord_x, coord_y)

Ceci nous permet de récupérer la valeur 32 bit du pixel aux coordonnées (coord_x, coord_y). S'il le pixel est transparent, ceci retournera 0.

Vu que tout ceci tourne autour de coordonnées dans le plan (x,y) de notre image, nous auront intérêt à utiliser la classe Point.
D'aucun vous dirons que ce n'est pas la façon la plus optimisée de procéder, mais elle permet de bien prendre en main la chose !


Les pixels avoisinants

le principe

Comme nous le savons tous, les pixels sont disposés selon une grille uniforme, tout du moins en ce qui nous concerne. Et donc, pour tester les pixels immédiatement voisins d'un pixel donné, nous devons parcourir la grille de pixel 3×3 autour de ce dernier. Cette grille est la suivante :

Grille de Pixels

Le carré rouge représentant le pixel d'intérêt, et sous les chiffres 0,1,…,8 sont figurées les coordonnées relatives de ces pixels par rapport au pixel d'intérêt.
Grâce à cette grille, nous pouvons obtenir facilement les coordonnées de ces pixels voisins :

Donc sous cette grille, nous avons des pixels vide et des pixels plein, il ne reste plus qu'à tester tout ça !
Vous voyez le principe maintenant ? Et bien voyons comment le mettre en œuvre.


la pratique

Le plus simple pour parcourir cette grille, est bien évidemment d'utiliser une boucle. Nous pourrions mettre en œuvre 2 boucles imbriquées, une pour les “x”, une autre pour les “y”, mais ce n'est pas la manière la plus élégante à mon avis.
Souvenez-vous, nous allons utiliser la classe Point. Or nous avons les coordonnées relatives des pixels voisins….. Il nous suffit donc d'avoir un tableau de points avec ces fameuse coordonnées relatives, que nous n'aurons plus qu'à ajouter aux coordonnées de notre point central :

var _scan_points:Vector.<Point> = Vector.<Point>([
	new Point(-1,-1), new Point(0,-1), new Point(1,-1),
	new Point(-1,0), new Point(1,0),
	new Point(-1,1), new Point(0,1), new Point(1,1)
]); 
var pointInteret :Point=new Point(x, y)
var pointScan :Point ;
var pointTest :Point ;
for each(pointScan in  _scan_points){
                pointTest= pointInteret.add(pointScan);
}

Vous voyez l'utilisation de la méthode “add” de la classe Point qui permet d'ajouter les coordonnées de 2 points, et de retourner un nouveau point avec celles-ci.
Ainsi, “pointTest” pointera tour à tour vers les pixels immédiatement voisins de “pointInteret”. Comme vous pouvez le voir, il n'y a pas de Point(0,0) qui ferait pointer le scan sur le pixel en cours, ce qui n'est d'aucun intérêt ici.



La classe

Nous allons donc créer la classe qui va gérer le grignotage de notre image. Alors commençons avec ces informations :

package {
	import flash.display.Bitmap;
	import flash.display.BitmapData;
	import flash.geom.Point;// on va utiliser des points
	import flash.utils.Timer;// et un timer, histoire de faire bouger tout ça
	import flash.events.TimerEvent;// ben faut bien suivre ce que fait le timer…..
        import flash.events.Event; //pour le dispatchEvent
 
 
	public class GrignoteImage extends Bitmap {
		private static var _scan_points:Vector.<Point>=Vector.<Point>([new Point(-1,-1),new Point(0,-1),new Point(1,-1),new Point(-1,0),new Point(1,0),new Point(-1,1),new Point(0,1),new Point(1,1)]);// vous reconnaissez le tableau de point pour parcourir la grille de pixels
 
		private var timer:Timer;// le timer, moteur de notre effet
		private var timerDelay:int=10;// une variable qui va nous permettre de fixer la vitesse du timer, en millisecondes!
 
		private var fronts:Vector.<Point>;// cf ci dessous
 
		public function GrignoteImage(bmd:BitmapData=null) {
			super(bmd);
			timer=new Timer(timerDelay);// on instancie notre Timer, vu que de toute façon on va l'utiliser….
		}
	}
}

Rien de bien méchant ici, on étend Bitmap, on peut fournir au constructeur un bitmapData sur lequel on va travailler. On peut également fournir un BitmapData à notre classe grâce à l'appel de ”.bitmapData”, propre à la classe Bitmap :

var monGrignoteImage=new GrignoteImage();
monGrignoteImage.bitmapData=monBitmapData;

la variable :

private var fronts:Vector.<Point>;

Va nous permettre de stocker les points relatifs aux pixels du front de disparition.



L'animation de base

Nous allons maintenant mettre tout ça en mouvement


la fonction "start"

Cette fonction va permettre de lancer l'animation, le timer, à partir d'un point que l'on va fournir. Avant de faire partir tout ça, nous allons tester si ce point de départ correspond bien à un pixel “plein”.

		public function start(startPoint :Point) {
 
			// on vérifie qu'on bien un pixel non vide sous le startPoint
			var pixelValue:int=this.bitmapData.getPixel32(startPoint.x,startPoint.y);
			if (! pixelValue) {
			// s'il n'y a rien (0), on renvoi une erreur (pour faire bien…)
				throw new Error("pas de pixel coloré sous le point de départ");
				return;
			}
			// on test si le timer est déjà en route, s'il l'est, on arrête tout.
			if (timer.running) {
				return;
			}
			//on initialise le tableau de front de pixels
			fronts=new Vector.<Point>();
			//on y ajoute notre point de départ
			fronts.push(startPoint);
			// et on démarre notre timer, en prenant soin de l'écouter.
			timer.addEventListener(TimerEvent.TIMER, updateBitmapData);
			timer.start();
		}

La fonction “updateBitmapData”, qui est appelée à chaque fois que notre timer avance (TimerEvent.TIMER), est celle qui va traiter le bitmapData, selon le front de pixel, ici initialisé et rempli uniquement de notre point de départ.


la fonction "stop"

Cette fonction va nous permettre d'arrêter l'animation, et est notamment appelée une fois que l'on a fait disparaître tout les pixels.

		public function stop() {
			if (timer.running) {
				timer.stop();
				timer.removeEventListener(TimerEvent.TIMER, updateBitmapData);
			}
			this.dispatchEvent(new Event(Event.COMPLETE));
		}

On a donc arrêté le timer, s'il est en route, et l'on a enlevé l'écouteur sur ce dernier.
J'ai également ajouté :

this.dispatchEvent(new Event(Event.COMPLETE)); 

Qui permet à notre occurrence de GrignoteImage de diffuser l'évènement “Event.COMPLETE” une fois la fonction stop appelé. Ceci permet donc au parent de celle-ci de suivre la fin de l'animation. Ça va nous être utile!


la fonction updateBitmapData de traitement de l'image

Il ne nous reste plus qu'a grignoter notre image. Comme nous allons devoir parcourir notre tableau de pixels de front (fronts), qui peut être très grand, et donc générer une boucle avec de nombreuse itérations, il est préférable de déclarer les objets dont nous allons nous servir hors de cette boucle.

Les objets dont nous allons avoir besoin sont :

  • p:Point : qui va prendre pour valeur les point successifs du front.
  • pScan:Point : les points représentatifs de la grille de scan des pixels voisins.
  • pScanned:Point : les point issus de l'ajout de p à pScan (donc les pixels voisins).
  • long:int=fronts.length : la longueur de notre tableau de front.
  • pixelColor:int : va nous permettre de récupérer la valeur de la couleur du pixel. Elle n'est pas nécessaire, on peut faire plus court, mais c'est pour mettre en relief le procédé.
  • i:int : est l'incrément de notre boucle.
		private function updateBitmapData(ev:TimerEvent) {
			var p:Point;
			var pScan:Point;
			var pScanned:Point;
			var long:int=fronts.length;
			var pixelColor:int;
			var i:int;
 
			this.bitmapData.lock();
 
			for (i=0; i<long; i++) {
				p=fronts.splice(0,1)[0];
				for each (pScan in _scan_points) {
					pScanned=p.add(pScan);
					pixelColor=this.bitmapData.getPixel32(pScanned.x,pScanned.y);
					if (pixelColor) {
						this.bitmapData.setPixel32(pScanned.x, pScanned.y,0);
						fronts.push(pScanned);
					}
				}
 
			}
			if (fronts.length==0) {
				stop();
			}
			this.bitmapData.unlock();
			ev.updateAfterEvent();
		}

Explications : En gros, pour chaque point du front, on l'enlève du tableau de point du front de disparition, et on test les pixel avoisinants à ce dernier. S'il y a de la couleur ('if (pixelColor){') on affecte la valeur 0 à la couleur de ce pixel, et on ajoute ce dernier au tableau de front de disparition.

  • “for (i=0; i<long; i++) {….” Permet de parcourir l'ensemble des points de notre front, référencés dans “fronts”.
  • “p=fronts.splice(0,1)[0];” va récupérer le point du front situé à l'index 0, tout en supprimant celui-ci du tableau.
  • “for each (pScan in _scan_points) {” Comme vu précédemment, nous permet de parcourir la grille de pixel autour de notre pixel.
  • if (fronts.length==0) { Nous permet de détecter la fin de notre animation. Donc si le front est vide, en gros il n'y avait pas de pixel voisins aux pixels du front courant, on appelle “stop()”.
  • this.bitmapData.lock(); et this.bitmapData.unlock(); permet de verrouiller/déverrouiller le bitmap pendant la modification du BitmapData, et évite la mise à jour systématique de celui-ci.
  • ev.updateAfterEvent(); Va permettre de mettre à jour l'affichage après chaque modification de l'image, pour chaque évènement TIMER du Timer.


code pour tester

J'ai importé dans le fichier une image, que j'ai exporté pour AS, sous le nom de classe “Image”. J'ai également placé l'occurrence de GrignoteImage dans un conteneur Sprite, afin de récupérer les évènements souris dessus. Cela aurait pu être évité en étendant directement Sprite, mais bon, c'eût été un poil moins simple, libre à vous de le tester !

var BmD:BitmapData=new Image(300,208);
import GrignoteImage;
var GI=new GrignoteImage();
GI.bitmapData==BmD.clone();// on utilise une copie, histoire de la réutiliser
var conteneur:Sprite=new Sprite();
addChild(conteneur);
conteneur.addChild(GI);
 
conteneur.addEventListener(MouseEvent.CLICK, clicHandler);
function  clicHandler(ev:MouseEvent):void{
	var startPoint:Point=new Point(conteneur.mouseX, conteneur.mouseY);
	GI.start(startPoint);
}

La ligne:
var startPoint:Point=new Point(conteneur.mouseX, conteneur.mouseY);
nous permet de créer un point avec les coordonnées de la souris relatives au conteneur.
Nous pouvons même suivre la fin de l'animation, histoire de renouveler le bitmapData :

GI.addEventListener(Event.COMPLETE, grignotageFini);
function grignotageFini(ev:Event){
	GI.bitmapData=BmD.clone();//on redonne une copie du BitmapData de l'image !
}

Une fois l'animation finie, on va renouveler le BitmapData de notre occurrence, histoire de ré-afficher quelque chose. Ceci est juste là pour le test.


ce que ça donne

Voyons voir ce que ça donne :

L"extension Adobe Flash Plugin est nécessaire pour afficher ce contenu.

Bon, c'est carré, en effet ceci est du à notre grille 3×3 pour tester les pixels voisins, et c'est donc assez moche tel quel, mais c'est le principe de départ.



1ère amélioration : Plusieurs points de départ

Nous allons faire ceci très très simplement, et c'est pourquoi ça ne nécessite pas de nouveau chapitre.
Il suffit d'ajouter une ligne dans la fonction start :

			//……
			if (timer.running) {
				fronts.push(startPoint);//<---------------------ICI
				return;
			}
			//………

Ceci nous permet de rajouter le point dans le front en croissance, ce qui va générer un deuxième front. Pour ce faire, il suffit uniquement d'appeler start(monNouveauPoint). Ce qui est pratique, puisque c'est ce que nous faisons dans l'écouteur du clic de la souris…!!
Le Test :

L"extension Adobe Flash Plugin est nécessaire pour afficher ce contenu.

Vous pouvez désormais cliquer en différents endroits de l'image pour faire apparaître autant d'autres fronts.

(pensez à renommer en GrignoteImage.as pour l'utiliser)



Intégrer un p'tit peu d'aléatoire

Dans ce chapitre, nous allons voir des manières possibles pour ne pas avoir cet aspect “carré”, et générer un contrôle de la vitesse.


Préparation

Pour faire ceci, nous allons jouer de Math.random() qui permet de fournir un nombre aléatoire tel que:
0 < Math.random() <= 1

Or les appels à cette fonction sont assez gourmand en ressources, c'est pourquoi nous allons créer un tableau suffisamment grand contenant des nombres aléatoires. Nous n'aurons plus qu'à aller pécher une valeur dans ce tableau pour simuler cet aléatoire.
Nous allons ajouter 2 objets : le tableau, et un nombre que l'on va incrémenter qui va nous servir à aller chercher les valeurs dans le tableau.

private var random:Vector.<Number>;
private var indexRandom:int=0;

Dans le constructeur, nous allons maintenant remplir ce tableau :

			random=new Vector.<Number>(1000);
			var i:int;
			for (i=0; i<1000; i++) {
				random[i]=Math.random();
			}

Et voila, nous avons notre vrai pseudo aléatoire !


Un front moins linéaire

J'ai trouvé 2 façons quasi-équivalentes de faire ceci :

  1. Ne pas prendre les points dans l'ordre dans le tableau de point du front
  2. Ne pas ajouter à la fin du tableau (avec “push”) les nouveaux points de front calculés

Alors, comment cela se traduit ‘il ? tout se joue dans la fonction updateBitmapData. Voici les deux exemples :

		private function updateBitmapData(ev:TimerEvent) {
			//…..blablalbalbla
 
			// on ajoute un objet qui va contenir la donnée pseudo aléatoire
			var r:Number;//<------------------------------------on ajoute cet objet!
 
			for (i=0; i<long; i++) {
				// on incrémente la variable d'index du random
				indexRandom++
				r=random[indexRandom%999];//<-------- on choppe une valeur dans le tableau
 
				//1 ere solution :
				p=fronts.splice(Math.floor(r*(fronts.length-1)),1)[0];//on prend un point au "hazard" dans le tableau
				//……..
 
					// 2nde  Solution : 
					if (pixelColor) {
						this.bitmapData.setPixel32(pScanned.x, pScanned.y,0);
						fronts.splice(r*(fronts.length-1),0,pScanned);//-----ICI
						// on a introduit au hasard le nouveau point dans le tableau
					}
 
				//…….blablabla

Et voila… ce n'est pas grand chose, et il y a certainement d'autres moyens pour lisser ce front.

Le test : (solution 1)

[inclure swf 3]


Réguler le bruit sur le front

Ceci est joli, enfin…, mais ce n'est pas paramétrable. Nous allons mettre en place un moyen de réguler l'influence du random.
Pour ce faire, nous allons introduire une autre variable, sur laquelle nous allons agir par un setter.

		private var noiseA:Number=1;
 
		public function set frontNoise(value:Number):void {
			value=Math.min(value,1);
			value=Math.max(value,0);
			noiseA=value;
		}

On contraint ici la valeur entre 0 et 1 (avec les min et les max).

Et dans updateBitmapData, pour la selection du pixel du front :

p=fronts.splice(Math.floor(noiseA*r*(fronts.length-1)),1)[0]; 

Tout simple..

Ce que ça donne :

[inclure swf 4]


Réguler la vitesse

2 solutions ici aussi. La première, en modifiant le delay du timer, donc le faire s'exécuter plus vite ou plus lentement, mais on est contraint à son rythme. La seconde, en bidouillant la boucle, je vous en dit pas plus….


par la modification du delait du timer

Tout simple ici aussi, nous allons agir sur les objets que nous avons déjà créés, et ce par l'intermédiaire d'un autre setter :

		public function set delay(value:int):void {
			timerDelay=value;
			timer.delay=value;
		}

Ce que ça donne :

L"extension Adobe Flash Plugin est nécessaire pour afficher ce contenu.

Comme vous pouvez le voir, on peut facilement ralentir le mouvement, même s'il parait très haché du coup, et on ne peut pas obtenir une disparition éclair !
C'est pourquoi nous allons introduire un coefficient….


over-Itération de la boucle

Nous allons utiliser un coefficient de vitesse, enfin, c'est un grand mot, c'est un coefficient qui va permettre plusieurs passage dans la boucle…. Mais ce n'est pas tout, selon un ratio par rapport à la longueur de notre front de pixel…. Mouais, vous aller voir avec le code :

		private var _coeff:Number=1;
		public function set speedCoeff(value:Number):void {
			_coeff=value;
		}

Voila, on a notre coefficient ! Maintenant, dans “updateBitmapData” :

		private function updateBitmapData(ev:TimerEvent) {
			////…………………………… les autres variables
			var currCoeff=_coeff;
			var sousCoeff:Number=1;
			while (currCoeff>0) {
				sousCoeff=Math.min(currCoeff,1);
				long=Math.max(Math.floor(sousCoeff*(fronts.length)),1);
				currCoeff—
                              // et on encadre tout le reste de la boucle
                              //……………….
                              //…..
                              }
                             indexRandom++;
				if (fronts.length==0) {
					stop();
					return;
				}
 
			}
			this.bitmapData.unlock();
			ev.updateAfterEvent();
		}

Voila on effectue la boucle tant que le coefficient est supérieur à 0, une fois < à 1 la totalité du front n'est pas pris en compte, d'où le ralentissement plus joli qu'avec le delay du timer.

Ce que ça donne :

[inclure swf 6]

[Source fla-4] ;


Du bruit dans la vitesse

Si comme moi vous vouliez faire un effet “image qui brule”, il faut introduire une vitesse non linéaire, vu que le feu ne se propage pas tout a fait à la même vitesse sur le papier…. Pas vraiment nécessaire, et y'a certainement mieux pour le faire….
Sur le même modèle que pour le front, nous allons introduire de l'aléatoire, maîtrisé encore une fois…
On ajoute 2 variable et un setter :

		private var noiseB:Number=1;
		private var noiseC:Number=0;
		public function set frontNoise(value:Number):void {
			value=Math.min(value,1);
			value=Math.max(value,0);
			noiseB=value;
			noiseC=1-value;
		}

Puis dans la fonction d'update :

long=Math.max(Math.floor((noiseB*r+noiseC)*sousCoeff*(fronts.length)),1);

Ce que ça donne :

[include swf 6]

Bon, ce n'est pas flagrant, ça se voit plus quand vous augmentez la durée du délai du Timer.



Pour aller plus loin

Voila, à partir d'ici vous pouvez vous amusez à faire varier plein de paramètres, modifier l'ordre de prise en charge des pixels, etc, etc…. Je vous invite tout de même à suivre une suite de ce tuto : Effet de couleurs sur le rognage d'image!!