Content-aware image cropping with ChunkyPNG
Are you ready for another ChunkyPNG brawl? This week, the challenge is to build a cropping tool that knows which parts to cut off by looking at the contents of the image and deciding what the point of interest is.
Here are the three images we’ll use. A cat, a dog and some ducks. You’ll have to crop all three of them and the resulting images should be 100 by 100 pixels. Your solution should use ChunkyPNG, but of course you can use additional gems if you think you’ll need them.
How you solve this is entirely up to you, but solutions that need human input or are built only to support these images will get disqualified. Please be creative.
When you’re done, throw your implementation, a README and all three resulting images (please don’t include the input images) in a Gist. You can’t add images in the Gist web interface, so you’ll have to clone your Gist and add your output image using plain git. If you don’t know how, just send me a message and I’ll help you out.
You have one week to get your entry in, but we’ll add more time if necessary. I can’t wait to see how you solve this one. Good luck!
Prize
This week’s winner gets one month of free Github Micro!
-
Finished in 1st place with a final score of 4.1/5. (View the Gist)README.md
Content-aware image cropping with ChunkyPNG
cake(content-aware kropping [sic] by entropy) is a greedy image cropper inspired by Reddit's scraper library. It works by moving a sliding window over a given image that is continually reduced to the size of a specified crop width and height. Two copies of the current window boundary are made, and for each, 16px are cropped on opposite sides (left-right on the first pass and top-bottom on the second). The entropies of the cropped windows are compared, and the smaller of the two will be discarded, moving the window toward the larger.The current "smart" implementation takes the smaller dimension of the input image as the crop width and height. The optimal crop position is found, and the image is then scaled to 100x100 px.
Requirements
Make sure that you have
chunky_pnginstalled, and you're good to go. This has also only been tested on Ruby 1.9.3p0.Usage
Usage: cake.rb [options] <input ...> -o, --output <string> Specify output file -s, --smart Selects the largest, optimal square to crop and rescales. Good for thumbnails. --debug Draws the bounding crop area instead of cropping the image -d <integer>x<integer>, Specify output dimensions (width x height) in pixels --dimensionsExamples
$ ruby cake.rb -s cat.png$ ruby cake.rb --debug -d 240x240 soundlab.pngImprovements
Because of the large number of colors in an image, it would probably be more ideal to do color quantization before processing.
duck_cropped.png
dog_cropped.png
cat_cropped.png
cake.rbView full entryrequire 'chunky_png' require 'optparse' class Cake attr_accessor :debug def initialize(file) @image = ChunkyPNG::Image.from_file(file) end def crop_and_scale(new_width = 100, new_height = 100) width, height = @image.width, @image.height if width > height width = height else height = width end result = crop(width, height) result.resample_bilinear!(new_width, new_height) unless debug result end def crop(crop_width = 100, crop_height = 100) x, y, width, height = 0, 0, @image.width, @image.height slice_length = 16 while (width - x) > crop_width slice_width = [width - x - crop_width, slice_length].min left = @image.crop(x, 0, slice_width, @image.height) right = @image.crop(width - slice_width, 0, slice_width, @image.height) if entropy(left) < entropy(right) x += slice_width else width -= slice_width end end while (height - y) > crop_height slice_height = [height - y - crop_height, slice_length].min top = @image.crop(0, y, @image.width, slice_height) bottom = @image.crop(0, height - slice_height, @image.width, slice_height) if entropy(top) < entropy(bottom) y += slice_height else height -= slice_height end end if debug return @image.rect(x, y, x + crop_width, y + crop_height, ChunkyPNG::Color::WHITE) end @image.crop(x, y, crop_width, crop_height) end private def histogram(image) hist = Hash.new(0) image.height.times do |y| image.width.times do |x| hist[image[x,y]] += 1 end end hist end # http://www.mathworks.com/help/toolbox/images/ref/entropy.html def entropy(image) hist = histogram(image.grayscale) area = image.area.to_f -hist.values.reduce(0.0) do |e, freq| p = freq / area e + p * Math.log2(p) end end end options = { :width => 100, :height => 100 } option_parser = OptionParser.new do |opts| opts.banner = "Usage: #{__FILE__} [options] <input ...>" opts.on('-o', '--output <string>', 'Specify output file') do |filename| options[:output] = filename end opts.on('-s', '--smart', 'Selects the largest, optimal square to crop and then resamples. Good for thumbnails.') do options[:smart] = true end opts.on(nil, '--debug', 'Draws the bounding crop area instead of cropping the image') do options[:debug] = true end opts.on('-d', '--dimensions <integer>x<integer>', 'Specify output dimensions (width x height) in pixels') do |dim| options[:width], options[:height] = dim.split('x').map(&:to_i) end end option_parser.parse! if ARGV.empty? puts option_parser.help exit 1 end ARGV.each_with_index do |file, i| c = Cake.new(file) c.debug = true if options[:debug] output = if options[:output] suffix = "_#{i}" if ARGV.size > 1 "#{File.basename(options[:output], '.png')}#{suffix}.png" else "#{File.basename(file, '.png')}_cropped.png" end if options[:smart] c.crop_and_scale(options[:width], options[:height]).save(output) else c.crop(options[:width], options[:height]).save(output) end end
-
Finished in 2nd place with a final score of 3.6/5. (View the Gist)README.md
Content-aware image cropping
How to use
Syntax is as follows:
ruby cropper.rb input output width height noiseHow it works
The idea is to calculate the energy of each pixel (something like how important this pixel is for the whole image) and then crop the part that has the highes energy.
The energy function in this case is edge detection via the Sobel operator. You can swap in any other energy function, as long as it returns a grayscale canvas.
After getting the grayscale image, the total energy of each column and row are calculated. Then, it takes lines from the left or right (or top and bottom) of the image, depending on which has the smaller total energy. What is left is a high energy image.
This works pretty good if there is one large edgy thing (like the cat or dog), but breaks down if there are a lot of small edges (like the dirt in the duck image). For this cases, the cacrop method takes an additional noise operator, which makes it ignore values below a certain threshold. All images have been generated with a noise value of 70.
The three images are all very hard to crop to 100x100. You may want to try with 150x150, which gives much better results
duck_cropped.png
cat_cropped.png
dog_cropped.png
sobel.rbmodule EdgeDetection def edge_detection res = ChunkyPNG::Image.new(width, height) (1...width - 1).each do |x| (1...height - 1).each do |y| x_edge = (-1 * ChunkyPNG::Color.grayscale_teint(self[x - 1, y - 1])) + (-2 * ChunkyPNG::Color.grayscale_teint(self[x - 1, y ])) + (-1 * ChunkyPNG::Color.grayscale_teint(self[x - 1, y + 1])) + ( 1 * ChunkyPNG::Color.grayscale_teint(self[x + 1, y - 1])) + ( 2 * ChunkyPNG::Color.grayscale_teint(self[x + 1, y ])) + ( 1 * ChunkyPNG::Color.grayscale_teint(self[x + 1, y + 1])) y_edge = (-1 * ChunkyPNG::Color.grayscale_teint(self[x - 1, y - 1])) + (-2 * ChunkyPNG::Color.grayscale_teint(self[x , y - 1])) + (-1 * ChunkyPNG::Color.grayscale_teint(self[x + 1, y - 1])) + ( 1 * ChunkyPNG::Color.grayscale_teint(self[x - 1, y + 1])) + ( 2 * ChunkyPNG::Color.grayscale_teint(self[x , y + 1])) + ( 1 * ChunkyPNG::Color.grayscale_teint(self[x + 1, y + 1])) edge = ((x_edge.abs / 4) + (y_edge.abs / 4)) / 2 res[x,y] = ChunkyPNG::Color.grayscale(edge) end end res end end
cropper.rbrequire "rubygems" require "chunky_png" require "pry" require_relative "sobel" require_relative "cacrop" input = ARGV[0] output = ARGV[1] width = (ARGV[2] || 100).to_i height = (ARGV[3] || 100).to_i noise = (ARGV[4] || 0).to_i image = ChunkyPNG::Image.from_file(input) image.extend(EdgeDetection) image.extend(CACrop) image = image.cacrop width, height, noise image.save(output)
cacrop.rbView full entrymodule CACrop # This tries to crop the most energy dense parts of the image as determined # by the Sobel operator. def cacrop width, height, noise = 0 # Get the edge detected image edges = edge_detection # This calculates arrays of the sums of each pixel in each row / column. # This is a simple measure of the activity in each row / column columns = (0...self.width).map{|i| edges.column(i).map{|c| ChunkyPNG::Color.r(c)}.map{|c| c > noise ? c : 0}.inject(&:+)} rows = (0...self.height).map{|i| edges.row(i).map{|c| ChunkyPNG::Color.r(c)}.map{|c| c > noise ? c : 0 }.inject(&:+)} x = take_smallest_until columns, width y = take_smallest_until rows, height crop(x, y, width, height) end # Takes the smalles value from the end or the beginning of the array. # This continues until the size of the array is right. # It returns the numbers of dropped values from the left. def take_smallest_until arr, goal res = [] arr.first < arr.last ? res << arr.shift : arr.pop while arr.size > goal res.size end end
-
Finished in 3rd place with a final score of 3.2/5. (View the Gist)README.md
Robocrop: attempt at content-aware image cropping
Basic idea is to find some area on the image that stands out the most from the rest of the image. There are quite a few ways of doing this, and I decided to use only difference of color and cotrast levels, for each block vs entire image.
I use stddev of colors of 3x3 pixel block with the center on the pixel as a pixel's 'contrast' here.
robocrop.rb - as simple as possible
The first version of robocrop is simple and only supports 100x100 blocks, you can't control how block weight is calculated etc.
There are two weights used here to determine what block to use in the end.
First is simply an average contrast level of block's pixels. This promotes blocks with high contrast, such as cat's fur and folds on dog's skin.
Using only this gives passable results for cat/dog, but fails on the ducks, since ground have higher contrast there.
Second is diversity of block's pixels contrast. This weight promotes blocks that has areas of both low and high contrast, making algorithm to more likely capture the 'edges' of objects.
In the case of 'cat' and 'dog', adding diversity to weight will make cropped image to contain some of the blurry background behind the cat/dog, producing better results. It also helps on ducks, since the duck itself has a low contrast, while the ground has high, algorithm will try to fit both duck and the ground into a block, resulting in a block centered on duck's body.
robocrop2.rb - as a library
Same algorithm, packed as a new method of Image class, :robocrop ( and :robocrop! ), that allow to override some parameters. Accepts hash of arguments.
:width, :height - dimensions of resulting canvas.
:contrast, :diversity - additional weights that specify how much average contrast and diversity contributes to block weight.
:precision - specifies how many blocks are actually checked. Higher precision means more accurate results, but worse performance. For :precision => X, at most X*X blocks will be tested.
Random thoughts
Despite being very slow, this algorithm is simple and usually produce ok results. Interesting is that there is little to no point in comparing block's color versus image color, using only contrast is good enough, if not better.
Also, I like how algorithm captures the edges. Similar result could be accomplished by using some edge detection library ( imagemagick for example ) first, but it would add complexity to the solution.
I use NArray for calclulations, since its fast and does have nice api.
dog_cropped.png
duck_cropped.png
cat_cropped.png
robocrop2.rbrequire 'rubygems' require 'chunky_png' require 'narray' class ChunkyPNG::Image def robocrop(options = {}) dup.robocrop(options) end def robocrop!(options = {}) options = { # dimensions :width => 100, :height => 100, # weights :contrast => 1, :diversity => 1, # complexity :precision => 100, }.merge!(options) # create 3 width*height matrixes, that stores red, green or blue channel of each pixel image_rgb = NArray.byte(3, width, height) # fill the colors matrix width.times do |x| image_rgb[true, x, true] = column(x).collect do |color| ChunkyPNG::Color.to_truecolor_bytes(color) end end # calculate 'contrast' for every pixel of the image # i use stddev of colors of 3x3 pixel block with the center on the pixel as a pixel's 'contrast' here # stddev is calculated separately for every color channel image_contrast = NArray.float(width, height) width.times do |x| height.times do |y| image_contrast[x, y] = image_rgb[ true, ([0, x-1].max)..([width-1, x+1].min), ([0, y-1].max)..([height-1, y+1].min) ].stddev(1..2).sum end end # for every crop_width*crop_height block possible, find its top-left most pixel x_step = [(width-options[:width]).to_f/options[:precision], 1].max y_step = [(height-options[:height]).to_f/options[:precision], 1].max northwest_pixels = (0..(width-options[:width])).step(x_step).to_a northwest_pixels = northwest_pixels.product((0..(height-options[:height])).step(y_step).to_a) # iterate over all possible blocks crop_x, crop_y, block_weight = northwest_pixels.collect {|x, y| # contrast levels for pixels of this block block_contrast = image_contrast[x...(x+options[:width]), y...(y+options[:height])] contrast = block_contrast.mean ** options[:contrast] contrast_diversity = block_contrast.stddev ** options[:diversity] # capture block coords with its weight [x, y, contrast * contrast_diversity] }.max_by{|x, y, weight| weight} crop!(crop_x, crop_y, options[:width], options[:height]) end end
robocrop.rbView full entryrequire 'rubygems' require 'chunky_png' require 'narray' def robocrop(original_filename, cropped_filename) image = ChunkyPNG::Image.from_file(original_filename) # create 3 width*height matrixes, that stores red, green or blue channel of each pixel image_rgb = NArray.byte(3, image.width, image.height) # fill the colors matrix image.width.times do |x| image_rgb[true, x, true] = image.column(x).collect do |color| ChunkyPNG::Color.to_truecolor_bytes(color) end end # calculate 'contrast' for every pixel of the image # i use stddev of colors of 3x3 pixel block with the center on the pixel as a pixel's 'contrast' here # stddev is calculated separately for every color channel image_contrast = NArray.float(image.width, image.height) image.width.times do |x| image.height.times do |y| image_contrast[x, y] = image_rgb[ true, ([0, x-1].max)..([image.width-1, x+1].min), ([0, y-1].max)..([image.height-1, y+1].min) ].stddev(1..2).sum end end # for every 100x100 block possible, find its top-left most pixel x_step = 1 y_step = 1 northwest_pixels = (0..(image.width-100)).step(x_step).to_a northwest_pixels = northwest_pixels.product((0..(image.height-100)).step(y_step).to_a) # iterate over all possible blocks crop_x, crop_y, block_weight = northwest_pixels.collect {|x, y| # contrast levels for pixels of this block block_contrast = image_contrast[x...(x+100), y...(y+100)] contrast = block_contrast.mean contrast_diversity = block_contrast.stddev # capture block coords with its weight [x, y, contrast * contrast_diversity] }.max_by{|x, y, weight| weight} # crop the image and save it with a new filename image.crop!(crop_x, crop_y, 100, 100) image.save(cropped_filename) end robocrop('cat.png', 'cat_cropped.png') robocrop('dog.png', 'dog_cropped.png') robocrop('duck.png', 'duck_cropped.png')
-
Finished in 4th place with a final score of 3.2/5. (View the Gist)README.md
Simplest possible solution to image cropping problem:
- grab the most "exciting" part of the image
- "exciting" => least average according to unweighed color
- checks every single 100 X 100 splotch, so it's very inefficient. Not checking every single subimage would be the most obvious improvement, but frankly, doing image processing in native ruby is probably not the best idea anyhow.
- special bonus! also finds the least interesting 100X100 subimage.
- usage:
ruby chunky.rb <inputfn.png> - most "exciting" crop is saved as
<inputfn.png>.max.png - most "boring" crop is saved as
<inputfn.png>.min.png
This works:
- extremely well for the duck
- well for the cat
- so-so for the dog
Actually in the case of the dog, the bit identified as "most boring" is a pretty good crop. Reason being that the dog picture has little variation: half big black dog, half green. All said, though, the dog crop is acceptable and by far not the worst possible choice.
Possible improvements:
- tons of performance optimizations
- calculating the averages differently, especially perception weighting the individual color channels or hsv components.
- doing this properly would require more sophisticated algorithms, I doubt it would be feasible with ChunkyPNG
dog.png.min.png
cat.png.min.png
duck.png.min.png
dog.png.max.png
duck.png.max.png
cat.png.max.png
chunky.rbView full entryrequire 'chunky_png' include ChunkyPNG def usage STDERR.puts "usage: [bla] filename.png" exit 1 end usage if 1 != ARGV.length # determine "average" color of a splotch. def avg img sum = img.pixels.inject(0) {|sum, pixel| sum += pixel } sum/img.pixels.length end # iterate over every possible 100 X 100 # subimage def each img # replace `upto` with `step` here to # speed things up 0.upto(img.width-100) {|x| 0.upto(img.height-100) {|y| yield img.crop(x,y,100,100) } } end img = Image.from_file(ARGV[0]) img_avg = avg(img) max = 0 min = 0xffffffffffff img_max, img_min = nil, nil each(img) { |i| d = (avg(i)-img_avg).abs max, img_max = d, i if d > max min, img_min = d, i if d < min } img_max.to_image.save(ARGV[0]+".max.png") img_min.to_image.save(ARGV[0]+".min.png")
- grab the most "exciting" part of the image
-
Finished in 5th place with a final score of 2.8/5. (View the Gist)readme.md
Crop
This solution crops to the center 100 pixel square in the image.
Show me the Output!
Why this is a great solution
- It's wicked fast
- It's easily maintainable
- It gets the right crop at least as often as more complicated solutions
- The most interesting part of an image is frequently at the center
Seriously?
I spent a lot of time thinking about this and tried other solutions. The solution I came to is that there aren't a lot of great solutions. An algorithm gets it wrong as much as it gets it right. Centering the crop was the first solution I wrote up as the stupid simple case. It was meant to be the solution against which I compared all other solutions. What I found is that I was happier with it for cropping than other solutions.
Let me try and convince you a bit more. Let's talk about the duck image:

The duck image has no true focal point. There are no lines leading to a central point in the photo. There is no strong use of contrasting color. However if we're talking human focal points then we're probably talking about the heads. We would like to see the ducks cropped to the ducks' heads:

In this image I've highlighted the focal points in pink. I've also highlighted 'ideal' crops in orange. Notice anything interesting? Even if the algorithm could perfectly determine the focal point the crop wouldn't be right. Another thing that's interesting? This is completely a matter of opinion. Cropping is something that's pretty subjective.
Another interesting thing- this center crop picked a solution that was very similar to another I had toyed with that looked for standout colors. This is because the duck's legs are one of the brightest parts of the image. That algorithm was considerably slower.
Now let's talk about the dog and the cat- there is a much clearer center point. This centering method grabs the right picture.
Another good thing about this method is that it's fast. There's no deep calculation that needs to go on so this solution is able to churn through images very quickly. It's also small so it's very maintainable.
If this brawl was a challenge to ship code, this would definitely be the solution I'd ship.
Other solutions
As I said I explored other solutions and I'd like to share some quick notes:
Blob Detection
I explored blob detection by flipping the image grayscale and upping the brightness and contrast until there were black blobs on the page. I then looked for the box that best fit the most number of black pixels. This worked surprisingly well for the ducks, not good for the cat, and awful for the dog. The algorithm falls apart on any images that are dark.
Interest Hue Detection
This algorithm looked for interesting hues. I was really disappointed with the results.
dog-output.png
cat-output.png
duck-output.png
crop.rbView full entryrequire 'rubygems' require 'chunky_png' module ChunkyPNG::Canvas::Operations def crop_square_at_point_of_interest(x,y,size) x = x - ( size / 2) x = 0 if x<0 y = y - ( size / 2 ) y = 0 if y<0 self.crop(x,y,size,size) end end def center_point_of_interest(image) [image.dimension.width/2, image.dimension.height/2] end ['cat', 'duck', 'dog'].each do |test_image| input = ChunkyPNG::Image.from_file("#{test_image}.png") point_of_interest = center_point_of_interest(input) input.crop_square_at_point_of_interest(point_of_interest[0], point_of_interest[1], 100).save("#{test_image}-output.png") end
-
Finished in 6th place with a final score of 2.4/5. (View the Gist)README
Program is run by typing "ruby main-single.rb input.png output.png". The output contains the cropped image in png format.
dog.png
cat.png
duck.png
main.rbView full entryrequire 'rubygems' require 'chunky_png' WIDTH = 100 HEIGHT = 100 class TwodimensionalArray def initialize(width, height) @width = width @height = height @array = Array.new(width) @array.map! {Array.new(height)} end def [](x, y) @array[x][y] end def []=(x, y, value) @array[x][y] = value end def width @width end def height @height end def average sum = 0 0.upto(@width-1) do |x| 0.upto(@height-1) do |y| sum += @array[x][y] end end sum / (@width * @height) end def key_values max = min = @array[0][0] sum = 0 0.upto(@width-1) do |x| 0.upto(@height-1) do |y| sum += @array[x][y] max = @array[x][y] if max < @array[x][y] min = @array[x][y] if min > @array[x][y] end end [min, sum / (@width * @height), max] end def neighbours(x, y, distance = 1) result = [] (-distance).upto(distance) do |dx| (-distance).upto(distance) do |dy| result << @array[x + dx][y + dy] unless ((dx == 0 && dy == 0) || (x + dx < 0) || (x + dx >= width) || (y + dy < 0) || (y + dy >= height)) end end result end def self.from_canvas(source) result = TwodimensionalArray.new(source.width, source.height) 0.upto(source.width-1) do |x| 0.upto(source.height-1) do |y| result[x,y] = Pixel.from_int(source.get_pixel(x, y)) end end result end end class SumArray def initialize(source_array, sum_width, sum_height) @sum_width = sum_width @sum_height = sum_height @source_array = source_array cumulative_array = cumulative(source_array) @array = TwodimensionalArray.new(source_array.width - sum_width, source_array.height - sum_height) 0.upto(@array.width-1) do |x| 0.upto(@array.height-1) do |y| @array[x,y] = cumulative_array[x+sum_width-1,y+sum_height-1] @array[x,y] -= cumulative_array[x-1,y+sum_height-1] if (x-1 >= 0) @array[x,y] -= cumulative_array[x+sum_width-1,y-1] if (y-1 >= 0) @array[x,y] += cumulative_array[x-1,y-1] if ((x-1 >= 0) && (y-1 >= 0)) @array[x,y] /= (sum_width*sum_height) end end end def [](x, y) @array[x, y] end def sum_width @sum_width end def sum_height @sum_height end def max_difference average_contrast = @source_array.average max_x = max_y = max_difference = 0; 0.upto(@array.width-1) do |x| 0.upto(@array.height-1) do |y| if ((@array[x,y] - average_contrast).abs > max_difference) max_difference = (@array[x,y] - average_contrast).abs max_x = x max_y = y end end end [max_x, max_y] end private def cumulative(source_array) result = TwodimensionalArray.new(source_array.width, source_array.height) 0.upto(source_array.width-1) do |x| 0.upto(source_array.height-1) do |y| result[x,y] = source_array[x,y] result[x,y] += result[x-1,y] if (x-1 >= 0) result[x,y] += result[x,y-1] if (y-1 >= 0) result[x,y] -= result[x-1,y-1] if ((x-1 >= 0) && (y-1 >= 0)) end end result end end class AreaOfInterest def initialize(width, height) @width, @height = width, height end def process(source) img = TwodimensionalArray.from_canvas(source) contrast_field = get_contrast_field(img) avg = contrast_field.average min, avg, max = contrast_field.key_values 0.upto(contrast_field.width-1) do |x| 0.upto(contrast_field.height-1) do |y| contrast_field[x,y] = ((contrast_field[x,y] < avg) ? min : max) #contrast_field[x, y] = 0 if contrast_field[x,y] < avg end end area_of_interest = get_area_of_interest(contrast_field, @width, @height) source.crop(area_of_interest[0], area_of_interest[1], @width, @height) #to_png(contrast_field) end private def get_area_of_interest(contrast_field, width, height) SumArray.new(contrast_field, width, height).max_difference end def get_contrast_field(pixel_field) result = TwodimensionalArray.new(pixel_field.width, pixel_field.height) 0.upto(pixel_field.width-1) do |x| 0.upto(pixel_field.height-1) do |y| neighbours = pixel_field.neighbours(x, y) result[x,y] = neighbours.inject(0) {|sum, neighbour| sum += pixel_field[x, y].absolute_difference(neighbour)} / neighbours.count end end result end def to_png(input) result = ChunkyPNG::Canvas.new(input.width, input.height) 0.upto(input.width-1) do |x| 0.upto(input.height-1) do |y| result.set_pixel(x, y, input[x,y]) end end result end end class Pixel def self.from_int(value) Pixel.new((value >> 24) % 0xFF, (value >> 16) % 0xFF, (value >> 8) % 0xFF, value % 0xFF) end def initialize(alpha, red, green, blue) @blue = blue @green = green @red = red @alpha = alpha end def red @red end def red=(value) @red = value end def green @green end def green=(value) @green = value end def blue @blue end def blue=(value) @blue = value end def alpha @alpha end def alpha=(value) @alpha = value end def absolute_difference(another_pixel) (red - another_pixel.red).abs + (green - another_pixel.green).abs + (blue - another_pixel.blue).abs + (alpha - another_pixel.alpha).abs end end source = ChunkyPNG::Image.from_file(ARGV[0]) processor = AreaOfInterest.new(WIDTH, HEIGHT) result = processor.process(source) result.save(ARGV[1] || "output.png")


