Minimum Enclosing Circle in Ruby

MEC for 15 Random Points
MEC for 15 Random Points
(click to enlarge)

This is a solution of Ruby Quiz #157: The Smallest Circle. The problem is to find the smallest circle that encloses a given set of points. In order to solve it, I Googled around and found the following websites with Java solutions:

Writing this code turned out to be more of a challenge than I anticipated because I did it late at night and made a bunch of newbish mistakes. One pernicious problem was forgetting to ensure that a method I wrote returned the right thing. I forgot to put an explicit return statement in the method that calculates the upper or lower half of the Convex Hull of the set of points, but the last evalutated statement, which happened to be the input set of points, was returned by default. This is SOP for ruby, but it bit me and I had to spend a few hours tracing through the complex algorithm until I saw what was going on and fixed it.

Another issue is that the algorithm used to solve this problem is very complex and the code is difficult to understand. I had to take that the algorithm even worked on faith basically. My math skills are so out of date that a proof that it is correct would be lost on me.

I ended up getting it right in the end I think. Some quick benchmarks show that the solution scales roughly at O(Cn) (some constant linear multiplier of the number of points being enclosed).

$ ruby mec.rb benchmark
                     user     system      total        real
points: 1        0.010000   0.000000   0.010000 (  0.004706)
points: 10       0.130000   0.000000   0.130000 (  0.136395)
points: 100      1.390000   0.090000   1.480000 (  1.484994)
points: 1000    11.940000   0.800000  12.740000 ( 12.755417)
points: 10000  224.350000  11.700000 236.050000 (238.382341)

Using RMagick For Building Graphics

Perhaps the funnest part of solution was coding up a visualization of the Minimum Enclosing Circle by using RMagick to draw a png of the results (see example to the right). Having the picture along with the debug output was an invaluable tool for validating the results. It also taught me a lot about the RMagick gem and it's capabilities. The interface is clean and really fun to work with.

Running the Code

Here is the code:

class Point < Struct.new(:x, :y)
  # Generates a random point around (0,0) with radius = 1
  def self.random
    radius = rand
    theta = 360 * rand
    Point.new(radius*Math.cos(theta), radius*Math.sin(theta))
  end

  # Returns true if x is close to y as defined by eps
  def self.close_to(x, y, eps = 1e-9)
    return (x-y).abs < eps
  end

  def self.rad2deg(a)
    return (a * 180.0) / Math::PI
  end

  def normal_left
    Point.new(self.y, -self.x)
  end

  # Returns the point at the center of this point
  # and the other_point
  def center(other_point)
    Point.new((self.x + other_point.x)/2.0, (self.y + other_point.y)/2.0)
  end

  # Computes the distance between self and other_point
  def distance(other_point)
    ((self.x - other_point.x) ** 2 + (self.y - other_point.y) ** 2) ** 0.5
  end

  def self.inf_line_intersection(base1, v1, base2, v2)
    if (Point.close_to(v1.x, 0.0) && Point.close_to(v2.y, 0.0))
      return Point.new(base1.x, base2.y)
    end
    if (Point.close_to(v1.y, 0.0) && Point.close_to(v2.x, 0.0))
      return Point.new(base2.x, base1.y)
    end

    m1 = !Point.close_to(v1.x, 0.0) ? v1.y / v1.x : 0.0
    m2 = !Point.close_to(v2.x, 0.0) ? v2.y / v2.x : 0.0

    if Point.close_to(m1, m2)
      return nil
    end

    c1 = base1.y - m1 * base1.x
    c2 = base2.y - m2 * base2.x
    ix = (c2 - c1) / (m1 - m2)
    iy = m1 * ix + c1
    if Point.close_to(v1.x, 0.0)
      return Point.new(base1.x, base1.x * m2 + c2)
    end
    if Point.close_to(v2.x, 0.0)
      return Point.new(base2.x, base2.x * m1 + c1)
    end
    return Point.new(ix, iy)
  end

  # compute the polar angle of this point
  def polar
    theta = 0.0
    if Point.close_to(self.x, 0)
      if self.y > 0
        theta = 90.0
      elsif self.y < 0
        theta = 270.0
      end
    else
      theta = Point.rad2deg(Math.atan(self.y/self.x))
      if self.x < 0.0
        theta += 180.0
      end
      if theta < 0.0
        theta += 360.0
      end
    end
    theta
  end

  # Computes the angle formed by p1 - self - p2
  def angle_between(p1, p2)
    vect_p1 = p1 - self
    vect_p2 = p2 - self

    theta = vect_p1.polar - vect_p2.polar
    theta += 360.0 if theta < 0.0
    theta = 360.0 - theta if theta > 180.0
    return theta
  end

  def ==(other_point)
    return Point.close_to(self.x, other_point.x) && Point.close_to(self.y, other_point.y)
  end

  # We define sort order of Points based on x coordinate, then y coordinate
  def <=>(other_point)
    ret = self.x <=> other_point.x
    if ret == 0
      ret = self.y <=> other_point.y
    end
    ret
  end

  # Subtract other_point from self
  def -(other_point)
    Point.new(self.x - other_point.x, self.y - other_point.y)
  end

  # Add other_point to self
  def +(other_point)
    Point.new(self.x + other_point.x, self.y + other_point.y)
  end

  # Scale this point by the provided factor
  def scale(factor)
    Point.new(self.x * factor, self.y * factor)
  end

  # Pretty print a point
  def to_s
    "(%5.4f, %5.4f)" % [x, y]
  end
