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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.txt +21 -0
- data/NOTICE +14 -0
- data/README.md +222 -0
- data/exe/qr_reader +66 -0
- data/lib/qr_reader/binarizer.rb +170 -0
- data/lib/qr_reader/bit_matrix.rb +120 -0
- data/lib/qr_reader/decoder/bitstream_parser.rb +307 -0
- data/lib/qr_reader/decoder/character_set.rb +163 -0
- data/lib/qr_reader/decoder/codewords.rb +164 -0
- data/lib/qr_reader/decoder/data_mask.rb +57 -0
- data/lib/qr_reader/decoder/format_reader.rb +204 -0
- data/lib/qr_reader/decoder/function_patterns.rb +62 -0
- data/lib/qr_reader/decoder/galois_field.rb +69 -0
- data/lib/qr_reader/decoder/reed_solomon.rb +312 -0
- data/lib/qr_reader/decoder.rb +241 -0
- data/lib/qr_reader/detector/alignment_grid.rb +127 -0
- data/lib/qr_reader/detector/alignment_pattern_finder.rb +191 -0
- data/lib/qr_reader/detector/candidate/dimension.rb +237 -0
- data/lib/qr_reader/detector/candidate/strokes.rb +126 -0
- data/lib/qr_reader/detector/candidate.rb +436 -0
- data/lib/qr_reader/detector/finder_pattern.rb +63 -0
- data/lib/qr_reader/detector/finder_pattern_finder.rb +199 -0
- data/lib/qr_reader/detector/geometry.rb +118 -0
- data/lib/qr_reader/detector/grid_sampler.rb +350 -0
- data/lib/qr_reader/detector/pattern_sets.rb +235 -0
- data/lib/qr_reader/detector/perspective_transform.rb +117 -0
- data/lib/qr_reader/detector/pure_detector.rb +197 -0
- data/lib/qr_reader/detector.rb +215 -0
- data/lib/qr_reader/errors.rb +14 -0
- data/lib/qr_reader/format_information.rb +79 -0
- data/lib/qr_reader/formats/bmp.rb +434 -0
- data/lib/qr_reader/formats/ccitt.rb +81 -0
- data/lib/qr_reader/formats/gif.rb +272 -0
- data/lib/qr_reader/formats/jpeg.rb +152 -0
- data/lib/qr_reader/formats/pdf.rb +2935 -0
- data/lib/qr_reader/formats/png.rb +311 -0
- data/lib/qr_reader/formats/pnm.rb +286 -0
- data/lib/qr_reader/formats/tiff.rb +1345 -0
- data/lib/qr_reader/formats.rb +279 -0
- data/lib/qr_reader/image.rb +231 -0
- data/lib/qr_reader/kernels/binarizer.rb +475 -0
- data/lib/qr_reader/kernels/ccitt.rb +554 -0
- data/lib/qr_reader/kernels/detector.rb +587 -0
- data/lib/qr_reader/kernels/image.rb +65 -0
- data/lib/qr_reader/kernels/jpeg.rb +3421 -0
- data/lib/qr_reader/kernels/lzw.rb +173 -0
- data/lib/qr_reader/kernels/pixels.rb +116 -0
- data/lib/qr_reader/kernels/png.rb +1138 -0
- data/lib/qr_reader/kernels.rb +14 -0
- data/lib/qr_reader/native_kernels.rb +130 -0
- data/lib/qr_reader/reader.rb +348 -0
- data/lib/qr_reader/result.rb +49 -0
- data/lib/qr_reader/symbol_version.rb +173 -0
- data/lib/qr_reader/version.rb +5 -0
- data/lib/qr_reader.rb +77 -0
- 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
|