sqed 0.3.2 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,264 +1,273 @@
1
- # Sqed Boundary Finders find boundaries on images and return co-ordinates of those boundaries. They do not
2
- # return derivative images. Finders operate on cropped images, i.e. only the "stage".
3
- #
4
- class Sqed::BoundaryFinder
1
+ class Sqed
5
2
 
6
- THUMB_SIZE = 100
7
- COLOR_DELTA = 1.3 # color (e.g. red) must be this much be *COLOR_DELTA > than other values (e.g. blue/green)
3
+ # Sqed Boundary Finders find boundaries on images and return co-ordinates of
4
+ # those boundaries. They do not return derivative images.
5
+ # Finders operate on cropped images, i.e. only the "stage".
6
+ #
7
+ class BoundaryFinder
8
8
 
9
- # the passed image
10
- attr_reader :image
9
+ THUMB_SIZE = 100
10
+ COLOR_DELTA = 1.3 # color (e.g. red) must be this much be *COLOR_DELTA > than other values (e.g. blue/green)
11
11
 
12
- # a symbol from SqedConfig::LAYOUTS
13
- attr_reader :layout
12
+ # the passed image
13
+ attr_reader :image
14
14
 
15
- # A Sqed::Boundaries instance, stores the coordinates of all of the layout sections
16
- attr_reader :boundaries
15
+ # a symbol from SqedConfig::LAYOUTS
16
+ attr_reader :layout
17
17
 
18
- # Whether to compress the original image to a thumbnail when finding boundaries
19
- attr_reader :use_thumbnail
18
+ # A Sqed::Boundaries instance, stores the coordinates of all of the layout sections
19
+ attr_reader :boundaries
20
20
 
21
- # when we compute using a derived thumbnail we temporarily store the full size image here
22
- attr_reader :original_image
21
+ # Whether to compress the original image to a thumbnail when finding boundaries
22
+ attr_reader :use_thumbnail
23
23
 
24
- def initialize(target_image: image, target_layout: layout, use_thumbnail: true)
25
- raise 'No layout provided.' if target_layout.nil?
26
- raise 'No image provided.' if target_image.nil? || target_image.class.name != 'Magick::Image'
24
+ # when we compute using a derived thumbnail we temporarily store the full size image here
25
+ attr_reader :original_image
27
26
 
28
- @use_thumbnail = use_thumbnail
27
+ def initialize(**opts)
28
+ # image: image, layout: layout, use_thumbnail: true
29
+ @use_thumbnail = opts[:use_thumbnail]
30
+ @use_thumbnail = true if @use_thumbnail.nil?
31
+ @layout = opts[:layout]
32
+ @image = opts[:image]
29
33
 
30
- @layout = target_layout
31
- @image = target_image
32
- true
33
- end
34
+ raise 'No layout provided.' if layout.nil?
35
+ raise 'No image provided.' if image.nil? || image.class.name != 'Magick::Image'
34
36
 
35
- # Returns a Sqed::Boundaries instance initialized to the number of sections in the passed layout.
36
- def boundaries
37
- @boundaries ||= Sqed::Boundaries.new(@layout)
38
- end
39
-
40
- def longest_thumbnail_axis
41
- image.columns > image.rows ? :width : :height
42
- end
43
-
44
- def thumbnail_height
45
- if longest_thumbnail_axis == :height
46
- THUMB_SIZE
47
- else
48
- (image.rows.to_f * (THUMB_SIZE.to_f / image.columns.to_f)).round.to_i
37
+ true
49
38
  end
50
- end
51
39
 
52
- def thumbnail_width
53
- if longest_thumbnail_axis == :width
54
- THUMB_SIZE
55
- else
56
- (image.columns.to_f * (THUMB_SIZE.to_f / image.rows.to_f)).round.to_i
40
+ # Returns a Sqed::Boundaries instance initialized to the number of sections in the passed layout.
41
+ def boundaries
42
+ @boundaries ||= Sqed::Boundaries.new(@layout)
57
43
  end
