sqed 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
-