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).
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:
classPoint<Struct.new(:x,:y)# Generates a random point around (0,0) with radius = 1defself.randomradius=randtheta=360*randPoint.new(radius*Math.cos(theta),radius*Math.sin(theta))end# Returns true if x is close to y as defined by epsdefself.close_to(x,y,eps=1e-9)return(x-y).abs<epsenddefself.rad2deg(a)return(a*180.0)/Math::PIenddefnormal_leftPoint.new(self.y,-self.x)end# Returns the point at the center of this point# and the other_pointdefcenter(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_pointdefdistance(other_point)((self.x-other_point.x)**2+(self.y-other_point.y)**2)**0.5enddefself.inf_line_intersection(base1,v1,base2,v2)if(Point.close_to(v1.x,0.0)&&Point.close_to(v2.y,0.0))returnPoint.new(base1.x,base2.y)endif(Point.close_to(v1.y,0.0)&&Point.close_to(v2.x,0.0))returnPoint.new(base2.x,base1.y)endm1=!Point.close_to(v1.x,0.0)?v1.y/v1.x:0.0m2=!Point.close_to(v2.x,0.0)?v2.y/v2.x:0.0ifPoint.close_to(m1,m2)returnnilendc1=base1.y-m1*base1.xc2=base2.y-m2*base2.xix=(c2-c1)/(m1-m2)iy=m1*ix+c1ifPoint.close_to(v1.x,0.0)returnPoint.new(base1.x,base1.x*m2+c2)endifPoint.close_to(v2.x,0.0)returnPoint.new(base2.x,base2.x*m1+c1)endreturnPoint.new(ix,iy)end# compute the polar angle of this pointdefpolartheta=0.0ifPoint.close_to(self.x,0)ifself.y>0theta=90.0elsifself.y<0theta=270.0endelsetheta=Point.rad2deg(Math.atan(self.y/self.x))ifself.x<0.0theta+=180.0endiftheta<0.0theta+=360.0endendthetaend# Computes the angle formed by p1 - self - p2defangle_between(p1,p2)vect_p1=p1-selfvect_p2=p2-selftheta=vect_p1.polar-vect_p2.polartheta+=360.0iftheta<0.0theta=360.0-thetaiftheta>180.0returnthetaenddef==(other_point)returnPoint.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 coordinatedef<=>(other_point)ret=self.x<=>other_point.xifret==0ret=self.y<=>other_point.yendretend# Subtract other_point from selfdef-(other_point)Point.new(self.x-other_point.x,self.y-other_point.y)end# Add other_point to selfdef+(other_point)Point.new(self.x+other_point.x,self.y+other_point.y)end# Scale this point by the provided factordefscale(factor)Point.new(self.x*factor,self.y*factor)end# Pretty print a pointdefto_s"(%5.4f, %5.4f)"%[x,y]endendclassCircle<Struct.new(:center,:radius)# Returns true if this Circle contains the given Pointdefcontains?(point)point.distance(self.center)<self.radius||(point.distance(self.center)-self.radius).abs<1e-9enddefto_s"{center:%s, radius:%5.4f}}"%[center.to_s,radius]endendrequire'set'classSolver<Struct.new(:mec)definitialize(debug)@points=SortedSet.new@debug=debugend# add a point to the solver and recalcluate the minimum enclosing# circle if the point lies outise the current MEC.defadd_point(point)puts"adding p: #{point}"if@debug@points<<pointifself.mec&&self.mec.centerunlessself.mec.contains?(point)iterateendelseiterateendend# 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 linedefdirection(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 @pointsdefconvex_hulla=@points.to_aleft=a.firsta=a[1..-1]right=a.popputs"left = #{left} right = #{right} a = #{a.map{|p|p.to_s}.join(",")}"if@debugupper=[]lower=[]# Partition remaining points into upper and lower bucketsa.eachdo|p|dir=direction(left,right,p)puts"p = #{p} dir = #{dir}"if@debugifdir<0upper<<pelselower<<pendendputs"upper = #{upper.map{|p|p.to_s}.join(', ')}"if@debugputs"lower = #{lower.map{|p|p.to_s}.join(', ')}"if@debugupper_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@debugputs"lower_hull = #{lower_hull.map{|p|p.to_s}.join(', ')}"if@debug(upper_hull+lower_hull).uniqend# Computs a half hulldefhalf_hull(left,right,input,factor)input=input.dupinput<<righthalf=[]half<<leftputs"input: #{input.map{|p|p.to_s}.join(', ')}"if@debuginput.eachdo|p|half<<pputs"half: #{half.map{|p|p.to_s}.join(', ')}"if@debugwhilehalf.size>=3dir=factor*direction(half[-3],half[-1],half[-2])ifdir<=0half.delete_at(half.size-2)elsebreakendendendhalfend# Recalculates the MEC after the addition of a new pointdefiterateself.mec||=Circle.new# Handle degenerate cases firstif@points.size==1self.mec.center=@points.to_a[0]self.mec.radius=0elsif@points.size==2a=@points.to_aself.mec.center=a[0].center(a[1])self.mec.radius=a[0].distance(self.mec.center)elseputs"points = #{@points.map{|p|p.to_s}.join(",")}"if@debuga=convex_hullputs"convex hull = #{a.map{|p|p.to_s}.join(', ')}"if@debugpoint_a=a[0]point_c=a[1]while(true)point_b=nilbest_theta=180.0;forpointin@pointsifpoint!=point_a&&point!=point_ctheta_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@debugiftheta_abc<best_thetapoint_b=pointbest_theta=theta_abcendendendputs"Found best theta: #{best_theta}, a = #{point_a}, b = #{point_b}, c = #{point_c}"if@debugifbest_theta>=90.0||point_b.nil?self.mec.center=point_a.center(point_c)self.mec.radius=point_a.distance(self.mec.center)returnself.mecendang_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@debugifang_bca>90.0point_c=point_belsifang_cab<=90.0puts"breaking ..."if@debugbreakelsepoint_a=point_bendendch1=(point_b-point_a).scale(0.5)ch2=(point_c-point_a).scale(0.5)n1=ch1.normal_leftn2=ch2.normal_leftch1=point_a+ch1ch2=point_a+ch2self.mec.center=Point.inf_line_intersection(ch1,n1,ch2,n2)self.mec.radius=self.mec.center.distance(point_a)endself.mecendenddefdistance(p1,p2)returnp1.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 objectdefencircle(points,debug=false)solver=Solver.new(debug)points.each{|p|solver.add_point(p)}puts"SOLVED: mec: #{solver.mec}"ifdebugreturnsolver.mecend# Generate an array of random Point objects in the unit circle# with center (0,0)defgenerate_samples(n)(1..n).map{Point.random}end# Creates an image using rmagick showing the points and the MEC and# saves it as filenamedefdraw(points,circle,filename)require'rubygems'require'RMagick'# Change these to control the outputdim_x=500# width of the box to draw the circle indim_y=500# height of the box to draw the circle ino_x=dim_x/2# x-origin on the grapho_y=dim_y/2# y-origin on the graph# Create a canvas and drawing context using rmagickcanvas=Magick::Image.new(dim_x,dim_y,Magick::HatchFill.new('white','lightcyan2',dim_y/20))gc=Magick::Draw.newgc.stroke_width(1)# Draw axesgc.stroke('black')gc.line(0,o_x,dim_y,o_x)gc.line(o_y,0,o_y,dim_x)# Draw the MECgc.stroke('red')gc.fill_opacity(0)cx=o_x+circle.center.x*dim_x/2cy=o_y-circle.center.y*dim_y/2cy_edge=cy-circle.radius*dim_y/2gc.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 radiusgc.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 radiusgc.stroke('darkgreen').fill('darkgreen')gc.text(cx_edge-5,cy_edge+5,"R:%5.4f"%circle.radius)# Annotate the centergc.stroke('darkgreen').fill('darkgreen')gc.text(cx+5,cy,"(%3.2f, %3.2f)"%[circle.center.x,circle.center.y])# Draw the pointspoints.eachdo|point|gc.stroke('blue')gc.fill('blue')gc.fill_opacity(1)px=o_x+point.x*dim_x/2py=o_y-point.y*dim_y/2gc.circle(px,py,px,py+3)# Annotate the pointgc.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 filegc.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 solutiondefcheck_solution(points,circle)points.eachdo|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}"endendreturntrueendif__FILE__==$0srandifARGV[0]&&ARGV[0]=='draw'n=ARGV[1]?ARGV[1].to_i:15points=generate_samples(n)circle=encircle(points)draw(points,circle,'circle.png')puts"SOLUTION: #{circle}"elsifARGV[0]&&ARGV[0]=='benchmark'require'benchmark'Benchmark.bm(15)do|x|5.timesdo|i|n=10**ix.report("points: #{n}")do25.timesdopoints=generate_samples(n)circle=encircle(points)check_solution(points,circle)endendendendelsifARGV[0]&&ARGV[0]=='once'n=ARGV[1]?ARGV[1].to_i:15points=generate_samples(n)putsencircle(points)elseputs"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"endend
$ 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.