58
- end
59
44
 
60
- # see https://rmagick.github.io/image3.html#thumbnail
61
- def thumbnail
62
- image.thumbnail(thumbnail_width, thumbnail_height)
63
- end
64
-
65
- def width_factor
66
- image.columns.to_f / thumbnail_width.to_f
67
- end
45
+ def longest_thumbnail_axis
46
+ image.columns > image.rows ? :width : :height
47
+ end
68
48
 
69
- def height_factor
70
- image.rows.to_f / thumbnail_height.to_f
71
- end
49
+ def thumbnail_height
50
+ if longest_thumbnail_axis == :height
51
+ THUMB_SIZE
52
+ else
53
+ (image.rows.to_f * (THUMB_SIZE.to_f / image.columns.to_f)).round.to_i
54
+ end
55
+ end
72
56
 
73
- def zoom_boundaries
74
- boundaries.zoom(width_factor, height_factor )
75
- end
57
+ def thumbnail_width
58
+ if longest_thumbnail_axis == :width
59
+ THUMB_SIZE
60
+ else
61
+ (image.columns.to_f * (THUMB_SIZE.to_f / image.rows.to_f)).round.to_i
62
+ end
63
+ end
76
64
 
77
- # return [Integer, nil]
78
- # sample more with small images, less with large images
79
- # we want to return larger numbers (= faster sampling)
80
- def self.get_subdivision_size(image_width)
81
- case image_width
82
- when nil
83
- nil
84
- when 0..140
85
- 6
86
- when 141..640
87
- 12
88
- when 641..1000
89
- 16
90
- when 1001..3000
91
- 60
92
- when 3001..6400
93
- 80
94
- else
95
- 140
65
+ # see https://rmagick.github.io/image3.html#thumbnail
66
+ def thumbnail
67
+ image.thumbnail(thumbnail_width, thumbnail_height)
96
68
  end
97
- end
98
69
 
99
- # @return [Array]
100
- # 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]
101
- #
102
- # @param image
103
- # the image to sample
104
- #
105
- # @param sample_subdivision_size
106
- # an Integer, the distance in pixels b/w samples
107
- #
108
- # @param sample_cutoff_factor: (0.0-1.0)
109
- # if provided over-rides the default cutoff calculation by reducing the number of pixels required to be considered a border hit
110
- # - 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
111
- # then only posititions with 8 ((100/10)*.8) or more hits
112
- # - when nil the cutoff defaults to the maximum of the pairwise difference between hit counts
113
- #
114
- # @param scan
115
- # (:rows|:columns), :rows finds vertical borders, :columns finds horizontal borders
116
- #
117
- def self.color_boundary_finder(target_image: image, sample_subdivision_size: nil, sample_cutoff_factor: nil, scan: :rows, boundary_color: :green)
70
+ def width_factor
71
+ image.columns.to_f / thumbnail_width.to_f
72
+ end
118
73
 
119
- image_width = target_image.send(scan)
120
- sample_subdivision_size = get_subdivision_size(image_width) if sample_subdivision_size.nil?
121
- samples_to_take = (image_width / sample_subdivision_size).to_i - 1
74
+ def height_factor
75
+ image.rows.to_f / thumbnail_height.to_f
76
+ end
122
77
 
123
- border_hits = {}
78
+ def zoom_boundaries
79
+ boundaries.zoom(width_factor, height_factor )
80
+ end
124
81
 
125
- (0..samples_to_take).each do |s|
126
- # Create a sample image a single pixel tall
127
- if scan == :rows
128
- j = target_image.crop(0, s * sample_subdivision_size, target_image.columns, 1, true)
129
- elsif scan == :columns
130
- j = target_image.crop(s * sample_subdivision_size, 0, 1, target_image.rows, true)
82
+ # return [Integer, nil]
83
+ # sample more with small images, less with large images
84
+ # we want to return larger numbers (= faster sampling)
85
+ def self.get_subdivision_size(image_width)
86
+ case image_width
87
+ when nil
88
+ nil
89
+ when 0..140
90
+ 6
91
+ when 141..640
92
+ 12
93
+ when 641..1000
94
+ 16
95
+ when 1001..3000
96
+ 60
97
+ when 3001..6400
98
+ 80
131
99
  else
