PixelArt

Le pixel art en GD

GD

En trainant sur Pinterest, j’ai vu un super beau visuel et je me suis demandé s’il était possible de le réaliser en code. Et je me suis dit pourquoi pas faire un peu de GD aujourd’hui car ça fait super longtemps. Et par la même occasion, faire un peu de POO, comme c’est toujours fort demandé chez ceux qui apprennent.

Class

Je crée un fichier que je nomme « class.pixelArt.php » et j’affiche un message dans le constructeur, histoire de voir que ça tourne bien.

class pixelArt{
	
	public function __construct(){
		echo "Ca marche !";
	}
	
}

$imageRendering = new pixelArt();

Image

Je crée un tableau de paramètres, et dans le constructeur de la classe, je charge l’image dans une variable. Le var dump m’affiche quelque chose comme « Ressource(2, gd) », ce qui signifie que mon image a bien été créée.

<?php


	class pixelArt{
		
		private $pathImage = "image/";
		
		public function __construct( $params = array() ){
			if( isset($params["fileName"]) && $params["fileName"] != null){
				//get image
				$this->url = $this->pathImage . $params["fileName"];
			  	$image = imagecreatefromjpeg( $this->url );

				var_dump($image);
			}
		}
		
	}
	
	//params for class
	$params = array(
		"fileName" => "pikachu.jpg"
	);
	
	//launch object	
	$imageRendering = new pixelArt($params);

Au niveau du constructeur, je récupère également les propriétés de l’image comme la largeur et la hauteur. J’en profite également pour sauvegarder tous les paramètres car je vais devoir en faire passer quelques uns de plus.

public function __construct( $params = array() ){

	//save params
	$this->params = $params;

	if( isset($params["fileName"]) && $params["fileName"] != null){
		//get image
		$this->url 				= $this->pathImage . $params["fileName"];
		$this->imageRessource 	= imagecreatefromjpeg( $this->url );
		
		//save height and width of image
		if( $this->imageRessource ){
	    	$this->imageWidth 	= imagesx( $this->imageRessource );
	    	$this->imageHeight 	= imagesy( $this->imageRessource );
	    	
	    	pre($this);
		}else{
			echo "Image not loading";
		}
	}
}

Pixels

Je crée une méthode qui va collecter des pixels dans l’image de manière aléatoire, et je les enregistre dans un tableau.

public function collectPixel(){
	
	//check if sufficient point
	if( !isset($this->params["nbrPoint"]) || $this->params["nbrPoint"] < 1 ){
		die("Not sufficient point");
	}
	
	$currentPoint = 0;
	$this->listOfPoint = array();
	while( $currentPoint < $this->params["nbrPoint"] ){
		$currentPoint++;
		
		$tmpX = rand(0,$this->imageWidth-1);
		$tmpY = rand(0,$this->imageHeight-1);
		
		$tmpPixel = $this->getPixelColor($tmpX, $tmpY);
		$this->listOfPoint[] = array($tmpX, $tmpY, $tmpPixel);
	}
	
}

Et pour la récupération du code couleur du pixel, j’utilise deux fonctions de GD. Une qui permet de récupérer toutes les données du pixel. Et une autre qui permet de rendre lisibles ces données en me retournant un tableau avec les valeurs rouge/vert/bleu ainsi que la couche alpha du pixel.

public function getPixelColor($x, $y){
	$rgb = imagecolorat($this->imageRessource, $x, $y);
	return imagecolorsforindex($this->imageRessource, $rgb);
}

Forme

Je vais ajouter deux paramètres de plus. Le type de forme et un paramètre pour générer des tailles de formes différente suivant un minima et un maxima. Je fais appel à la méthode makingShape qui va me permettre de lancer le processus de création de forme avec les pixel collectés.

//params for class
$params = array(
	"fileName"            => "pikachu.jpg",
	"nbrPoint"            => 5,
	"shape"               => "rect",
	"rangeSizeShape"	 => array(10,30)
);

//launch object	
$imageRendering = new pixelArt($params);
$imageRendering->collectPixel();
$imageRendering->makingShape();

