Raster graphics operations/Ruby
The Code
Collecting all the Ruby code from Category:Raster graphics operations, so one can invoke: require 'raster_graphics'
Uses the ChunkyPNG pure-Ruby PNG library. Important to note won't work with `frozen_string_literal: true` uses mutable String operations.
<lang ruby>###########################################################################
- frozen_string_literal: false
- Represents an RGB[1] colour.
class RGBColour
# Red, green and blue values must fall in the range 0..255. def initialize(red, green, blue) ok = [red, green, blue].inject(true) { |ok, c| ok &= c.between?(0, 255) } raise ArgumentError, "invalid RGB parameters: #{[red, green, blue].inspect}" unless ok
@red = red @green = green @blue = blue end attr_reader :red, :green, :blue alias r red alias g green alias b blue
# Return the list of [red, green, blue] values. # RGBColour.new(100,150,200).values # => [100, 150, 200] # call-seq: # values -> array # def values [@red, @green, @blue] end
# Equality test: two RGBColour objects are equal if they have the same # red, green and blue values. # call-seq: # ==(a_colour) -> true or false # def ==(a_colour) values == a_colour.values end
# Comparison test: compares two RGBColour objects based on their #luminosity value # call-seq: # <=>(a_colour) -> -1, 0, +1 # def <=>(a_colour) luminosity <=> a_colour.luminosity end
# Calculate a integer luminosity value, in the range 0..255 # RGBColour.new(100,150,200).luminosity # => 142 # call-seq: # luminosity -> int # def luminosity Integer(0.2126 * @red + 0.7152 * @green + 0.0722 * @blue) end
# Return a new RGBColour value where all the red, green, blue values are the # #luminosity value. # RGBColour.new(100,150,200).to_grayscale.values # => [142, 142, 142] # call-seq: # to_grayscale -> a_colour # def to_grayscale l = luminosity self.class.new(l, l, l) end
# Return a new RGBColour object given an iteration value for the Pixmap.mandelbrot # method. def self.mandel_colour(i) new(16 * (i % 15), 32 * (i % 7), 8 * (i % 31)) end
RED = RGBColour.new(255, 0, 0) GREEN = RGBColour.new(0, 255, 0) BLUE = RGBColour.new(0, 0, 255) YELLOW = RGBColour.new(255, 255, 0) BLACK = RGBColour.new(0, 0, 0) WHITE = RGBColour.new(255, 255, 255)
end
- A Pixel represents an (x,y) point in a Pixmap.
Pixel = Struct.new(:x, :y)
class Pixmap
def initialize(width, height) @width = width @height = height @data = fill(RGBColour::WHITE) end attr_reader :width, :height
def fill(colour) @data = Array.new(@width) { Array.new(@height, colour) } end
def validate_pixel(x, y) unless x.between?(0, @width - 1) && y.between?(0, @height - 1) raise ArgumentError, "requested pixel (#{x}, #{y}) is outside dimensions of this bitmap" end end
############################################### def [](x, y) validate_pixel(x, y) @data[x][y] end alias get_pixel []
def []=(x, y, colour) validate_pixel(x, y) @data[x][y] = colour end alias set_pixel []=
def each_pixel if block_given? @height.times { |y| @width.times { |x| yield x, y } } else to_enum(:each_pixel) end end
############################################### # write to file/stream PIXMAP_FORMATS = %w[P3 P6].freeze # implemented output formats PIXMAP_BINARY_FORMATS = ['P6'].freeze # implemented output formats which are binary
def write_ppm(ios, format = 'P6') raise NotImplementedError, "pixmap format #{format} has not been implemented" unless PIXMAP_FORMATS.include?(format)
ios.puts format, "#{@width} #{@height}", '255' ios.binmode if PIXMAP_BINARY_FORMATS.include?(format) each_pixel do |x, y| case format when 'P3' then ios.print @data[x][y].values.join(' '), "\n" when 'P6' then ios.print @data[x][y].values.pack('C3') end end end
def save(filename, opts = { format: 'P6' }) File.open(filename, 'w') do |f| write_ppm(f, opts[:format]) end end alias write save
def print(opts = { format: 'P6' }) write_ppm($stdout, opts[:format]) end
def save_as_jpeg(filename, quality = 75) # using the ImageMagick convert tool
pipe = IO.popen("convert ppm:- -quality #{quality} jpg:#{filename}", 'w') write_ppm(pipe) rescue SystemCallError => e warn "problem writing data to 'convert' utility -- does it exist in your $PATH?" ensure begin pipe.close rescue StandardError false end end
def save_as_png(filename) require 'chunky_png' stream = StringIO.new(, 'r+') each_pixel { |x, y| stream << self[x, y].values.pack('ccc') } stream.seek(0) ChunkyPNG::Canvas.extend(ChunkyPNG::Canvas::StreamImporting) canvas = ChunkyPNG::Canvas.from_rgb_stream(width, height, stream) canvas.to_image.save(filename) end
############################################### # read from file/pipe def self.read_ppm(ios) format = ios.gets.chomp width, height = ios.gets.chomp.split.map(&:to_i) max_colour = ios.gets.chomp
if !PIXMAP_FORMATS.include?(format) || (width < 1) || (height < 1) || (max_colour != '255') ios.close raise StandardError, "file '#{filename}' does not start with the expected header" end ios.binmode if PIXMAP_BINARY_FORMATS.include?(format)
bitmap = new(width, height) bitmap.each_pixel do |x, y| # read 3 bytes red, green, blue = case format when 'P3' then ios.gets.chomp.split when 'P6' then ios.read(3).unpack('C3') end bitmap[x, y] = RGBColour.new(red, green, blue) end ios.close bitmap end
def self.open(filename) read_ppm(File.open(filename, 'r')) end
def self.open_from_jpeg(filename) raise ArgumentError, "#{filename} does not exists or is not readable." unless File.readable?(filename)
begin pipe = IO.popen("convert jpg:#{filename} ppm:-", 'r') read_ppm(pipe) rescue SystemCallError => e warn "problem reading data from 'convert' utility -- does it exist in your $PATH?" ensure begin pipe.close rescue StandardError false end end end
############################################### # conversion methods def to_grayscale gray = self.class.new(@width, @height) each_pixel do |x, y| gray[x, y] = self[x, y].to_grayscale end gray end
def to_blackandwhite hist = histogram
# find the median luminosity median = nil sum = 0 hist.keys.sort.each do |lum| sum += hist[lum] if sum > @height * @width / 2 median = lum break end end
# create the black and white image bw = self.class.new(@width, @height) each_pixel do |x, y| bw[x, y] = self[x, y].luminosity < median ? RGBColour::BLACK : RGBColour::WHITE end bw end
def save_as_blackandwhite(filename) to_blackandwhite.save(filename) end
############################################### def draw_line(p1, p2, colour) validate_pixel(p1.x, p2.y) validate_pixel(p2.x, p2.y)
x1 = p1.x y1 = p1.y x2 = p2.x y2 = p2.y
steep = (y2 - y1).abs > (x2 - x1).abs if steep x1, y1 = y1, x1 x2, y2 = y2, x2 end if x1 > x2 x1, x2 = x2, x1 y1, y2 = y2, y1 end
deltax = x2 - x1 deltay = (y2 - y1).abs error = deltax / 2 ystep = y1 < y2 ? 1 : -1
y = y1 x1.upto(x2) do |x| pixel = steep ? [y, x] : [x, y] self[*pixel] = colour error -= deltay if error.negative? y += ystep error += deltax end end end
############################################### def draw_line_antialised(p1, p2, colour) x1 = p1.x y1 = p1.y x2 = p2.x y2 = p2.y
steep = (y2 - y1).abs > (x2 - x1).abs if steep x1, y1 = y1, x1 x2, y2 = y2, x2 end if x1 > x2 x1, x2 = x2, x1 y1, y2 = y2, y1 end deltax = x2 - x1 deltay = (y2 - y1).abs gradient = 1.0 * deltay / deltax
# handle the first endpoint xend = x1.round yend = y1 + gradient * (xend - x1) xgap = (x1 + 0.5).rfpart xpxl1 = xend ypxl1 = yend.truncate put_colour(xpxl1, ypxl1, colour, steep, yend.rfpart * xgap) put_colour(xpxl1, ypxl1 + 1, colour, steep, yend.fpart * xgap) itery = yend + gradient
# handle the second endpoint xend = x2.round yend = y2 + gradient * (xend - x2) xgap = (x2 + 0.5).rfpart xpxl2 = xend ypxl2 = yend.truncate put_colour(xpxl2, ypxl2, colour, steep, yend.rfpart * xgap) put_colour(xpxl2, ypxl2 + 1, colour, steep, yend.fpart * xgap)
# in between (xpxl1 + 1).upto(xpxl2 - 1).each do |x| put_colour(x, itery.truncate, colour, steep, itery.rfpart) put_colour(x, itery.truncate + 1, colour, steep, itery.fpart) itery += gradient end end
def put_colour(x, y, colour, steep, c) x, y = y, x if steep self[x, y] = anti_alias(colour, self[x, y], c) end
def anti_alias(new, old, ratio) blended = new.values.zip(old.values).map { |n, o| (n * ratio + o * (1.0 - ratio)).round } RGBColour.new(*blended) end
############################################### def draw_circle(pixel, radius, colour) validate_pixel(pixel.x, pixel.y)
self[pixel.x, pixel.y + radius] = colour self[pixel.x, pixel.y - radius] = colour self[pixel.x + radius, pixel.y] = colour self[pixel.x - radius, pixel.y] = colour
f = 1 - radius ddF_x = 1 ddF_y = -2 * radius x = 0 y = radius while x < y if f >= 0 y -= 1 ddF_y += 2 f += ddF_y end x += 1 ddF_x += 2 f += ddF_x self[pixel.x + x, pixel.y + y] = colour self[pixel.x + x, pixel.y - y] = colour self[pixel.x - x, pixel.y + y] = colour self[pixel.x - x, pixel.y - y] = colour self[pixel.x + y, pixel.y + x] = colour self[pixel.x + y, pixel.y - x] = colour self[pixel.x - y, pixel.y + x] = colour self[pixel.x - y, pixel.y - x] = colour end end
############################################### def flood_fill(pixel, new_colour) current_colour = self[pixel.x, pixel.y] queue = Queue.new queue.enqueue(pixel) until queue.empty? p = queue.dequeue next unless self[p.x, p.y] == current_colour
west = find_border(p, current_colour, :west) east = find_border(p, current_colour, :east) draw_line(west, east, new_colour) q = west while q.x <= east.x %i[north south].each do |direction| n = neighbour(q, direction) queue.enqueue(n) if self[n.x, n.y] == current_colour end q = neighbour(q, :east) end end end
def neighbour(pixel, direction) case direction when :north then Pixel[pixel.x, pixel.y - 1] when :south then Pixel[pixel.x, pixel.y + 1] when :east then Pixel[pixel.x + 1, pixel.y] when :west then Pixel[pixel.x - 1, pixel.y] end end
def find_border(pixel, colour, direction) nextp = neighbour(pixel, direction) while self[nextp.x, nextp.y] == colour pixel = nextp nextp = neighbour(pixel, direction) end pixel end
############################################### def median_filter(radius = 3) radius += 1 if radius.even? filtered = self.class.new(@width, @height)
$stdout.puts "processing #{@height} rows" pb = ProgressBar.new(@height) if $DEBUG
@height.times do |y| @width.times do |x| window = [] (x - radius).upto(x + radius).each do |win_x| (y - radius).upto(y + radius).each do |win_y| win_x = 0 if win_x.negative? win_y = 0 if win_y.negative? win_x = @width - 1 if win_x >= @width win_y = @height - 1 if win_y >= @height window << self[win_x, win_y] end end # median filtered[x, y] = window.sort[window.length / 2] end pb.update(y) if $DEBUG end
pb.close if $DEBUG
filtered end
############################################### def magnify(factor) bigger = self.class.new(@width * factor, @height * factor) each_pixel do |x, y| colour = self[x, y] (x * factor..x * factor + factor - 1).each do |xx| (y * factor..y * factor + factor - 1).each do |yy| bigger[xx, yy] = colour end end end bigger end
############################################### def histogram histogram = Hash.new(0) each_pixel do |x, y| histogram[self[x, y].luminosity] += 1 end histogram end
############################################### def draw_bezier_curve(points, colour) # ensure the points are increasing along the x-axis points = points.sort_by { |p| [p.x, p.y] } xmin = points[0].x xmax = points[-1].x increment = 2 prev = points[0] ((xmin + increment)..xmax).step(increment) do |x| t = 1.0 * (x - xmin) / (xmax - xmin) p = Pixel[x, bezier(t, points).round] draw_line(prev, p, colour) prev = p end end
# the generalized n-degree Bezier summation def bezier(t, points) n = points.length - 1 points.each_with_index.inject(0.0) do |sum, (point, i)| sum += n.choose(i) * (1 - t)**(n - i) * t**i * point.y end end
############################################### def self.mandelbrot(width, height) mandel = Pixmap.new(width, height) pb = ProgressBar.new(width) if $DEBUG width.times do |x| height.times do |y| x_ish = Float(x - width * 11 / 15) / (width / 3) y_ish = Float(y - height / 2) / (height * 3 / 10) mandel[x, y] = RGBColour.mandel_colour(mandel_iters(x_ish, y_ish)) end pb.update(x) if $DEBUG end pb.close if $DEBUG mandel end
def self.mandel_iters(cx, cy) x = y = 0.0 count = 0 while (Math.hypot(x, y) < 2) && (count < 255) x, y = (x**2 - y**2 + cx), (2 * x * y + cy) count += 1 end count end
############################################### # Apply a convolution kernel to a whole image def convolute(kernel) newimg = Pixmap.new(@width, @height) pb = ProgressBar.new(@width) if $DEBUG @width.times do |x| @height.times do |y| apply_kernel(x, y, kernel, newimg) end pb.update(x) if $DEBUG end pb.close if $DEBUG newimg end
# Applies a convolution kernel to produce a single pixel in the destination def apply_kernel(x, y, kernel, newimg) x0 = [0, x - 1].max y0 = [0, y - 1].max x1 = x y1 = y x2 = [@width - 1, x + 1].min y2 = [@height - 1, y + 1].min
r = g = b = 0.0 [x0, x1, x2].zip(kernel).each do |xx, kcol| [y0, y1, y2].zip(kcol).each do |yy, k| r += k * self[xx, yy].r g += k * self[xx, yy].g b += k * self[xx, yy].b end end newimg[x, y] = RGBColour.new(luma(r), luma(g), luma(b)) end
# Function for clamping values to those that we can use with colors def luma(value) if value.negative? 0 elsif value > 255 255 else value end end
end
- Utilities
class ProgressBar
def initialize(max) $stdout.sync = true @progress_max = max @progress_pos = 0 @progress_view = 68 $stdout.print "[#{'-' * @progress_view}]\r[" end
def update(n) new_pos = n * @progress_view / @progress_max if new_pos > @progress_pos @progress_pos = new_pos $stdout.print '=' end end
def close $stdout.puts '=]' end
end
class Queue # < Array
alias enqueue push alias dequeue shift
end
class Numeric
def fpart self - truncate end
def rfpart 1.0 - fpart end
end
class Integer
def choose(k) factorial / (k.factorial * (self - k).factorial) end
def factorial (2..self).reduce(1, :*) end
end</lang>
A Test Suite
<lang ruby>def display_pixmap(filename)
puts "displaying #{filename}" system "./ppmview.rb #{filename} &"
end
if $0 == __FILE__
old_debug = $DEBUG $DEBUG = true
# for testing class Pixmap def ==(a_bitmap) return false if @width != a_bitmap.width or @height != a_bitmap.height @width.times {|x| @height.times {|y| return false if not self[x,y] == (a_bitmap[x,y]) }} true end end
require 'test/unit' class TestRGBColour < Test::Unit::TestCase def test_init color = RGBColour.new(0,100,200) assert_equal(100, color.g) end def test_constants assert_equal([255,0,0], [RGBColour::RED.r,RGBColour::RED.g,RGBColour::RED.b]) assert_equal([0,255,0], [RGBColour::GREEN.r,RGBColour::GREEN.g,RGBColour::GREEN.b]) assert_equal([0,0,255], [RGBColour::BLUE.r,RGBColour::BLUE.g,RGBColour::BLUE.b]) end def test_error color = RGBColour.new(0,100,200) assert_raise(ArgumentError) {RGBColour.new(0,0,256)} end end class TestPixmap < Test::Unit::TestCase def setup @w = 20 @h = 30 @bitmap = Pixmap.new(@w,@h) end def test_init assert_equal(@w, @bitmap.width) assert_equal(@h, @bitmap.height) assert_equal(RGBColour::WHITE, @bitmap.get_pixel(10,10)) end def test_fill @bitmap.fill(RGBColour::RED) assert_equal(255,@bitmap[10,10].red) assert_equal(0,@bitmap[10,10].green) assert_equal(0,@bitmap[10,10].blue) end def test_get_pixel assert_equal(@bitmap[5,6], @bitmap.get_pixel(5,6)) assert_raise(ArgumentError) {@bitmap[100,100]} end def test_grayscale @bitmap.fill(RGBColour::BLUE) @bitmap.height.times {|y| [9,10,11].each {|x| @bitmap[x,y]=RGBColour::GREEN}} @bitmap.width.times {|x| [14,15,16].each {|y| @bitmap[x,y]=RGBColour::GREEN}} @bitmap.save('testcross.ppm') Pixmap.open('testcross.ppm').to_grayscale.save('testgray.ppm') end def test_save @bitmap.fill(RGBColour::BLUE) filename = 'test.ppm' @bitmap.save(filename) expected_size = 3 + (@w.to_s.length + 1 + @h.to_s.length + 1) + 4 + (@w * @h * 3) assert_equal(expected_size, File.size(filename)) end def test_open @bitmap.fill(RGBColour::RED) @bitmap.set_pixel(10,15, RGBColour::WHITE) filename = 'test.ppm' @bitmap.save(filename) new = Pixmap.open(filename) assert(@bitmap == new) end end
# a green cross on a blue background colour_bitmap = Pixmap.new(20, 30) colour_bitmap.fill(RGBColour::BLUE) colour_bitmap.height.times {|y| [9,10,11].each {|x| colour_bitmap[x,y]=RGBColour::GREEN}} colour_bitmap.width.times {|x| [14,15,16].each {|y| colour_bitmap[x,y]=RGBColour::GREEN}} colour_bitmap.save('testcross.ppm') display_pixmap 'testcross.ppm'
Pixmap.open('testcross.ppm').to_grayscale.save('testgray.ppm')
image = Pixmap.open('testcross.ppm') image.save_as_jpeg('testcross.jpg') #image.print(:format => "P3")
bitmap = Pixmap.open_from_jpeg('testcross.jpg') savefile = 'testcross_from_jpeg.ppm' bitmap.save(savefile) display_pixmap savefile
bitmap = Pixmap.new(500, 500) bitmap.fill(RGBColour::BLUE) 10.step(430, 60) do |a| bitmap.draw_line(Pixel[10, 10], Pixel[490,a], RGBColour::YELLOW) bitmap.draw_line(Pixel[10, 10], Pixel[a,490], RGBColour::YELLOW) end bitmap.draw_line(Pixel[10, 10], Pixel[490,490], RGBColour::YELLOW) savefile = 'testlines4.ppm' bitmap.save(savefile) display_pixmap savefile
bitmap = Pixmap.new(30, 30) bitmap.draw_circle(Pixel[14,14], 12, RGBColour::BLACK) savefile = 'testcircle.ppm' bitmap.save(savefile) display_pixmap savefile
bitmap = Pixmap.new(300, 300) bitmap.draw_circle(Pixel[149,149], 120, RGBColour::BLACK) bitmap.draw_circle(Pixel[200,100], 40, RGBColour::BLACK) bitmap.flood_fill(Pixel[140,160], RGBColour::BLUE) savefile = 'testflood.ppm' bitmap.save(savefile) display_pixmap savefile
bitmap = Pixmap.new(500, 500) bitmap.fill(RGBColour::BLUE) 10.step(430, 60) do |a| bitmap.draw_line_antialised(Pixel[10, 10], Pixel[490,a], RGBColour::YELLOW) bitmap.draw_line_antialised(Pixel[10, 10], Pixel[a,490], RGBColour::YELLOW) end bitmap.draw_line_antialised(Pixel[10, 10], Pixel[490,490], RGBColour::YELLOW) bitmap.save('testantialias.ppm') display_pixmap 'testantialias.ppm'
file = 'teapot.ppm' display_pixmap file bitmap = Pixmap.open(file) # test new grayscale savefile = 'teapotgray.ppm' gray = bitmap.to_grayscale gray.save(savefile) display_pixmap savefile # savefile = 'testfiltered.ppm' filtered = bitmap.median_filter filtered.save(savefile) display_pixmap savefile
file = 'teapot.ppm' savefile = 'teapotbw.ppm' display_pixmap file Pixmap.open(file).save_as_blackandwhite(savefile) display_pixmap savefile
bitmap = Pixmap.new(400, 400) points = [ Pixel[40,100], Pixel[100,350], Pixel[150,50], Pixel[150,150], Pixel[350,250], Pixel[250,250] ] points.each {|p| bitmap.draw_circle(p, 3, RGBColour::RED)} bitmap.draw_bezier_curve(points, RGBColour::BLUE) savefile = 'testbezier.ppm' bitmap.save(savefile) display_pixmap savefile
savefile = 'testmandel.ppm' Pixmap.mandelbrot(500,500).save(savefile) display_pixmap savefile # Demonstration code using the teapot image from Tk's widget demo teapot = Pixmap.open('teapot.ppm') [ ['Emboss', [[-2.0, -1.0, 0.0], [-1.0, 1.0, 1.0], [0.0, 1.0, 2.0]]], ['Sharpen', [[-1.0, -1.0, -1.0], [-1.0, 9.0, -1.0], [-1.0, -1.0, -1.0]]], ['Blur', [[0.1111,0.1111,0.1111],[0.1111,0.1111,0.1111],[0.1111,0.1111,0.1111]]], ].each do |label, kernel| savefile = 'test' + label.downcase + '.ppm' teapot.convolute(kernel).save(savefile) display_pixmap savefile end
$DEBUG = old_debug
end </lang>
An Image Viewer
The ppmview.rb
program is:
<lang ruby>#!/usr/bin/ruby
require 'tk'
if ARGV.empty?
$stderr.puts "usage: #{File.basename($0)} imagefile" exit 1
end
filename = ARGV.shift unless File.readable?(filename)
raise ArgumentError, "can't read file '#{filename}'"
end
root = TkRoot.new('title' => File.basename(filename)) label = TkLabel.new(root) {image TkPhotoImage.new('file' => filename)} label.pack Tk.mainloop </lang>