end 

class Circle < Struct.new(:center, :radius)
  # Returns true if this Circle contains the given Point
  def contains?(point)
    point.distance(self.center) < self.radius || (point.distance(self.center) - self.radius).abs < 1e-9
  end
  def to_s
    "{center:%s, radius:%5.4f}}" % [center.to_s, radius]
  end
end

require 'set'
class Solver < Struct.new(:mec)
  def initialize(debug)
    @points = SortedSet.new
    @debug = debug
  end

  # add a point to the solver and recalcluate the minimum enclosing
  # circle if the point lies outise the current MEC.
  def add_point(point)
    puts "adding p: #{point}" if @debug
    @points << point
    if self.mec && self.mec.center
      unless self.mec.contains?(point)
        iterate
      end
    else
      iterate
    end
  end

  # Tests if a point is left|on|right of a infinite line
  # return >0 for p2 left of line through p0 and p1
  # return = 0 for p2 on the line
  # return <0 for p2 right of the line
  def direction(p0, p1, p2)
    return (p0.x-p1.x)*(p2.y-p1.y) - (p2.x-p1.x)*(p0.y-p1.y)
  end

  # Computes the set of points that represents the
  # convex hull of the Solver's @points
  def convex_hull
    a = @points.to_a
    left = a.first
    a = a[1..-1]
    right = a.pop
    puts "left = #{left} right = #{right} a = #{a.map{|p| p.to_s}.join(", ")}" if @debug
    upper = []
    lower = []
    # Partition remaining points into upper and lower buckets
    a.each do |p|
      dir = direction(left, right, p)
      puts "p = #{p} dir = #{dir}" if @debug
      if dir < 0
        upper << p
      else
        lower << p
      end
    end
    puts "upper = #{upper.map{|p| p.to_s}.join(', ')}" if @debug
    puts "lower = #{lower.map{|p| p.to_s}.join(', ')}" if @debug
    upper_hull = half_hull(left, right, upper, -1)
    lower_hull = half_hull(left, right, lower, 1)
    puts "upper_hull = #{upper_hull.map{|p| p.to_s}.join(', ')}" if @debug
    puts "lower_hull = #{lower_hull.map{|p| p.to_s}.join(', ')}" if @debug
    (upper_hull + lower_hull).uniq
  end

  # Computs a half hull
  def half_hull(left, right, input, factor)
    input = input.dup
    input << right
    half = []
    half << left
    puts "input: #{input.map{|p| p.to_s}.join(', ')}" if @debug
    input.each do |p|
      half << p
      puts "half: #{half.map{|p| p.to_s}.join(', ')}" if @debug
      while half.size >= 3
        dir = factor * direction(half[-3], half[-1], half[-2])
        if dir <= 0
          half.delete_at(half.size-2)
        else
          break
        end
      end
    end
    half
  end

  # Recalculates the MEC after the addition of a new point
  def iterate
    self.mec ||= Circle.new
    # Handle degenerate cases first
    if @points.size == 1
      self.mec.center = @points.to_a[0]
      self.mec.radius = 0
    elsif @points.size == 2
      a = @points.to_a
      self.mec.center = a[0].center(a[1])
      self.mec.radius = a[0].distance(self.mec.center)
    else
      puts "points = #{@points.map{|p| p.to_s}.join(", ")}" if @debug
      a = convex_hull
      puts "convex hull = #{a.map{|p| p.to_s}.join(', ')}" if @debug
      point_a = a[0]
      point_c = a[1]
      while (true)
        point_b = nil
        best_theta = 180.0;
        for point in @points
          if point != point_a && point != point_c
            theta_abc = point.angle_between(point_a, point_c)
            puts "a: #{point_a} c: #{point_c} p: #{point} best_theta: #{best_theta} theta_abc: #{theta_abc}" if @debug
            if theta_abc < best_theta
              point_b = point
              best_theta = theta_abc
            end
          end
        end
        puts "Found best theta: #{best_theta}, a = #{point_a}, b = #{point_b}, c = #{point_c}" if @debug
        if best_theta >= 90.0 || point_b.nil?
          self.mec.center = point_a.center(point_c)
          self.mec.radius = point_a.distance(self.mec.center)
          return self.mec
        end
        ang_bca = point_c.angle_between(point_b, point_a)
        ang_cab = point_a.angle_between(point_c, point_b)
        puts "ang_bca = #{ang_bca}, ang_cab = #{ang_cab}" if @debug
        if ang_bca > 90.0
          point_c = point_b
        elsif ang_cab <= 90.0
          puts "breaking ..." if @debug
          break
        else
          point_a = point_b
        end
      end
      ch1 = (point_b - point_a).scale(0.5)
      ch2 = (point_c - point_a).scale(0.5)
      n1 = ch1.normal_left
      n2 = ch2.normal_left
      ch1 = point_a + ch1
      ch2 = point_a + ch2
      self.mec.center = Point.inf_line_intersection(ch1, n1, ch2, n2)
      self.mec.radius = self.mec.center.distance(point_a)
    end
    self.mec
  end