132
- raise
100
+ 140
133
101
  end
102
+ end
134
103
 
135
- j.each_pixel do |pixel, c, r|
136
- index = ( (scan == :rows) ? c : r)
137
-
138
- # Our hit metric is dirt simple, if there is some percentage more of the boundary_color than the others, count + 1 for that column
139
- if send("is_#{boundary_color}?", pixel)
140
- # we have already hit that column previously, increment
141
- if border_hits[index]
142
- border_hits[index] += 1
143
- # initialize the newly hit column 1
144
- else
145
- border_hits[index] = 1
104
+ # @return [Array]
105
+ # 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]
106
+ #
107
+ # @param image
108
+ # the image to sample
109
+ #
110
+ # @param sample_subdivision_size
111
+ # an Integer, the distance in pixels b/w samples
112
+ #
113
+ # @param sample_cutoff_factor: (0.0-1.0)
114
+ # if provided over-rides the default cutoff calculation by reducing the number of pixels required to be considered a border hit
115
+ # - 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
116
+ # then only posititions with 8 ((100/10)*.8) or more hits
117
+ # - when nil the cutoff defaults to the maximum of the pairwise difference between hit counts
118
+ #
119
+ # @param scan
120
+ # (:rows|:columns), :rows finds vertical borders, :columns finds horizontal borders
121
+ #
122
+ def self.color_boundary_finder(**opts)
123
+ # image: image, sample_subdivision_size: nil, sample_cutoff_factor: nil, scan: :rows, boundary_color: :green)
124
+ image = opts[:image]
125
+ sample_subdivision_size = opts[:sample_subdivision_size]
126
+ sample_cutoff_factor = opts[:sample_cutoff_factor]
127
+ scan = opts[:scan] || :rows
128
+ boundary_color = opts[:boundary_color] || :green
129
+
130
+ image_width = image.send(scan)
131
+ sample_subdivision_size = get_subdivision_size(image_width) if sample_subdivision_size.nil?
132
+ samples_to_take = (image_width / sample_subdivision_size).to_i - 1
133
+
134
+ border_hits = {}
135
+
136
+ (0..samples_to_take).each do |s|
137
+ # Create a sample image a single pixel tall
138
+ if scan == :rows
139
+ j = image.crop(0, s * sample_subdivision_size, image.columns, 1, true)
140
+ elsif scan == :columns
141
+ j = image.crop(s * sample_subdivision_size, 0, 1, image.rows, true)
142
+ else
143
+ raise
144
+ end
145
+
146
+ j.each_pixel do |pixel, c, r|
147
+ index = (scan == :rows) ? c : r
148
+
149
+ # Our hit metric is dirt simple, if there is some percentage more of the boundary_color than the others, count + 1 for that column
150
+ if send("is_#{boundary_color}?", pixel)
151
+ # we have already hit that column previously, increment
152
+ if border_hits[index]
153
+ border_hits[index] += 1
154
+ # initialize the newly hit column 1
155
+ else
156
+ border_hits[index] = 1
157
+ end
146
158
  end
147
159
  end
148
160
  end
149
- end
150
161
 
151
- return nil if border_hits.length < 2
162
+ return nil if border_hits.length < 2
152
163
 
153
- if sample_cutoff_factor.nil?
154
- cutoff = max_difference(border_hits.values)
164
+ if sample_cutoff_factor.nil?
165
+ cutoff = max_difference(border_hits.values)
155
166
 
