Selective color with ChunkyPNG

Remember our last ChunkyPNG contest? We had a lot of entries, a lot of fun and a bunch of emails asking us to do it again, so here’s another one!

You’ve probably seen images with the selective color effect before, where the image is in black and white but there’s some element in there that’s colored. Some camera’s have this built in, but most are post-processed color pictures.

We’re not going to use fancy camera’s or expensive photo editing tools. We’re programmers, so we should be able to build a tool that does this for us.

Crayons for the ChunkyPNG contest

Here’s the image we’ll be working with. Choose one color – bonus points if you choose the yellow or one of the blue ones – and create a black and white image with only one of the crayons in color using ChunkyPNG.

Remember, the crayons have a range of colors, they’re not just yellow, for example. It might be a good idea to check out some other color scheme than plain old RGB to support that.

Please put your entry in a Gist with the resulting image, like this example. Please don’t include the input image and don’t fork the example Gist. You can’t add images in the Gist web interface, so you’ll have to clone your Gist and add yout output image using 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, so I’m sure you can make something awesome! Good luck!

Prize

This week’s winner can choose between $25 of 6sync hosting credit or one month of free Github Micro!

This contest is finished

Congratulations to this week's winners! The entries and the contestant names are shown below.

  • README.md

    How to get the selective color effect

    When I started thinking about this Codebrawl, I had essentially two ideas how to do get the selective color effect: Try to detect each crayon as an object and keep one of them in color, or take a reference color and just keep the colors that are similar to it. The first idea would be more universal, but seems to me much harder to implement. I chose the second one, so I needed a way to measure the distance between to colors.

    How to measure the distance between colors

    There are several ways to get a value that tells you how much colors are like each other. If you have an RGB pixel, you could just take these 3 values as coordinates in a 3D space and calculate the distance there. I did not actually implement this, as it does not represent similar colors in the way we would see them. For example, the color (255,0,0) and (127,0,0) would both be just red, but they would have the same distance as (255, 128, 0), which introduces green to the color mix and looks pretty different.

    This also tells us something about what kind of representation we want: One where only the color counts, but not the brightness (or luminance). I first looked at a color representation called rg chromaticty space.

    rg chromacity

    rg chromacity is a simple way to remove intensity information from your colors and only keep the proportions of red, green and blue. You just normalize the values of the r, g and b components to be between 0 and 1, with r + g + b always adding up to one. It is called rg chromacity because only the red and green components are needed to describe a color, as the blue component is always b = 1 - (r + g). For example, rgb(255,0,0) is rg(1,0), as is rgb(200,0,0).

    You now have a 2d space, so distances can be easily calculated. This gives somewhat satisfactory results, at least for some of the crayons. But in the end, it just was not as good as I had hoped.

    Part of this surely is grounded in how this color space actually looks. It is somewhat uneven, as the distance between pure red, rg(1,0) and pure blue, rg(0,0) is one. But between red and green,rg(1,0) and rg(0,1), it is the square root of two! By just measuring the distance, I am essentially cutting a circle of colors I want to keep, but similar colors in this space are not uniformly distributed.

    I had a lot of ideas how to counter these problems: Make colors spread from a reference color if the neighbor are similar, make negative cuts in this space by specifying colors that should always be made gray, etc...

    What I really needed was a better distance function for my colors. Let's look at HSV.

    HSV

    If you look at color spaces on Wikipedia, you will find HSL and HSV pretty soon. They each use the hue, saturation, and lightness or value to define a color. If you take a look at some pictures of this color space, you will quickly see that the hue component looks pretty much like what we need.

    Now, calculating the hue is more complicated than calculating rg chromacity colors. If I understood it correctly, you make a hexagon, put red at 0°, green at 120° and blue at 240° and then calculate where your color lies. You can see the formula I used in the code. I did move the result around by a few degrees so pure red will be zero. I probably have a bug somewhere, but even if I would not correct it, it does not matter where red, green and blue lie exactly, as long as they have the right distance from each other.

    Calculating the distance is just subtracting one hue from the other. We have to do it two times, calculating the modular distance, as hue is circular and has red on both ends. This gives good results for most colors. I could not get the yellow and red crayon to seperate perfectly... This may be because I did not find a good reference color, or just that the dark yellow in the tip of the crayon and the light red are actually too similar and this approach won't work at all. If other entries managed to get a perfect red crayon with no yellow using just the hue, you know which one it is ;-)

    Better color distances

    I tackled the problem of color similarity from a rather primitive point of view: Numerical values of single pixels. As it turns out, this is not enough to mirror human color perception. Take a look at this: http://gizmodo.com/5839481/the-most-wicked-optical-illusion-ive-seen-so-far. Both spirals have the same color, but you would never think that if you didn't know or check. As far as I know, no algorithm that works for cases like this exists, yet.

    There is a standard for measuring color difference by the International Commission on Illumination (sounds good, right?), that takes into account how humans perceive color, so it should give you better results - if you are human, that is. I did not try it, though, as it seems somewhat complicated and I wanted to keep this entry short and to the point. Maybe you want to take a shot?

    BONUS: Javascript version

    I also wrote a javascript version of the same algorithm which you can use to quickly check the effects of changing the reference color or the color distance. You can find it at http://severe-autumn-9391.heroku.com/index.html.

    Use the slider to set the color distance that will still be colored, and just click on any point in the image on the right to set it as reference. Have fun!

    output.png
    extensions.rb
    module ChunkyPNG::Color
      # See http://en.wikipedia.org/wiki/Hue#Computing_hue_from_RGB
      def self.hue(pixel)
        r, g, b = r(pixel), g(pixel), b(pixel)
        return 0 if r == b and b == g 
        ((180 / Math::PI * Math.atan2((2 * r) - g - b, Math.sqrt(3) * (g - b))) - 90) % 360
      end
    
      # The modular distance, as the hue is circular
      def self.distance(pixel, poxel)
        hue_pixel, hue_poxel = hue(pixel), hue(poxel)
        [(hue_pixel - hue_poxel) % 360, (hue_poxel - hue_pixel) % 360].min
      end
    end
    
    module SelectiveColor
      # Really simple, just change the pixels to grayscale if their distance to a
      # reference hue is larger than a delta value.
      def to_selective_color!(reference, delta)
        pixels.map!{|pixel| ChunkyPNG::Color.distance(pixel, reference) > delta ? ChunkyPNG::Color.to_grayscale(pixel) : pixel}
        self
      end
    end
    
    selective_color.rb
    require "chunky_png"
    require "./extensions.rb"
    
    image = ChunkyPNG::Image.from_file("input.png")
    
    image.extend(SelectiveColor)
    
    # Try the other colors if you like!
    # keep = ChunkyPNG::Color.rgb(221,57,42) 
    # keep = ChunkyPNG::Color.rgb(152,216,56)
    keep = ChunkyPNG::Color.rgb(0,125,209)
    
    image.to_selective_color!(keep, 15)
    
    image.save("output.png")
    
    View full entry
    Finished in 1st place with a final score of 4.1/5. (View the Gist)
  • README.md

    Singucolor

    Singular was written for this Code Brawl and is a small library for filtering selective colors with ChunkyPNG. An example of using the library by processing input.png and outputing five images each with a different crayon selectively colored is included in singular.rb. Also included are a few utilities for calculating the hue of a given RGB color, drawing a Gruff histogram, and a primitive engine for guessing which hues the user might be interested in filtering. Ideally the user would use their favorite photo editor to select the desired color. In an attempt to appeal to programmers, this example is also capable of guessing which colors the user might be interested in filtering.

    All code was written for Code Brawl. Besides chunky_png the only other dependencies are Ruby 1.9.2 and Gruff. Gruff is used only for drawing the hue chart.

    Designed for Ruby 1.9.2 stable

    How to run the example

    • download or clone the gist and copy input.png
    • rvm 1.9.2 # or otherwise ensure you are running Ruby 1.9.2
    • gem install chunky_png gruff
    • ruby singucolor.rb

    The Fun Part

    Skipping ahead to the fun part, here is how the library is used to filter each crayon...

    crayons = [ { :name => 'green',  :color => 'a8e060', :range => 20 },
                { :name => 'yellow', :color => 'de8800', :range => 20 },
                { :name => 'blue',   :color => '0061af', :range => 20 },
                { :name => 'purple', :color => '4c4bae', :range => 20 },
                { :name => 'red',    :color => '650100', :range => 12 } ]
    crayons.each do |crayon|
      puts "Processing #{crayon[:name]} crayon..."
      ColorFilter.new('input.png')
             .color(crayon[:color])
             .range(crayon[:range])
             .write("output-#{crayon[:name]}-crayon.png")
    end
    

    The Guts

    There are three classes in the Singucolor library: ColorFilter, ColorAnalyzer, and MultiFormatColor.

    ColorFilter accepts a png filename, a color or hue, and an optional hue range and writes a filtered image to a new png file. ColorFilter uses MultiFormatColor to calculate the desired hue and input hues. For each input pixel a hue is calculated. If the difference between the input hue and desired hue exceeds the specified range (or default of 20 degrees) then a grayscale pixel is written in its place, otherwise the original pixel is written.

    MultiFormatColor represents a color and it's associated hue and integer value. It delegates most operations, except for hue calculation, to ChunkyPNG::Color for the sake of convenience.

    ColorAnalyzer processes an image and tries to determine which hues are the most popular. It also leverages MultiFormatColor for processing color information. Using Gruff it can also generate a graph of hue vs pixel quantity.

    output-red-crayon.png
    output-purple-crayon.png
    hue_chart.png
    output-green-crayon.png
    output-blue-crayon.png
    output-yellow-crayon.png
    color_filter.rb
    require 'rubygems'
    require 'chunky_png'
    require 'multi_format_color'
    
    class ColorFilter
      def initialize(filename)
        @png   = ChunkyPNG::Image.from_file(filename)
        @range = 20
      end
    
      def color(color_as_hex)
        @hue = MultiFormatColor.from_hex(color_as_hex).hue
        self
      end
    
      def hue(hue_in_degrees)
        @hue = hue_in_degrees
        self
      end
    
      def range(range_in_degrees)
        @range = range_in_degrees
        self
      end
    
      def write(filename)
        for w in (0..@png.width-1)
          for h in (0..@png.height-1)
            @png.set_pixel(w, h, filter_pixel(@png[w,h]))
          end
        end
        @png.save(filename)
      end
    
      private
    
      def filter_pixel(input_pixel)
        input_color = MultiFormatColor.new(input_pixel)
        if (input_color.hue - @hue).abs > @range
          input_color.to_grayscale
        else
          input_pixel
        end
      end
    end
    
    multi_format_color.rb
    require 'rubygems'
    require 'chunky_png'
    
    class MultiFormatColor
      def initialize(color_as_integer)
        @red   = ChunkyPNG::Color.r(color_as_integer)
        @green = ChunkyPNG::Color.g(color_as_integer)
        @blue  = ChunkyPNG::Color.b(color_as_integer)
        @integer_value = color_as_integer
      end
    
      def self.from_hex(color_as_hex)
        self.new(ChunkyPNG::Color.from_hex(color_as_hex))
      end
    
      def hue
        @hue ||= Math.atan2(2*@red-@green-@blue+0.001, Math.sqrt(3)*(@green-@blue+0.001)) * 180 / Math::PI
      end
    
      def to_grayscale
        ChunkyPNG::Color.to_grayscale(@integer_value)
      end
    end
    
    singucolor.rb
    #!/usr/bin/ruby
    
    $LOAD_PATH << './lib'
    require 'rubygems'
    require 'chunky_png'
    require 'color_filter'
    require 'color_analyzer'
    
    # SPECIFIY EACH CRAYON COLOR AND LET SINGUCOLOR DO THE REST
    # Use your favorite photo editor to grab the color of each crayon.
    # Range defaults to 20 but you can override for more control over bleeding.
    crayons = [ { :name => 'green',  :color => 'a8e060', :range => 20 },
                { :name => 'yellow', :color => 'de8800', :range => 20 },
                { :name => 'blue',   :color => '0061af', :range => 20 },
                { :name => 'purple', :color => '4c4bae', :range => 20 },
                { :name => 'red',    :color => '650100', :range => 12 } ]
    crayons.each do |crayon|
      puts "Processing #{crayon[:name]} crayon..."
      ColorFilter.new('input.png')
                 .color(crayon[:color])
                 .range(crayon[:range])
                 .write("output-#{crayon[:name]}-crayon.png")
    end
    
    # ASK SINGUCOLOR FOR SOME HINTS
    puts "My best guess of the top five hues:"
    analyzer = ColorAnalyzer.new('input.png')
    analyzer.chart('hue_chart.png')
    analyzer.top(5).each_peak_hue do |peak_hue|
      puts "  - #{peak_hue} degrees"
      # Uncomment the following line if you want to render the guesses
      # ColorFilter.new('input.png').hue(peak_hue).range(20).write("output-auto-#{peak_hue}.png")
    end
    
    color_analyzer.rb
    require 'rubygems'
    require 'chunky_png'
    require 'gruff'
    require 'multi_format_color'
    
    class ColorAnalyzer
      def initialize(filename)
        @png = ChunkyPNG::Image.from_file(filename)
        @top = 5
      end
    
      def top(count)
        @top = count
        @max_hues = nil
        self
      end
    
      def chart(filename)
        load_data
        g = Gruff::Line.new
        g.title = "Hue Chart"
        g.labels = { 0 => "0", 180 => "180", 359 => "359" }
        g.data("Pixels", @raw_hues)
        g.x_axis_label = "Hue (degrees)"
        g.y_axis_label = "Quantity"
        g.write filename
        self
      end
    
      def each_peak_hue
        load_data
        @max_hues.each do |max_hue|
          yield max_hue[:hue]
        end
      end
    
      private
    
      def load_data
        if @raw_hues.nil?
          # Collect stats on the hues of each pixel
          @raw_hues = Array.new(360) { 0 }
          @png.pixels.each do |pixel|
            @raw_hues[MultiFormatColor.new(pixel).hue.round] += 1
          end
        end
        if @max_hues.nil?
          # Determine the 5 most popular hues
          @max_hues = Array.new(@top) { { :hue => 0, :quantity => 0 } }
          max_quantity = @png.height * @png.width
          @max_hues.each do |max_hue|
            max_hue[:hue]      = max_below(@raw_hues, max_quantity)
            max_hue[:quantity] = @raw_hues[max_hue[:hue]]
            max_quantity       = max_hue[:quantity]
          end
        end
      end
    
      def max_below(ary, bound)
        max_value = 0
        max_index = 0
        ary.each_with_index do |value, index|
          if value > max_value && value < bound
            max_index = index
            max_value = value
          end
        end
        max_index
      end
    end
    
    View full entry
    Finished in 2nd place with a final score of 3.8/5. (View the Gist)
  • README.md

    Colr.rb

    Colr selects colors based on hue calculations. It knows nothing about the image before initialization and works with any valid HTML color: red, green, aqua, brown, etc.

    Colr allows you to adjust the tolerance and hue settings so you can select colors from any image!

    Example:

    @colr = Colr.new('input.png')
    @colr.select(:red).save("red.png")
    
    # To select all the colors:
    
    Colr.new('input.png').select(:yellow, 40, -40).save("yellow.png")
    Colr.new('input.png').select(:red).save("red.png")
    Colr.new('input.png').select(:green, 30, -60).save("green.png")
    Colr.new('input.png').select(:blue, 20, -50).save("blue.png")
    Colr.new('input.png').select(:violet, 20, -70).save("violet.png")
    
    violet.png
    blue.png
    yellow.png
    red.png
    green.png
    test_colr.rb
    require 'minitest/autorun'
    require File.join(File.dirname(__FILE__), 'colr.rb')
    
    describe Colr do
    
      before do
        @colr = Colr.new('input.png')
      end
    
      it 'calculates hues for html colors' do
        @colr.hue(0xff000000).must_equal 0
      end
    
      it "returns a range for positive values" do
        @colr.swatch(:red, 10, 0).must_equal 0..10
      end
    
      it 'returns values for negative degrees' do
        @colr.swatch(:green, 30, -60).must_equal 60..90
      end
    
    end
    
    colr.rb
    require 'chunky_png'
    
    class Colr
      include ChunkyPNG::Color
    
      def initialize(png)
        @input = ChunkyPNG::Image.from_file(png)
      end
    
      # Converts each pixel of the image to grayscale except the given color. You
      # can use the tolerance and degrees (hues) to tune Colr to match any image!
      #
      # Example:
      #  @colr.select(:red)  #=> ChunkyPNG::Image
      #
      # Returns the resulting image
      def select(color, tolerance = 10, degrees = 0)
        hues = swatch(color, tolerance, degrees)
        @input.pixels.map! { |c| hues.include?( hue(c) ) ? c : to_grayscale(c) }
        @input
      end
    
      # Calculates a hue of the given color
      # Algorithm from http://en.wikipedia.org/wiki/HSL_and_HSV
      #
      # Example:
      #  @colr.hue(0xff000000)  #=> 0
      #
      # Returns the resulting integer value of the hue
      def hue(color)
        r,g,b = to_truecolor_bytes(color)
        min, max = [r,g,b].min, [r,g,b].max
        d = max - min
        h = case max
            when min; 0
            when r; 60 * (g-b)/d
            when g; 60 * (b-r)/d + 120
            when b; 60 * (r-g)/d + 240
            end % 360
      end
    
      # Calculates a range to hues given the color, tolerance, and
      #
      # Example:
      #   @colr.swatch(:red) #=> 0..10
      #
      # Returns a new Range of hues
      def swatch(color, tolerance, degrees)
        h = hue(PREDEFINED_COLORS[color.to_sym]) + degrees
        Range.new(h, h+tolerance)
      end
    
    end
    
    View full entry
    Finished in 3rd place with a final score of 3.6/5. (View the Gist)
  • README.md

    Description

    A new ChunkyPNG::Image#select! method requires 3 parameters (x, y and tolerance), picks a pixel from the position (x, y) and decolorizes only those pixels of the image, which have different hue from the target pixel. tolerance parameter is the tolerance level to use when colorizing parts of image. Increase this level if too many pixels grayed out, decrease it if too many left colorized.

    Usage

    image = ChunkyPNG::Image.from_file('input.png')
    image.select!(100, 101, 15.0)
    image.save("output.png")
    
    output_yellow.png
    output_green.png
    output_blue.png
    input.png
    output_dark_blue.png
    output_red.png
    selective_color.rb
    require 'chunky_png'
    
    module ChunkyPNG
      class Image
        def select!(x, y, tolerance)
          target_hue = hue(self[x, y])
          for i in 0...width
            for j in 0...height
              rgb = self[i, j]
              if (target_hue - hue(rgb)).abs >= tolerance
                self[i, j] = to_gray(rgb)
              end
            end
          end
        end
    
        def to_gray(rgb)
          r = Color::r(rgb)
          g = Color::g(rgb)
          b = Color::b(rgb)
          gray = (r*0.30 + g*0.59 + b*0.11).round.to_i
          ChunkyPNG::Color(gray, gray, gray)
        end
    
        def hue(rgb)
          r = Color::r(rgb).to_f
          g = Color::g(rgb).to_f
          b = Color::b(rgb).to_f
    
          mx = [r, g, b].max
          mn = [r, g, b].min
    
          c = mx - mn
    
          60 * if c == 0
            0
          elsif mx == g
            (b - r) / c + 2
          elsif mx == b
            (r - g) / c + 4
          elsif g < b
            (g - b) / c + 6
          else
            (g - b) / c 
          end
        end
      end
    end
    
    { :dark_blue => [ 60,  60, 10.0],
      :blue      => [ 80,  80, 18.0],
      :green     => [100, 100, 18.0],
      :yellow    => [130, 135, 22.0],
      :red       => [150, 160, 11.0]
    }.each do |crayon, (x, y, tolerance)|
      image = ChunkyPNG::Image.from_file('input.png')
      image.select!(x, y, tolerance)
      image.save("output_#{crayon}.png")
    end
    
    View full entry
    Finished in 4th place with a final score of 3.5/5. (View the Gist)
  • red.png
    blue.png
    violet.png
    green.png
    yellow.png
    00-hue.rb
    require 'chunky_png'
    extend  ChunkyPNG::Color
    
    # So you didn't like yellow, yes? Me neither.
    colors = {
      red:    0.00..0.04,
      yellow: 0.04..0.18,
      green:  0.18..0.33,
      blue:   0.34..0.60,
      violet: 0.61..1.00
    }
    
    # Returns the hue from 0..1.00. (0.00 = red, 0.33 = green, 0.66 = blue)
    def h(c)
      rgb  = [r(c), g(c), b(c)]
      min, mid, max = rgb.sort
      hue  = 0.3333 * rgb.index(max)           # Start with the brightest color. Then,
      inf  = 0.1666 * (mid-min) / (max-min)    # influence it with the middle color.
      inf *= -1  if rgb.index(mid) == (rgb.index(max)-1)%3
      hue + inf
    end
    
    # ALL THE COLORS!
    colors.each do |color, range|
      image = ChunkyPNG::Image.from_file('input.png')
      image.pixels.map! { |c| range.include?(h c) ? c : to_grayscale(c) }
      image.save "#{color}.png"
    end
    
    # BONUS! To turn `h` into `hsb` (hue-sat-brightness), change line 20 to:
    # [ hue+inf, (max-min)*1.0/max, max/255.0 ]
    
    View full entry
    Finished in 5th place with a final score of 3.5/5. (View the Gist)
  • Gemfile
    source "http://rubygems.org"
    
    gem "chunky_png", "~> 1.2.1"
    gem "colour", "~> 0.4.0"
    
    .gitignore
    input.png
    /Gemfile.lock
    readme.md

    selective-color

    Turns the input image (input.png) into a selectively colored image, turning it into grayscale with just the lighter blue crayon left colored.

    dependencies

    usage

    $ bundle install
    $ curl -o input.png https://raw.github.com/gist/7addc24b123aad374832/96656b5a75287f8ca8beac523f1d32eee4030aa5/output.png
    $ ruby selective_color.rb
    
    output.png
    selective_color.rb
    require 'chunky_png'
    require 'colour'
    
    #color_range = (14..65) # yellow
    color_range = (170..220) # lighter blue
    
    input = ChunkyPNG::Image.from_file('input.png')
    output = input.grayscale
    
    input.pixels.each_index do |x| 
        pixel = input.pixels[x]
        hsv = RGB.new(ChunkyPNG::Color.r(pixel), ChunkyPNG::Color.g(pixel), ChunkyPNG::Color.b(pixel)).to_hsv
        output.pixels[x] = pixel if color_range.cover? hsv.h
    end
    
    output.save('output.png')
    
    View full entry
    Finished in 6th place with a final score of 3.4/5. (View the Gist)
  • output.png
    decolour.rb
    require 'chunky_png'
    
    module ChunkyPNG
      module Color
        def nearly(c, t)
          (hue(c) - hue(t)).abs < 10
        end
        def hue(c)
          180/Math::PI*Math.atan2(Math.sqrt(3)*(g(c)-b(c)) , 2*(r(c)-g(c)-b(c)) )
        end
      end
      class Image
        def decolour!
          target = Color.rgba(235,73,54,255)
          target = Color.rgba(21,138,209,255)
          width.times do |n|
            height.times do |m|
              current = self[n,m]
              self[n,m] = Color.nearly(current, target)? current : Color.to_grayscale(current)
            end
          end
        end
      end
    end
    
    image = ChunkyPNG::Image.from_file('input.png')
    image.decolour!
    image.save('output.png')
    
    View full entry
    Finished in 7th place with a final score of 3.3/5. (View the Gist)
  • Gemfile
    # A sample Gemfile
    source "http://rubygems.org"
    
    gem "chunky_png"
    
    # gem "rails"
    
    Gemfile.lock
    GEM
      remote: http://rubygems.org/
      specs:
        chunky_png (1.2.4)
    
    PLATFORMS
      ruby
    
    DEPENDENCIES
      chunky_png
    
    README.md

    Codebrawl Selective Color entry

    All the code so far is in select.rb. I set up a few helper functions at the top of the file to get color components in floating point for the hue calculations. The hue calculation itself is ripped straight from Wikipedia HSL and HSV, implemented in pure Ruby. I also took advantage of ChunkyPNG's already-implemented grayscale conversion (after rolling my own first, then realizing my mistake).

    The commands used to generate all the versions are:

    ./select.rb 20 65 input.png yellow.png
    ./select.rb 0 18 input.png red.png
    ./select.rb 65 90 input.png green.png
    ./select.rb 185 215 input.png blue.png
    ./select.rb 235 245 input.png violet.png
    

    where the command line syntax is:

    select.rb <lower hue limit, 0-360.0> <upper hue limit, 0-360.0> <input file> <output file>
    

    Since I'm not a GUI expert, I haven't wrapped this up with its own color picker - I used the ColorSync Utility built-in to my MacBook to estimate Hue value ranges for the different crayons.

    yellow.png
    violet.png
    blue.png
    red.png
    green.png
    select.rb
    #!/usr/bin/env ruby
    
    require 'bundler'
    require 'chunky_png'
    
    def r_f(color)
      ChunkyPNG::Color.r(color) / 255.0
    end
    def g_f(color)
      ChunkyPNG::Color.g(color) / 255.0
    end
    def b_f(color)
      ChunkyPNG::Color.b(color) / 255.0
    end
    
    def hue(color)
      return 0.0 if ChunkyPNG::Color.grayscale? color
    
      alpha = 0.5 * (2*r_f(color) - g_f(color) - b_f(color))
      beta = Math.sqrt(3)/2.0 * (g_f(color) - b_f(color))
      rad = Math.atan2(beta, alpha)
      rad = 2*Math::PI + rad if rad < 0
      rad / (2*Math::PI) * 360.0
    end
    
    unless ARGV.length == 4
      puts <<HELP
    Usage:
      select.rb <lower hue limit, 0-360.0> <upper hue limit, 0-360.0> <input file> <output file>
    HELP
      exit(0)
    end
    
    lower_limit = Float(ARGV[0])
    upper_limit = Float(ARGV[1])
    input_file = ARGV[2]
    output_file = ARGV[3]
    
    puts "reading from #{input_file}"
    puts "writing to #{output_file}"
    
    input = ChunkyPNG::Image.from_file(input_file)
    
    puts "keeping pixels with #{lower_limit} <= hue <= #{upper_limit}"
    
    (0...input.height).each do |y|
      puts "row #{y}"
      (0...input.width).each do |x|
        h = hue input[x,y] 
        unless lower_limit <= h && h <= upper_limit
          input[x,y] = ChunkyPNG::Color.grayscale ChunkyPNG::Color.grayscale_teint input[x,y]
        end
      end
    end
    
    input.save(output_file)
    
    View full entry
    Finished in 8th place with a final score of 3.2/5. (View the Gist)
  • README.md

    Selective color effect

    This script applies the widely-known selective color effect on a picture.

    It's run by executing ruby selective_color.rb on the shell.

    The script will generate an output.png in the same folder as the script based on the source picture input.png.

    Pre-requisites

    You need the gem chunky_png installed on your environment. Just run this:

    gem install chunky_png

    Overrides

    With the attached example input.png you can modify the script to extract the color of a specific crayon (default is yellow).

    Just change line #6 with the color of the crayon you want.

    output.png
    input.png
    chunky_png.rb
    require 'chunky_png'
    
    module ChunkyPNG
      class Image
        def [](x,y)
          Pixel.new(x, y, get_pixel(x,y), self)
        end
      end
      
      class Pixel
        def initialize(x,y,pixel,image)
          @x, @y, @pixel, @image = x, y, pixel, image
        end
        
        # Gets the hue of a color
        def hue
          r, g, b = rgb = Color.to_truecolor_bytes(@pixel).map{|v|v/255.0}
          min, max = rgb.min, rgb.max
          { r => (g-b)/(r-min)+0,
            g => (b-r)/(g-min)+2,
            b => (r-g)/(b-min)+4 }[max] / 6
        end
        
        # Sets the pixel to a grayscaled color
        def make_gray
          @image.set_pixel(@x, @y, Color.to_grayscale(@pixel))
        end
      end
    end
    
    selective_color.rb
    require_relative 'chunky_png'
    
    rainbow = [0.000,  0.037,  0.185, 0.360,   0.600, 0.725]
    h       = [:red, :yellow, :green, :blue, :purple]
    
    color   = :yellow
    t       = rainbow[h.index(color), 2]
    the_hues_i_want = (t[0]..t[1])
    
    image = ChunkyPNG::Image.from_file('input.png')
    image.width.times {|x| image.height.times {|y|
      pixel = image[x,y]
      pixel.make_gray unless the_hues_i_want.include? pixel.hue
    } }
    image.save('output.png')
    
    View full entry
    Finished in 9th place with a final score of 3.2/5. (View the Gist)
  • .gitignore
    input.png
    
    README.mkd
    Selective Desaturation
    =======================
    
    It's not perfect, but this solution uses [Delta-E][] color differerence formula from the
    [Lab color space][LAB] (specifically the [CIE76][] definition) to compare every
    pixel in the image to the target color. If the difference is greater than some
    threshold, the pixels is set to grayscale.
    
    Thanks for [Bruce Lindbloom][Conversions] for the correct formulas, although I'm
    pretty sure they are exactly correct. It seems to work well enough, so it might
    just be the scale that is off.
    
    ## Example
    
        image = ChunkyPNG::Image.from_file('input.png')
    
        green = ChunkyPNG::Color.rgb(151, 207, 63)
        
        # this method takes an optional threshold paramter - 3.5 works for this
        image.selective_color!(green)
    
        image.save('output.png')
    
    [Conversions]: http://www.brucelindbloom.com/
    [CIE76]: http://en.wikipedia.org/wiki/Color_difference#CIE76
    [Delta-E]: http://www.colorwiki.com/wiki/Delta_E:_The_Color_Difference
    [LAB]: http://en.wikipedia.org/wiki/Lab_color_space
    
    output.png
    solution.rb
    require 'oily_png'
    require 'matrix'
    
    module ChunkyPNG
      class Image
        def selective_color!(color, threshold=3.5)
          scores = []
          pixels.map! do |pixel|
            score = ChunkyPNG::Color.lab_difference(pixel, color)
            scores << score
            if score > threshold
              ChunkyPNG::Color.to_grayscale(pixel)
            else
              pixel
            end
          end
          puts "Difference ranges from #{scores.min} -> #{scores.max}"
        end
      end
    
      module Color
        SRGB_GAMMA = 2.2
        # http://www.brucelindbloom.com/
        SRGB_MATRIX = ::Matrix[[0.4124564, 0.3575761, 0.1804375],
            [0.2126729, 0.7151522, 0.0721750],
            [0.0193339, 0.1191920, 0.9503041]]
        K = 24389.0/27.0
        E = 216.0/24389.0
        # http://en.wikipedia.org/wiki/Illuminant_D65
        REFERENCE_WHITE_D65 = [95.047, 100.00, 108.883]
    
        # Calculate Delta E difference score
        # http://en.wikipedia.org/wiki/Color_difference#Delta_E
        def lab_difference(color, other_color)
          lab_color = rgb_to_lab(color)
          other_lab_color = rgb_to_lab(other_color)
          score = (0..2).collect do |i|
            (lab_color[i] - other_lab_color[i]) ** 2
          end.reduce(:+)
          score = Math.sqrt(score)
          score
        end
    
        def rgb_to_lab(color)
          xyz_to_lab(rgb_to_xyz(color))
        end
    
        def rgb_to_xyz(color)
          linearized = linearize(color)
          result = SRGB_MATRIX * linearized
          result.column_vectors[0].to_a
        end
    
        def xyz_to_lab(channels)
          lab = channels.collect.with_index do |channel, i|
            xyz_channel_to_lab_partial(channel / REFERENCE_WHITE_D65[i])
          end
    
          l = 116 * lab[0] - 16
          a = 500 * (lab[0] - lab[1])
          b = 200 * (lab[1] - lab[2])
          [l, a, b]
        end
    
        def xyz_channel_to_lab_partial(x)
          if x > E
            x ** 1/3.0
          else
            (K * x + 16) / 116
          end
        end
    
        def linearize(color)
          ::Matrix[[linearize_component(r(color))],
              [linearize_component(g(color))],
              [linearize_component(b(color))]]
        end
    
        def linearize_component(component)
          component / 255.0
        end
      end
    end
    
    image = ChunkyPNG::Image.from_file('input.png')
    
    purple = ChunkyPNG::Color.rgb(57, 54, 166)
    blue = ChunkyPNG::Color.rgb(7, 127, 87)
    green = ChunkyPNG::Color.rgb(151, 207, 63)
    yellow = ChunkyPNG::Color.rgb(255, 214, 20)
    red = ChunkyPNG::Color.rgb(229, 41, 18)
    # 50, 68, 59 in LAB
    # 34, 19, 2 in XYZ
    
    image.selective_color!(green)
    image.save('output.png')
    
    View full entry
    Finished in 10th place with a final score of 3.0/5. (View the Gist)
  • output_5.png
    output_3.png
    output_1.png
    output_2.png
    output_4.png
    gistfile1.rb
    require 'chunky_png'
    
    module ChunkyPNG::Color
      LUMA_WEIGHTS = [0.30, 0.59, 0.11]
      #based on http://en.wikipedia.org/wiki/HSL_and_HSV
    
      def self.to_hcy_floats(i) #creates hue, chroma, luma tuple
        r,g,b = to_truecolor_bytes(i).map {|x| x/255.0}
        minx,maxx = [r,g,b].minmax 
    
        chroma = maxx-minx
    
        hue = if chroma == 0
          0
        else
          case maxx
            when r
              ((g-b)/chroma) % 6
            when g
              (b-r)/chroma + 2
            when b
              (r-g)/chroma + 4
          end * 60
        end
    
        luma = [r,g,b].zip(LUMA_WEIGHTS).map {|x,w| x*w}.inject(&:+)
    
        [hue,chroma,luma]
      end
    
      def self.hcy(hue,chroma,luma)
        segment = hue / 60
        h2 = (1 - ((segment % 2) - 1).abs) #h2 is the portion of hue from the second-largest component. it gives us the distance from the 'max' component, along a hexagon edge, to the actual hue angle. is a float in 0..1 range.
    
        rgb1 = [
          [1,h2,0],
          [h2,1,0],
          [0,1,h2],
          [0,h2,1],
          [h2,0,1],
          [1,0,h2]
        ][segment.floor].map {|x| chroma*x }
    
        m = luma - rgb1.zip(LUMA_WEIGHTS).map {|x,w| x*w}.inject(&:+)
        r,g,b = rgb1.map {|x| (x+m)}.map {|x| (x*255).to_i}
    
        rgb(r,g,b)
      end
    end
    
    
    if __FILE__ == $0 #unless we're being loaded as a library
      #TODO: get chunks and prompt
      colors = [0,36,75,205,240]
      tolerance = 19
      colors.each_with_index do |color,i|
        image = ChunkyPNG::Image.from_file("input.png")
    
        image.pixels.map! do |p| h,c,y = ChunkyPNG::Color.to_hcy_floats(p)
          if (h-color).abs < tolerance || (h-color+360).abs < tolerance
            p
          else
           ChunkyPNG::Color.hcy(h,0,y)
          end 
        end
    
        image.save("output_#{i+1}.png")
      end
    end
    
    View full entry
    Finished in 11th place with a final score of 3.0/5. (View the Gist)
  • output.png
    solution.rb
    #!/usr/bin/env ruby
    
    require 'chunky_png'
    
    # Convert the RGB coordinates to polar form (HSV) and get the hue.
    # Formula from: http://en.wikipedia.org/wiki/Hue#Computing_hue_from_RGB
    # Doesn't handle all the cases (R >= B > G, for example), but it gets the job done.
    def get_hue(r,g,b)
    	max = [r,g,b].max
    	if max == r
    		hue = 0.0 + 60.0 * (g - b)
    		if(hue < 0.0)
    			hue += 360.0
    		end
    	elsif max == g
    		hue = 120.0 + 60.0 * (b-r)
    	else
    		hue= 240+60*(r-g)
    	end
    	return hue
    end
    
    image = ChunkyPNG::Image.from_file('input.png')
    
    # The hue of the color we want to select
    color = 204
    req_range = color-34..color+34
    
    # Iterate over the pixels in the image.
    # If the hue for the pixel is NOT in the required range, convert that pixel to grayscale.
    # The required range is 20 degrees +/- a single hue.
    # Here blue has hue of 240, so our range is 220 -> 260.
    for i in 0...image.width
    	for j in 0...image.height
    		pixel = image.get_pixel(i,j)
    
    		r = ChunkyPNG::Color.r(pixel).to_f
    		g = ChunkyPNG::Color.g(pixel).to_f
    		b = ChunkyPNG::Color.b(pixel).to_f
    
    		hue = get_hue(r/255,g/255,b/255) # Convert co-ordinates to be between 0 and 1
    
    		if not req_range.include?(hue)
    			image.set_pixel(i,j,ChunkyPNG::Color.to_grayscale(pixel))
    		end
    	end
    end
    
    image.save('output.png')
    
    View full entry
    Finished in 12th place with a final score of 3.0/5. (View the Gist)
  • README.md

    Selective Color

    The algorithm calculates the hue of each pixel. Each hue interval represents a color hue, so knowing the intervals allow us to filter out hues we do not want (to change them to grayscale).

    output_violet.png
    selective.rb
    require 'chunky_png'
    include ChunkyPNG
    
    def hue pixel
      rgb = { r: Color.r(pixel), g: Color.g(pixel), b: Color.b(pixel) }
      y = 2 * rgb[:r] - rgb[:g] - rgb[:b]
      return 0 if y.zero?
      x = Math.sqrt(3) * (rgb[:g] - rgb[:b])
      (57.296 * Math.atan2(y, x)).to_i + 180
    end
    
    def hue_pass(hue, color)
      case color
        when :violet then (hue >= 0 and hue <= 35)
        else true
      end
    end
    
    def selective_color(input, output, color)
      image = Image.from_file input
      
      image.pixels.map! do |pixel|
        unless hue_pass hue(pixel), color
          Color.to_grayscale pixel
        else
          pixel
        end
      end
    
      image.save output
    end
    
    selective_color 'input.png', 'output_violet.png', :violet
    
    View full entry
    Finished in 13th place with a final score of 3.0/5. (View the Gist)
  • README.md

    This is an entry to CodeBrawl Contest Selective color with ChunkyPNG

    • Here I used a struct to store the rgba info (although I could've just used rgb instead).
    • Added to_rgba method to Fixnum.
    • Created an iterator for canvas that passes the rgba value to the block and assigns the result to the pixel at that point.
    • Indigo Color Detection determined through brute force, deducing the needed conditions using any dropper/color metering tool on the image.
    output.png
    scolor.rb
    require 'chunky_png'
    
    class Color < Struct.new(
      :red, :green, :blue, :alpha)
      def to_i
        ChunkyPNG::Color.rgba(*self)
      end
    end
    
    class Fixnum
      def to_rgba
        Color[ChunkyPNG::Color.r(self),
              ChunkyPNG::Color.g(self),
              ChunkyPNG::Color.b(self),
              ChunkyPNG::Color.a(self)]
      end
    end
    
    class ChunkyPNG::Canvas
      def diff!(canvas, &block)
        area.times do |i|
          @pixels[i] = block.call(self.pixels[i].to_rgba, canvas.pixels[i].to_rgba)
        end
      end
    end
    
    image    = ChunkyPNG::Canvas.from_file('input.png')
    gs_image = image.grayscale
    
    image.diff!(gs_image) do |original, grayscale|
      if(original.red < 120 && original.green < 120 && original.blue > 40 &&
         original.blue - original.red   > 40 &&
         original.blue - original.green > 40 &&
         (original.red - original.green).abs <= 10)
        original.to_i
      else
        grayscale.to_i
      end
    end
    
    image.save('output.png')
    
    View full entry
    Finished in 14th place with a final score of 2.8/5. (View the Gist)
  • output.png
    selective_color.rb
    require 'chunky_png'
    
    module ChunkyPNG::Color
      def rgb_to_hsl(color)
        r = r(color) / 255.0 #  0..255 -> 0.0..1.0
        g = g(color) / 255.0
        b = b(color) / 255.0
        max = [r, g, b].max
        min = [r, g, b].min
    
        l = (max + min) / 2.0
        d = max - min
    
        if d == 0
          h = s = 0
        else
    
          if ( l < 0.5 ) 
            s = d / ( max + min )
          else
            s = d / ( 2.0 - max - min )
          end
    
          d_r = (((max - r) / 6.0) + (max / 2.0)) / d
          d_g = (((max - g) / 6.0) + (max / 2.0)) / d
          d_b = (((max - b) / 6.0) + (max / 2.0)) / d
    
          if max == r
            h = d_b - d_g
          elsif max == g
            h = (1/3.0) + d_r - d_b
          else
            h = (2/3.0) + d_g - d_r
          end
          h += 1.0 if h < 0.0
          h -= 1.0 if h > 1.0
        end
    
        [h * 360, s * 100, l * 100].map(&:to_i)
      end
    end
    
    class ChunkyPNG::Canvas
      # delta between two angles in range (0..360)
      # e.g. smallest_delta_degree 5, 1   # => 4
      #      smallest_delta_degree 5, 359 # => 6
      def smallest_delta_degree deg1, deg2
        [deg1 - deg2, deg1 + 360 - deg2, deg2 - deg1 + 360].map(&:abs).min
      end
      
      # checks if a degree is in a degree range; handles special wrap around 360째 case
      def in_degree_range deg_start, deg_end, deg
        (deg_start > deg_end) ? (deg >= deg_start || deg <= deg_end) : (deg >= deg_start && deg <= deg_end)
      end
      
      def ajust_alpha color, alpha
        ChunkyPNG::Color.rgba(ChunkyPNG::Color.r(color), ChunkyPNG::Color.g(color), ChunkyPNG::Color.b(color), ChunkyPNG::Color.a(color) * alpha)
      end
      
      # Clears out (sets to transparent) all pixels that are in a specific hue range.
      # The hue values need to be ordered (but wrapping around the 360째 break is ok).
      #
      # Uses a linear ramp to soften the edges:
      #   + If the hue of a pixel is between hue_clear_start and hue_clear_stop 
      #     (marked with 1 in the ASCII art) the pixel's alpha value will be set to 255
      #   + If the hue of the pixel is between hue_clear_start_ramp and hue_clear_start (2)
      #     or if it is between hue_clear_stop and hue_clear_stop_ramp (3) the pixel's 
      #     alpha value gets proportional adjusted depending the distance to 
      #     hue_clear_start / hue_clear_stop
      # 
      # 
      #                ___________________________________                    <-- fully transparent
      #              / 11111111111111111111111111111111111`
      #            / 221111111111111111111111111111111111133`
      #          / 2222111111111111111111111111111111111113333`
      # _______/ 22222211111111111111111111111111111111111333333`___________   <-- fully opaque
      #
      #        ^      ^                                  ^      ^
      #        |      |                                  |      | 
      #        |      ` hue_clear_start                  |      ` hue_clear_stop_ramp
      #        |                                         |
      #        ` hue_clear_start_ramp                    ` hue_clear_stop
      #
      def clear_hue_range_with_linear_ramp! hue_clear_start_ramp, hue_clear_start, hue_clear_stop, hue_clear_stop_ramp, min_luminance
        start_ramp_size = smallest_delta_degree(hue_clear_start, hue_clear_start_ramp)
        stop_ramp_size  = smallest_delta_degree(hue_clear_stop_ramp, hue_clear_stop)
        @pixels.each_with_index do |color, index|
          hue, saturation, luminance = ChunkyPNG::Color::rgb_to_hsl(color)
          # skip pixel if it is nearly black, not really necesssary, since these colors tend to be perceived as black anyways
          next if luminance < min_luminance 
          if in_degree_range(hue_clear_start, hue_clear_stop, hue)          # (1)
            @pixels[index] = ajust_alpha(color, 255)
          elsif in_degree_range(hue_clear_start_ramp, hue_clear_start, hue) # (2)
            alpha          = (smallest_delta_degree(hue_clear_start, hue) / start_ramp_size.to_f * 255.0).to_i
            @pixels[index] = ajust_alpha(color, alpha)
          elsif in_degree_range(hue_clear_stop, hue_clear_stop_ramp, hue)   # (3)
            alpha          = (smallest_delta_degree(hue_clear_stop, hue) / stop_ramp_size.to_f * 255.0).to_i
            @pixels[index] = ajust_alpha(color, alpha)
          end
        end
      end
    end
    
    
    input = ChunkyPNG::Image.from_file('input.png')
    grayscale = input.dup # work on copy of input
    grayscale.clear_hue_range_with_linear_ramp!(176, 189, 214, 230, 12) # remove the blue crayon from grayscale image
    # grayscale.save("debug.png")
    grayscale.grayscale! # transform image into grayscale
    input.compose(grayscale).save("output.png") # copy over grayscale image on top of input image  
    
    View full entry
    Finished in 15th place with a final score of 2.8/5. (View the Gist)
  • README.md

    Code Brawl #10: Selective Color With ChunkyPNG

    Code Brawl #10: Selective Color With ChunkyPNG

    ColorFilter can be used to selectively color a png. The following colors are supported: blue, green, purple, red, yellow

    Example:

    # load png
    png = ColorFilter.new 'input.png'
    
    # save png with purple selective coloring
    png.filter('purple').save 'output.png'
    
    # save png for each supported color
    ['purple','blue','green','yellow','red'].each do |c|
      png.filter(c).save("output_#{c}.png")
    end
    
    .output_0_purple.png
    .output_1_blue.png
    .output_2_green.png
    .output_4_red.png
    .output_3_yellow.png
    color_filter.rb
    require 'chunky_png'
    
    class ColorFilter
      def initialize(png)
        @png = ChunkyPNG::Image.from_file(png)
      end
    
      def filter(c)
        valid_c = respond_to?("#{c}?")
        filter = @png
        filter.pixels.each_with_index do |v, i|
          if !valid_c || !method("#{c}?").call(v)
            filter.pixels[i] = ChunkyPNG::Color.to_grayscale(v)
          end
        end
        filter
      end
    
      def blue?(c)
        r(c) < 110 && g(c) > 100 && b(c) > 150
      end
    
      def green?(c)
        r(c) > 50 && r(c) < 200 && g(c) > 120 && b(c) < 150
      end
    
      def purple?(c)
        r(c) > 0 && g(c) < 105 && b(c) > 80
      end
    
      def red?(c)
        r(c) > 50 && g(c) < 90 && b(c) < 100
      end
    
      def yellow?(c)
        r(c) > 200 && g(c) > 110 && b(c) < 200
      end
    
      private
    
      def r(c); ChunkyPNG::Color.r(c); end
      def g(c); ChunkyPNG::Color.g(c); end
      def b(c); ChunkyPNG::Color.b(c); end
    end
    
    View full entry
    Finished in 16th place with a final score of 2.6/5. (View the Gist)