Skip to content

Commit

Permalink
add low_poly_reconstruction example
Browse files Browse the repository at this point in the history
  • Loading branch information
Thomascountz committed Jul 29, 2023
1 parent 8957a57 commit 58ac49a
Show file tree
Hide file tree
Showing 9 changed files with 3,801 additions and 3 deletions.
28 changes: 26 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ If you add new code, remember to add corresponding tests and ensure all tests pa

Several example problems are configured and solved in the `/examples` directory.

### Lazy Dog Example
### Lazy Dog

The `lazy_dog_example.rb` is an example of using the Petri Dish library to solve a simple problem: Evolving a string to match "the quick brown fox jumped over the lazy white dog". This is a classic example of using a genetic algorithm to find a solution to a problem.

Expand All @@ -203,7 +203,7 @@ To run the example, simply execute the following command in your terminal:
bundle exec ruby examples/lazy_dog_example.rb
```

### Traveling Salesperson Example
### Traveling Salesperson

The `salesperson_example.rb` is an example of using the Petri Dish library to solve a more complex problem: The Traveling Salesperson Problem. In this problem, a salesperson needs to visit a number of cities, each at a different location, and return to the starting city. The goal is to find the shortest possible route that visits each city exactly once.

Expand All @@ -221,6 +221,30 @@ To run the example, simply execute the following command in your terminal:
bundle exec ruby examples/salesperson_example.rb
```

### Low-Poly Image Reconstruction

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.

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.

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.

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.

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.

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.

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.

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:

```bash
bundle exec ruby <PATH_TO_SCRIPT>/low_poly_image_reconstruction.rb
```

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.

