qr_reader 0.0.1

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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.txt +21 -0
  4. data/NOTICE +14 -0
  5. data/README.md +222 -0
  6. data/exe/qr_reader +66 -0
  7. data/lib/qr_reader/binarizer.rb +170 -0
  8. data/lib/qr_reader/bit_matrix.rb +120 -0
  9. data/lib/qr_reader/decoder/bitstream_parser.rb +307 -0
  10. data/lib/qr_reader/decoder/character_set.rb +163 -0
  11. data/lib/qr_reader/decoder/codewords.rb +164 -0
  12. data/lib/qr_reader/decoder/data_mask.rb +57 -0
  13. data/lib/qr_reader/decoder/format_reader.rb +204 -0
  14. data/lib/qr_reader/decoder/function_patterns.rb +62 -0
  15. data/lib/qr_reader/decoder/galois_field.rb +69 -0
  16. data/lib/qr_reader/decoder/reed_solomon.rb +312 -0
  17. data/lib/qr_reader/decoder.rb +241 -0
  18. data/lib/qr_reader/detector/alignment_grid.rb +127 -0
  19. data/lib/qr_reader/detector/alignment_pattern_finder.rb +191 -0
  20. data/lib/qr_reader/detector/candidate/dimension.rb +237 -0
  21. data/lib/qr_reader/detector/candidate/strokes.rb +126 -0
  22. data/lib/qr_reader/detector/candidate.rb +436 -0
  23. data/lib/qr_reader/detector/finder_pattern.rb +63 -0
  24. data/lib/qr_reader/detector/finder_pattern_finder.rb +199 -0
  25. data/lib/qr_reader/detector/geometry.rb +118 -0
  26. data/lib/qr_reader/detector/grid_sampler.rb +350 -0
  27. data/lib/qr_reader/detector/pattern_sets.rb +235 -0
  28. data/lib/qr_reader/detector/perspective_transform.rb +117 -0
  29. data/lib/qr_reader/detector/pure_detector.rb +197 -0
  30. data/lib/qr_reader/detector.rb +215 -0
  31. data/lib/qr_reader/errors.rb +14 -0
  32. data/lib/qr_reader/format_information.rb +79 -0
  33. data/lib/qr_reader/formats/bmp.rb +434 -0
  34. data/lib/qr_reader/formats/ccitt.rb +81 -0
  35. data/lib/qr_reader/formats/gif.rb +272 -0
  36. data/lib/qr_reader/formats/jpeg.rb +152 -0
  37. data/lib/qr_reader/formats/pdf.rb +2935 -0
  38. data/lib/qr_reader/formats/png.rb +311 -0
  39. data/lib/qr_reader/formats/pnm.rb +286 -0
  40. data/lib/qr_reader/formats/tiff.rb +1345 -0
  41. data/lib/qr_reader/formats.rb +279 -0
  42. data/lib/qr_reader/image.rb +231 -0
  43. data/lib/qr_reader/kernels/binarizer.rb +475 -0
  44. data/lib/qr_reader/kernels/ccitt.rb +554 -0
  45. data/lib/qr_reader/kernels/detector.rb +587 -0
  46. data/lib/qr_reader/kernels/image.rb +65 -0
  47. data/lib/qr_reader/kernels/jpeg.rb +3421 -0
  48. data/lib/qr_reader/kernels/lzw.rb +173 -0
  49. data/lib/qr_reader/kernels/pixels.rb +116 -0
  50. data/lib/qr_reader/kernels/png.rb +1138 -0
  51. data/lib/qr_reader/kernels.rb +14 -0
  52. data/lib/qr_reader/native_kernels.rb +130 -0
  53. data/lib/qr_reader/reader.rb +348 -0
  54. data/lib/qr_reader/result.rb +49 -0
  55. data/lib/qr_reader/symbol_version.rb +173 -0
  56. data/lib/qr_reader/version.rb +5 -0
  57. data/lib/qr_reader.rb +77 -0
  58. metadata +103 -0
