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,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "decoder/galois_field"
4
+ require_relative "decoder/reed_solomon"
5
+ require_relative "decoder/function_patterns"
6
+ require_relative "decoder/data_mask"
7
+ require_relative "decoder/codewords"
8
+ require_relative "decoder/format_reader"
9
+ require_relative "decoder/character_set"
10
+ require_relative "decoder/bitstream_parser"
11
+
12
+ module QrReader
13
+ # Decodes a sampled QR code module grid (ISO/IEC 18004:2015, Model 2).
14
+ #
15
+ # result = QrReader::Decoder.decode(bit_matrix) # => Result or nil
16
+ # QrReader::Decoder.diagnose(bit_matrix) # => { status: :reed_solomon, ... }
17
+ #
18
+ # The pipeline is ordered so that garbage fails as early and cheaply as
19
+ # possible (the detector may try many candidate grids): dimension, format
20
+ # information (at most two candidates per orientation when the copies
21
+ # disagree), version information, then the RS blocks one at a time, and
22
+ # only then the bit stream.
23
+ module Decoder
24
+ # Diagnostic statuses in pipeline order (later = got further).
25
+ STATUSES = %i[dimension format_info version_info reed_solomon bitstream ok].freeze
26
+
27
+ # Array#pack formats "C<count>" that take the first +count+ codewords of
28
+ # an RS block (at most 123 data codewords per block).
29
+ DATA_FORMATS = Array.new(124) { |count| "C#{count}".freeze }.freeze
30
+ # Strength order of the EC levels.
31
+ EC_STRENGTH = { L: 0, M: 1, Q: 2, H: 3 }.freeze
32
+ private_constant :DATA_FORMATS, :EC_STRENGTH
33
+
34
+ class << self
35
+ # Decodes +bit_matrix+ (N x N, N = 21..177, 1 = dark, no quiet zone).
36
+ # Returns a Result (with points: nil) or nil when the grid is not a
37
+ # decodable symbol. With +try_mirrored+, the grid is also read
38
+ # transposed (first, if its format information reads better that way);
39
+ # a Result read so is marked mirrored.
40
+ #
41
+ # Never raises for any grid content; raises TypeError if +bit_matrix+
42
+ # is not a QrReader::BitMatrix.
43
+ def decode(bit_matrix, try_mirrored: true)
44
+ bits = module_bits(bit_matrix)
45
+ bits && run(bits, bit_matrix.width, try_mirrored, nil)
46
+ end
47
+
48
+ # Runs the decoder with bookkeeping and returns a Hash describing how far
49
+ # it got: :status (one of STATUSES), :mirrored, :dimension, :format_bits,
50
+ # :ec_level (:l, :m, :q, :h), :mask, :format_distance, :version, :version_bits,
51
+ # :version_info (decoded version numbers of both copies), :blocks,
52
+ # :block_errors (corrected codewords per RS block, nil = uncorrectable),
53
+ # :errors_corrected, :error (bit stream message) and :result.
54
+ #
55
+ # Makes exactly the attempts #decode makes, so :status is :ok exactly
56
+ # when #decode returns a Result (then :result is that Result);
57
+ # otherwise the attempt that got furthest is reported.
58
+ def diagnose(bit_matrix, try_mirrored: true)
59
+ bits = module_bits(bit_matrix)
60
+ return { status: :dimension, mirrored: false, dimension: [bit_matrix.width, bit_matrix.height] } unless bits
61
+
62
+ diags = []
63
+ result = run(bits, bit_matrix.width, try_mirrored, diags)
64
+ return diags.find { |diag| diag[:result].equal?(result) } if result
65
+
66
+ diags.max_by.with_index { |diag, i| [STATUSES.index(diag[:status]), -i] }
67
+ end
68
+
69
+ private
70
+
71
+ # Tries the format information candidates of the readings in turn and
72
+ # returns the first Result, or nil. With +diags+ (an Array), a Hash per
73
+ # attempt is appended (an attempt without format information too).
74
+ #
75
+ # A code word of a stronger EC level is also one of a weaker level with
76
+ # the same block layout (its generator has the weaker one's roots and
77
+ # more), so when damaged format information names both levels with the
78
+ # same mask, the weaker reading would succeed - with the wrong level and,
79
+ # for a symbol filled to capacity, parity bytes parsed as data. The
80
+ # same-mask runner-up is therefore tried as well and the stronger
81
+ # successful reading wins.
82
+ def run(bits, n, try_mirrored, diags)
83
+ readings(bits, n, try_mirrored).each do |mirrored, words, candidates|
84
+ candidates = [nil] if candidates.empty? && diags
85
+ candidates.each_with_index do |format, k|
86
+ result = attempt(bits, n, mirrored, words, format, diags && (diags << { mirrored: mirrored }).last)
87
+ next unless result
88
+
89
+ sibling = candidates[k + 1]
90
+ if sibling && stronger_reading?(sibling[0], format[0])
91
+ stronger = attempt(bits, n, mirrored, words, sibling, diags && (diags << { mirrored: mirrored }).last)
92
+ return stronger if stronger
93
+ end
94
+ return result
95
+ end
96
+ end
97
+ nil
98
+ end
99
+
100
+ # True if +other+ (FormatInformation) has the mask of +info+ and a
101
+ # stronger EC level.
102
+ def stronger_reading?(other, info)
103
+ other.mask == info.mask && EC_STRENGTH[other.ec_level] > EC_STRENGTH[info.ec_level]
104
+ end
105
+
106
+ # The module String with only 0/1 bytes, or nil if the matrix cannot be
107
+ # a symbol (not square, wrong dimension, inconsistent storage).
108
+ def module_bits(matrix)
109
+ raise TypeError, "expected a QrReader::BitMatrix, got #{matrix.class}" unless matrix.is_a?(BitMatrix)
110
+
111
+ n = matrix.width
112
+ bits = matrix.bits
113
+ return nil unless n.is_a?(Integer) && bits.is_a?(String) && matrix.height == n &&
114
+ SymbolVersion.for_dimension(n) && bits.bytesize == n * n
115
+
116
+ # Byte-wise operations below need a binary String (the BitMatrix
117
+ # constructor ensures one, but the String is mutable).
118
+ bits = bits.b unless bits.encoding == Encoding::BINARY
119
+ # Any byte other than 1 counts as light (like BitMatrix#dark?).
120
+ bits.count("\x00\x01") == bits.bytesize ? bits : bits.tr("^\x01", "\x00")
121
+ end
122
+
123
+ # The orientations worth trying, the one whose format information reads
124
+ # better first: Array of [mirrored, format_words, format_candidates].
125
+ #
126
+ # A symbol read the wrong way round yields format words at distance 2
127
+ # or more from every (masked) code word, so when both copies of one
128
+ # orientation read the same exact code word and the other orientation
129
+ # is at least 2 away, only the first is tried. (Copies written without
130
+ # the 0x5412 mask can read exactly both ways round; then both
131
+ # orientations are tried.)
132
+ def readings(bits, n, try_mirrored)
133
+ normal = reading(bits, n, false)
134
+ return [normal] unless try_mirrored
135
+
136
+ mirrored = reading(bits, n, true)
137
+ first, second = best_distance(mirrored) < best_distance(normal) ? [mirrored, normal] : [normal, mirrored]
138
+ words = first[1]
139
+ exact = words[0] == words[1] && best_distance(first).zero?
140
+ exact && best_distance(second) >= 2 ? [first] : [first, second]
141
+ end
142
+
143
+ def reading(bits, n, mirrored)
144
+ words = FormatReader.format_bits(bits, n, mirrored: mirrored)
145
+ [mirrored, words, FormatReader.format_candidates(*words)]
146
+ end
147
+
148
+ # Smallest format distance of a reading (4 when nothing is within 3).
149
+ def best_distance(reading)
150
+ best = 4
151
+ reading[2].each { |_, distance| best = distance if distance < best }
152
+ best
153
+ end
154
+
155
+ # One decoding pass in one orientation, given the format information
156
+ # words read for it and the format information to assume
157
+ # ([FormatInformation, distance] or nil). Returns a Result or nil;
158
+ # fills +diag+ if given.
159
+ def attempt(bits, n, mirrored, format_words, format, diag)
160
+ version = SymbolVersion.for_dimension(n)
161
+ diag&.merge!(dimension: n, version: version.number, format_bits: format_words)
162
+ return failure(diag, :format_info) unless format
163
+
164
+ info, distance = format
165
+ level = info.ec_level
166
+ mask = info.mask
167
+ diag&.merge!(ec_level: level.downcase, mask: mask, format_distance: distance)
168
+ return failure(diag, :version_info) unless version_info_consistent?(bits, n, version, mirrored, diag)
169
+
170
+ data, corrected = correct_blocks(bits, version, level, mask, mirrored, diag)
171
+ return failure(diag, :reed_solomon) unless data
172
+
173
+ content = begin
174
+ BitstreamParser.parse(data, version.number)
175
+ rescue FormatError => e
176
+ diag[:error] = e.message if diag
177
+ return failure(diag, :bitstream)
178
+ end
179
+
180
+ result = Result.new(text: content.text, raw_bytes: content.raw_bytes, version: version.number,
181
+ ec_level: level.downcase, mask: mask, mirrored: mirrored, errors_corrected: corrected,
182
+ eci: content.eci, fnc1: content.fnc1, structured_append: content.structured_append,
183
+ symbology_identifier: content.symbology_identifier, segments: content.segments)
184
+ diag&.merge!(status: :ok, result: result)
185
+ result
186
+ end
187
+
188
+ def failure(diag, status)
189
+ diag[:status] = status if diag
190
+ nil
191
+ end
192
+
193
+ # Versions 7+ carry their version number twice. The dimension decides:
194
+ # the grid is rejected only when both copies name the same other
195
+ # version (it was most likely sampled at the wrong size - a hint for
196
+ # the detector). Unreadable copies, or a single copy damaged into
197
+ # another version's code word, fall back to the dimension; Reed-Solomon
198
+ # has the final word.
199
+ def version_info_consistent?(bits, n, version, mirrored, diag)
200
+ return true if version.number < 7
201
+
202
+ copies = FormatReader.version_bits(bits, n, mirrored: mirrored)
203
+ diag[:version_bits] = copies if diag
204
+ return true if copies.include?(version.version_info_bits) && !diag
205
+
206
+ first = FormatReader.decode_version(copies[0])
207
+ second = FormatReader.decode_version(copies[1])
208
+ diag[:version_info] = [first, second] if diag
209
+ first.nil? || first != second || first == version.number
210
+ end
211
+
212
+ # Reads, error-corrects and concatenates the data codewords of all RS
213
+ # blocks. Returns [data String, corrected count] or nil at the first
214
+ # uncorrectable block (with +diag+, all blocks are checked).
215
+ def correct_blocks(bits, version, level, mask, mirrored, diag)
216
+ layout = Codewords.layout(version.number)
217
+ order = mirrored ? layout.mirrored_order : layout.order
218
+ mask_codewords = layout.mask_codewords(mask)
219
+ ec_count = version.ec_blocks(level).ec_codewords_per_block
220
+ blocks = layout.blocks(level)
221
+ data = "".b
222
+ corrected = 0
223
+ block_errors = diag ? [] : nil
224
+ blocks.each do |data_count, indices|
225
+ codewords = Codewords.read_block(bits, order, mask_codewords, indices)
226
+ fixed = ReedSolomon.decode(codewords, ec_count)
227
+ block_errors << fixed if diag
228
+ if fixed
229
+ corrected += fixed
230
+ data << codewords.pack(DATA_FORMATS[data_count])
231
+ elsif !diag
232
+ return nil
233
+ end
234
+ end
235
+ failed = diag && block_errors.include?(nil)
236
+ diag&.merge!(blocks: blocks.size, block_errors: block_errors, errors_corrected: failed ? nil : corrected)
237
+ failed ? nil : [data, corrected]
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ class Detector
5
+ # Piecewise sampling support for versions 7+ (zxing-cpp's approach):
6
+ # locates every alignment pattern, predicting each one from its already
7
+ # located neighbours, and returns one local transform per cell of the
8
+ # alignment pattern grid. This follows bent, curved or unevenly
9
+ # stretched symbols far better than a single homography.
10
+ class AlignmentGrid
11
+ # +finder_corners+ - callable taking the last grid index n and
12
+ # returning measured positions of the grid points next to the finder
13
+ # patterns ({[x, y] => [px, py]}, possibly incomplete).
14
+ def initialize(detector, transform, dimension, module_size, finder_corners = nil)
15
+ @finder = detector.alignment_finder
16
+ @transform = transform
17
+ @dimension = dimension
18
+ @module_size = module_size
19
+ @finder_corners = finder_corners
20
+ end
21
+
22
+ # [[x0, x1, y0, y1, transform], ...] covering the whole symbol, or nil
23
+ # if the version has no alignment grid or a cell is degenerate.
24
+ def regions
25
+ version = SymbolVersion.for_dimension(@dimension) or return
26
+ centres = version.alignment_centers
27
+ n = centres.size - 1
28
+ return if n < 1
29
+
30
+ positions = locate_all(centres, n)
31
+ return if positions.any? { |row| row.any?(&:nil?) }
32
+
33
+ out = []
34
+ n.times do |y|
35
+ n.times do |x|
36
+ x0 = centres[x]
37
+ x1 = centres[x + 1]
38
+ y0 = centres[y]
39
+ y1 = centres[y + 1]
40
+ t = PerspectiveTransform.quad_to_quad(
41
+ [[x0 + 0.5, y0 + 0.5], [x1 + 0.5, y0 + 0.5], [x1 + 0.5, y1 + 0.5], [x0 + 0.5, y1 + 0.5]],
42
+ [positions[y][x], positions[y][x + 1], positions[y + 1][x + 1], positions[y + 1][x]]
43
+ )
44
+ # Cells on the border also cover the modules out to the edge.
45
+ region = [x.zero? ? 0 : x0, x == n - 1 ? @dimension : x1,
46
+ y.zero? ? 0 : y0, y == n - 1 ? @dimension : y1]
47
+ return unless t&.regular_over?(region[0], region[2], region[1], region[3])
48
+
49
+ out << [*region, t]
50
+ end
51
+ end
52
+ out
53
+ end
54
+
55
+ private
56
+
57
+ # Pixel positions of all grid points ([y][x]); points next to the
58
+ # finder patterns and points that cannot be found are projected.
59
+ def locate_all(centres, n)
60
+ found = Array.new(n + 1) { Array.new(n + 1) }
61
+ @finder_corners&.call(n)&.each { |(x, y), p| found[y][x] = p }
62
+ project = ->(x, y) { @transform.apply(centres[x] + 0.5, centres[y] + 0.5) }
63
+ best = ->(x, y) { found[y][x] || project.call(x, y) }
64
+ finder_adjacent = ->(x, y) { (x.zero? && y.zero?) || (x.zero? && y == n) || (x == n && y.zero?) }
65
+
66
+ (0..n).each do |y|
67
+ (0..n).each do |x|
68
+ next if finder_adjacent.call(x, y) || found[y][x]
69
+
70
+ guess =
71
+ if x.zero? || y.zero?
72
+ project.call(x, y)
73
+ else
74
+ a = best.call(x - 1, y)
75
+ b = best.call(x, y - 1)
76
+ c = best.call(x - 1, y - 1)
77
+ a && b && c ? [a[0] + b[0] - c[0], a[1] + b[1] - c[1]] : project.call(x, y)
78
+ end
79
+ found[y][x] = search(guess, centres[x], centres[y]) if guess
80
+ end
81
+ end
82
+
83
+ (0..n).map do |y|
84
+ (0..n).map { |x| found[y][x] || interpolate(found, x, y, n) || project.call(x, y) }
85
+ end
86
+ end
87
+
88
+ # Intersection of the lines through the nearest two found neighbours
89
+ # in the same row and in the same column, if both exist.
90
+ def interpolate(found, x, y, n)
91
+ row = nearest(n) { |i| found[y][x + i] if (x + i).between?(0, n) }
92
+ col = nearest(n) { |i| found[y + i][x] if (y + i).between?(0, n) }
93
+ return unless row.size == 2 && col.size == 2
94
+
95
+ intersect(row[0], row[1], col[0], col[1])
96
+ end
97
+
98
+ def nearest(n)
99
+ out = []
100
+ (1..n).each do |d|
101
+ [-d, d].each do |i|
102
+ p = yield(i)
103
+ out << p if p && out.size < 2
104
+ end
105
+ end
106
+ out
107
+ end
108
+
109
+ def intersect(p1, p2, p3, p4)
110
+ d = (p1[0] - p2[0]) * (p3[1] - p4[1]) - (p1[1] - p2[1]) * (p3[0] - p4[0])
111
+ return if d.abs < 1e-9
112
+
113
+ a = p1[0] * p2[1] - p1[1] * p2[0]
114
+ b = p3[0] * p4[1] - p3[1] * p4[0]
115
+ [(a * (p3[0] - p4[0]) - (p1[0] - p2[0]) * b) / d, (a * (p3[1] - p4[1]) - (p1[1] - p2[1]) * b) / d]
116
+ end
117
+
118
+ # Alignment pattern near +guess+ (module vectors from the transform).
119
+ def search(guess, mx, my)
120
+ c = @transform.apply(mx + 0.5, my + 0.5) or return
121
+ u = @transform.apply(mx + 1.5, my + 0.5) or return
122
+ v = @transform.apply(mx + 0.5, my + 1.5) or return
123
+ @finder.find(guess[0], guess[1], 3 * @module_size + 1, u[0] - c[0], u[1] - c[1], v[0] - c[0], v[1] - c[1])
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ class Detector
5
+ # Finds alignment patterns (a dark module inside a light 3x3 ring inside
6
+ # a dark 5x5 ring) near a predicted position.
7
+ #
8
+ # Rows of a window around the prediction are scanned for light-dark-light
9
+ # runs of about one module each (from the middle row outwards), hits are
10
+ # cross-checked vertically and then verified by sampling the 5x5
11
+ # structure along the symbol's own axes, which rejects the many
12
+ # look-alikes in the data area. The verified hit closest to the
13
+ # prediction wins.
14
+ class AlignmentPatternFinder
15
+ DARK = BitMatrix::DARK
16
+ LIGHT = BitMatrix::LIGHT
17
+ # Of the 25 cells of the 5x5 pattern this many may read wrong.
18
+ MAX_MISMATCHES = 5
19
+
20
+ # Module offsets of the 8 light ring cells and the 16 dark ring cells.
21
+ LIGHT_RING = [[-1, -1], [0, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [0, 1], [1, 1]].freeze
22
+ DARK_RING = ((-2..2).flat_map { |i| [[i, -2], [i, 2]] } + (-1..1).flat_map { |i| [[-2, i], [2, i]] }).freeze
23
+
24
+ def initialize(matrix)
25
+ @matrix = matrix
26
+ @bits = matrix.bits
27
+ @width = matrix.width
28
+ @height = matrix.height
29
+ end
30
+
31
+ # Searches the square window of +radius+ pixels around (x, y).
32
+ # +ux+, +uy+ / +vx+, +vy+ are the pixel vectors of one module step
33
+ # along the symbol's x and y axes at that location (for verification).
34
+ # Returns [cx, cy] or nil.
35
+ def find(x, y, radius, ux, uy, vx, vy)
36
+ module_size = Math.sqrt((ux * ux + uy * uy + vx * vx + vy * vy) * 0.5)
37
+ return if module_size < 0.8 || !x.finite? || !y.finite?
38
+
39
+ x0 = (x - radius).floor.clamp(0, @width - 1)
40
+ x1 = (x + radius).ceil.clamp(0, @width - 1)
41
+ y0 = (y - radius).floor.clamp(0, @height - 1)
42
+ y1 = (y + radius).ceil.clamp(0, @height - 1)
43
+ return if x1 - x0 < 3 || y1 - y0 < 3
44
+
45
+ best = nil
46
+ best_score = Float::INFINITY
47
+ # Rows through the same pattern confirm the same centre: verify each
48
+ # centre once. (Exact keys: verification samples whole pixels, so a
49
+ # centre a fraction of a pixel away can verify when this one fails.)
50
+ verified = {}
51
+ mid = y.floor.clamp(y0, y1)
52
+ max_offset = [mid - y0, y1 - mid].max
53
+ offset = 0
54
+ while offset <= max_offset
55
+ [mid - offset, mid + offset].uniq.each do |row|
56
+ next if row < y0 || row > y1
57
+
58
+ scan_row(row, x0, x1, module_size) do |cx|
59
+ centre = confirm(cx, row, module_size)
60
+ next unless centre
61
+
62
+ next if verified.key?(centre)
63
+
64
+ verified[centre] = true
65
+ mismatches = verify(centre[0], centre[1], ux, uy, vx, vy)
66
+ next if mismatches > MAX_MISMATCHES
67
+
68
+ score = Math.hypot(centre[0] - x, centre[1] - y) / module_size + mismatches
69
+ if score < best_score
70
+ best_score = score
71
+ best = centre
72
+ end
73
+ end
74
+ end
75
+ # Stop early once a clean pattern close to the prediction is known.
76
+ break if best && best_score < offset / module_size - 1.0
77
+
78
+ offset += 1
79
+ end
80
+ best
81
+ end
82
+
83
+ private
84
+
85
+ # Yields the centre column of every light-dark-light run triple with
86
+ # runs of about one module in row y between columns x0 and x1, where
87
+ # the dark ring continues on both sides.
88
+ def scan_row(y, x0, x1, module_size)
89
+ w = @width
90
+ off = y * w
91
+ row = @bits.byteslice(off, w)
92
+ max = module_size * 1.6 + 1
93
+ min = module_size * 0.4 - 1
94
+ pos = row.index(LIGHT, x0) or return
95
+ while pos < x1
96
+ d0 = row.index(DARK, pos) or return
97
+ d1 = row.index(LIGHT, d0) or return
98
+ l1 = row.index(DARK, d1) or return
99
+ a = d0 - pos
100
+ b = d1 - d0
101
+ c = l1 - d1
102
+ # The light run before must be preceded by dark (ring), which holds
103
+ # unless it starts the row segment.
104
+ if b <= max && b >= min && a <= max && a >= min && c <= max && c >= min &&
105
+ pos.positive? && row.getbyte(pos - 1) == 1
106
+ yield (d0 + d1) >> 1
107
+ end
108
+ pos = d1
109
+ end
110
+ end
111
+
112
+ # Vertical then horizontal cross-check through dark pixel (x, y).
113
+ # Returns the refined centre [cx, cy] or nil.
114
+ def confirm(x, y, module_size)
115
+ bits = @bits
116
+ w = @width
117
+ h = @height
118
+ max = module_size * 1.6 + 1
119
+ min = module_size * 0.4 - 1
120
+ # Vertical: dark centre, light above/below, dark ring beyond. The
121
+ # walks stop just past the longest acceptable run, so a long dark
122
+ # column (page border, large dark area) costs nothing extra.
123
+ top = y
124
+ top -= 1 while top > 0 && y - top <= max && bits.getbyte((top - 1) * w + x) == 1
125
+ bottom = y
126
+ bottom += 1 while bottom < h - 1 && bottom - y <= max && bits.getbyte((bottom + 1) * w + x) == 1
127
+ centre_v = bottom - top + 1
128
+ return if centre_v > max || centre_v < min
129
+
130
+ lt = top - 1
131
+ lt -= 1 while lt >= 0 && top - lt <= max + 1 && bits.getbyte(lt * w + x).zero?
132
+ lb = bottom + 1
133
+ lb += 1 while lb < h && lb - bottom <= max + 1 && bits.getbyte(lb * w + x).zero?
134
+ return if lt.negative? || lb >= h
135
+
136
+ light_top = top - lt - 1
137
+ light_bottom = lb - bottom - 1
138
+ return if light_top < min || light_top > max || light_bottom < min || light_bottom > max
139
+
140
+ cy = (top + bottom + 1) * 0.5
141
+ span_y = (lt + 1 + lb) * 0.5
142
+ iy = cy.floor
143
+ # Horizontal at the vertical centre.
144
+ row = bits.byteslice(iy * w, w)
145
+ return unless row.getbyte(x) == 1
146
+
147
+ r0 = row.index(LIGHT, x) or return
148
+ r1 = row.index(DARK, r0) or return
149
+ l0 = row.rindex(LIGHT, x) or return
150
+ l1 = row.rindex(DARK, l0) or return
151
+ centre_h = r0 - l0 - 1
152
+ return if centre_h > max || centre_h < min || r1 - r0 > max || l0 - l1 > max
153
+
154
+ cx = (l0 + 1 + r0) * 0.5
155
+ span_x = (l1 + 1 + r1) * 0.5
156
+ [(cx + span_x) * 0.5, (cy + span_y) * 0.5]
157
+ end
158
+
159
+ # Number of cells of the 5x5 pattern centred at (x, y) that have the
160
+ # wrong colour (out of 25), sampling along the module vectors.
161
+ # Stops counting past MAX_MISMATCHES.
162
+ def verify(x, y, ux, uy, vx, vy)
163
+ bits = @bits
164
+ w = @width
165
+ h = @height
166
+ wrong = dark_at?(bits, w, h, x, y) ? 0 : 1
167
+ k = 0
168
+ while k < 8
169
+ i, j = LIGHT_RING[k]
170
+ wrong += 1 if dark_at?(bits, w, h, x + i * ux + j * vx, y + i * uy + j * vy)
171
+ k += 1
172
+ end
173
+ return wrong if wrong > MAX_MISMATCHES
174
+
175
+ k = 0
176
+ while k < 16
177
+ i, j = DARK_RING[k]
178
+ wrong += 1 unless dark_at?(bits, w, h, x + i * ux + j * vx, y + i * uy + j * vy)
179
+ return wrong if wrong > MAX_MISMATCHES
180
+
181
+ k += 1
182
+ end
183
+ wrong
184
+ end
185
+
186
+ def dark_at?(bits, w, h, x, y)
187
+ x >= 0 && y >= 0 && x < w && y < h && bits.getbyte(y.to_i * w + x.to_i) == 1
188
+ end
189
+ end
190
+ end
191
+ end