## Resources
- [Genetic Algorithms Explained By Example - Youtube](https://www.youtube.com/watch?v=uQj5UNhCPuo)
- [Genetic Algorithms for Autonomous Robot Navigation - Paper](http://citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.208.9941&rep=rep1&type=pdf)
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
176 changes: 176 additions & 0 deletions examples/low_poly_reconstruction/low_poly_reconstruction.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
require_relative "../../lib/petri_dish"
require "bundler/inline"

$stdout.sync = true

gemfile do
source "https://rubygems.org"
gem "rmagick", require: "rmagick"
gem "delaunator", require: true
end

LOW_POLY_RECONSTUCTION_PATH = "examples/low_poly_reconstruction".freeze
INPUT_IMAGE_PATH = "#{LOW_POLY_RECONSTUCTION_PATH}/ruby.svg".freeze
CONVERTED_INPUT_IMAGE_PATH = "#{LOW_POLY_RECONSTUCTION_PATH}/input_convert.png".freeze
OUT_DIR = "#{LOW_POLY_RECONSTUCTION_PATH}/out".freeze
IMAGE_HEIGHT_PX = 100
IMAGE_WIDTH_PX = 100
GREYSCALE_VALUES = (0..255).to_a

class LowPolyImageReconstruction
Point = Struct.new(:x, :y, :grayscale)

def initialize
@current_generation = 0
end

def run
init_members = Array.new(configuration.population_size) do
PetriDish::Member.new(
genes: (0..IMAGE_WIDTH_PX).step(10).map do |x|
(0..IMAGE_HEIGHT_PX).step(10).map do |y|
Point.new(x + point_jitter, y + point_jitter, GREYSCALE_VALUES.sample)
end
end.flatten,
fitness_function: calculate_fitness(target_image)
)
end

PetriDish::World.run(configuration: configuration, members: init_members)
end

def configuration
PetriDish::Configuration.configure do |config|
config.population_size = 50
config.mutation_rate = 0.1
config.elitism_rate = 0.1
config.max_generations = 2500
config.fitness_function = calculate_fitness(target_image)
config.parents_selection_function = roulette_wheel_parent_selection_function
config.crossover_function = random_midpoint_crossover_function(config)
config.mutation_function = nudge_mutation_function(config)
config.highest_fitness_callback = ->(member) { save_image(member_to_image(member, IMAGE_WIDTH_PX, IMAGE_HEIGHT_PX)) }
config.generation_start_callback = ->(current_generation) { generation_start_callback(current_generation) }
config.end_condition_function = ->(_member) { false }
end
end

# Introduce some randomness to the points due to the implementation of the
# Delaunay algorithm leading to a divide by zero error when points are collinear
def point_jitter
jitter = 0.0001
rand(-jitter..jitter)
end

def target_image
@target_image ||= if File.exist?(CONVERTED_INPUT_IMAGE_PATH)
Magick::Image.read(CONVERTED_INPUT_IMAGE_PATH).first
else
import_target_image(INPUT_IMAGE_PATH, CONVERTED_INPUT_IMAGE_PATH)
end
end

def import_target_image(input_path, output_path)
image = Magick::Image.read(input_path).first

crop_size = [image.columns, image.rows].min
crop_x = (image.columns - crop_size) / 2
crop_y = (image.rows - crop_size) / 2

image
.crop(crop_x, crop_y, crop_size, crop_size)
.resize(IMAGE_HEIGHT_PX, IMAGE_WIDTH_PX)
.quantize(256, Magick::GRAYColorspace)
.write(output_path)

image
end

# This is a variant of the roulette wheel selection method, sometimes called stochastic acceptance.
#
# The method calculates the total fitness of the population and then, for each member,
# it generates a random number raised to the power of the inverse of the member's fitness divided by the total fitness.
# This gives a larger result for members with higher fitness.
# The member with the highest result from this operation is selected.
#
# The method thus gives a higher chance of selection to members with higher fitness,
# but also allows for the possibility of members with lower fitness being selected.
def roulette_wheel_parent_selection_function
->(members) do
population_fitness = members.sum(&:fitness)
members.max_by(2) do |member|
weighted_fitness = member.fitness / population_fitness.to_f
rand**(1.0 / weighted_fitness)
end
end
end

def random_midpoint_crossover_function(configuration)
->(parents) do
midpoint = rand(parents[0].genes.length)
PetriDish::Member.new(genes: parents[0].genes[0...midpoint] + parents[1].genes[midpoint..], fitness_function: configuration.fitness_function)
end
end

def nudge_mutation_function(configuration)
->(member) do
mutated_genes = member.genes.dup.map do |gene|
if rand < configuration.mutation_rate
Point.new(
gene.x + rand(-5..5) + point_jitter,
gene.y + rand(-5..5) + point_jitter,
(gene.grayscale + rand(-5..5)).clamp(0, 255)
)
else
gene
end
end
PetriDish::Member.new(genes: mutated_genes, fitness_function: configuration.fitness_function)
end
end

def calculate_fitness(target_image)
->(member) do
member_image = member_to_image(member, IMAGE_WIDTH_PX, IMAGE_HEIGHT_PX)
# Difference is a tuple of [mean_error_per_pixel, normalized_mean_error, normalized_maximum_error]
1 / (target_image.difference(member_image)[0]**2) # Use the mean error per pixel as the fitness
end
end

def member_to_image(member, width, height)
image = Magick::Image.new(width, height) { |options| options.background_color = "white" }
draw = Magick::Draw.new

# Perform Delaunay triangulation on the points
# Delaunator.triangulate accepts a nested array of [[x1, y1], [xN, yN]]
# coordinates and returns an array of triangle vertex indices where each
# group of three numbers forms a triangle
triangles = Delaunator.triangulate(member.genes.map { |point| [point.x, point.y] })

triangles.each_slice(3) do |i, j, k|
# Get the vertices of the triangle
triangle_points = member.genes.values_at(i, j, k)

# Take the average color from all three points
color = triangle_points.map(&:grayscale).sum / 3
draw.fill("rgb(#{color}, #{color}, #{color})")

# RMagick::Image#draw takes an array of vertices in the form [x1, y1,..., xN, yN]
vertices = triangle_points.map { |point| [point.x, point.y] }
draw.polygon(*vertices.flatten)
end

draw.draw(image)
image
end

def save_image(image)
image.write("#{OUT_DIR}/gen-#{@current_generation}.png")
end

def generation_start_callback(current_generation)
@current_generation = current_generation
end
end

LowPolyImageReconstruction.new.run
Binary file added examples/low_poly_reconstruction/montage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/low_poly_reconstruction/out/gen-0000.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added examples/low_poly_reconstruction/out/gen-2473.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 58ac49a

Please sign in to comment.