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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +48 -81
- data/examples/lazy_dog/README.md +64 -0
- data/examples/{lazy_dog_example.rb → lazy_dog/lazy_dog_example.rb} +8 -8
- data/examples/lazy_dog/log.txt +194 -0
- data/examples/low_poly_reconstruction/README.md +28 -0
- data/examples/low_poly_reconstruction/input_convert.png +0 -0
- data/examples/low_poly_reconstruction/low_poly_reconstruction.rb +176 -0
- data/examples/low_poly_reconstruction/montage.png +0 -0
- data/examples/low_poly_reconstruction/out/gen-0000.png +0 -0
- data/examples/low_poly_reconstruction/out/gen-2473.png +0 -0
- data/examples/low_poly_reconstruction/out/log.txt +2650 -0
- data/examples/low_poly_reconstruction/result.png +0 -0
- data/examples/low_poly_reconstruction/ruby.svg +948 -0
- data/examples/traveling_salesperson/README.md +39 -0
- data/examples/traveling_salesperson/best_member.csv +12 -0
- data/examples/traveling_salesperson/log.txt +113 -0
- data/examples/traveling_salesperson/plot.txt +48 -0
- data/examples/{salesperson_example.rb → traveling_salesperson/salesperson_example.rb} +1 -2
- data/lib/petri_dish/configuration.rb +11 -17
- data/lib/petri_dish/member.rb +2 -2
- data/lib/petri_dish/version.rb +1 -1
- data/lib/petri_dish/world.rb +4 -4
- metadata +19 -5
- data/.DS_Store +0 -0
@@ -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.
|
Binary file
|
@@ -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
|
Binary file
|
Binary file
|
Binary file
|