lib/sqed/boundary_finder.rb in sqed-0.3.2 vs lib/sqed/boundary_finder.rb in sqed-0.4.0
- old
+ new
@@ -1,264 +1,273 @@
-# Sqed Boundary Finders find boundaries on images and return co-ordinates of those boundaries. They do not
-# return derivative images. Finders operate on cropped images, i.e. only the "stage".
-#
-class Sqed::BoundaryFinder
+class Sqed
- THUMB_SIZE = 100
- COLOR_DELTA = 1.3 # color (e.g. red) must be this much be *COLOR_DELTA > than other values (e.g. blue/green)
+ # Sqed Boundary Finders find boundaries on images and return co-ordinates of
+ # those boundaries. They do not return derivative images.
+ # Finders operate on cropped images, i.e. only the "stage".
+ #
+ class BoundaryFinder
- # the passed image
- attr_reader :image
+ THUMB_SIZE = 100
+ COLOR_DELTA = 1.3 # color (e.g. red) must be this much be *COLOR_DELTA > than other values (e.g. blue/green)
- # a symbol from SqedConfig::LAYOUTS
- attr_reader :layout
+ # the passed image
+ attr_reader :image
- # A Sqed::Boundaries instance, stores the coordinates of all of the layout sections
- attr_reader :boundaries
+ # a symbol from SqedConfig::LAYOUTS
+ attr_reader :layout
- # Whether to compress the original image to a thumbnail when finding boundaries
- attr_reader :use_thumbnail
+ # A Sqed::Boundaries instance, stores the coordinates of all of the layout sections
+ attr_reader :boundaries
- # when we compute using a derived thumbnail we temporarily store the full size image here
- attr_reader :original_image
+ # Whether to compress the original image to a thumbnail when finding boundaries
+ attr_reader :use_thumbnail
- def initialize(target_image: image, target_layout: layout, use_thumbnail: true)
- raise 'No layout provided.' if target_layout.nil?
- raise 'No image provided.' if target_image.nil? || target_image.class.name != 'Magick::Image'
+ # when we compute using a derived thumbnail we temporarily store the full size image here
+ attr_reader :original_image
- @use_thumbnail = use_thumbnail
+ def initialize(**opts)
+ # image: image, layout: layout, use_thumbnail: true
+ @use_thumbnail = opts[:use_thumbnail]
+ @use_thumbnail = true if @use_thumbnail.nil?
+ @layout = opts[:layout]
+ @image = opts[:image]
- @layout = target_layout
- @image = target_image
- true
- end
+ raise 'No layout provided.' if layout.nil?
+ raise 'No image provided.' if image.nil? || image.class.name != 'Magick::Image'
- # Returns a Sqed::Boundaries instance initialized to the number of sections in the passed layout.
- def boundaries
- @boundaries ||= Sqed::Boundaries.new(@layout)
- end
+ true
+ end
- def longest_thumbnail_axis
- image.columns > image.rows ? :width : :height
- end
+ # Returns a Sqed::Boundaries instance initialized to the number of sections in the passed layout.
+ def boundaries
+ @boundaries ||= Sqed::Boundaries.new(@layout)
+ end
- def thumbnail_height
- if longest_thumbnail_axis == :height
- THUMB_SIZE
- else
- (image.rows.to_f * (THUMB_SIZE.to_f / image.columns.to_f)).round.to_i
+ def longest_thumbnail_axis
+ image.columns > image.rows ? :width : :height
end
- end
- def thumbnail_width
- if longest_thumbnail_axis == :width
- THUMB_SIZE
- else
- (image.columns.to_f * (THUMB_SIZE.to_f / image.rows.to_f)).round.to_i
+ def thumbnail_height
+ if longest_thumbnail_axis == :height
+ THUMB_SIZE
+ else
+ (image.rows.to_f * (THUMB_SIZE.to_f / image.columns.to_f)).round.to_i
+ end
end
- end
- # see https://rmagick.github.io/image3.html#thumbnail
- def thumbnail
- image.thumbnail(thumbnail_width, thumbnail_height)
- end
+ def thumbnail_width
+ if longest_thumbnail_axis == :width
+ THUMB_SIZE
+ else
+ (image.columns.to_f * (THUMB_SIZE.to_f / image.rows.to_f)).round.to_i
+ end
+ end
- def width_factor
- image.columns.to_f / thumbnail_width.to_f
- end
+ # see https://rmagick.github.io/image3.html#thumbnail
+ def thumbnail
+ image.thumbnail(thumbnail_width, thumbnail_height)
+ end
- def height_factor
- image.rows.to_f / thumbnail_height.to_f
- end
+ def width_factor
+ image.columns.to_f / thumbnail_width.to_f
+ end
- def zoom_boundaries
- boundaries.zoom(width_factor, height_factor )
- end
+ def height_factor
+ image.rows.to_f / thumbnail_height.to_f
+ end
- # return [Integer, nil]
- # sample more with small images, less with large images
- # we want to return larger numbers (= faster sampling)
- def self.get_subdivision_size(image_width)
- case image_width
- when nil
- nil
- when 0..140
- 6
- when 141..640
- 12
- when 641..1000
- 16
- when 1001..3000
- 60
- when 3001..6400
- 80
- else
- 140
+ def zoom_boundaries
+ boundaries.zoom(width_factor, height_factor )
end
- end
- # @return [Array]
- # the x or y position returned as a start, mid, and end coordinate that represent the width of the colored line that completely divides the image, e.g. [9, 15, 16]
- #
- # @param image
- # the image to sample
- #
- # @param sample_subdivision_size
- # an Integer, the distance in pixels b/w samples
- #
- # @param sample_cutoff_factor: (0.0-1.0)
- # if provided over-rides the default cutoff calculation by reducing the number of pixels required to be considered a border hit
- # - for example, if you have an image of height 100 pixels, and a sample_subdivision_size of 10, and a sample_cutoff_factor of .8
- # then only posititions with 8 ((100/10)*.8) or more hits
- # - when nil the cutoff defaults to the maximum of the pairwise difference between hit counts
- #
- # @param scan
- # (:rows|:columns), :rows finds vertical borders, :columns finds horizontal borders
- #
- def self.color_boundary_finder(target_image: image, sample_subdivision_size: nil, sample_cutoff_factor: nil, scan: :rows, boundary_color: :green)
+ # return [Integer, nil]
+ # sample more with small images, less with large images
+ # we want to return larger numbers (= faster sampling)
+ def self.get_subdivision_size(image_width)
+ case image_width
+ when nil
+ nil
+ when 0..140
+ 6
+ when 141..640
+ 12
+ when 641..1000
+ 16
+ when 1001..3000
+ 60
+ when 3001..6400
+ 80
+ else
+ 140
+ end
+ end
- image_width = target_image.send(scan)
- sample_subdivision_size = get_subdivision_size(image_width) if sample_subdivision_size.nil?
- samples_to_take = (image_width / sample_subdivision_size).to_i - 1
+ # @return [Array]
+ # the x or y position returned as a start, mid, and end coordinate that represent the width of the colored line that completely divides the image, e.g. [9, 15, 16]
+ #
+ # @param image
+ # the image to sample
+ #
+ # @param sample_subdivision_size
+ # an Integer, the distance in pixels b/w samples
+ #
+ # @param sample_cutoff_factor: (0.0-1.0)
+ # if provided over-rides the default cutoff calculation by reducing the number of pixels required to be considered a border hit
+ # - for example, if you have an image of height 100 pixels, and a sample_subdivision_size of 10, and a sample_cutoff_factor of .8
+ # then only posititions with 8 ((100/10)*.8) or more hits
+ # - when nil the cutoff defaults to the maximum of the pairwise difference between hit counts
+ #
+ # @param scan
+ # (:rows|:columns), :rows finds vertical borders, :columns finds horizontal borders
+ #
+ def self.color_boundary_finder(**opts)
+ # image: image, sample_subdivision_size: nil, sample_cutoff_factor: nil, scan: :rows, boundary_color: :green)
+ image = opts[:image]
+ sample_subdivision_size = opts[:sample_subdivision_size]
+ sample_cutoff_factor = opts[:sample_cutoff_factor]
+ scan = opts[:scan] || :rows
+ boundary_color = opts[:boundary_color] || :green
- border_hits = {}
+ image_width = image.send(scan)
+ sample_subdivision_size = get_subdivision_size(image_width) if sample_subdivision_size.nil?
+ samples_to_take = (image_width / sample_subdivision_size).to_i - 1
- (0..samples_to_take).each do |s|
- # Create a sample image a single pixel tall
- if scan == :rows
- j = target_image.crop(0, s * sample_subdivision_size, target_image.columns, 1, true)
- elsif scan == :columns
- j = target_image.crop(s * sample_subdivision_size, 0, 1, target_image.rows, true)
- else
- raise
- end
+ border_hits = {}
- j.each_pixel do |pixel, c, r|
- index = ( (scan == :rows) ? c : r)
+ (0..samples_to_take).each do |s|
+ # Create a sample image a single pixel tall
+ if scan == :rows
+ j = image.crop(0, s * sample_subdivision_size, image.columns, 1, true)
+ elsif scan == :columns
+ j = image.crop(s * sample_subdivision_size, 0, 1, image.rows, true)
+ else
+ raise
+ end
- # Our hit metric is dirt simple, if there is some percentage more of the boundary_color than the others, count + 1 for that column
- if send("is_#{boundary_color}?", pixel)
- # we have already hit that column previously, increment
- if border_hits[index]
- border_hits[index] += 1
- # initialize the newly hit column 1
- else
- border_hits[index] = 1
+ j.each_pixel do |pixel, c, r|
+ index = (scan == :rows) ? c : r
+
+ # Our hit metric is dirt simple, if there is some percentage more of the boundary_color than the others, count + 1 for that column
+ if send("is_#{boundary_color}?", pixel)
+ # we have already hit that column previously, increment
+ if border_hits[index]
+ border_hits[index] += 1
+ # initialize the newly hit column 1
+ else
+ border_hits[index] = 1
+ end
end
end
end
- end
- return nil if border_hits.length < 2
+ return nil if border_hits.length < 2
- if sample_cutoff_factor.nil?
- cutoff = max_difference(border_hits.values)
+ if sample_cutoff_factor.nil?
+ cutoff = max_difference(border_hits.values)
- cutoff = border_hits.values.first - 1 if cutoff == 0 # difference of two identical things is 0
- else
- cutoff = (samples_to_take * sample_cutoff_factor).to_i
+ cutoff = border_hits.values.first - 1 if cutoff == 0 # difference of two identical things is 0
+ else
+ cutoff = (samples_to_take * sample_cutoff_factor).to_i
+ end
+
+ frequency_stats(border_hits, cutoff)
end
- frequency_stats(border_hits, cutoff)
- end
+ def self.is_green?(pixel)
+ (pixel.green > pixel.red*COLOR_DELTA) && (pixel.green > pixel.blue*COLOR_DELTA)
+ end
- def self.is_green?(pixel)
- (pixel.green > pixel.red*COLOR_DELTA) && (pixel.green > pixel.blue*COLOR_DELTA)
- end
+ def self.is_blue?(pixel)
+ (pixel.blue > pixel.red*COLOR_DELTA) && (pixel.blue > pixel.green*COLOR_DELTA)
+ end
- def self.is_blue?(pixel)
- (pixel.blue > pixel.red*COLOR_DELTA) && (pixel.blue > pixel.green*COLOR_DELTA)
- end
+ def self.is_red?(pixel)
+ (pixel.red > pixel.blue*COLOR_DELTA) && (pixel.red > pixel.green*COLOR_DELTA)
+ end
- def self.is_red?(pixel)
- (pixel.red > pixel.blue*COLOR_DELTA) && (pixel.red > pixel.green*COLOR_DELTA)
- end
+ def self.is_black?(pixel)
+ black_threshold = 65535 * 0.15 #tune for black
+ (pixel.red < black_threshold) && (pixel.blue < black_threshold) && (pixel.green < black_threshold)
+ end
- def self.is_black?(pixel)
- black_threshold = 65535*0.15 #tune for black
- (pixel.red < black_threshold) && (pixel.blue < black_threshold) && (pixel.green < black_threshold)
- end
+ # return [Array]
+ # the start, mid, endpoint position of all (pixel) positions that have a count greater than the cutoff
+ def self.frequency_stats(frequency_hash, sample_cutoff = 0)
- # return [Array]
- # the start, mid, endpoint position of all (pixel) positions that have a count greater than the cutoff
- def self.frequency_stats(frequency_hash, sample_cutoff = 0)
-
- return nil if sample_cutoff.nil? || sample_cutoff < 1
- hit_ranges = []
+ return nil if sample_cutoff.nil? || sample_cutoff < 1
+ hit_ranges = []
- frequency_hash.each do |position, count|
- if count >= sample_cutoff
- hit_ranges.push(position)
+ frequency_hash.each do |position, count|
+ if count >= sample_cutoff
+ hit_ranges.push(position)
+ end
end
- end
- case hit_ranges.size
- when 1
- c = hit_ranges[0]
- hit_ranges = [c - 1, c, c + 1]
- when 2
+ case hit_ranges.size
+ when 1
+ c = hit_ranges[0]
+ hit_ranges = [c - 1, c, c + 1]
+ when 2
+ hit_ranges.sort!
+ c1 = hit_ranges[0]
+ c2 = hit_ranges[1]
+ hit_ranges = [c1, c2, c2 + (c2 - c1)]
+ when 0
+ return nil
+ end
+
+ # we have to sort because the keys (positions) we examined came unordered from a hash originally
hit_ranges.sort!
- c1 = hit_ranges[0]
- c2 = hit_ranges[1]
- hit_ranges = [c1, c2, c2 + (c2 - c1)]
- when 0
- return nil
+
+ # return the position exactly in the middle of the array
+ [hit_ranges.first, hit_ranges[(hit_ranges.length / 2).to_i], hit_ranges.last]
end
- # we have to sort because the keys (positions) we examined came unordered from a hash originally
- hit_ranges.sort!
+ # @return [Array]
+ # like [0,1,2]
+ # If median-min or max-median * width_factor are different from one another (by more than width_factor) then replace the larger wth the median +/- 1/2 the smaller
+ # Given [10, 12, 20] and width_factor 2 the result will be [10, 12, 13]
+ #
+ def corrected_frequency(frequency_stats, width_factor = 3.0)
+ v0 = frequency_stats[0]
+ m = frequency_stats[1]
+ v2 = frequency_stats[2]
- # return the position exactly in the middle of the array
- [hit_ranges.first, hit_ranges[(hit_ranges.length / 2).to_i], hit_ranges.last]
- end
+ a = m - v0
+ b = v2 - m
- # @return [Array]
- # like [0,1,2]
- # If median-min or max-median * width_factor are different from one another (by more than width_factor) then replace the larger wth the median +/- 1/2 the smaller
- # Given [10, 12, 20] and width_factor 2 the result will be [10, 12, 13]
- #
- def corrected_frequency(frequency_stats, width_factor = 3.0 )
- v0 = frequency_stats[0]
- m = frequency_stats[1]
- v2 = frequency_stats[2]
+ largest = (a > b ? a : b)
- a = m - v0
- b = v2 - m
+ if a * width_factor > largest
+ [(m - (v2 - m) / 2).to_i, m, v2]
+ elsif b * width_factor > largest
+ [v0, m, (m + (m - v0) / 2).to_i]
+ else
+ frequency_stats
+ end
+ end
- largest = (a > b ? a : b)
+ # Returns an Integer, the maximum of the pairwise differences of the values in the array
+ # For example, given
+ # [1,2,3,9,6,2,0]
+ # The resulting pairwise array is
+ # [1,1,6,3,4,2]
+ # The max (value returned) is
+ # 6
+ def self.max_pairwise_difference(array)
+ (0..array.length - 2).map{|i| (array[i] - array[i + 1]).abs }.max
+ end
- if a * width_factor > largest
- [(m - (v2 - m)/2).to_i, m, v2]
- elsif b * width_factor > largest
- [ v0, m, (m + (m - v0)/2).to_i ]
- else
- frequency_stats
+ def self.max_difference(array)
+ array.max - array.min
end
- end
+ def self.derivative_signs(array)
+ (0..array.length - 2).map { |i| (array[i + 1] - array[i]) <=> 0 }
+ end
+ def self.derivative(array)
+ (0..array.length - 2).map { |i| array[i + 1] - array[i] }
+ end
- # Returns an Integer, the maximum of the pairwise differences of the values in the array
- # For example, given
- # [1,2,3,9,6,2,0]
- # The resulting pairwise array is
- # [1,1,6,3,4,2]
- # The max (value returned) is
- # 6
- def self.max_pairwise_difference(array)
- (0..array.length-2).map{|i| (array[i] - array[i+1]).abs }.max
end
-
- def self.max_difference(array)
- array.max - array.min
- end
-
- def self.derivative_signs(array)
- (0..array.length-2).map { |i| (array[i+1] - array[i]) <=> 0 }
- end
-
- def self.derivative(array)
- (0..array.length-2).map { |i| array[i+1] - array[i] }
- end
-
end
-