end

def distance(p1, p2)
  return p1.distance(p2)
end

# Use algorithm invented by Pr. Chrystal (1885)
# Port of code in a Java Applet found at
# http://www.personal.kent.edu/~rmuhamma/Compgeometry/MyCG/CG-Applets/Center/centercli.htm
# MEC Is determined by the Convex Hull of the set of points
# takes array of Point objects
# returns a Circle object
def encircle(points, debug = false)
  solver = Solver.new(debug)
  points.each { |p| solver.add_point(p) }
  puts "SOLVED: mec: #{solver.mec}" if debug
  return solver.mec
end

# Generate an array of random Point objects in the unit circle
# with center (0,0)
def generate_samples(n)
  (1..n).map { Point.random }
end

# Creates an image using rmagick showing the points and the MEC and
# saves it as filename
def draw(points, circle, filename) 
  require 'rubygems'
  require 'RMagick'

  # Change these to control the output
  dim_x = 500                 # width of the box to draw the circle in
  dim_y = 500                 # height of the box to draw the circle in
  o_x = dim_x / 2             # x-origin on the graph
  o_y = dim_y / 2             # y-origin on the graph

  # Create a canvas and drawing context using rmagick
  canvas = Magick::Image.new(dim_x, dim_y, Magick::HatchFill.new('white', 'lightcyan2', dim_y / 20))
  gc = Magick::Draw.new
  gc.stroke_width(1)

  # Draw axes
  gc.stroke('black')
  gc.line(0, o_x, dim_y, o_x) 
  gc.line(o_y, 0, o_y, dim_x) 

  # Draw the MEC
  gc.stroke('red')
  gc.fill_opacity(0)
  cx = o_x + circle.center.x * dim_x / 2
  cy = o_y - circle.center.y * dim_y / 2
  cy_edge = cy - circle.radius * dim_y / 2
  gc.circle(cx, cy, cx, cy_edge)
  gc.fill_opacity(1)
  gc.fill('red')
  gc.circle(cx, cy, cx, cy+3)
  gc.fill_opacity(0)

  # Draw the MEC radius
  gc.stroke('green')
  cx_edge = cx - circle.radius * dim_x / 2 * (1 / (2 ** 0.5))
  cy_edge = cy + circle.radius * dim_y / 2 * (1 / (2 ** 0.5))
  gc.line(cx, cy, cx_edge, cy_edge)

  # Annotate the radius
  gc.stroke('darkgreen').fill('darkgreen')
  gc.text(cx_edge - 5, cy_edge + 5, "R:%5.4f" % circle.radius)

  # Annotate the center
  gc.stroke('darkgreen').fill('darkgreen')
  gc.text(cx + 5, cy, "(%3.2f, %3.2f)" % [circle.center.x, circle.center.y])

  # Draw the points
  points.each do |point|
    gc.stroke('blue')
    gc.fill('blue')
    gc.fill_opacity(1)
    px = o_x + point.x * dim_x / 2
    py = o_y - point.y * dim_y / 2
    gc.circle(px, py, px, py+3)
    # Annotate the point
    gc.stroke('black').fill('black')
    gc.text(px + 5, py, "(%2.2f, %2.2f)" % [point.x, point.y])
  end

  # draw the graphics context on the canvas and dump it to a file
  gc.draw(canvas)
  canvas.write(filename)
