petri_dish_lab 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,28 @@
1
+ ### Low-Poly Image Reconstruction
2
+
3
+ <p align="center">
4
+ <img src="result.png" style="display: block; margin: 0 auto 10px auto;">
5
+ <img src="montage.png" style="display: block; margin: 0 auto;">
6
+ </p>
7
+
8
+ The `low_poly_image_reconstruction.rb` script demonstrates a use of the Petri Dish library for generating a low-poly representation of an image. It gives us a glimpse of the potential applications of genetic algorithms in creative digital tasks.
9
+
10
+ In this script, each member of the population represents a unique rendering of an image. The genetic material, or "genes", for each member are a series of `Point`-s spread across the image, each with its own `x` and `y` coordinates and `grayscale` value. These `Point`-s serve as vertices for triangles, which are created using the Delaunay triangulation algorithm, a method for efficiently generating triangles from a given set of points.
11
+
12
+ To initialize the image, evenly spaced vertices are selected (though small amount of randomness, or "jitter", is introduced to the point locations to prevent issues with the triangulation algorithm when points align). The `grayscale` value of each vertices is initialized as a random 8-bit number and the final fill value for the triangle is calculated by averaging the grayscale values of its three vertices.
13
+
14
+ The fitness of each member is then calculated by comparing its generated image with the target image. The closer the resemblance, the higher the fitness. Specifically, a fitness function based on the mean error per pixel is used: the lower the mean error (i.e., the closer the member's image to the target), the higher the fitness score.
15
+
16
+ To create a "generation" of the population of images, parents are selected using the roulette wheel method, also known as stochastic acceptance. This method works by calculating a "wheel of fortune" where each member of the population gets a slice proportional to their fitness. Then, a random number is generated to select two members from the wheel. This method provides a balance, giving members with higher fitness a better chance of being selected, while still allowing less fit members a shot, helping to allow diversity in the population.
17
+
18
+ To further maintain diversity in the population, the script uses a high mutation rate of `0.1`. Given the relatively small population size of `50`, and an elitism rate of `0.1` (meaning that 5 highest fitness members are carried over to the next generation unmutated), a high mutation rate helps ensure that there is enough diversity in the gene pool to allow for continual exploration of the search space.
19
+
20
+ The script is designed to run for a fixed number of generations (2500 in this case), and during crossover, if a new high fitness score has been achieved, the corresponding image is saved to an output directory via the `highest_fitness_callback`. This way, we can track the progress of the algorithm and observe how the images evolve over time.
21
+
22
+ To set off on this journey yourself, update the `LOW_POLY_RECONSTRUCTION_PATH` and `INPUT_IMAGE_PATH` to point to the working directory and image you want to reconstruct, respectively. Then, run the following command in your terminal:
23
+
24
+ ```bash
25
+ bundle exec ruby <PATH_TO_SCRIPT>/low_poly_image_reconstruction.rb
26
+ ```
27
+
28
+ Remember, this script requires the RMagick and Delaunator libraries for image processing and triangulation. These will be installed automatically via an inline Gemfile. It reads an input image and saves the progressively evolved images to a specified output directory.
@@ -0,0 +1,176 @@
1
+ require_relative "../../lib/petri_dish"
2
+ require "bundler/inline"
3
+
4
+ $stdout.sync = true
5
+
6
+ gemfile do
7
+ source "https://rubygems.org"
8
+ gem "rmagick", require: "rmagick"
9
+ gem "delaunator", require: true
10
+ end
11
+
12
+ LOW_POLY_RECONSTUCTION_PATH = "examples/low_poly_reconstruction".freeze
13
+ INPUT_IMAGE_PATH = "#{LOW_POLY_RECONSTUCTION_PATH}/ruby.svg".freeze
14
+ CONVERTED_INPUT_IMAGE_PATH = "#{LOW_POLY_RECONSTUCTION_PATH}/input_convert.png".freeze
15
+ OUT_DIR = "#{LOW_POLY_RECONSTUCTION_PATH}/out".freeze
16
+ IMAGE_HEIGHT_PX = 100
17
+ IMAGE_WIDTH_PX = 100
18
+ GREYSCALE_VALUES = (0..255).to_a
19
+
20
+ class LowPolyImageReconstruction
21
+ Point = Struct.new(:x, :y, :grayscale)
22
+
23
+ def initialize
24
+ @current_generation = 0
25
+ end
26
+
27
+ def run
28
+ init_members = Array.new(configuration.population_size) do
29
+ PetriDish::Member.new(
30
+ genes: (0..IMAGE_WIDTH_PX).step(10).map do |x|
31
+ (0..IMAGE_HEIGHT_PX).step(10).map do |y|
32
+ Point.new(x + point_jitter, y + point_jitter, GREYSCALE_VALUES.sample)
33
+ end
34
+ end.flatten,
35
+ fitness_function: calculate_fitness(target_image)
36
+ )
37
+ end
38
+
39
+ PetriDish::World.run(configuration: configuration, members: init_members)
40
+ end
41
+
42
+ def configuration
43
+ PetriDish::Configuration.configure do |config|
44
+ config.population_size = 50
45
+ config.mutation_rate = 0.1
46
+ config.elitism_rate = 0.1
47
+ config.max_generations = 2500
48
+ config.fitness_function = calculate_fitness(target_image)
49
+ config.parents_selection_function = roulette_wheel_parent_selection_function
50
+ config.crossover_function = random_midpoint_crossover_function(config)
51
+ config.mutation_function = nudge_mutation_function(config)
52
+ config.highest_fitness_callback = ->(member) { save_image(member_to_image(member, IMAGE_WIDTH_PX, IMAGE_HEIGHT_PX)) }
53
+ config.generation_start_callback = ->(current_generation) { generation_start_callback(current_generation) }
54
+ config.end_condition_function = ->(_member) { false }
55
+ end
56
+ end
57
+
58
+ # Introduce some randomness to the points due to the implementation of the
59
+ # Delaunay algorithm leading to a divide by zero error when points are collinear
60
+ def point_jitter
61
+ jitter = 0.0001
62
+ rand(-jitter..jitter)
63
+ end
64
+
65
+ def target_image
66
+ @target_image ||= if File.exist?(CONVERTED_INPUT_IMAGE_PATH)
67
+ Magick::Image.read(CONVERTED_INPUT_IMAGE_PATH).first
68
+ else
69
+ import_target_image(INPUT_IMAGE_PATH, CONVERTED_INPUT_IMAGE_PATH)
70
+ end
71
+ end
72
+
73
+ def import_target_image(input_path, output_path)
74
+ image = Magick::Image.read(input_path).first
75
+
76
+ crop_size = [image.columns, image.rows].min
77
+ crop_x = (image.columns - crop_size) / 2
78
+ crop_y = (image.rows - crop_size) / 2
79
+
80
+ image
81
+ .crop(crop_x, crop_y, crop_size, crop_size)
82
+ .resize(IMAGE_HEIGHT_PX, IMAGE_WIDTH_PX)
83
+ .quantize(256, Magick::GRAYColorspace)
84
+ .write(output_path)
85
+
86
+ image
87
+ end
88
+
89
+ # This is a variant of the roulette wheel selection method, sometimes called stochastic acceptance.
90
+ #
91
+ # The method calculates the total fitness of the population and then, for each member,
92
+ # it generates a random number raised to the power of the inverse of the member's fitness divided by the total fitness.
93
+ # This gives a larger result for members with higher fitness.
94
+ # The member with the highest result from this operation is selected.
95
+ #
96
+ # The method thus gives a higher chance of selection to members with higher fitness,
97
+ # but also allows for the possibility of members with lower fitness being selected.
98
+ def roulette_wheel_parent_selection_function
99
+ ->(members) do
100
+ population_fitness = members.sum(&:fitness)
101
+ members.max_by(2) do |member|
102
+ weighted_fitness = member.fitness / population_fitness.to_f
103
+ rand**(1.0 / weighted_fitness)
104
+ end
105
+ end
106
+ end
107
+
108
+ def random_midpoint_crossover_function(configuration)
109
+ ->(parents) do
110
+ midpoint = rand(parents[0].genes.length)
111
+ PetriDish::Member.new(genes: parents[0].genes[0...midpoint] + parents[1].genes[midpoint..], fitness_function: configuration.fitness_function)
112
+ end
113
+ end
114
+
115
+ def nudge_mutation_function(configuration)
116
+ ->(member) do
117
+ mutated_genes = member.genes.dup.map do |gene|
118
+ if rand < configuration.mutation_rate
119
+ Point.new(
120
+ gene.x + rand(-5..5) + point_jitter,
121
+ gene.y + rand(-5..5) + point_jitter,
122
+ (gene.grayscale + rand(-5..5)).clamp(0, 255)
123
+ )
124
+ else
125
+ gene
126
+ end
127
+ end
128
+ PetriDish::Member.new(genes: mutated_genes, fitness_function: configuration.fitness_function)
129
+ end
130
+ end
131
+
132
+ def calculate_fitness(target_image)
133
+ ->(member) do
134
+ member_image = member_to_image(member, IMAGE_WIDTH_PX, IMAGE_HEIGHT_PX)
135
+ # Difference is a tuple of [mean_error_per_pixel, normalized_mean_error, normalized_maximum_error]
136
+ 1 / (target_image.difference(member_image)[0]**2) # Use the mean error per pixel as the fitness
137
+ end
138
+ end
139
+
140
+ def member_to_image(member, width, height)
141
+ image = Magick::Image.new(width, height) { |options| options.background_color = "white" }
142
+ draw = Magick::Draw.new
143
+
144
+ # Perform Delaunay triangulation on the points
145
+ # Delaunator.triangulate accepts a nested array of [[x1, y1], [xN, yN]]
146
+ # coordinates and returns an array of triangle vertex indices where each
147
+ # group of three numbers forms a triangle
148
+ triangles = Delaunator.triangulate(member.genes.map { |point| [point.x, point.y] })
149
+
150
+ triangles.each_slice(3) do |i, j, k|
151
+ # Get the vertices of the triangle
152
+ triangle_points = member.genes.values_at(i, j, k)
153
+
154
+ # Take the average color from all three points
155
+ color = triangle_points.map(&:grayscale).sum / 3
156
+ draw.fill("rgb(#{color}, #{color}, #{color})")
157
+
158
+ # RMagick::Image#draw takes an array of vertices in the form [x1, y1,..., xN, yN]
159
+ vertices = triangle_points.map { |point| [point.x, point.y] }
160
+ draw.polygon(*vertices.flatten)
161
+ end
162
+
163
+ draw.draw(image)
164
+ image
165
+ end
166
+
167
+ def save_image(image)
168
+ image.write("#{OUT_DIR}/gen-#{@current_generation}.png")
169
+ end
170
+
171
+ def generation_start_callback(current_generation)
172
+ @current_generation = current_generation
173
+ end
174
+ end
175
+
176
+ LowPolyImageReconstruction.new.run