La fonction makingShape va appeler la méthode de la valeur de shape, c’est-à-dire que si la valeur de shape vaut rect alors c’est cette méthode qui va être executée. C’est une technique pour faire appel à des méthodes de manière dynamique.

public function makingShape(){
	
	//check if sufficient point
	if( !isset($this->params["shape"]) ){
		die("Not shape defined");
	}
	
	$this->{$this->params["shape"]}();
}

Rectangle

L’objectif maintenant, ça va être de réaliser une fonction qui prend chaque pixel collecté et les repositionne sur une image vierge. La taille de chaque pixel sera générée de manière semi-aléatoire en fonction de la valeur de « rangeSizeShape ». Le positionnement devra également prendre en compte ce changement de taille.

La méthode generateFusionImage va me permettre de créer une nouvelle image de la même dimension que l’image d’origine (bah oui sinon c’est con).

Ensuite je fais un foreach pour boucler sur tous les pixels qui ont été collectés.

Je génère une taille aléatoire et je la stocke dans la variable sizeShape. Juste en dessous, je calcule la moitié de cette taille, histoire de générer les bonnes positions de ma nouvelle forme.

Au niveau de la couleur, je reprends celle du pixel de référence.

Une fois que tous les carrés ont été dessinés, j’enregistre l’image et je l’affiche.

public function generateFusionImage(){
	$tmpImage  	= imagecreatetruecolor($this->imageWidth, $this->imageHeight);
	$whiteBg 	= imagecolorallocate($tmpImage, 255, 255, 255);
	imagefill($tmpImage, 0, 0, $whiteBg);
	
	return $tmpImage;
}

public function rect(){
	
	//check if point exist
	if( empty($this->listOfPoint) ){
		die("Empty listOfPoint");
	}
	
	//check if range for size of shape
	if( !isset($this->params["rangeSizeShape"][0]) || !isset($this->params["rangeSizeShape"][1]) ){
		die("Missig range size shape");	
	}
	
	//generate blank image 
	$tmpImage = $this->generateFusionImage();
	
	//making shape for each point
	foreach($this->listOfPoint as $point){

		//generate random size and get the middle of shape
		$sizeShape 		= rand($this->params["rangeSizeShape"][0], $this->params["rangeSizeShape"][1]);
		$middleOfShape 	= round($sizeShape/2);
		
		//calculate the position of begin shape
		$shapeX = $point["x"] - $middleOfShape;
		$shapeY = $point["y"] - $middleOfShape;
		
		//calculate end position of shape
		$shapeXEnd = $shapeX + $sizeShape;
		$shapeYEnd = $shapeY + $sizeShape;

		//prepare color
		$tmpColor = imagecolorallocatealpha($tmpImage, $point["color"]["red"], $point["color"]["green"], $point["color"]["blue"], $point["color"]["alpha"]);
		//construct shape on tmpImage
		imagefilledrectangle($tmpImage, $shapeX, $shapeY, $shapeXEnd, $shapeYEnd, $tmpColor);
			
	}
	
	imagepng($tmpImage, "image_created/rect.png");
	imagedestroy($tmpImage);
	
	?><img src="image_created/rect.png?<?php uniqid(); ?>" /><?php
}

Premier résultat

Voici le résultat avec 5 points et puis avec 1000 points !

Opacité

Il y a certains carrés qui passent au-dessus des autres, ça veut dire qu’il sont générés inutilement. Je vais donc leur appliquer une opacité aléatoire.

Je fais en sorte que l’utilisateur puisse choisir une opacité minimum (0 pour totalement caché et 100 pour totalement visible) via les paramètres. Malheureusement la fonction GD imagecolorallocatealpha renvoi un alpha compris entre 0 (totalement visible) et 127 (totalement masqué)… C’est le bordel quoi, mais rien de grave, je fais juste un p’tit calcul au niveau du constructeur qui permet de faire la conversion et d’obtenir un minOpacity correcte.

//convert params % to correct value
if( isset( $this->params["minOpacity"] ) ){
	$this->minOpacity = 127 - ($this->params["minOpacity"] * 127/100);
}

Au sein de la méthode rect, j’appelle une nouvelle méthode qui me génère mon opacity juste avant de lancer la construction de la forme.

