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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QrReader
|
|
4
|
+
# Image file decoders. Each one turns encoded bytes into an 8-bit luminance
|
|
5
|
+
# QrReader::Image: colour is converted with Image.luma and alpha is composited
|
|
6
|
+
# over white, exactly like Image.from_pixels, so every format agrees.
|
|
7
|
+
#
|
|
8
|
+
# QrReader::Formats.decode(File.binread("code.png")) # => #<QrReader::Image 290x290>
|
|
9
|
+
# QrReader::Formats.format_of(bytes) # => :png
|
|
10
|
+
# QrReader::Formats.dimensions(bytes) # => [290, 290]
|
|
11
|
+
#
|
|
12
|
+
# Unknown data raises ImageError; formats that are recognised but not
|
|
13
|
+
# supported (WebP, HEIC, AVIF, JPEG XL, ...) raise UnsupportedImageError.
|
|
14
|
+
module Formats
|
|
15
|
+
# Largest image, in pixels, that the decoders will allocate. Larger
|
|
16
|
+
# (or maliciously declared) sizes raise UnsupportedImageError.
|
|
17
|
+
MAX_PIXELS = 1 << 27
|
|
18
|
+
|
|
19
|
+
GIF_MAGIC = /\AGIF8[79]a/n
|
|
20
|
+
PNM_MAGIC = /\AP[1-7][\s#]/n
|
|
21
|
+
TIFF_MAGIC = ["II*\0", "MM\0*", "II+\0", "MM\0+"].map(&:b).freeze
|
|
22
|
+
WHITESPACE = " \t\n\v\f\r"
|
|
23
|
+
ALL_BYTES = (0..255).to_a.freeze
|
|
24
|
+
WHITE = "\xFF".b
|
|
25
|
+
|
|
26
|
+
# Recognised formats that Formats.decode does not read: [pattern, name,
|
|
27
|
+
# module that reads them (nil if none)].
|
|
28
|
+
UNSUPPORTED = [
|
|
29
|
+
[/\ARIFF....WEBP/mn, "WebP"],
|
|
30
|
+
[/\A....ftyp(?:avif|avis)/mn, "AVIF"],
|
|
31
|
+
[/\A....ftyp(?:heic|heix|hevc|hevx|heim|heis|mif1|msf1)/mn, "HEIC/HEIF"],
|
|
32
|
+
[/\A(?:\xFF\x0A|\0\0\0\x0CJXL \r\n\x87\n)/n, "JPEG XL"],
|
|
33
|
+
[/\A(?:\0\0\0\x0CjP \r\n\x87\n|\xFF\x4F\xFF\x51)/n, "JPEG 2000"],
|
|
34
|
+
[/\AP[Ff][\s#]/n, "PFM (floating point PNM)"],
|
|
35
|
+
[/\A8BPS/n, "Photoshop PSD"],
|
|
36
|
+
[/\A\s*<(?:\?xml|svg|!DOCTYPE svg)/n, "SVG"]
|
|
37
|
+
].freeze
|
|
38
|
+
private_constant :GIF_MAGIC, :PNM_MAGIC, :TIFF_MAGIC, :WHITESPACE, :ALL_BYTES, :WHITE, :UNSUPPORTED
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Decodes an encoded image (format sniffed from its magic bytes) into a
|
|
42
|
+
# luminance Image. +scale+ (1, 2, 4 or 8) is honoured by JPEG only.
|
|
43
|
+
def decode(bytes, scale: 1)
|
|
44
|
+
bytes = Support.binary(bytes)
|
|
45
|
+
case format_of(bytes)
|
|
46
|
+
when :png then PNG.decode(bytes)
|
|
47
|
+
when :jpeg then JPEG.decode(bytes, scale: scale)
|
|
48
|
+
when :gif then GIF.decode(bytes)
|
|
49
|
+
when :bmp then BMP.decode(bytes)
|
|
50
|
+
when :pnm then PNM.decode(bytes)
|
|
51
|
+
when :tiff then TIFF.decode(bytes)
|
|
52
|
+
when :pdf then first_pdf_image(bytes)
|
|
53
|
+
else raise unrecognized(bytes)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# :png, :jpeg, :gif, :bmp, :pnm, :tiff, :pdf, or nil when the magic
|
|
58
|
+
# bytes are not those of a supported format. TIFF and PDF are
|
|
59
|
+
# multi-page: Formats.decode returns their first page or image.
|
|
60
|
+
def format_of(bytes)
|
|
61
|
+
return nil unless bytes.is_a?(String) && bytes.bytesize >= 2
|
|
62
|
+
|
|
63
|
+
head = bytes.byteslice(0, 32).b
|
|
64
|
+
b0 = head.getbyte(0)
|
|
65
|
+
if b0 == 0x89 then :png if head.start_with?(PNG::SIGNATURE)
|
|
66
|
+
elsif b0 == 0xFF then :jpeg if head.getbyte(1) == 0xD8 && head.getbyte(2) == 0xFF
|
|
67
|
+
elsif b0 == 0x47 then :gif if GIF_MAGIC.match?(head)
|
|
68
|
+
elsif b0 == 0x42 then :bmp if BMP.bmp?(head)
|
|
69
|
+
elsif b0 == 0x50 then :pnm if PNM_MAGIC.match?(head)
|
|
70
|
+
elsif b0 == 0x49 || b0 == 0x4D then :tiff if TIFF_MAGIC.include?(head.byteslice(0, 4))
|
|
71
|
+
elsif b0 == 0x25 then :pdf if head.start_with?("%PDF-")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# [width, height] of an encoded image, read from its header only (the
|
|
76
|
+
# same size Formats.decode would produce at scale 1).
|
|
77
|
+
def dimensions(bytes)
|
|
78
|
+
bytes = Support.binary(bytes)
|
|
79
|
+
case format_of(bytes)
|
|
80
|
+
when :png then PNG.dimensions(bytes)
|
|
81
|
+
when :jpeg then JPEG.dimensions(bytes)
|
|
82
|
+
when :gif then GIF.dimensions(bytes)
|
|
83
|
+
when :bmp then BMP.dimensions(bytes)
|
|
84
|
+
when :pnm then PNM.dimensions(bytes)
|
|
85
|
+
when :tiff then TIFF.dimensions(bytes)
|
|
86
|
+
when :pdf then first_pdf_image(bytes).then { |image| [image.width, image.height] }
|
|
87
|
+
else raise unrecognized(bytes)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def first_pdf_image(bytes)
|
|
94
|
+
PDF.each_image(bytes) { |image, _info| return image }
|
|
95
|
+
raise UnsupportedImageError, "PDF contains no decodable raster image"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def unrecognized(bytes)
|
|
99
|
+
head = bytes.byteslice(0, 64)
|
|
100
|
+
UNSUPPORTED.each do |pattern, name, container|
|
|
101
|
+
next unless pattern.match?(head)
|
|
102
|
+
|
|
103
|
+
where = " here (QrReader.read and QrReader::Formats::#{container} read them)" if container
|
|
104
|
+
return UnsupportedImageError.new(
|
|
105
|
+
"#{name} images are not supported#{where}; convert to PNG, JPEG, GIF, BMP or PNM first, " \
|
|
106
|
+
"e.g. `magick input output.png`"
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
return ImageError.new("empty image data") if bytes.empty?
|
|
110
|
+
|
|
111
|
+
ImageError.new("unrecognised image format (magic bytes #{head.byteslice(0, 8).unpack1('H*')})")
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Helpers shared by the decoders.
|
|
116
|
+
module Support
|
|
117
|
+
module_function
|
|
118
|
+
|
|
119
|
+
# An empty binary String with room for +size+ bytes. Hot methods call
|
|
120
|
+
# this instead of String.new(capacity:, encoding:) themselves: YJIT
|
|
121
|
+
# side-exits on that keyword-argument call, and the caller's loop
|
|
122
|
+
# would then run in the interpreter for the rest of the call.
|
|
123
|
+
def buffer(size)
|
|
124
|
+
String.new(capacity: size, encoding: Encoding::BINARY)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# +bytes+ as a binary String (decoders work on bytes, never characters).
|
|
128
|
+
def binary(bytes)
|
|
129
|
+
raise ImageError, "expected a String of encoded image data, got #{bytes.class}" unless bytes.is_a?(String)
|
|
130
|
+
|
|
131
|
+
bytes.encoding == Encoding::BINARY ? bytes : bytes.b
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Validates declared image dimensions before anything is allocated.
|
|
135
|
+
def check_dimensions(width, height, format)
|
|
136
|
+
raise ImageError, "invalid #{format} dimensions #{width}x#{height}" unless width.positive? && height.positive?
|
|
137
|
+
return if width * height <= MAX_PIXELS
|
|
138
|
+
|
|
139
|
+
raise UnsupportedImageError, "#{format} image too large (#{width}x#{height}, limit #{MAX_PIXELS} pixels)"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Composites luminance +v+ with alpha +a+ over white (Image.from_pixels).
|
|
143
|
+
def over_white(v, a)
|
|
144
|
+
a == 255 ? v : (v * a + 255 * (255 - a) + 127) / 255
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# over_white for every alpha and luminance as a 64 KiB String indexed
|
|
148
|
+
# by a << 8 | v: one getbyte instead of a division per pixel. Built on
|
|
149
|
+
# first use (about 65k iterations), then shared.
|
|
150
|
+
def over_white_table
|
|
151
|
+
@over_white_table ||= begin
|
|
152
|
+
table = "\0".b * 65_536
|
|
153
|
+
a = 0
|
|
154
|
+
while a < 256
|
|
155
|
+
fill_over_white_row(table, a)
|
|
156
|
+
a += 1
|
|
157
|
+
end
|
|
158
|
+
table.freeze
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def fill_over_white_row(table, a)
|
|
163
|
+
base = a << 8
|
|
164
|
+
rest = 255 * (255 - a) + 127
|
|
165
|
+
v = 0
|
|
166
|
+
while v < 256
|
|
167
|
+
table.setbyte(base | v, (v * a + rest) / 255)
|
|
168
|
+
v += 1
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Translates every byte of +str+ in place: from_bytes[i] -> to_bytes[i].
|
|
173
|
+
def translate!(str, from_bytes, to_bytes)
|
|
174
|
+
str.tr!(Image::TR_ESCAPE.call(from_bytes), Image::TR_ESCAPE.call(to_bytes))
|
|
175
|
+
str
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Luminance of 256 palette entries; +palette+ holds packed colour
|
|
179
|
+
# triples (+order+ :rgb or :bgr, +entry_size+ bytes apart), +alpha+ is
|
|
180
|
+
# an optional String of per-entry alpha. Missing entries are black.
|
|
181
|
+
def palette_lumas(palette, entry_size: 3, order: :rgb, alpha: nil)
|
|
182
|
+
count = [palette.bytesize / entry_size, 256].min
|
|
183
|
+
ro, bo = order == :rgb ? [0, 2] : [2, 0]
|
|
184
|
+
lumas = Array.new(256, 0)
|
|
185
|
+
i = 0
|
|
186
|
+
while i < count
|
|
187
|
+
o = i * entry_size
|
|
188
|
+
v = Image.luma(palette.getbyte(o + ro), palette.getbyte(o + 1), palette.getbyte(o + bo))
|
|
189
|
+
a = alpha&.getbyte(i)
|
|
190
|
+
lumas[i] = a ? over_white(v, a) : v
|
|
191
|
+
i += 1
|
|
192
|
+
end
|
|
193
|
+
lumas
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
private_constant :Support
|
|
197
|
+
|
|
198
|
+
# Per-row pixel converters shared by the decoders: they read +n+ pixels
|
|
199
|
+
# of +stride+ bytes starting at byte +off+ of +src+ and append one
|
|
200
|
+
# luminance byte per pixel to +out+, with the arithmetic of
|
|
201
|
+
# Image.from_pixels inlined. Channel offsets select the layout (BGR,
|
|
202
|
+
# BGRX, or the high bytes of big-endian 16-bit samples). Decoders call
|
|
203
|
+
# them once per row so that YJIT compiles them: it never compiles a
|
|
204
|
+
# method that is entered only once, however long its loop runs.
|
|
205
|
+
module Rows
|
|
206
|
+
# Byte -> its four 2-bit samples as bytes 0..3.
|
|
207
|
+
TWO_BIT = Array.new(256) { |b| [b >> 6, (b >> 4) & 3, (b >> 2) & 3, b & 3].pack("C4").freeze }.freeze
|
|
208
|
+
|
|
209
|
+
module_function
|
|
210
|
+
|
|
211
|
+
def rgb(src, off, n, out, ro = 0, go = 1, bo = 2, stride = 3)
|
|
212
|
+
i = off
|
|
213
|
+
stop = off + n * stride
|
|
214
|
+
while i < stop
|
|
215
|
+
out << ((306 * src.getbyte(i + ro) + 601 * src.getbyte(i + go) + 117 * src.getbyte(i + bo) + 0x200) >> 10)
|
|
216
|
+
i += stride
|
|
217
|
+
end
|
|
218
|
+
out
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Colour + alpha, composited over white through +table+
|
|
222
|
+
# (Support.over_white_table).
|
|
223
|
+
def rgba(src, off, n, out, table, ro = 0, go = 1, bo = 2, ao = 3, stride = 4)
|
|
224
|
+
i = off
|
|
225
|
+
stop = off + n * stride
|
|
226
|
+
while i < stop
|
|
227
|
+
v = (306 * src.getbyte(i + ro) + 601 * src.getbyte(i + go) + 117 * src.getbyte(i + bo) + 0x200) >> 10
|
|
228
|
+
out << table.getbyte((src.getbyte(i + ao) << 8) | v)
|
|
229
|
+
i += stride
|
|
230
|
+
end
|
|
231
|
+
out
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Gray + alpha, composited over white through +table+.
|
|
235
|
+
def gray_alpha(src, off, n, out, table, ao = 1, stride = 2)
|
|
236
|
+
i = off
|
|
237
|
+
stop = off + n * stride
|
|
238
|
+
while i < stop
|
|
239
|
+
out << table.getbyte((src.getbyte(i + ao) << 8) | src.getbyte(i))
|
|
240
|
+
i += stride
|
|
241
|
+
end
|
|
242
|
+
out
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# The high byte of +n+ big-endian 16-bit samples. (A plain loop: it
|
|
246
|
+
# beats unpack("v*").pack("C*") by 6x with YJIT and 1.3x without.)
|
|
247
|
+
def high_bytes(src, off, n, out)
|
|
248
|
+
i = off
|
|
249
|
+
stop = off + 2 * n
|
|
250
|
+
while i < stop
|
|
251
|
+
out << src.getbyte(i)
|
|
252
|
+
i += 2
|
|
253
|
+
end
|
|
254
|
+
out
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# 2-bit samples (MSB first) -> one byte 0..3 per pixel.
|
|
258
|
+
def unpack2(src, off, n, out)
|
|
259
|
+
full = n >> 2
|
|
260
|
+
i = off
|
|
261
|
+
stop = off + full
|
|
262
|
+
while i < stop
|
|
263
|
+
out << TWO_BIT[src.getbyte(i)]
|
|
264
|
+
i += 1
|
|
265
|
+
end
|
|
266
|
+
rest = n & 3
|
|
267
|
+
out << TWO_BIT[src.getbyte(stop)].byteslice(0, rest) if rest.positive?
|
|
268
|
+
out
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
private_constant :Rows
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
require_relative "formats/png"
|
|
276
|
+
require_relative "formats/gif"
|
|
277
|
+
require_relative "formats/bmp"
|
|
278
|
+
require_relative "formats/pnm"
|
|
279
|
+
require_relative "formats/jpeg"
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module QrReader
|
|
4
|
+
# An 8-bit grayscale (luminance) image: one byte per pixel, row-major,
|
|
5
|
+
# 0 = black, 255 = white. +data+ is a binary String of width * height bytes.
|
|
6
|
+
#
|
|
7
|
+
# This is the common currency of the library: every image format decoder
|
|
8
|
+
# produces one and the detector consumes one.
|
|
9
|
+
class Image
|
|
10
|
+
attr_reader :width, :height, :data
|
|
11
|
+
|
|
12
|
+
# Channel layouts accepted by Image.from_pixels.
|
|
13
|
+
PIXEL_FORMATS = {
|
|
14
|
+
gray: 1, gray_alpha: 2, rgb: 3, rgba: 4, bgr: 3, bgra: 4, argb: 4, abgr: 4
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
# String#tr tables mapping every byte v to 255 - v ('\\', '-' and '^' escaped).
|
|
18
|
+
TR_ESCAPE = ->(bytes) { bytes.map { |b| c = b.chr.b; "\\-^".include?(c) ? "\\".b + c : c }.join.b.freeze }
|
|
19
|
+
INVERT_FROM = TR_ESCAPE.call((0..255).to_a)
|
|
20
|
+
INVERT_TO = TR_ESCAPE.call(255.downto(0).to_a)
|
|
21
|
+
|
|
22
|
+
# [red, green, blue, alpha] byte offsets within a pixel.
|
|
23
|
+
CHANNEL_OFFSETS = {
|
|
24
|
+
rgb: [0, 1, 2, nil], bgr: [2, 1, 0, nil], rgba: [0, 1, 2, 3], bgra: [2, 1, 0, 3],
|
|
25
|
+
argb: [1, 2, 3, 0], abgr: [3, 2, 1, 0], gray_alpha: []
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
def initialize(width, height, data)
|
|
29
|
+
unless width.is_a?(Integer) && height.is_a?(Integer) && width.positive? && height.positive?
|
|
30
|
+
raise ArgumentError, "invalid image size #{width.inspect}x#{height.inspect}"
|
|
31
|
+
end
|
|
32
|
+
unless data.bytesize == width * height
|
|
33
|
+
raise ArgumentError, "expected #{width * height} bytes of pixel data, got #{data.bytesize}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
@width = width
|
|
37
|
+
@height = height
|
|
38
|
+
@data = data.encoding == Encoding::BINARY ? data : data.b
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def from_gray(width, height, data)
|
|
43
|
+
new(width, height, data)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Builds an image from interleaved 8-bit samples. Alpha is composited
|
|
47
|
+
# over a white background (transparent QR codes are usually dark modules
|
|
48
|
+
# on a transparent background).
|
|
49
|
+
def from_pixels(width, height, data, format = :rgba)
|
|
50
|
+
channels = PIXEL_FORMATS.fetch(format) { raise ArgumentError, "unknown pixel format #{format.inspect}" }
|
|
51
|
+
expected = width * height * channels
|
|
52
|
+
raise ArgumentError, "expected #{expected} bytes of #{format} data, got #{data.bytesize}" if data.bytesize != expected
|
|
53
|
+
return new(width, height, data) if format == :gray
|
|
54
|
+
|
|
55
|
+
out = buffer(width * height)
|
|
56
|
+
ro, go, bo, ao = CHANNEL_OFFSETS.fetch(format)
|
|
57
|
+
stride = width * channels
|
|
58
|
+
y = 0
|
|
59
|
+
# One call per row: YJIT compiles methods, not loops, so a whole-image
|
|
60
|
+
# loop entered once would stay interpreted.
|
|
61
|
+
while y < height
|
|
62
|
+
if format == :gray_alpha
|
|
63
|
+
convert_gray_alpha_row(data, out, y * stride, width)
|
|
64
|
+
elsif ao
|
|
65
|
+
convert_rgba_row(data, out, y * stride, width, ro, go, bo, ao)
|
|
66
|
+
else
|
|
67
|
+
convert_rgb_row(data, out, y * stride, width, ro, go, bo)
|
|
68
|
+
end
|
|
69
|
+
y += 1
|
|
70
|
+
end
|
|
71
|
+
new(width, height, out)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def from_rgb(width, height, data)
|
|
75
|
+
from_pixels(width, height, data, :rgb)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def from_rgba(width, height, data)
|
|
79
|
+
from_pixels(width, height, data, :rgba)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Rec. 601 luma with 10-bit fixed point weights (same as zxing-cpp).
|
|
83
|
+
def luma(r, g, b)
|
|
84
|
+
(306 * r + 601 * g + 117 * b + 0x200) >> 10
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Empty binary String with room for +size+ bytes.
|
|
88
|
+
def buffer(size)
|
|
89
|
+
String.new(capacity: size, encoding: Encoding::BINARY)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def convert_gray_alpha_row(src, out, j, n)
|
|
95
|
+
e = j + 2 * n
|
|
96
|
+
while j < e
|
|
97
|
+
v = src.getbyte(j)
|
|
98
|
+
a = src.getbyte(j + 1)
|
|
99
|
+
out << (a == 255 ? v : (v * a + 255 * (255 - a) + 127) / 255)
|
|
100
|
+
j += 2
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def convert_rgb_row(src, out, j, n, ro, go, bo)
|
|
105
|
+
e = j + 3 * n
|
|
106
|
+
while j < e
|
|
107
|
+
out << ((306 * src.getbyte(j + ro) + 601 * src.getbyte(j + go) + 117 * src.getbyte(j + bo) + 0x200) >> 10)
|
|
108
|
+
j += 3
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def convert_rgba_row(src, out, j, n, ro, go, bo, ao)
|
|
113
|
+
e = j + 4 * n
|
|
114
|
+
while j < e
|
|
115
|
+
v = (306 * src.getbyte(j + ro) + 601 * src.getbyte(j + go) + 117 * src.getbyte(j + bo) + 0x200) >> 10
|
|
116
|
+
a = src.getbyte(j + ao)
|
|
117
|
+
out << (a == 255 ? v : (v * a + 255 * (255 - a) + 127) / 255)
|
|
118
|
+
j += 4
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Luminance at (x, y).
|
|
124
|
+
def [](x, y)
|
|
125
|
+
@data.getbyte(y * @width + x)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def size
|
|
129
|
+
@width * @height
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Bytes of row y as a binary String.
|
|
133
|
+
def row(y)
|
|
134
|
+
@data.byteslice(y * @width, @width)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Photographic negative (for light-on-dark codes).
|
|
138
|
+
def invert
|
|
139
|
+
Image.new(@width, @height, @data.tr(INVERT_FROM, INVERT_TO))
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def rotate180
|
|
143
|
+
Image.new(@width, @height, @data.reverse)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Rotates 90 degrees clockwise.
|
|
147
|
+
def rotate90
|
|
148
|
+
w = @width
|
|
149
|
+
h = @height
|
|
150
|
+
out = Image.buffer(w * h)
|
|
151
|
+
x = 0
|
|
152
|
+
# Output row x is source column x read bottom to top.
|
|
153
|
+
while x < w
|
|
154
|
+
column_up(out, x)
|
|
155
|
+
x += 1
|
|
156
|
+
end
|
|
157
|
+
Image.new(h, w, out)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Rotates 90 degrees counter-clockwise.
|
|
161
|
+
def rotate270
|
|
162
|
+
rotate90.rotate180
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Rotates by a multiple of 90 degrees (clockwise).
|
|
166
|
+
def rotate(degrees)
|
|
167
|
+
case degrees % 360
|
|
168
|
+
when 0 then self
|
|
169
|
+
when 90 then rotate90
|
|
170
|
+
when 180 then rotate180
|
|
171
|
+
when 270 then rotate270
|
|
172
|
+
else raise ArgumentError, "rotation must be a multiple of 90 degrees"
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Horizontal mirror image.
|
|
177
|
+
def mirror
|
|
178
|
+
w = @width
|
|
179
|
+
out = String.new(capacity: @data.bytesize, encoding: Encoding::BINARY)
|
|
180
|
+
@height.times { |y| out << @data.byteslice(y * w, w).reverse }
|
|
181
|
+
Image.new(w, @height, out)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# The image stretched (or shrunk) vertically to +new_height+ rows by
|
|
185
|
+
# repeating (or dropping) whole rows - nearest neighbour, C-speed copies.
|
|
186
|
+
def stretch_rows(new_height)
|
|
187
|
+
raise ArgumentError, "invalid height #{new_height}" unless new_height.positive?
|
|
188
|
+
|
|
189
|
+
out = Image.buffer(@width * new_height)
|
|
190
|
+
y = 0
|
|
191
|
+
while y < new_height
|
|
192
|
+
out << row(y * @height / new_height)
|
|
193
|
+
y += 1
|
|
194
|
+
end
|
|
195
|
+
Image.new(@width, new_height, out)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def crop(x, y, w, h)
|
|
199
|
+
raise ArgumentError, "crop rectangle outside image" if x.negative? || y.negative? || w < 1 || h < 1 || x + w > @width || y + h > @height
|
|
200
|
+
|
|
201
|
+
out = String.new(capacity: w * h, encoding: Encoding::BINARY)
|
|
202
|
+
h.times { |dy| out << @data.byteslice((y + dy) * @width + x, w) }
|
|
203
|
+
Image.new(w, h, out)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Box-filter downscale by an integer factor (partial edge blocks are dropped).
|
|
207
|
+
def downscale(factor)
|
|
208
|
+
return self if factor <= 1
|
|
209
|
+
raise ArgumentError, "downscale factor too large" if @width < factor || @height < factor
|
|
210
|
+
|
|
211
|
+
Image.new(@width / factor, @height / factor, QrReaderKernels.downscale(@data, @width, @height, factor))
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def inspect
|
|
215
|
+
"#<QrReader::Image #{@width}x#{@height}>"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
private
|
|
219
|
+
|
|
220
|
+
# Appends source column x, bottom to top, to +out+.
|
|
221
|
+
def column_up(out, x)
|
|
222
|
+
src = @data
|
|
223
|
+
w = @width
|
|
224
|
+
i = (@height - 1) * w + x
|
|
225
|
+
while i >= 0
|
|
226
|
+
out << src.getbyte(i)
|
|
227
|
+
i -= w
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|