156
- cutoff = border_hits.values.first - 1 if cutoff == 0 # difference of two identical things is 0
157
- else
158
- cutoff = (samples_to_take * sample_cutoff_factor).to_i
167
+ cutoff = border_hits.values.first - 1 if cutoff == 0 # difference of two identical things is 0
168
+ else
169
+ cutoff = (samples_to_take * sample_cutoff_factor).to_i
170
+ end
171
+
172
+ frequency_stats(border_hits, cutoff)
159
173
  end
160
174
 
161
- frequency_stats(border_hits, cutoff)
162
- end
175
+ def self.is_green?(pixel)
176
+ (pixel.green > pixel.red*COLOR_DELTA) && (pixel.green > pixel.blue*COLOR_DELTA)
177
+ end
163
178
 
164
- def self.is_green?(pixel)
165
- (pixel.green > pixel.red*COLOR_DELTA) && (pixel.green > pixel.blue*COLOR_DELTA)
166
- end
179
+ def self.is_blue?(pixel)
180
+ (pixel.blue > pixel.red*COLOR_DELTA) && (pixel.blue > pixel.green*COLOR_DELTA)
181
+ end
167
182
 
168
- def self.is_blue?(pixel)
169
- (pixel.blue > pixel.red*COLOR_DELTA) && (pixel.blue > pixel.green*COLOR_DELTA)
170
- end
183
+ def self.is_red?(pixel)
184
+ (pixel.red > pixel.blue*COLOR_DELTA) && (pixel.red > pixel.green*COLOR_DELTA)
185
+ end
171
186
 
172
- def self.is_red?(pixel)
173
- (pixel.red > pixel.blue*COLOR_DELTA) && (pixel.red > pixel.green*COLOR_DELTA)
174
- end
187
+ def self.is_black?(pixel)
188
+ black_threshold = 65535 * 0.15 #tune for black
189
+ (pixel.red < black_threshold) && (pixel.blue < black_threshold) && (pixel.green < black_threshold)
190
+ end
175
191
 
176
- def self.is_black?(pixel)
177
- black_threshold = 65535*0.15 #tune for black
178
- (pixel.red < black_threshold) && (pixel.blue < black_threshold) && (pixel.green < black_threshold)
179
- end
192
+ # return [Array]
193
+ # the start, mid, endpoint position of all (pixel) positions that have a count greater than the cutoff
194
+ def self.frequency_stats(frequency_hash, sample_cutoff = 0)
180
195
 
181
- # return [Array]
182
- # the start, mid, endpoint position of all (pixel) positions that have a count greater than the cutoff
183
- def self.frequency_stats(frequency_hash, sample_cutoff = 0)
184
-
185
- return nil if sample_cutoff.nil? || sample_cutoff < 1
186
- hit_ranges = []
196
+ return nil if sample_cutoff.nil? || sample_cutoff < 1
197
+ hit_ranges = []
187
198
 
188
- frequency_hash.each do |position, count|
189
- if count >= sample_cutoff
190
- hit_ranges.push(position)
199
+ frequency_hash.each do |position, count|
200
+ if count >= sample_cutoff
201
+ hit_ranges.push(position)
202
+ end
191
203
  end
192
- end
193
204
 
194
- case hit_ranges.size
195
- when 1
196
- c = hit_ranges[0]
197
- hit_ranges = [c - 1, c, c + 1]
198
- when 2
199
- hit_ranges.sort!
200
- c1 = hit_ranges[0]
201
- c2 = hit_ranges[1]
202
- hit_ranges = [c1, c2, c2 + (c2 - c1)]
203
- when 0
204
- return nil
205
- end
206
-
207
- # we have to sort because the keys (positions) we examined came unordered from a hash originally
208
- hit_ranges.sort!
205
+ case hit_ranges.size
206
+ when 1
207
+ c = hit_ranges[0]
208
+ hit_ranges = [c - 1, c, c + 1]
209
+ when 2
210
+ hit_ranges.sort!
211
+ c1 = hit_ranges[0]
212
+ c2 = hit_ranges[1]
213
+ hit_ranges = [c1, c2, c2 + (c2 - c1)]
214
+ when 0
215
+ return nil
216
+ end
209
217
 