//generate random opacity
$opacity = $this->getOpacity($point["color"]["alpha"], $sizeShape);
public function getOpacity( $opacity, $size ){
	
	//check if min opacity exist
	if( isset( $this->params["minOpacity"] ) ){
		return rand(0, $this->minOpacity);
	}else{
		return $opacity;
	}

}

Big size

Le traitement sur l’opacité marche, mais si j’augmente la taille des rectangles, le rendu est horrible.

Pour palier à ce problème, j’ai eu une idée. Appliquer une formule qui permet de faire plus de petites formes que de grosses. Comment ? Et bien via un paramètre lowerizationLvl. Je fais une fonction récursive qui, chaque tour, va faire un random entre la limite basse et la limite haute, elle-même générée via un random afin d’avoir un comportement plus aléatoire. Donc à chaque passage de boucle, la limite haute va décroitre.

//code dans la méthode rect
$sizeShape = $this->generateSizeShape($this->params["rangeSizeShape"][0], $this->params["rangeSizeShape"][1], $this->params["lowerizationLvl"]);
public function generateSizeShape($a, $b, $lvl){
	if( $lvl == 1 ){
		return rand($a, $b);
	}else{
		$lvl = $lvl-1;
		return $this->generateSizeShape($a, rand($a, $b), $lvl);
	}
}

Défini à 2000 points avec un lowerizationLvl de 5, le rendu est grandement amélioré. Il n’y a que quelques gros carrés visibles.

Opacity Size

En faisant quelques rendus, je me rends compte que l’opacité qui est générée de manière aléatoire n’est pas optimale, car il est possible d’avoir un gros carré avec une opacité élevée qui masque plein d’autres petits carrés.

Du coup, la solution que j’ai mise en place est d’appliquer une opacité en fonction de la taille, c’est-à-dire que plus la taille de la shape est grosse et moins elle est visible.

La première étape va être de calculer une sorte de ratio entre le range d’opacité et la taille de la forme, comment ? Via la formule ci-dessous dont je stocke le résultat dans la propriété stepOpacityPercent.

//au niveau du constructeur
$this->stepOpacityPercent = ( 100 - $this->params["minOpacity"] ) / ( $this->params["rangeSizeShape"][1] - $this->params["rangeSizeShape"][0] );

// Ex:
// 100 - 10 / 100 - 10 = 1 
// cad 90 steps de 1% d'opacité à partir de 10%

Je crée une nouvelle méthode pour convertir plus facilement des pourcentages d’opacité en opacité exploitable par GD.

public function convertOpacityPercentToRealOpacityValue( $opacityPercent ){
	return 127 - $opacityPercent * $this->ratioOpacity;
}

Deuxième étape ! Générer l’opacité en fonction de la taille et du ratio calculé précédemment. Je te laisse lire les commentaires dans le code, ce sera beaucoup plus simple pour comprendre.

public function getOpacity( $opacity, $size ){
	// Grosse forme : 100 - 1 * (90 - 10) => Opacité de 0 (moins visible)
	// Petite forme : 100 - 1 * (10 - 10) => Opacité de 100 (fort visible)
	return $this->convertOpacityPercentToRealOpacityValue( 100 - $this->stepOpacityPercent * ( $size - $this->params["rangeSizeShape"][0]) );
}

Le rendu est un peu mieux mais je pense que ce serait intéressant d’ajouter une notion de random pour ne pas avoir toutes les cases d’une taille x à la même opacity.

Finalement j’ai opté pour avoir une opacité allant de 0 jusqu’au maxOpacity. Je fais un random entre la taille multipliée par le ratio (le rapport entre la plus grosse taille de shape et 127 qui est l’alpha maximum) et le maxOpacity du params.

//ratio opacity
if( isset($this->params["rangeSizeShape"][0]) ){
	if( isset($this->params["minOpacity"]) ){
		//max opacity in real alpha
		$this->maxOpacity = 127 - 127/100*$this->params["minOpacity"];
	}
	$this->ratioOpacity = $this->maxOpacity / $this->params["rangeSizeShape"][1];
}
public function getOpacity( $opacity, $size ){
	return rand($size * $this->ratioOpacity, $this->maxOpacity);
}

