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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 8eabc5559f8483ccce0478d0910040e632a16c36f742eff00c8db2af43b69fad
4
+ data.tar.gz: b6c9afad43a6996e18791869703c23aa5730719dbede44e96099fbbc84967f80
5
+ SHA512:
6
+ metadata.gz: f9f50d8bb7cfffa3ddd89b11898e458e4f346975816df4d7362470c4c1afa43fb79df92c751c4ae76a00e7cd150b8eef3036e5342e025f00f6f20b1c6ccf5717
7
+ data.tar.gz: 5c71745f5e75ac79d759f6f22364020a72e99c3b3350e1a1bf06849f14b344b0ac0a9143f73e0fc96dbd6b868406097f19576485a8a25dfe433a6c0cc9ab659d
data/CHANGELOG.md ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [0.0.1] - 2026-10-13
6
+
7
+ - Initial release: pure-Ruby QR code reader (Model 2, versions 1–40; numeric,
8
+ alphanumeric, byte, Kanji, Hanzi, ECI, structured append, FNC1).
9
+ - Input: PNG, JPEG (baseline/progressive), GIF, BMP, PNM, TIFF (incl. CCITT
10
+ G3/G4 fax, LZW, Deflate, PackBits, JPEG), PDF (embedded images: JPEG, CCITT,
11
+ JBIG2 generic regions, Flate, LZW, ...), raw pixel buffers.
12
+ - Photos and scans: resolution pyramid for large pages, reduced-size JPEG
13
+ decoding, fax pixel-aspect correction, multi-page TIFF/PDF (`Result#page`).
14
+ - API: `decode`, `decode!`, `decode_all`, `read`, `read_all`, `scan`;
15
+ `qr_reader` command-line tool.
16
+ - Optional `qr_reader-native` gem: the same kernels compiled with Spinel.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andi Idogawa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/NOTICE ADDED
@@ -0,0 +1,14 @@
1
+ qr_reader
2
+ Copyright (c) 2026 Andi Idogawa (MIT License, see LICENSE.txt)
3
+
4
+ Test material derived from the ZXing project (https://github.com/zxing/zxing):
5
+ some fixtures under test/fixtures/jpeg and test/decoder/fixtures were made from
6
+ images of the ZXing blackbox QR code test corpus, which is licensed under the
7
+ Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0).
8
+ The corpus itself is not part of this repository or the gem; `rake
9
+ corpus:fetch` downloads it into test/corpus/zxing.
10
+
11
+ The qr_reader-native gem (native/) contains C code generated by Spinel and the
12
+ Spinel runtime (MIT, Copyright (c) 2024- Yukihiro Matsumoto), parts of which
13
+ derive from mruby (MIT, Copyright (c) 2010- mruby developers); see
14
+ native/LICENSE-spinel.txt and native/LICENSE-mruby.txt.
data/README.md ADDED
@@ -0,0 +1,222 @@
1
+ # qr_reader
2
+
3
+ A fast, accurate QR code reader in pure Ruby. No native extensions, no
4
+ ImageMagick, no zbar — just Ruby (and its standard library). Reads photos and
5
+ scanned documents: PNG, JPEG, GIF, BMP, PNM, TIFF (including CCITT G3/G4 fax
6
+ compression) and the images inside scanned PDFs.
7
+
8
+ ```ruby
9
+ require "qr_reader"
10
+
11
+ QrReader.decode("ticket.jpg") # => "https://example.com/t/8F2K" (or nil)
12
+ QrReader.decode_all("invoice.pdf") # => ["SPC\n0200\n1\nCH44...", ...]
13
+
14
+ code = QrReader.read("label.png")
15
+ code.text # => "https://example.com"
16
+ code.version # => 3
17
+ code.ec_level # => :m
18
+ code.points # => [[x, y], ...] corners: top-left, top-right, bottom-right, bottom-left
19
+
20
+ QrReader.scan("scans.tiff") do |code| # lazily, page by page
21
+ puts "page #{code.page}: #{code.text}"
22
+ end
23
+ ```
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ bundle add qr_reader
29
+ ```
30
+
31
+ Requires Ruby 3.2 or newer and nothing else: no C compiler, no ImageMagick,
32
+ no native libraries — it also runs on Rubies without C extensions. Enable YJIT
33
+ for the best speed (`RUBY_YJIT_ENABLE=1`, `ruby --yjit`, or
34
+ `RubyVM::YJIT.enable` at boot).
35
+
36
+ ### Optional: native kernels (qr_reader-native)
37
+
38
+ The hot loops (image decoding, binarization, pattern scanning) are written as
39
+ plain-Ruby "kernels" that the [Spinel](https://github.com/matz/spinel) AOT
40
+ compiler can turn into C. The separate gem `qr_reader-native` contains exactly
41
+ those kernels compiled; with it installed, qr_reader uses them automatically:
42
+
43
+ ```ruby
44
+ gem "qr_reader"
45
+ gem "qr_reader-native" # optional, needs a C compiler at install time
46
+ ```
47
+
48
+ `QrReader.native?` tells whether they are in use. It mainly pays off on Rubies
49
+ running without YJIT (see "Accuracy and speed"); results are identical, and
50
+ without the gem — or with `QR_READER_NATIVE=0` — the same code runs as Ruby.
51
+
52
+ ## Usage
53
+
54
+ | method | returns |
55
+ |---|---|
56
+ | `QrReader.decode(input)` | text of the first code, or `nil` |
57
+ | `QrReader.decode!(input)` | text of the first code, or raises `QrReader::NotFoundError` |
58
+ | `QrReader.decode_all(input)` | texts of all codes (all pages) |
59
+ | `QrReader.read(input)` | first code as a `QrReader::Result`, or `nil` |
60
+ | `QrReader.read_all(input)` | all codes as `QrReader::Result`s |
61
+ | `QrReader.scan(input) { \|result\| }` | yields codes as they are found; an `Enumerator` without a block |
62
+
63
+ `input` can be a file path (`String` or `Pathname`), an `IO`, a binary
64
+ `String` with the encoded image, a `QrReader::Image`, or any object with
65
+ `width`, `height` and `pixels` (e.g. a `ChunkyPNG::Image`). Raw pixel buffers
66
+ can be wrapped with `QrReader::Image.from_pixels(width, height, bytes, :rgba)`
67
+ (also `:rgb`, `:bgr`, `:bgra`, `:gray`, ...).
68
+
69
+ Options (for every method, and `QrReader::Reader.new` for reuse):
70
+
71
+ - `try_harder: true` — when the fast strategies find nothing, keep trying
72
+ slower ones. `false` gives up early (faster on images without a code).
73
+ - `inverted: :auto` — also look for light-on-dark codes; `true` only those,
74
+ `false` never.
75
+
76
+ A `QrReader::Result` is an immutable `Data` object:
77
+
78
+ | attribute | |
79
+ |---|---|
80
+ | `text` | decoded content as a UTF-8 `String` (`to_s` returns it too) |
81
+ | `raw_bytes` | the payload bytes before character-set decoding (binary `String`) |
82
+ | `version`, `ec_level`, `mask` | symbol version (1–40), error correction level (`:l`, `:m`, `:q`, `:h`), mask |
83
+ | `points` | corners in image pixels: top-left, top-right, bottom-right, bottom-left |
84
+ | `page` | page/image index for multi-page TIFF and PDF input, else `nil` |
85
+ | `mirrored?`, `errors_corrected` | read mirrored? / number of repaired codewords |
86
+ | `eci`, `segments`, `structured_append`, `fnc1`, `symbology_identifier` | encoding details |
87
+
88
+ Errors: `QrReader::ImageError` (unreadable image data),
89
+ `QrReader::UnsupportedImageError` (e.g. WebP, HEIC, encrypted PDF),
90
+ `Errno::ENOENT` for missing files.
91
+
92
+ ### Command line
93
+
94
+ ```bash
95
+ qr_reader photo.jpg # prints the text of the first code
96
+ qr_reader --all --json scan.pdf # every code, with metadata, as JSON lines
97
+ ```
98
+
99
+ ### Large images
100
+
101
+ There is no need to shrink photos or page scans before reading them: large
102
+ images are searched through a resolution pyramid (coarse levels first) and
103
+ large JPEGs are first decoded at reduced size, so a big code is found quickly
104
+ while a small code on a 300/600 DPI scan is still read at full resolution.
105
+ Shrinking beforehand can make small codes unreadable — a 12 mm code on a
106
+ 300 DPI A4 scan is lost when the page is reduced to 1000 px.
107
+
108
+ What does help with big inputs:
109
+
110
+ - Run with YJIT (see above).
111
+ - Pass `try_harder: false` when most images contain a code and you prefer a
112
+ quick "no" over a thorough search (images without a code are the slowest
113
+ case, since every strategy runs to the end).
114
+ - If you already use ImageMagick or libvips, letting it *decode* big PNG/TIFF
115
+ files can be faster than the pure-Ruby decoders. Keep at least ~2500 px on
116
+ the long side for scans:
117
+
118
+ ```ruby
119
+ require "open3"
120
+
121
+ def read_with_imagemagick(path)
122
+ out, = Open3.capture2("magick", path, "-resize", "2500x2500>", "-colorspace", "Gray", "-depth", "8",
123
+ "-format", "%w %h\n", "-write", "info:-", "gray:-", binmode: true)
124
+ header, pixels = out.split("\n", 2)
125
+ width, height = header.split.map(&:to_i)
126
+ QrReader.read(QrReader::Image.new(width, height, pixels))
127
+ end
128
+ ```
129
+
130
+ ## What it handles
131
+
132
+ - QR Code Model 2, versions 1–40, all error correction levels and masks;
133
+ numeric, alphanumeric, byte, Kanji and Hanzi (GB2312) segments, ECI
134
+ character sets, structured append, FNC1 (GS1); mirrored symbols;
135
+ light-on-dark codes.
136
+ - Photos: perspective, rotation, uneven lighting, blur, noise, JPEG artifacts.
137
+ - Scans: small codes on large pages (searched through a resolution pyramid),
138
+ 150–600 DPI, fax pages (CCITT G3/G4, non-square 204x98 dpi pixels),
139
+ B&W/dithered scanner modes, multi-page TIFF and PDF, Swiss QR-bills (the
140
+ cross in the middle is just damage the error correction repairs).
141
+ - Scanned PDFs: images compressed with JPEG, CCITT fax, JBIG2 (generic
142
+ regions), Flate, LZW, run-length; xref and object streams; PDFs encrypted
143
+ with an empty user password.
144
+ - Multiple codes per image (`decode_all` / `read_all`).
145
+
146
+ Vector-drawn QR codes in digitally created PDFs are not rendered — only raster
147
+ images embedded in the PDF are scanned. JPEG 2000 images in PDFs are skipped.
148
+ Images larger than 67 megapixels (JPEG) or 134 megapixels (other formats) are
149
+ rejected rather than risking huge memory use.
150
+
151
+ ## Accuracy and speed
152
+
153
+ On the ZXing blackbox QR corpus (179 real-world photos, each also rotated by
154
+ 90°, 180° and 270°) qr_reader decodes **688 of 716** cases, more than
155
+ zxing-cpp (664) and ZXing Java (~619), and every case zxing-cpp decodes.
156
+
157
+ On a corpus of 259 simulated scanned documents (`rake scans:generate`; A4
158
+ letters, invoices, Swiss QR-bills, receipts, labels, fax pages; 150–600 DPI;
159
+ skew, blur, noise, dot gain, fading, dithering, JPEG/TIFF/PDF output) it reads
160
+ **233 of 259** — all 109 "common" office-scanner cases, 91 of 97 "hard" and 33
161
+ of 53 "extreme" ones — against 226 for zxing-cpp, and never returns a wrong
162
+ text. A scanned page takes about 0.7 s on average (YJIT).
163
+
164
+ Time per reading (warm, one process; i7-10750H, Ruby 4.0), with and without
165
+ YJIT, and with the optional qr_reader-native kernels:
166
+
167
+ | input | YJIT | YJIT + native | no YJIT | no YJIT + native | peak RAM |
168
+ |---|---|---|---|---|---|
169
+ | small photo (ZXing corpus PNG) | 23 ms | 21 ms | 29 ms | 14 ms | 2 MB |
170
+ | 12 MP phone photo (JPEG) | 506 ms | 491 ms | 2.2 s | 1.8 s | 24 MB |
171
+ | A4 300 dpi scan (JPEG) | 199 ms | 165 ms | 712 ms | 555 ms | 13 MB |
172
+ | A4 300 dpi scan (PDF with JPEG) | 202 ms | 180 ms | 676 ms | 541 ms | 14 MB |
173
+ | A4 300 dpi scan (1-bit TIFF G4) | 121 ms | 97 ms | 444 ms | 72 ms | 28 MB |
174
+ | A4 300 dpi scan (gray PNG) | 567 ms | 545 ms | 2.6 s | 857 ms | 63 MB |
175
+ | A4 page without a code | 953 ms | 507 ms | 3.9 s | 853 ms | 63 MB |
176
+ | 12 MP photo without a code | 1.9 s | 1.1 s | 7.7 s | 2.3 s | 89 MB |
177
+
178
+ Images without a code are the slowest case: every strategy runs to the end
179
+ (`try_harder: false` stops early). The library itself needs about 10 MB.
180
+
181
+ Benchmarks: `bundle exec ruby bench/reading.rb` (time and memory per reading),
182
+ `bench/corpus.rb` (ZXing corpus), `bench/scans/run.rb` (scanned documents),
183
+ `bench/synthetic.rb` (distortion stress test).
184
+
185
+ ## Development
186
+
187
+ ```bash
188
+ bundle install
189
+ bundle exec rake corpus:fetch # ZXing test images (Apache-2.0), used by some tests
190
+ bundle exec rake test
191
+ ```
192
+
193
+ ### Native kernels
194
+
195
+ Hot loops live in `lib/qr_reader/kernels/` and must stay within the Ruby
196
+ subset Spinel compiles (see DESIGN.md, "Kernels"). After changing them,
197
+ regenerate the qr_reader-native sources with a Spinel checkout:
198
+
199
+ ```bash
200
+ SPINEL_HOME=~/workspace/spinel bundle exec rake native:generate
201
+ bundle exec rake native:compile native:test
202
+ ```
203
+
204
+ CI fails when the generated sources are out of date.
205
+
206
+ ### Releasing
207
+
208
+ Releases are published from GitHub Actions with RubyGems
209
+ [trusted publishing](https://guides.rubygems.org/trusted-publishing/) — no API
210
+ key is stored anywhere. One tag releases both gems, qr_reader and
211
+ qr_reader-native, at the same version. Bump `QrReader::VERSION`, update
212
+ `CHANGELOG.md`, then:
213
+
214
+ ```bash
215
+ git tag v0.0.1
216
+ git push origin v0.0.1
217
+ ```
218
+
219
+ ## License
220
+
221
+ MIT. The ZXing test images fetched by `rake corpus:fetch` are Apache-2.0 and
222
+ are not part of the gem.
data/exe/qr_reader ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "json"
6
+ require_relative "../lib/qr_reader"
7
+
8
+ options = { all: false, format: :text, try_harder: true, inverted: :auto }
9
+ parser = OptionParser.new do |o|
10
+ o.banner = "Usage: qr_reader [options] IMAGE...\n" \
11
+ "Decodes QR codes in PNG, JPEG, GIF, BMP and PNM images (use - for stdin)."
12
+ o.on("-a", "--all", "Print every code found (default: first code only)") { options[:all] = true }
13
+ o.on("-j", "--json", "Print results as JSON lines with metadata") { options[:format] = :json }
14
+ o.on("-v", "--verbose", "Print metadata (version, EC level, position) after the text") { options[:format] = :verbose }
15
+ o.on("--fast", "Skip the slower fallback strategies") { options[:try_harder] = false }
16
+ o.on("--[no-]inverted", "Only / never look for light-on-dark codes (default: both)") { |v| options[:inverted] = v }
17
+ o.on("--version", "Print version") { puts QrReader::VERSION; exit }
18
+ end
19
+
20
+ begin
21
+ files = parser.parse(ARGV)
22
+ rescue OptionParser::ParseError => e
23
+ warn e.message, parser.help
24
+ exit 2
25
+ end
26
+ if files.empty?
27
+ warn parser.help
28
+ exit 2
29
+ end
30
+
31
+ status = 0
32
+ reader = QrReader::Reader.new(try_harder: options[:try_harder], inverted: options[:inverted])
33
+ files.each do |file|
34
+ input = file == "-" ? $stdin.binmode.read : file
35
+ results = begin
36
+ options[:all] ? reader.read_all(input) : [reader.read(input)].compact
37
+ rescue QrReader::Error, SystemCallError => e
38
+ warn "#{file}: #{e.message}"
39
+ status = 2
40
+ next
41
+ end
42
+ if results.empty?
43
+ warn "#{file}: no QR code found"
44
+ status = 1 if status.zero?
45
+ next
46
+ end
47
+
48
+ results.each do |r|
49
+ case options[:format]
50
+ when :json
51
+ puts JSON.generate(file: file, text: r.text, version: r.version, ec_level: r.ec_level, mask: r.mask,
52
+ points: r.points&.map { |pt| pt.map { |c| c.round(1) } }, mirrored: r.mirrored,
53
+ errors_corrected: r.errors_corrected, eci: r.eci,
54
+ symbology_identifier: r.symbology_identifier)
55
+ when :verbose
56
+ puts r.text
57
+ corners = r.points&.map { |x, y| "(#{x.round},#{y.round})" }&.join(" ")
58
+ warn " #{file}: version #{r.version}, EC #{r.ec_level}, mask #{r.mask}, " \
59
+ "#{r.errors_corrected} codewords corrected#{r.mirrored ? ', mirrored' : ''}" \
60
+ "#{corners ? ", corners #{corners}" : ''}"
61
+ else
62
+ puts files.size > 1 ? "#{file}: #{r.text}" : r.text
63
+ end
64
+ end
65
+ end
66
+ exit status
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "kernels/binarizer"
4
+
5
+ module QrReader
6
+ # Converts a grayscale Image into a BitMatrix (1 = dark, one cell per pixel).
7
+ #
8
+ # * hybrid - local thresholds as in zxing-cpp's HybridBinarizer:
9
+ # copes with shadows, gradients, glare and low
10
+ # contrast; the default.
11
+ # * local_mean - local thresholds as in ZXing (Java)'s
12
+ # HybridBinarizer (block means; flat blocks pushed
13
+ # towards white).
14
+ # * global_histogram - one threshold for the whole image from the valley
15
+ # of the luminance histogram (ZXing's
16
+ # GlobalHistogramBinarizer); the local methods use it
17
+ # for images smaller than 40 pixels.
18
+ # * threshold - fixed threshold.
19
+ #
20
+ # All methods return a BitMatrix of the image's size and never fail: an
21
+ # image without usable contrast simply binarizes to all light.
22
+ #
23
+ # The per-pixel work of the local methods lives in kernels/binarizer.rb
24
+ # (QrReaderKernels, optionally compiled to C by qr_reader-native).
25
+ module Binarizer
26
+ # Blocks with a luminance range up to this are treated as flat.
27
+ MIN_DYNAMIC_RANGE = QrReaderKernels::BIN_MIN_RANGE
28
+ # The local methods need 5x5 blocks of 8x8 pixels.
29
+ MINIMUM_DIMENSION = 40
30
+ # Histogram resolution of global_histogram: 32 buckets of 8 grey levels.
31
+ LUMINANCE_SHIFT = 3
32
+ LUMINANCE_BUCKETS = 256 >> LUMINANCE_SHIFT
33
+
34
+ # String#tr source listing all 256 byte values (escaped for tr).
35
+ TR_ALL_BYTES = Image::INVERT_FROM
36
+ # tr targets for level t: bytes < t become "\x01", all others "\x00".
37
+ TR_TARGETS = Array.new(257) { |t| ("\x01" * t + "\x00" * (256 - t)).b.freeze }.freeze
38
+ private_constant :TR_ALL_BYTES, :TR_TARGETS
39
+
40
+ class << self
41
+ # Local-threshold binarization. The image is divided into blocks of
42
+ # 8x8 pixels. A block with contrast gets the threshold
43
+ # (min + max) / 2, a flat block none - unless an edge runs exactly
44
+ # along its border: two neighbouring flat blocks whose luminance
45
+ # ranges lie more than MIN_DYNAMIC_RANGE apart both get the threshold
46
+ # halfway between them (crisp generated images whose module size is a
47
+ # multiple of 8 have no edge inside any block). Each block's threshold
48
+ # is then the mean over the 5x5 blocks around it that have one, and
49
+ # blocks in large flat areas take the nearest threshold in scan
50
+ # order. Pixels at or below their block's threshold become dark.
51
+ # Unlike local_mean, a flat area (quiet zone, large modules) does not
52
+ # drag its neighbours' thresholds down, which matters for low-contrast
53
+ # symbols.
54
+ def hybrid(image)
55
+ local_threshold(image, :hybrid)
56
+ end
57
+
58
+ # ZXing's HybridBinarizer: each 8x8 block's black point is its mean
59
+ # luminance, or for flat blocks half its darkest value unless its
60
+ # neighbours suggest it lies inside a dark area; black points are
61
+ # averaged over 5x5 blocks.
62
+ def local_mean(image)
63
+ local_threshold(image, :local_mean)
64
+ end
65
+
66
+ # Global threshold at the histogram valley between the dark and the
67
+ # light peak. Low contrast images become all light.
68
+ def global_histogram(image)
69
+ black_point = global_black_point(image)
70
+ black_point ? threshold(image, black_point) : BitMatrix.new(image.width, image.height)
71
+ end
72
+
73
+ # Pixels with luminance < +level+ become dark (C speed via String#tr).
74
+ def threshold(image, level)
75
+ BitMatrix.new(image.width, image.height, image.data.tr(TR_ALL_BYTES, TR_TARGETS[level.clamp(0, 256)]))
76
+ end
77
+
78
+ # The threshold global_histogram uses, or nil when the image has too
79
+ # little contrast.
80
+ def global_black_point(image)
81
+ estimate_black_point(histogram(image))
82
+ end
83
+
84
+ private
85
+
86
+ # Shared driver of the block-threshold methods (the per-pixel work is
87
+ # done by QrReaderKernels.binarize_*, see kernels/binarizer.rb).
88
+ def local_threshold(image, method)
89
+ width = image.width
90
+ height = image.height
91
+ return global_histogram(image) if width < MINIMUM_DIMENSION || height < MINIMUM_DIMENSION
92
+
93
+ bits = if method == :hybrid
94
+ QrReaderKernels.binarize_hybrid(image.data, width, height)
95
+ else
96
+ QrReaderKernels.binarize_local_mean(image.data, width, height)
97
+ end
98
+ BitMatrix.new(width, height, bits)
99
+ end
100
+
101
+ # 32-bucket luminance histogram of the central 3/5 of up to 16 rows
102
+ # spread evenly over the image (all rows of small images).
103
+ def histogram(image)
104
+ width = image.width
105
+ height = image.height
106
+ data = image.data
107
+ buckets = Array.new(LUMINANCE_BUCKETS, 0)
108
+ left = width / 5
109
+ right = width * 4 / 5
110
+ left, right = 0, width if right - left < 8
111
+ rows = height < 64 ? height : 16
112
+ k = 0
113
+ while k < rows
114
+ y = rows == height ? k : height * (k + 1) / (rows + 1)
115
+ i = y * width + left
116
+ stop = y * width + right
117
+ while i < stop
118
+ buckets[data.getbyte(i) >> LUMINANCE_SHIFT] += 1
119
+ i += 1
120
+ end
121
+ k += 1
122
+ end
123
+ buckets
124
+ end
125
+
126
+ # ZXing's valley search: the tallest peak, a second peak weighted by
127
+ # squared distance, then the deepest valley between them, biased
128
+ # towards the light peak. nil if the peaks are too close together.
129
+ def estimate_black_point(buckets)
130
+ n = buckets.size
131
+ max_count = 0
132
+ first_peak = 0
133
+ n.times do |x|
134
+ if buckets[x] > max_count
135
+ first_peak = x
136
+ max_count = buckets[x]
137
+ end
138
+ end
139
+ second_peak = 0
140
+ second_score = 0
141
+ n.times do |x|
142
+ d = x - first_peak
143
+ score = buckets[x] * d * d
144
+ if score > second_score
145
+ second_peak = x
146
+ second_score = score
147
+ end
148
+ end
149
+ return nil if buckets[second_peak].zero?
150
+
151
+ first_peak, second_peak = second_peak, first_peak if first_peak > second_peak
152
+ return nil if second_peak - first_peak <= n / 16
153
+
154
+ best_valley = second_peak - 1
155
+ best_score = -1
156
+ x = second_peak - 1
157
+ while x > first_peak
158
+ from_first = x - first_peak
159
+ score = from_first * from_first * (second_peak - x) * (max_count - buckets[x])
160
+ if score > best_score
161
+ best_valley = x
162
+ best_score = score
163
+ end
164
+ x -= 1
165
+ end
166
+ best_valley << LUMINANCE_SHIFT
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module QrReader
4
+ # A 2D grid of dark/light cells. Used both for binarized images (one cell
5
+ # per pixel) and for sampled QR symbols (one cell per module).
6
+ #
7
+ # Storage is a binary String with one byte per cell, row-major:
8
+ # "\x01" = dark (black), "\x00" = light (white). The byte representation is
9
+ # deliberate: String#getbyte/#setbyte are fast (and YJIT-inlined), and runs
10
+ # can be found at C speed with String#index("\x01", pos) / ("\x00", pos).
11
+ class BitMatrix
12
+ DARK = "\x01".b.freeze
13
+ LIGHT = "\x00".b.freeze
14
+
15
+ attr_reader :width, :height, :bits
16
+
17
+ def initialize(width, height = width, bits = nil)
18
+ @width = width
19
+ @height = height
20
+ if bits
21
+ raise ArgumentError, "expected #{width * height} cells, got #{bits.bytesize}" if bits.bytesize != width * height
22
+
23
+ @bits = bits.encoding == Encoding::BINARY ? bits : bits.b
24
+ else
25
+ @bits = "\0".b * (width * height)
26
+ end
27
+ end
28
+
29
+ # Builds a matrix from rows given as Arrays of booleans/integers or as
30
+ # Strings where '#', 'X', '1' or 'x' mark dark cells.
31
+ def self.from_rows(rows)
32
+ h = rows.size
33
+ w = rows.first.size
34
+ m = new(w, h)
35
+ rows.each_with_index do |row, y|
36
+ cells = row.is_a?(String) ? row.chars.map { |c| "#X1x".include?(c) } : row
37
+ cells.each_with_index { |v, x| m.set(x, y) if v && v != 0 }
38
+ end
39
+ m
40
+ end
41
+
42
+ # 1 if dark, 0 if light.
43
+ def get(x, y)
44
+ @bits.getbyte(y * @width + x)
45
+ end
46
+
47
+ def dark?(x, y)
48
+ @bits.getbyte(y * @width + x) == 1
49
+ end
50
+ alias [] dark?
51
+
52
+ def set(x, y, value = 1)
53
+ @bits.setbyte(y * @width + x, value == true || value == 1 ? 1 : 0)
54
+ end
55
+
56
+ def unset(x, y)
57
+ @bits.setbyte(y * @width + x, 0)
58
+ end
59
+
60
+ def flip(x, y)
61
+ i = y * @width + x
62
+ @bits.setbyte(i, @bits.getbyte(i) ^ 1)
63
+ end
64
+
65
+ # Cells of row y as a binary String of "\x00"/"\x01" bytes.
66
+ def row(y)
67
+ @bits.byteslice(y * @width, @width)
68
+ end
69
+
70
+ def dark_count
71
+ @bits.count(DARK)
72
+ end
73
+
74
+ def dup
75
+ BitMatrix.new(@width, @height, @bits.dup)
76
+ end
77
+
78
+ # Matrix with all cells inverted (dark <-> light).
79
+ def invert
80
+ BitMatrix.new(@width, @height, @bits.tr("\x00\x01", "\x01\x00"))
81
+ end
82
+
83
+ # Mirror along the main diagonal (x <-> y).
84
+ def transpose
85
+ w = @width
86
+ h = @height
87
+ src = @bits
88
+ out = "\0".b * (w * h)
89
+ y = 0
90
+ while y < h
91
+ x = 0
92
+ off = y * w
93
+ while x < w
94
+ out.setbyte(x * h + y, src.getbyte(off + x))
95
+ x += 1
96
+ end
97
+ y += 1
98
+ end
99
+ BitMatrix.new(h, w, out)
100
+ end
101
+
102
+ def ==(other)
103
+ other.is_a?(BitMatrix) && other.width == @width && other.height == @height && other.bits == @bits
104
+ end
105
+ alias eql? ==
106
+
107
+ def hash
108
+ [@width, @height, @bits].hash
109
+ end
110
+
111
+ # ASCII rendering for debugging ("##" = dark, " " = light).
112
+ def to_s(dark: "##", light: " ")
113
+ (0...@height).map { |y| row(y).bytes.map { |b| b == 1 ? dark : light }.join }.join("\n")
114
+ end
115
+
116
+ def inspect
117
+ "#<QrReader::BitMatrix #{@width}x#{@height}>"
118
+ end
119
+ end
120
+ end