210
- # return the position exactly in the middle of the array
211
- [hit_ranges.first, hit_ranges[(hit_ranges.length / 2).to_i], hit_ranges.last]
212
- end
218
+ # we have to sort because the keys (positions) we examined came unordered from a hash originally
219
+ hit_ranges.sort!
213
220
 
214
- # @return [Array]
215
- # like [0,1,2]
216
- # 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
217
- # Given [10, 12, 20] and width_factor 2 the result will be [10, 12, 13]
218
- #
219
- def corrected_frequency(frequency_stats, width_factor = 3.0 )
220
- v0 = frequency_stats[0]
221
- m = frequency_stats[1]
222
- v2 = frequency_stats[2]
223
-
224
- a = m - v0
225
- b = v2 - m
226
-
227
- largest = (a > b ? a : b)
228
-
229
- if a * width_factor > largest
230
- [(m - (v2 - m)/2).to_i, m, v2]
231
- elsif b * width_factor > largest
232
- [ v0, m, (m + (m - v0)/2).to_i ]
233
- else
234
- frequency_stats
221
+ # return the position exactly in the middle of the array
222
+ [hit_ranges.first, hit_ranges[(hit_ranges.length / 2).to_i], hit_ranges.last]
235
223
  end
236
- end
237
224
 
225
+ # @return [Array]
226
+ # like [0,1,2]
227
+ # 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
228
+ # Given [10, 12, 20] and width_factor 2 the result will be [10, 12, 13]
229
+ #
230
+ def corrected_frequency(frequency_stats, width_factor = 3.0)
231
+ v0 = frequency_stats[0]
232
+ m = frequency_stats[1]
233
+ v2 = frequency_stats[2]
234
+
235
+ a = m - v0
236
+ b = v2 - m
237
+
238
+ largest = (a > b ? a : b)
239
+
240
+ if a * width_factor > largest
241
+ [(m - (v2 - m) / 2).to_i, m, v2]
242
+ elsif b * width_factor > largest
243
+ [v0, m, (m + (m - v0) / 2).to_i]
244
+ else
245
+ frequency_stats
246
+ end
247
+ end
238
248
 
249
+ # Returns an Integer, the maximum of the pairwise differences of the values in the array
250
+ # For example, given
251
+ # [1,2,3,9,6,2,0]
252
+ # The resulting pairwise array is
253
+ # [1,1,6,3,4,2]
254
+ # The max (value returned) is
255
+ # 6
256
+ def self.max_pairwise_difference(array)
257
+ (0..array.length - 2).map{|i| (array[i] - array[i + 1]).abs }.max
258
+ end
239
259
 
240
- # Returns an Integer, the maximum of the pairwise differences of the values in the array
241
- # For example, given
242
- # [1,2,3,9,6,2,0]
243
- # The resulting pairwise array is
244
- # [1,1,6,3,4,2]
245
- # The max (value returned) is
246
- # 6
247
- def self.max_pairwise_difference(array)
248
- (0..array.length-2).map{|i| (array[i] - array[i+1]).abs }.max
249
- end
260
+ def self.max_difference(array)
261
+ array.max - array.min
262
+ end
250
263
 
251
- def self.max_difference(array)
252
- array.max - array.min
253
- end
264
+ def self.derivative_signs(array)
265
+ (0..array.length - 2).map { |i| (array[i + 1] - array[i]) <=> 0 }
266
+ end
254
267
 
255
- def self.derivative_signs(array)
256
- (0..array.length-2).map { |i| (array[i+1] - array[i]) <=> 0 }
257
- end
268
+ def self.derivative(array)
269
+ (0..array.length - 2).map { |i| array[i + 1] - array[i] }
270
+ end
258
271
 
259
- def self.derivative(array)
260
- (0..array.length-2).map { |i| array[i+1] - array[i] }
261
272
  end
262
-
263
273
  end
264
-