Voila, c’est exactement le rendu que j’attendais, ou du moins le rendu de l’image de base dont je me suis servi.

//params for class
$params = array(
	"fileName"            => "pikachu.jpg",
	"nbrPoint"            => 3000,
	"shape"               => "rect",
	"rangeSizeShape"	  => array(0,100),
	"minOpacity"		  => 30,	//0 = hide | 100 = visible
	"lowerizationLvl"	  => 3	
);

BorderLess

En générant beaucoup d’images, j’ai pu observer qu’il y avait plein de carrés blancs qui étaient générés sur fond blanc, donc inutiles.

Pour supprimer ce désagrément et rendre le truc un peut plus performant, j’ai créé une nouvelle méthode qui va récupérer les bords de l’image et qui va vérifier si la couleur du bord est présente à disons 90%. Ce qui me permet de déterminer la couleur de fond de l’image (dans le cas de ce genre d’image sur fond blanc ou noir).

public function getBorderColor(){
	$arrayColor = array();
	$nbrPixel = 0;
	//top and bottom
	for($w = 0; $w <= $this->imageWidth; $w++){
		$tmpPixel = $this->getPixelColor($w, 0);
		if( !isset($arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]]) ){
			$arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]] = 1;
		}else{
			$arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]]++;
		}
		$tmpPixel = $this->getPixelColor($w, $this->imageHeight);
		if( !isset($arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]]) ){
			$arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]] = 1;
		}else{
			$arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]]++;
		}
		$nbrPixel+=2;
	}
	
	//left and right
	for($h = 0; $h <= $this->imageHeight; $h++){
		$tmpPixel = $this->getPixelColor(0, $h);
		if( !isset($arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]]) ){
			$arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]] = 1;
		}else{
			$arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]]++;
		}
		$tmpPixel = $this->getPixelColor($this->imageWidth, $h);
		if( !isset($arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]]) ){
			$arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]] = 1;
		}else{
			$arrayColor[$tmpPixel["red"]][$tmpPixel["green"]][$tmpPixel["blue"]]++;
		}
		$nbrPixel+=2;
	}

	//get max
	$maxBorderOccurenceColor = array();
	$tmpMax = 0;
	foreach($arrayColor as $redValue => $redNext){
		foreach($redNext as $greenValue => $greenNext){
			foreach($greenNext as $blueValue => $occurence){
				if( $occurence > $tmpMax ){
					$tmpMax = $occurence;
					$maxBorderOccurenceColor = array($redValue, $greenValue, $blueValue);
				}
			}
		}
	}

	//check percent
	if( ($tmpMax / $nbrPixel * 100) > $this->percentBorderLess ){
		$this->borderLessColor = $maxBorderOccurenceColor;
	}
}

Et j’ajoute ce petit bout de code juste après l’appel de la méthode getPixelColor au niveau de la récupération des pixels. Si borderLessColor est définie, alors chaque pixel collecté qui correspond à la couleur du bord ne sera pas pris en compte.

if( $this->borderLessColor ){
	if( $tmpPixel["red"] == $this->borderLessColor["red"] && $tmpPixel["green"] == $this->borderLessColor["green"] && $tmpPixel["blue"] == $this->borderLessColor["blue"] ){
		continue;
	}
}

Plus de points

En montant le nombre de points, l’image est vraiment sexy. A titre d’information, le rendu est quasiment instantané. L’image d’origine est un JPG de 25Ko et l’image ci-dessous a un poids de 5Ko en PNG. Et il y a moyen de faire des trucs vraiment sympas je pense.

//params for class
$params = array(
	"fileName"            => "pikachu.jpg",
	"nbrPoint"            => 10000,
	"shape"               => "rect",
	"rangeSizeShape"	  => array(0,50),
	"minOpacity"		  => 30,	//0 = hide | 100 = visible
	"lowerizationLvl"	  => 3,
	"borderLess"		  => true	
);

21/10/2017

Yann Vangampelaere - nouslesdevs -

Sinon jete un coup d’oeil aux autres catégories

Ma boîte aux lettres

Tu veux me demander quelque chose ?