Genetische algoritmen zijn leuk, want ze vragen een andere manier van denken. Hier neem ik je mee door mijn proces om een algoritme te bouwen dat 150 willekeurige driehoeken laat evolueren tot een beroemd kunstwerk. Het werk dat ik koos om na te tekenen: Van Goghs De sterrennacht. Het origineel en de versie die het algoritme na 5000 generaties maakte, zie je hieronder.
De code voor deze post vind je hier op GitHub.
Update 25/03/2021: In versie 0.5.2 wijzigde de Evol-API: de functie apply() werd
hernoemd naar callback(). De code hier en in de repository werd bijgewerkt.
Genetische algoritmen
Voor we beginnen, eerst een korte uitleg over genetische algoritmen. Op WikiPedia staat een vrij uitvoerige beschrijving, maar hier houden we het kort en eenvoudig. Deze algoritmen zijn sterk gebaseerd op biologische principes zoals overerving, voortplanting en evolutie. Je hebt een aantal oplossingen, die samen de populatie vormen. Elke individuele oplossing heeft een lijst met onderdelen, waarden, … die het chromosoom wordt genoemd. Een fitnessfunctie beoordeelt hoe goed een bepaalde oplossing voor ons probleem is. Binnen deze populatie kunnen individuen worden verwijderd (die met de slechtste fitheid), zich voortplanten (hun chromosomen combineren tot een nieuw individu) en muteren (willekeurige wijzigingen in het chromosoom aanbrengen).
In deze context:
- de populatie is een verzameling van 200 schilderijen
- elk individu is één schilderij
- het chromosoom is een lijst van 150 driehoeken met een bepaalde vorm, positie en kleur (rood, groen, blauw en ondoorzichtigheid)
- de fitness is de afstand tot het doelschilderij op basis van pixels (we moeten deze score minimaliseren)
- individuen kunnen zich voortplanten en een nieuw schilderij maken met de helft van de driehoeken van de ene ouder en de helft van de andere
- individuen kunnen muteren: driehoeken kunnen bewegen, van vorm of kleur veranderen, van positie in het chromosoom wisselen of opnieuw worden ingesteld
- de populatie kan door een flessenhals gaan, waarbij een groot deel van de individuen (met de slechtste fitness) wordt weggegooid en de overlevenden vervolgens worden gebruikt om de populatie opnieuw tot de gewenste grootte te laten groeien
Het algoritme implementeren in Python
Zelf alle stappen programmeren zou niet zo moeilijk zijn, maar Evol biedt een geweldige API die alle scoring, voortplanting, … afhandelt en ingebouwde multiprocessing heeft om de fitness van elk individu in een populatie te berekenen.
De klasse Triangle
Een chromosoom is hier een lijst van driehoeken. Eén driehoek heeft drie punten met x- en y-coördinaten en een kleur met
ondoorzichtigheid. Tijdens een mutatie kunnen verschillende wijzigingen optreden: de driehoek kan bewegen (alle punten verschuiven), van vorm veranderen (punten
afzonderlijk verplaatsen) of van kleur veranderen. Er is ook een ingrijpendere gebeurtenis die de huidige driehoek vernietigt en
vervangt door een volledig willekeurige. De functie .mutate regelt dit allemaal. Met de waarde sigma kun je de
sterkte van een mutatie opgeven (hoe groot de verandering is), terwijl de gewichten bepalen welke soorten
mutaties vaker optreden dan andere.
import random
class Triangle:
def __init__(self, img_width, img_height):
x = random.randint(0, int(img_width))
y = random.randint(0, int(img_height))
self.points = [
(x + random.randint(-50, 50), y + random.randint(-50, 50)),
(x + random.randint(-50, 50), y + random.randint(-50, 50)),
(x + random.randint(-50, 50), y + random.randint(-50, 50))]
self.color = (
random.randint(0, 256),
random.randint(0, 256),
random.randint(0, 256),
random.randint(0, 256)
)
self._img_width = img_width
self._img_height = img_height
def __repr__(self):
return "Trangle: %s in color %s" % (','.join([str(p) for p in self.points]), str(self.color))
def mutate(self, sigma=1.0):
mutations = ['shift', 'point', 'color', 'reset']
weights = [30, 35, 30, 5]
mutation_type = random.choices(mutations, weights=weights, k=1)[0]
if mutation_type == 'shift':
x_shift = int(random.randint(-50, 50)*sigma)
y_shift = int(random.randint(-50, 50)*sigma)
self.points = [(x + x_shift, y + y_shift) for x, y in self.points]
elif mutation_type == 'point':
index = random.choice(list(range(len(self.points))))
self.points[index] = (self.points[index][0] + int(random.randint(-50, 50)*sigma),
self.points[index][1] + int(random.randint(-50, 50)*sigma),)
elif mutation_type == 'color':
self.color = tuple(
c + int(random.randint(-50, 50)*sigma) for c in self.color
)
# Ensure color is within correct range
self.color = tuple(
min(max(c, 0), 255) for c in self.color
)
else:
new_triangle = Triangle(self._img_width, self._img_height)
self.points = new_triangle.points
self.color = new_triangle.color
De klasse Painting
Het schilderij bevat een lijst met Triangles, samen met de nodige functies om de driehoeken te tekenen (met de Pillow-bibliotheek) en de afbeelding met het doel te vergelijken (met de Imagecompare-bibliotheek). Verder is er een functie om twee schilderijen te kruisen. Die is geïnspireerd op crossing-over in de biologie, waarbij twee chromosomen stukken uitwisselen om twee nieuwe chromosomen te maken.
from triangle import Triangle
from random import shuffle, randint
from PIL import Image, ImageDraw
from imgcompare import image_diff
import random
class Painting:
def __init__(self, num_triangles, target_image, background_color=(0, 0, 0)):
self._img_width, self._img_height = target_image.size
self.triangles = [Triangle(self._img_width, self._img_height) for _ in range(num_triangles)]
self._background_color = (*background_color, 255)
self.target_image = target_image
@property
def get_background_color(self):
return self._background_color[:3]
@property
def get_img_width(self):
return self._img_width
@property
def get_img_height(self):
return self._img_height
@property
def num_triangles(self):
return len(self.triangles)
def __repr__(self):
return "Painting with %d triangles" % self.num_triangles
def mutate_triangles(self, rate=0.04, swap=0.5, sigma=1.0):
total_mutations = int(rate*self.num_triangles)
random_indices = list(range(self.num_triangles))
shuffle(random_indices)
# mutate random triangles
for i in range(total_mutations):
index = random_indices[i]
self.triangles[index].mutate(sigma=sigma)
# Swap two triangles randomly
if random.random() < swap:
shuffle(random_indices)
self.triangles[random_indices[0]], self.triangles[random_indices[1]] = self.triangles[random_indices[1]], self.triangles[random_indices[0]]
def draw(self, scale=1) -> Image:
image = Image.new("RGBA", (self._img_width*scale, self._img_height*scale))
draw = ImageDraw.Draw(image)
if not hasattr(self, '_background_color'):
self._background_color = (0, 0, 0, 255)
draw.polygon([(0, 0), (0, self._img_height*scale), (self._img_width*scale, self._img_height*scale), (self._img_width*scale, 0)],
fill=self._background_color)
for t in self.triangles:
new_triangle = Image.new("RGBA", (self._img_width*scale, self._img_height*scale))
tdraw = ImageDraw.Draw(new_triangle)
tdraw.polygon([(x*scale, y*scale) for x, y in t.points], fill=t.color)
image = Image.alpha_composite(image, new_triangle)
return image
@staticmethod
def _mate_possible(a, b) -> bool:
return all([a.num_triangles == b.num_triangles,
a.get_img_width == b.get_img_width,
a.get_img_height == b.get_img_height])
@staticmethod
def mate(a, b):
if not Painting._mate_possible(a, b):
raise Exception("Cannot mate images with different dimensions or number of triangles")
ab = a.get_background_color
bb = b.get_background_color
new_background = (int((ab[i] + bb[i])/2) for i in range(3))
child_a = Painting(0, a.target_image, background_color=new_background)
child_b = Painting(0, a.target_image, background_color=new_background)
for at, bt in zip(a.triangles, b.triangles):
if randint(0, 1) == 0:
child_a.triangles.append(at)
child_b.triangles.append(bt)
else:
child_a.triangles.append(bt)
child_b.triangles.append(at)
return child_a, child_b
def image_diff(self, target: Image) -> float:
source = self.draw()
return image_diff(source, target)
Het algoritme samenstellen
Het pakket Evol neemt het meeste werk voor zijn rekening, maar we moeten wel enkele functies definiëren om te beginnen. We hebben een scorefunctie nodig die de afstand tot de doelafbeelding controleert, een functie die bepaalt hoe individuen een partner vinden om mee te kruisen en een definitie van hoe we de populatie willen laten evolueren. We voegen ook een functie toe die de fitnessscores weergeeft (zodat we zien of er nog vooruitgang is), de afbeelding van het beste individu opslaat en de populatie om de vijftig generaties bewaart (zodat we niet opnieuw hoeven te beginnen als er iets misgaat).
from PIL import Image
from evol import Evolution, Population
import random
import os
from copy import deepcopy
from painting import Painting
def score(x: Painting) -> float:
"""
Calculate the distance to the target image
:param x: a Painting object to calculate the distance for
:return: distance based on pixel differences
"""
current_score = x.image_diff(x.target_image)
print(".", end='', flush=True)
return current_score
def pick_best_and_random(pop, maximize=False):
"""
Here we select the best individual from a population and pair it with a random individual from a population
:param pop: input population
:param maximize: when true a higher fitness score is better, otherwise a lower score is considered better
:return: a tuple with the best and a random individual
"""
evaluated_individuals = tuple(filter(lambda x: x.fitness is not None, pop))
if len(evaluated_individuals) > 0:
mom = max(evaluated_individuals, key=lambda x: x.fitness if maximize else -x.fitness)
else:
mom = random.choice(pop)
dad = random.choice(pop)
return mom, dad
def mutate_painting(x: Painting, rate=0.04, swap=0.5, sigma=1) -> Painting:
"""
This will mutate a painting by randomly applying changes to the triangles.
:param x: Painting to mutate
:param rate: the chance a triangle will be mutated
:param swap: the chance a pair of traingles will be swapped
:param sigma: the strenght of the mutation (how much a triangle can be changed)
:return: New painting object with mutations
"""
x.mutate_triangles(rate=rate, swap=swap, sigma=sigma)
return deepcopy(x)
def mate(mom: Painting, dad: Painting):
"""
Takes two paintings, the mom and dad, to create a new painting object made up with triangles from both parents
:param mom: One parent painting
:param dad: Other parent painting
:return: new Painting with features from both parents
"""
child_a, child_b = Painting.mate(mom, dad)
return deepcopy(child_a)
def print_summary(pop, img_template="output%d.png", checkpoint_path="output") -> Population:
"""
This will print a summary of the population fitness and store an image of the best individual of the current
generation. Every fifty generations the entire population is stored.
:param pop: Population
:param img_template: a template for the name of the output images, should contain %d as the number of the generation is included
:param checkpoint_path: directory to write output.
:return: The input population
"""
avg_fitness = sum([i.fitness for i in pop.individuals])/len(pop.individuals)
print("\nCurrent generation %d, best score %f, pop. avg. %f " % (pop.generation,
pop.current_best.fitness,
avg_fitness))
img = pop.current_best.chromosome.draw()
img.save(img_template % pop.generation, 'PNG')
if pop.generation % 50 == 0:
pop.checkpoint(target=checkpoint_path, method='pickle')
return pop
if __name__ == "__main__":
target_image_path = "./img/starry_night_half.jpg"
checkpoint_path = "./starry_night/"
image_template = os.path.join(checkpoint_path, "drawing_%05d.png")
target_image = Image.open(target_image_path).convert('RGBA')
num_triangles = 150
population_size = 200
pop = Population(chromosomes=[Painting(num_triangles, target_image, background_color=(255, 255, 255)) for _ in range(population_size)],
eval_function=score, maximize=False, concurrent_workers=6)
evolution = (Evolution()
.survive(fraction=0.05)
.breed(parent_picker=pick_best_and_random, combiner=mate, population_size=population_size)
.mutate(mutate_function=mutate_painting, rate=0.05, swap=0.25)
.evaluate(lazy=False)
.callback(print_summary,
img_template=image_template,
checkpoint_path=checkpoint_path))
pop = pop.evolve(evolution, n=5000)
De uitvoer
Hier zie je het beste individu uit generatie 1, 250, 500, 750, 1000, 1500, 2500, 3500 en 4500 (van links naar rechts, van boven naar beneden).
Verschillende uitvoeringen, verschillende resultaten
Omdat zowel het begin van de evolutie als alle mutaties en voortplantingsgebeurtenissen willekeurig zijn, kan de uitvoer tussen uitvoeringen verschillen. Hieronder zie je het resultaat van twee onafhankelijke uitvoeringen van elk 5000 generaties. Hoewel deze afbeeldingen een vergelijkbare afstand tot de doelafbeelding hebben, verschillen ze sterk van elkaar.
Conclusie en vooruitblik
Deze resultaten zijn behoorlijk gaaf. Omdat er heel weinig verschil is tussen generatie 4500 en 5000, is dit waarschijnlijk ongeveer het beste wat met slechts 150 driehoeken haalbaar is. Toch zijn er enkele mogelijke verbeteringen. Het algoritme probeert zoveel mogelijk pixels correct te benaderen. Correcte pixels in grote egale vlakken leveren dus een veel betere score op dan correcte details. Je zou een masker kunnen maken dat gedetailleerde gebieden markeert en correcte pixels in die gebieden extra beloont.
De chromosomen zijn in dit geval erg statisch: ze hebben altijd dezelfde lengte (150 onderdelen) en afgezien van het occasioneel verwisselen van onderdelen gebeurt er niets. In de natuur evolueert de grootte van chromosomen voortdurend en is duplicatie van genen vaak een bron van innovatie. Je zou de mutatiefunctie kunnen aanpassen om af en toe een extra driehoek toe te voegen, of complexere manieren kunnen bedenken om schilderijen te kruisen zodat lijsten met verschillende aantallen onderdelen samen kunnen worden gebruikt. Extra driehoeken moeten echter een kost hebben die in de fitness wordt meegerekend. In de natuur betekent een groter genoom dat bij elke celdeling meer DNA moet worden gekopieerd, waardoor genomen niet onbeperkt groot kunnen worden. Ook hier is een vergelijkbare straf in de fitness nodig. Deze extra parameter afstellen kan moeilijk zijn.
Tot slot kunnen andere stijlen worden gebruikt. Ik koos hier voor een beperkt aantal driehoeken omdat dit het eerste idee was dat bij de start van het project in me opkwam. Tijdens het werk ontstonden echter nog enkele andere (en mogelijk betere) ideeën. Die werk ik uit en behandel ik in een latere post!
Vond je dit artikel interessant? Trakteer me op een koffie