Faster Color Replacement with RMagick

Let's say you would like to replace one or more specific colors in an image with other specific colors.

Replacing Colors the Slow Way

A naive approach is to use RMagick's opaque operation, which is used to replace one color in an image with another color.


require 'rmagick'

img = Magick::Image.read('controller.png').first

# Pull current colors in the image
colors = img.color_histogram.keys.map { |c| c.to_color(Magick::AllCompliance, false, 8, true) }

# Build some pseudo-arbitrary new colors
new_colors = colors.map { |c| "##{c[1..].reverse}" }

colors.each_with_index do |color, i|
  img = img.opaque(color, new_colors[i])
end

img.write('out.png')
      

There are a number of issues with this approach.

I would not recommend this approach unless you have a very specific usecase.

Replacing Colors the Fast Way with Palette Images

Provided the image you would like to manipulate has a relatively limited number of colors (less than 256 is standard), you should consider performing your recoloring operations with palette images. Instead of encoding each color in each pixel, the colors are stored in a lookup table that is referenced by individual pixels. We can recolor our image simply by manipulting that lookup table!


require 'rmagick'
require 'json'

img = Magick::Image.read('controller.png').first

# Convert the image to a palette image with indexed color
img.class_type = Magick::PseudoClass

# Extract the image's palette.  RMagick doesn't expose this on their Image class for reasons.
# We can work around this limitation by pulling a 1x1 pixel version of our image and dumping its data.
dummy = img.excerpt(0, 0, 1, 1)
dummy.format = 'JSON'
colors = JSON.parse(dummy.to_blob).first.dig('image', 'colormap').map do |color|
  Magick::Pixel.from_color(color).to_color(Magick::AllCompliance, false, 8, true)
end

# Build some pseudo-arbitrary new colors
new_colors = colors.map { |c| "##{c[1..].reverse}" }

# Replace colors in the color lookup table
new_colors.each_with_index do |new_color, i|
  img.colormap(i, new_color)
end

img.write('out.png')
      

Running this against the same test image as the earlier script, this operation takes around 1.6 seconds to complete including file I/O. If you have the opportunity to pre-process and cache the palette version of your input image, the entire operation including reading and writing the file only takes around 0.5 seconds, which is the roughly the same amount of time required to read the file in and write it back to disk without manipulation. This means we have nearly free recoloring once you have the image in palette format.

As an added bonus, palette images are smaller on disk -- they should only require 8 bits per pixel instead of 8 bits per channel per pixel. Your mileage will vary depending on how well compression works on your images.