@@ -0,0 +1,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "candidate/dimension"
4
+ require_relative "candidate/strokes"
5
+
6
+ module QrReader
7
+ class Detector
8
+ # A possible QR symbol located from three finder patterns.
9
+ #
10
+ # The geometry is estimated when the candidate is built
11
+ # (Candidate.locate); sampling happens lazily in #bits and
12
+ # #alternatives, so a reader that stops at the first decodable
13
+ # candidate only pays for what it uses.
14
+ #
15
+ # bits - sampled N x N BitMatrix (1 = dark)
16
+ # points - symbol corners [TL, TR, BR, BL] in pixel coordinates
17
+ # finder_patterns - finder centres [TL, TR, BL] (for debugging)
18
+ # alternatives - Enumerator of further samplings to try when +bits+
19
+ # does not decode, most promising first
20
+ # dimension - estimated modules per side
21
+ #
22
+ # The module grid is mapped to the image by a homography through the
23
+ # three finder centres and a fourth reference point at module
24
+ # (d - 6.5, d - 6.5): the bottom-right alignment pattern if one was
25
+ # found (mode :alignment), else the intersection of the finder ring
26
+ # strokes that point there (:strokes); failing both, the parallelogram
27
+ # corner at (d - 3.5, d - 3.5) is used (:parallelogram). Versions 7+ are
28
+ # sampled piecewise along their alignment pattern grid (AlignmentGrid).
29
+ class Candidate
30
+ include Dimension
31
+ include Strokes
32
+
33
+ # Timing pattern agreement above which a grid counts as plausible.
34
+ PLAUSIBLE_TIMING = 0.6
35
+
36
+ attr_reader :dimension, :dimensions, :score, :tl, :tr, :bl, :module_size, :mode
37
+
38
+ # The candidate for a PatternSets::Triple, or nil if the triple cannot
39
+ # be a symbol.
40
+ def self.locate(detector, triple)
41
+ candidate = new(detector, triple)
42
+ candidate.located? ? candidate : nil
43
+ end
44
+
45
+ def initialize(detector, triple)
46
+ @detector = detector
47
+ @matrix = detector.matrix
48
+ @bl = triple.bl
49
+ @tl = triple.tl
50
+ @tr = triple.tr
51
+ @score = triple.score
52
+ @alignment = nil
53
+ @stroke_point = nil
54
+ @version_confirmed = false
55
+ @transforms = {}
56
+ @located = estimate_geometry
57
+ end
58
+
59
+ # False if the geometry is unusable (then nothing else may be called).
60
+ def located?
61
+ @located
62
+ end
63
+
64
+ # The most likely sampling of the symbol.
65
+ def bits
66
+ @bits ||= begin
67
+ # Piecewise sampling costs an alignment pattern search per grid
68
+ # point: only spend it on grids that look like a real symbol.
69
+ piecewise = @dimension >= 45 && (@version_confirmed || promising?(@dimension, @mode))
70
+ sample(@dimension, @mode, piecewise: piecewise)
71
+ end
72
+ end
73
+
74
+ def finder_patterns
75
+ [@tl.to_a, @tr.to_a, @bl.to_a]
76
+ end
77
+
78
+ # True if +pattern+ (a FinderPattern) is one of this candidate's finders.
79
+ def uses?(pattern)
80
+ pattern.equal?(@tl) || pattern.equal?(@tr) || pattern.equal?(@bl)
81
+ end
82
+
83
+ # Corners of the symbol as [[x, y], ...]: TL, TR, BR, BL.
84
+ def points
85
+ @points ||= begin
86
+ t = transform_for(@dimension, @mode)
87
+ d = @dimension
88
+ [[0, 0], [d, 0], [d, d], [0, d]].map { |x, y| t.apply(x, y) || [Float::NAN, Float::NAN] }
89
+ end
90
+ end
91
+
92
+ # Further samplings worth trying when #bits does not decode: the
93
+ # global instead of the piecewise transform and the grayscale image
94
+ # (if the detector has it) when #bits looks like a symbol at all, then
95
+ # the other reference points and the other dimension estimates whose
96
+ # timing patterns read plausibly.
97
+ def alternatives
98
+ return enum_for(:alternatives) unless block_given?
99
+
100
+ seen = [bits]
101
+ try = lambda do |matrix|
102
+ next if matrix.nil? || seen.include?(matrix)
103
+
104
+ seen << matrix
105
+ yield matrix
106
+ end
107
+ d = @dimension
108
+ if plausible?
109
+ try.call(sample(d, @mode, piecewise: false)) if d >= 45
110
+ try.call(sample(d, @mode, piecewise: true, luminance: true)) if @detector.image
111
+ # The displacement field on an affine base suits bent surfaces,
112
+ # on the full homography perspective plus bending.
113
+ try.call(warped_sample(d, :parallelogram))
114
+ try.call(warped_sample(d, @mode)) if @mode != :parallelogram
115
+ end
116
+ %i[alignment strokes parallelogram].each do |m|
117
+ next if m == @mode || (m != :parallelogram && !reference(m))
118
+
119
+ try.call(sample(d, m, piecewise: true)) if promising?(d, m)
120
+ end
121
+ # Other dimensions with the first reference point that suits them
122
+ # (the alignment pattern may be a look-alike found for the first).
123
+ @dimensions.drop(1).each do |other|
124
+ mode = [@mode, :strokes, :parallelogram].uniq.find do |m|
125
+ (m == :parallelogram || reference(m)) && promising?(other, m)
126
+ end
127
+ try.call(sample(other, mode, piecewise: true)) if mode
128
+ end
129
+ nil
130
+ end
131
+
132
+ # Fraction of the timing pattern modules of #bits (row and column 6
133
+ # between the finders) with the expected colour: about 0.5 for false
134
+ # finder triples and wrong dimensions, close to 1 for real symbols.
135
+ def timing_quality
136
+ @timing_quality ||= begin
137
+ grid = bits
138
+ d = grid.width
139
+ good = 0
140
+ i = 8
141
+ while i <= d - 9
142
+ expected = i.even? ? 1 : 0
143
+ good += 1 if grid.get(i, 6) == expected
144
+ good += 1 if grid.get(6, i) == expected
145
+ i += 1
146
+ end
147
+ good.fdiv(2 * (d - 16))
148
+ end
149
+ end
150
+
151
+ # True unless the primary grid's timing patterns look like noise.
152
+ def plausible?
153
+ timing_quality >= PLAUSIBLE_TIMING
154
+ end
155
+
156
+ # Cheap estimate of #plausible? that reads only the timing pattern
157
+ # modules through the global transform (no full sampling). Symbols
158
+ # whose version information confirmed the dimension count as likely
159
+ # even when bending defeats the global transform (chance agreement of
160
+ # an 18-bit version block with the measured dimension is rare).
161
+ def likely?
162
+ @likely = @version_confirmed || promising?(@dimension, @mode) if @likely.nil?
163
+ @likely
164
+ end
165
+
166
+ # Identifies the finder pattern triple (for de-duplication by callers).
167
+ def key
168
+ [@tl, @tr, @bl].map { |p| [p.x.round, p.y.round] }
169
+ end
170
+
171
+ def inspect
172
+ format("#<Candidate dim=%d mode=%s tl=(%.1f,%.1f) tr=(%.1f,%.1f) bl=(%.1f,%.1f) score=%.3f>",
173
+ @dimension, @mode, @tl.x, @tl.y, @tr.x, @tr.y, @bl.x, @bl.y, @score)
174
+ end
175
+
176
+ private
177
+
178
+ # Estimates module sizes, the dimension candidates, the bottom-right
179
+ # reference point and, for versions 7+, the version. False if the
180
+ # geometry is unusable.
181
+ def estimate_geometry
182
+ right = unit(@tr.x - @tl.x, @tr.y - @tl.y)
183
+ down = unit(@bl.x - @tl.x, @bl.y - @tl.y)
184
+ @axes = [right, down]
185
+ # Module sizes of each finder along the symbol's axes.
186
+ @tl_right = line_module_size(@tl, *right) || @tl.module_size
187
+ @tl_down = line_module_size(@tl, *down) || @tl.module_size
188
+ @tr_left = line_module_size(@tr, -right[0], -right[1]) || @tr.module_size
189
+ @tr_down = line_module_size(@tr, *down) || @tr.module_size
190
+ @bl_up = line_module_size(@bl, -down[0], -down[1]) || @bl.module_size
191
+ @bl_right = line_module_size(@bl, *right) || @bl.module_size
192
+ # Under a projective map the distance between two points is the
193
+ # module distance times the geometric mean of the local scales at
194
+ # both ends, hence geometric means.
195
+ ms_top = Math.sqrt(@tl_right * @tr_left)
196
+ ms_left = Math.sqrt(@tl_down * @bl_up)
197
+ @module_size = (ms_top + ms_left) * 0.5
198
+ top = @tl.distance(@tr) / ms_top + 7
199
+ left = @tl.distance(@bl) / ms_left + 7
200
+ estimate = (top + left) * 0.5
201
+ return false unless estimate.finite? && estimate > 15 && estimate < 200
202
+
203
+ @dimensions = dimension_candidates(estimate, top, left)
204
+ return false if @dimensions.empty?
205
+
206
+ @stroke_point = stroke_intersection
207
+ locate_alignment(@dimensions.find { |d| d > 21 } || 25)
208
+ @mode = if @alignment
209
+ :alignment
210
+ elsif @stroke_point
211
+ :strokes
212
+ else
213
+ :parallelogram
214
+ end
215
+ if (version_dim = version_dimension(@dimensions))
216
+ @dimensions.delete(version_dim)
217
+ @dimensions.unshift(version_dim)
218
+ end
219
+ @dimension = @dimensions.first
220
+ !transform_for(@dimension, @mode).nil?
221
+ end
222
+
223
+ def unit(x, y)
224
+ len = Math.hypot(x, y)
225
+ len.zero? ? [0.0, 0.0] : [x / len, y / len]
226
+ end
227
+
228
+ # Looks for the bottom-right alignment pattern around its predicted
229
+ # position (from the stroke intersection if known, else from the
230
+ # parallelogram), verifying candidates with the local module vectors.
231
+ def locate_alignment(dimension)
232
+ t = (@stroke_point && transform_for(dimension, :strokes)) || parallelogram_transform(dimension)
233
+ return unless t
234
+
235
+ c = dimension - 6.5
236
+ x, y = t.apply(c, c)
237
+ x1, y1 = t.apply(c + 1, c)
238
+ x2, y2 = t.apply(c, c + 1)
239
+ return unless x && x1 && x2
240
+
241
+ @alignment = @detector.alignment_finder.find(x, y, 8 * @module_size + 2, x1 - x, y1 - y, x2 - x, y2 - y)
242
+ end
243
+
244
+ # Pixel position of the reference point for +mode+ (nil if unknown).
245
+ def reference(mode)
246
+ case mode
247
+ when :alignment then @alignment
248
+ when :strokes then @stroke_point
249
+ end
250
+ end
251
+
252
+ # Module-to-pixel transform for dimension d and reference +mode+;
253
+ # nil if degenerate.
254
+ def transform_for(d, mode)
255
+ key = [d, mode]
256
+ return @transforms[key] if @transforms.key?(key)
257
+
258
+ ref = reference(mode)
259
+ @transforms[key] =
260
+ if ref
261
+ c = d - 6.5
262
+ checked(PerspectiveTransform.quad_to_quad(
263
+ [[3.5, 3.5], [d - 3.5, 3.5], [c, c], [3.5, d - 3.5]],
264
+ [@tl.to_a, @tr.to_a, ref, @bl.to_a]
265
+ ), d)
266
+ else
267
+ parallelogram_transform(d)
268
+ end
269
+ end
270
+
271
+ def parallelogram_transform(d)
272
+ br = [@tr.x - @tl.x + @bl.x, @tr.y - @tl.y + @bl.y]
273
+ checked(PerspectiveTransform.quad_to_quad(
274
+ [[3.5, 3.5], [d - 3.5, 3.5], [d - 3.5, d - 3.5], [3.5, d - 3.5]],
275
+ [@tl.to_a, @tr.to_a, br, @bl.to_a]
276
+ ), d)
277
+ end
278
+
279
+ # The transform if it is regular over the whole symbol (no fold or
280
+ # point at infinity inside), else nil.
281
+ def checked(t, d)
282
+ t if t&.regular_over?(0, 0, d, d)
283
+ end
284
+
285
+ # Bit at continuous pixel position (x, y), 0 outside the image.
286
+ def pixel(x, y)
287
+ return 0 unless x >= 0 && y >= 0 && x < @matrix.width && y < @matrix.height
288
+
289
+ @matrix.bits.getbyte(y.to_i * @matrix.width + x.to_i)
290
+ end
291
+
292
+ # Bit of module (mx, my) under transform t (0 when outside the image
293
+ # or at infinity). Same as pixel(*t.apply(...)) without allocating.
294
+ def module_at(t, mx, my)
295
+ m = t.m
296
+ u = mx + 0.5
297
+ v = my + 0.5
298
+ den = m[6] * u + m[7] * v + m[8]
299
+ return 0 if den.abs < 1e-12
300
+
301
+ pixel((m[0] * u + m[1] * v + m[2]) / den, (m[3] * u + m[4] * v + m[5]) / den)
302
+ end
303
+
304
+ # Fraction of the timing pattern modules (row and column 6 between the
305
+ # finder patterns) that read as expected under the transform for d.
306
+ # Wrong dimensions and false finder triples score about 0.5.
307
+ def timing_score(d, mode)
308
+ t = transform_for(d, mode) or return 0.0
309
+ good = 0
310
+ i = 8
311
+ while i <= d - 9
312
+ expected = i.even? ? 1 : 0
313
+ good += 1 if module_at(t, i, 6) == expected
314
+ good += 1 if module_at(t, 6, i) == expected
315
+ i += 1
316
+ end
317
+ good.fdiv(2 * (d - 16))
318
+ end
319
+
320
+ # Cheap check whether sampling dimension d with +mode+ is worthwhile.
321
+ def promising?(d, mode)
322
+ timing_score(d, mode) >= PLAUSIBLE_TIMING
323
+ end
324
+
325
+ # Samples dimension d through the transform for +mode+ plus a pixel
326
+ # displacement field measured on the timing patterns: where row 6's
327
+ # (column 6's) module boundaries really are, compared to where the
328
+ # transform puts them, gives a displacement per module column (row);
329
+ # module (x, y) is shifted by the sum of its column's and its row's
330
+ # displacement. This follows symbols on curved surfaces (bottles,
331
+ # cans, fabric) whose stretch a homography cannot express. nil if a
332
+ # timing pattern cannot be traced.
333
+ def warped_sample(d, mode)
334
+ t = transform_for(d, mode) or return
335
+ cols = timing_shift(t, d, :row, 6.5) or return
336
+ # Trace column 6 where the row displacement says it is.
337
+ rows = timing_shift(t, d, :column, 6.5 + timing_offset(t, cols[6], 6.5, 6.5)) or return
338
+ GridSampler.sample_warped(@matrix, d, t, cols, rows, offset: GridSampler.vote_offsets(t, d))
339
+ end
340
+
341
+ # Per module column (+axis+ :row, tracing timing row 6 at module
342
+ # coordinate +across+) or row (:column) the pixel displacement [dx, dy]
343
+ # between where the timing pattern's module boundaries are and where
344
+ # the transform puts them. The pattern is sampled through +t+ at 1/8
345
+ # module steps from inside one finder ring to inside the other; its
346
+ # d - 13 boundaries (7 .. d - 7) are matched in order with the
347
+ # observed colour changes. Displacements are interpolated linearly
348
+ # (0 at the finder centres, which anchor the transform). nil unless
349
+ # exactly the expected number of boundaries is seen.
350
+ def timing_shift(t, d, axis, across)
351
+ step = 0.125
352
+ observed = []
353
+ previous = nil
354
+ u = 4.0
355
+ stop = d - 4.0
356
+ while u <= stop
357
+ x, y = axis == :row ? t.apply(u, across) : t.apply(across, u)
358
+ bit = x ? pixel(x, y) : 0
359
+ observed << u - step * 0.5 if previous && bit != previous
360
+ previous = bit
361
+ u += step
362
+ end
363
+ return unless observed.size == d - 13
364
+
365
+ point = ->(c) { axis == :row ? t.apply(c, across) : t.apply(across, c) }
366
+ knots = [[3.5, 0.0, 0.0]]
367
+ observed.each_with_index do |o, i|
368
+ nominal = point.call(7 + i) or return
369
+ actual = point.call(o) or return
370
+ knots << [7 + i, actual[0] - nominal[0], actual[1] - nominal[1]]
371
+ end
372
+ knots << [d - 3.5, 0.0, 0.0]
373
+ Array.new(d) { |i| interpolate(knots, i + 0.5) }
374
+ end
375
+
376
+ # Module-space offset along +axis+ equivalent to a pixel shift at
377
+ # module (u, v): used to find where a timing line really runs.
378
+ def timing_offset(t, shift, u, v, axis = :row)
379
+ p0 = t.apply(u, v) or return 0.0
380
+ p1 = axis == :row ? t.apply(u + 1, v) : t.apply(u, v + 1)
381
+ return 0.0 unless p1
382
+
383
+ ax = p1[0] - p0[0]
384
+ ay = p1[1] - p0[1]
385
+ len2 = ax * ax + ay * ay
386
+ len2.zero? ? 0.0 : (shift[0] * ax + shift[1] * ay) / len2
387
+ end
388
+
389
+ # Piecewise linear interpolation of [dx, dy] in knots sorted by x
390
+ # ([[x, dx, dy], ...]), extrapolating the end segments.
391
+ def interpolate(knots, x)
392
+ i = if x <= knots.first[0]
393
+ 1
394
+ elsif x >= knots.last[0]
395
+ knots.size - 1
396
+ else
397
+ knots.bsearch_index { |k| k[0] >= x }
398
+ end
399
+ x0, ax, ay = knots[i - 1]
400
+ x1, bx, by = knots[i]
401
+ f = (x - x0) / (x1 - x0)
402
+ [ax + (bx - ax) * f, ay + (by - ay) * f]
403
+ end
404
+
405
+ # Piecewise sampling regions for dimension d (versions 7+), memoized.
406
+ def regions(d, mode)
407
+ return if d < 45
408
+
409
+ @regions ||= {}
410
+ key = [d, mode]
411
+ return @regions[key] if @regions.key?(key)
412
+
413
+ t = transform_for(d, mode)
414
+ @regions[key] = t && AlignmentGrid.new(@detector, t, d, @module_size, ->(n) { finder_corners(n) }).regions
415
+ end
416
+
417
+ # Samples the grid for dimension d: piecewise along the alignment
418
+ # pattern grid for versions 7+ if requested; from the grayscale image
419
+ # with module-level thresholds if +luminance+, else from the binarized
420
+ # image (majority of five pixels per module along each axis in which
421
+ # modules span 3+ px).
422
+ def sample(d, mode, piecewise:, luminance: false)
423
+ t = transform_for(d, mode) or return
424
+ pieces = (regions(d, mode) if piecewise) || GridSampler.whole(d, t)
425
+ offset = GridSampler.vote_offsets(t, d)
426
+ if luminance
427
+ image = @detector.image or return
428
+ GridSampler.sample_luminance(image, d, pieces, @module_size, inverted: @detector.inverted_image?,
429
+ offset: offset)
430
+ else
431
+ GridSampler.sample(@matrix, d, pieces, offset: offset)
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ class Detector
5
+ # A located finder pattern (or alignment pattern).
6
+ #
7
+ # x, y - centre in continuous pixel coordinates (pixel (i, j)
8
+ # covers [i, i + 1) x [j, j + 1), so its centre is i + 0.5)
9
+ # module_size - estimated module size in pixels (rotation compensated)
10
+ # count - number of scan rows that hit this pattern
11
+ # quality - 0..1, how consistent the cross-check spreads were
12
+ class FinderPattern
13
+ attr_reader :x, :y, :module_size, :count
14
+ attr_accessor :quality
15
+
16
+ def initialize(x, y, module_size, count = 1, quality = 1.0)
17
+ @x = x
18
+ @y = y
19
+ @module_size = module_size
20
+ @count = count
21
+ @quality = quality
22
+ end
23
+
24
+ # Records another scan row crossing this pattern.
25
+ def add_hit
26
+ @count += 1
27
+ end
28
+
29
+ def distance(other)
30
+ Math.hypot(@x - other.x, @y - other.y)
31
+ end
32
+
33
+ def distance_to(x, y)
34
+ Math.hypot(@x - x, @y - y)
35
+ end
36
+
37
+ # True if (x, y) lies within +modules+ module sizes of the centre.
38
+ def near?(x, y, modules)
39
+ r = @module_size * modules
40
+ dx = @x - x
41
+ dy = @y - y
42
+ dx * dx + dy * dy < r * r
43
+ end
44
+
45
+ # Count-weighted average with another estimate of the same pattern.
46
+ def merge(other)
47
+ n = @count + other.count
48
+ FinderPattern.new((@x * @count + other.x * other.count) / n,
49
+ (@y * @count + other.y * other.count) / n,
50
+ (@module_size * @count + other.module_size * other.count) / n, n,
51
+ [@quality, other.quality].max)
52
+ end
53
+
54
+ def to_a
55
+ [@x, @y]
56
+ end
57
+
58
+ def inspect
59
+ format("#<FinderPattern (%.1f, %.1f) ms=%.2f n=%d>", @x, @y, @module_size, @count)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ class Detector
5
+ # Finds QR finder patterns - concentric 7x7, 5x5 and 3x3 squares that
6
+ # read dark-light-dark-light-dark in the ratio 1:1:3:1:1 along every
7
+ # line through their centre - in a binarized image.
8
+ #
9
+ # Rows are scanned as run lengths at C speed (String#index on the row's
10
+ # bytes). A 1:1:3:1:1 hit first passes a cheap vertical probe (stone
11
+ # height, light and dark ring), then cross-checks vertically,
12
+ # horizontally and along both diagonals; its centre is refined to the
13
+ # centroid of the 3x3 centre "stone" and its module size is estimated
14
+ # from the four spreads in a way that compensates for rotation. Along
15
+ # directions with modules of 3+ px, single noise pixels inside runs are
16
+ # bridged. Pixels need not be square (fax pages).
17
+ class FinderPatternFinder
18
+ DARK = BitMatrix::DARK
19
+ LIGHT = BitMatrix::LIGHT
20
+ # In normal mode rows are skipped so that a symbol of up to 97 modules
21
+ # (version 20) filling 3/4 of the image height is still hit 3 times.
22
+ MAX_MODULES_FAST = 97
23
+ MIN_SKIP = 3
24
+ # ... and then, where the image is not textured, every FINE_SKIP-th
25
+ # row, so that finder patterns down to about 2 px modules are still
26
+ # crossed by several rows on large page scans (whose coarser pyramid
27
+ # levels are too small for such symbols).
28
+ FINE_SKIP = 3
29
+ # A row neighbourhood whose sparse rows have more 1:1:3:1:1 ratio hits
30
+ # than this per 1000 px is texture (noise, halftone screens): those
31
+ # are expensive to scan densely and only scanned with try_harder.
32
+ # Documents and symbols have below 4.
33
+ TEXTURE_HITS = 8.0
34
+ # More patterns than this (noise, textures) are pruned by quality.
35
+ MAX_PATTERNS = 48
36
+ SQRT2 = Math.sqrt(2)
37
+ # Found patterns are indexed in a grid of cells of this many pixels
38
+ # for the "already found here?" test of every row hit.
39
+ CELL_SHIFT = 5
40
+
41
+ def initialize(matrix)
42
+ @bits = matrix.bits
43
+ @width = matrix.width
44
+ @height = matrix.height
45
+ # Patterns found so far, flat (see QrReaderKernels.finder_scan).
46
+ @state = []
47
+ @scanned = "\0".b * @height
48
+ # Ratio hits per scanned row.
49
+ @hits = Array.new(@height, 0)
50
+ end
51
+
52
+ # Finder patterns found so far after scanning every few rows (or every
53
+ # row with try_harder), at most +limit+ (the most consistent ones).
54
+ # Rows already scanned by an earlier call are not scanned again.
55
+ def find(try_harder: false, limit: MAX_PATTERNS)
56
+ if try_harder
57
+ scan(rows_every(1))
58
+ else
59
+ skip = (3 * @height) / (4 * MAX_MODULES_FAST)
60
+ skip = MIN_SKIP if skip < MIN_SKIP
61
+ scan(rows_every(skip))
62
+ scan(quiet_rows(FINE_SKIP, 2 * skip)) if skip > FINE_SKIP
63
+ end
64
+ prune(merge_duplicates(patterns), limit)
65
+ end
66
+
67
+ private
68
+
69
+ # Rows y = skip - 1, 2 * skip - 1, ... (all for skip 1) not scanned
70
+ # yet, as a String with 1 at their index (nil if none); marks them
71
+ # as scanned.
72
+ def rows_every(skip)
73
+ rows = nil
74
+ y = skip == 1 ? 0 : skip - 1
75
+ while y < @height
76
+ if @scanned.getbyte(y).zero?
77
+ @scanned.setbyte(y, 1)
78
+ (rows ||= "\0".b * @height).setbyte(y, 1)
79
+ end
80
+ y += skip
81
+ end
82
+ rows
83
+ end
84
+
85
+ # Like rows_every(skip), but only rows whose neighbourhood (+reach+
86
+ # rows up and down) had at most TEXTURE_HITS ratio hits per 1000 px
87
+ # in the rows scanned so far.
88
+ def quiet_rows(skip, reach)
89
+ # Prefix sums of hits and scanned rows.
90
+ hits = Array.new(@height + 1, 0)
91
+ count = Array.new(@height + 1, 0)
92
+ y = 0
93
+ while y < @height
94
+ scanned = @scanned.getbyte(y)
95
+ hits[y + 1] = hits[y] + (scanned == 1 ? @hits[y] : 0)
96
+ count[y + 1] = count[y] + scanned
97
+ y += 1
98
+ end
99
+ limit = TEXTURE_HITS * @width / 1000.0
100
+ rows = nil
101
+ y = skip - 1
102
+ while y < @height
103
+ if @scanned.getbyte(y).zero?
104
+ y0 = y > reach ? y - reach : 0
105
+ y1 = y + reach + 1 < @height ? y + reach + 1 : @height
106
+ n = count[y1] - count[y0]
107
+ if n.zero? || hits[y1] - hits[y0] <= limit * n
108
+ @scanned.setbyte(y, 1)
109
+ (rows ||= "\0".b * @height).setbyte(y, 1)
110
+ end
111
+ end
112
+ y += skip
113
+ end
114
+ rows
115
+ end
116
+
117
+ # Scans +rows+ (see rows_every; nil: nothing to do).
118
+ def scan(rows)
119
+ return unless rows
120
+
121
+ result = QrReaderKernels.finder_scan(@bits, @width, @height, rows, @state)
122
+ values = result.size - @height
123
+ @state = result.first(values)
124
+ y = 0
125
+ while y < @height
126
+ @hits[y] = result[values + y].to_i if rows.getbyte(y) == 1
127
+ y += 1
128
+ end
129
+ end
130
+
131
+ # FinderPatterns of the flat scan state, in the order found.
132
+ def patterns
133
+ state = @state
134
+ fields = QrReaderKernels::FINDER_FIELDS
135
+ out = []
136
+ i = 0
137
+ while i < state.size
138
+ out << FinderPattern.new(state[i], state[i + 1], state[i + 2], state[i + 4].to_i, state[i + 3])
139
+ i += fields
140
+ end
141
+ out
142
+ end
143
+
144
+ # Merges hits of the same pattern (centres within 2 modules, similar
145
+ # module sizes) into count-weighted averages; each hit joins the
146
+ # earliest matching pattern. Candidates come from a grid of cells, so
147
+ # textured photos with a thousand hits do not cost a million tests.
148
+ def merge_duplicates(patterns)
149
+ merged = []
150
+ grid = {}
151
+ patterns.each do |p|
152
+ i = mergeable(merged, grid, p)
153
+ if i
154
+ merged[i] = merged[i].merge(p)
155
+ else
156
+ i = merged.size
157
+ merged << p
158
+ end
159
+ q = merged[i]
160
+ (grid[cell_key(q.x, q.y)] ||= []) << i
161
+ end
162
+ merged
163
+ end
164
+
165
+ # Index of the earliest pattern in +merged+ that +p+ belongs to, or nil.
166
+ # A match lies within 2 of its own module sizes, at most 1.5 times
167
+ # p's, so cells within 3 of p's module sizes are searched.
168
+ def mergeable(merged, grid, p)
169
+ reach = 3 * p.module_size
170
+ x0 = ((p.x - reach).to_i >> CELL_SHIFT).clamp(0, @width >> CELL_SHIFT)
171
+ x1 = ((p.x + reach).to_i >> CELL_SHIFT).clamp(0, @width >> CELL_SHIFT)
172
+ y0 = ((p.y - reach).to_i >> CELL_SHIFT).clamp(0, @height >> CELL_SHIFT)
173
+ y1 = ((p.y + reach).to_i >> CELL_SHIFT).clamp(0, @height >> CELL_SHIFT)
174
+ best = nil
175
+ y0.upto(y1) do |cy|
176
+ x0.upto(x1) do |cx|
177
+ grid[(cy << 20) | cx]&.each do |i|
178
+ next if best && i >= best
179
+
180
+ q = merged[i]
181
+ best = i if q.near?(p.x, p.y, 2.0) && (p.module_size / q.module_size).between?(0.67, 1.5)
182
+ end
183
+ end
184
+ end
185
+ best
186
+ end
187
+
188
+ def cell_key(x, y)
189
+ ((y.to_i >> CELL_SHIFT) << 20) | (x.to_i >> CELL_SHIFT)
190
+ end
191
+
192
+ def prune(patterns, limit)
193
+ return patterns if patterns.size <= limit
194
+
195
+ patterns.max_by(limit) { |p| p.quality * Math.sqrt(p.count) }
196
+ end
197
+ end
198
+ end
199
+ end