end

# For debugging, checks the solution and if it doesn't work, creates the image
# and reruns encircle in debug mode for the failed solution
def check_solution(points, circle)
  points.each do |point|
    if !circle.contains?(point)
      encircle(points, true)
      draw(points, circle, 'circle.png')
      puts "point #{point} (#{point.distance(circle.center)}) is outside of circle #{circle}"
      raise "BAD SOLUTION: point #{point} (#{point.distance(circle.center)}) is outside of circle #{circle}"
    end
  end
  return true
end

if __FILE__ == $0
  srand

  if ARGV[0] && ARGV[0] == 'draw'
    n = ARGV[1] ? ARGV[1].to_i : 15
    points = generate_samples(n)
    circle = encircle(points)
    draw(points, circle, 'circle.png')
    puts "SOLUTION: #{circle}"
  elsif ARGV[0] && ARGV[0] == 'benchmark'
    require 'benchmark'

    Benchmark.bm(15) do |x|
      5.times do |i|
        n = 10 ** i
        x.report("points: #{n}") do
          25.times do
            points = generate_samples(n)
            circle = encircle(points)
            check_solution(points, circle)
          end
        end
      end
    end
  elsif ARGV[0] && ARGV[0] == 'once'
    n = ARGV[1] ? ARGV[1].to_i : 15
    points = generate_samples(n)
    puts encircle(points)
  else
    puts "Usage: ruby mec.rb [draw [num_points]] | [benchmark] | [once [num_points]]"
    puts "     once: Runs the solution once and puts the circle"
    puts "        num_points defaults to 15 if not specified."
    puts "     draw: draws num_points points and the minimum enclosing circle to the file circle.png"
    puts "        num_points defaults to 15 if not specified."
    puts "benchmark: benchmarks the algorithm"
  end
end

Download the source code here. To get usage information, run the code with no arguments.

$ ruby mec.rb
Usage: ruby mec.rb [draw [num_points]] | [benchmark] | [once [num_points]]
     once: Runs the solution once and puts the circle
        num_points defaults to 15 if not specified.
     draw: draws num_points points and the minimum enclosing circle to the file circle.png
        num_points defaults to 15 if not specified.
benchmark: benchmarks the algorithm

The image on this page was produced as follows:

$ ruby mec.rb draw 15

The image ends up in the file 'circle.png' in the current working directory.