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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/Guardfile +66 -0
- data/lib/sqed.rb +120 -68
- data/lib/sqed/boundaries.rb +30 -25
- data/lib/sqed/boundary_finder.rb +221 -212
- data/lib/sqed/boundary_finder/color_line_finder.rb +50 -42
- data/lib/sqed/boundary_finder/cross_finder.rb +3 -3
- data/lib/sqed/boundary_finder/stage_finder.rb +8 -3
- data/lib/sqed/extractor.rb +23 -25
- data/lib/sqed/parser.rb +4 -7
- data/lib/sqed/parser/barcode_parser.rb +5 -5
- data/lib/sqed/parser/ocr_parser.rb +46 -46
- data/lib/sqed/result.rb +60 -57
- data/lib/sqed/version.rb +1 -1
- data/lib/sqed_config.rb +52 -56
- data/spec/lib/sqed/boundaries_spec.rb +1 -1
- data/spec/lib/sqed/boundary_finder/color_line_finder_spec.rb +24 -24
- data/spec/lib/sqed/boundary_finder/cross_finder_spec.rb +1 -1
- data/spec/lib/sqed/boundary_finder/stage_finder_spec.rb +1 -1
- data/spec/lib/sqed/boundary_finder_spec.rb +73 -45
- data/spec/lib/sqed/extractor_spec.rb +4 -4
- data/spec/lib/sqed/parser/ocr_spec.rb +2 -2
- data/spec/lib/sqed_spec.rb +39 -39
- data/spec/lib/stage_handling/seven_slot_spec.rb +45 -9
- data/spec/support/files/stage_images/inhs_7_slot2.jpg +0 -0
- data/spec/support/image_helpers.rb +10 -9
- metadata +6 -3
data/lib/sqed/boundary_finder.rb
CHANGED
@@ -1,264 +1,273 @@
|
|
1
|
-
|
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
|
-
|
7
|
-
|
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
|
-
|
10
|
-
|
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
|
-
|
13
|
-
|
12
|
+
# the passed image
|
13
|
+
attr_reader :image
|
14
14
|
|
15
|
-
|
16
|
-
|
15
|
+
# a symbol from SqedConfig::LAYOUTS
|
16
|
+
attr_reader :layout
|
17
17
|
|
18
|
-
|
19
|
-
|
18
|
+
# A Sqed::Boundaries instance, stores the coordinates of all of the layout sections
|
19
|
+
attr_reader :boundaries
|
20
20
|
|
21
|
-
|
22
|
-
|
21
|
+
# Whether to compress the original image to a thumbnail when finding boundaries
|
22
|
+
attr_reader :use_thumbnail
|
23
23
|
|
24
|
-
|
25
|
-
|
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
|
-
|
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
|
-
|
31
|
-
|
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
|
-
|
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
|
-
|
53
|
-
|
54
|
-
|
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
|
-
|
61
|
-
|
62
|
-
|
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
|
-
|
70
|
-
|
71
|
-
|
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
|
-
|
74
|
-
|
75
|
-
|
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
|
-
|
78
|
-
|
79
|
-
|
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
|
-
|
100
|
-
|
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
|
-
|
120
|
-
|
121
|
-
|
74
|
+
def height_factor
|
75
|
+
image.rows.to_f / thumbnail_height.to_f
|
76
|
+
end
|
122
77
|
|
123
|
-
|
78
|
+
def zoom_boundaries
|
79
|
+
boundaries.zoom(width_factor, height_factor )
|
80
|
+
end
|
124
81
|
|
125
|
-
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
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
|
-
|
100
|
+
140
|
133
101
|
end
|
102
|
+
end
|
134
103
|
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
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
|
-
|
162
|
+
return nil if border_hits.length < 2
|
152
163
|
|
153
|
-
|
154
|
-
|
164
|
+
if sample_cutoff_factor.nil?
|
165
|
+
cutoff = max_difference(border_hits.values)
|
155
166
|
|
156
|
-
|
157
|
-
|
158
|
-
|
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
|
-
|
162
|
-
|
175
|
+
def self.is_green?(pixel)
|
176
|
+
(pixel.green > pixel.red*COLOR_DELTA) && (pixel.green > pixel.blue*COLOR_DELTA)
|
177
|
+
end
|
163
178
|
|
164
|
-
|
165
|
-
|
166
|
-
|
179
|
+
def self.is_blue?(pixel)
|
180
|
+
(pixel.blue > pixel.red*COLOR_DELTA) && (pixel.blue > pixel.green*COLOR_DELTA)
|
181
|
+
end
|
167
182
|
|
168
|
-
|
169
|
-
|
170
|
-
|
183
|
+
def self.is_red?(pixel)
|
184
|
+
(pixel.red > pixel.blue*COLOR_DELTA) && (pixel.red > pixel.green*COLOR_DELTA)
|
185
|
+
end
|
171
186
|
|
172
|
-
|
173
|
-
|
174
|
-
|
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
|
-
|
177
|
-
|
178
|
-
|
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
|
-
|
182
|
-
|
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
|
-
|
189
|
-
|
190
|
-
|
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
|
-
|
195
|
-
|
196
|
-
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
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
|
-
|
211
|
-
|
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
|
-
|
215
|
-
|
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
|
-
|
241
|
-
|
242
|
-
|
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
|
-
|
252
|
-
|
253
|
-
|
264
|
+
def self.derivative_signs(array)
|
265
|
+
(0..array.length - 2).map { |i| (array[i + 1] - array[i]) <=> 0 }
|
266
|
+
end
|
254
267
|
|
255
|
-
|
256
|
-
|
257
|
-
|
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
|
-
|