zxing_ffi 0.1.0
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 +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +275 -0
- data/exe/zxing-scan +6 -0
- data/lib/zxing_ffi/barcode.rb +77 -0
- data/lib/zxing_ffi/cli.rb +152 -0
- data/lib/zxing_ffi/config.rb +119 -0
- data/lib/zxing_ffi/dedupe.rb +79 -0
- data/lib/zxing_ffi/diagnostics.rb +69 -0
- data/lib/zxing_ffi/dpi.rb +103 -0
- data/lib/zxing_ffi/errors.rb +69 -0
- data/lib/zxing_ffi/formats.rb +207 -0
- data/lib/zxing_ffi/geometry.rb +508 -0
- data/lib/zxing_ffi/header_probe.rb +98 -0
- data/lib/zxing_ffi/image.rb +123 -0
- data/lib/zxing_ffi/image_magick.rb +95 -0
- data/lib/zxing_ffi/library_defaults.rb +7 -0
- data/lib/zxing_ffi/library_loader.rb +176 -0
- data/lib/zxing_ffi/loaders/base.rb +155 -0
- data/lib/zxing_ffi/loaders/image_magick.rb +159 -0
- data/lib/zxing_ffi/loaders/pnm.rb +113 -0
- data/lib/zxing_ffi/loaders/poppler.rb +260 -0
- data/lib/zxing_ffi/loaders/registry.rb +54 -0
- data/lib/zxing_ffi/loaders/vips.rb +332 -0
- data/lib/zxing_ffi/loaders.rb +41 -0
- data/lib/zxing_ffi/native.rb +237 -0
- data/lib/zxing_ffi/pnm.rb +676 -0
- data/lib/zxing_ffi/reader.rb +271 -0
- data/lib/zxing_ffi/scanner.rb +295 -0
- data/lib/zxing_ffi/sniffer.rb +155 -0
- data/lib/zxing_ffi/source.rb +77 -0
- data/lib/zxing_ffi/strategy.rb +293 -0
- data/lib/zxing_ffi/subprocess.rb +416 -0
- data/lib/zxing_ffi/transformers/base.rb +69 -0
- data/lib/zxing_ffi/transformers/image_magick.rb +72 -0
- data/lib/zxing_ffi/transformers/vips.rb +85 -0
- data/lib/zxing_ffi/transformers.rb +36 -0
- data/lib/zxing_ffi/version.rb +5 -0
- data/lib/zxing_ffi.rb +82 -0
- metadata +108 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# Identifies inputs by their magic bytes, never by file name or extension.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# ZXingFFI::Sniffer.sniff("upload.bin") # => :tiff
|
|
8
|
+
# ZXingFFI::Sniffer.detect("%PDF-1.7\n...") # => :pdf
|
|
9
|
+
module Sniffer
|
|
10
|
+
# Every kind {detect} and {sniff} can return.
|
|
11
|
+
KINDS = %i[pdf png jpeg tiff gif bmp webp heif avif pnm].freeze
|
|
12
|
+
# Number of leading bytes examined. {sniff} reads at most this many.
|
|
13
|
+
HEAD_SIZE = 1024
|
|
14
|
+
|
|
15
|
+
PNG_MAGIC = "\x89PNG\r\n\x1A\n".b.freeze
|
|
16
|
+
JPEG_MAGIC = "\xFF\xD8\xFF".b.freeze
|
|
17
|
+
# Classic TIFF and BigTIFF, little- and big-endian.
|
|
18
|
+
TIFF_MAGICS = ["II*\0", "MM\0*", "II+\0", "MM\0+"].freeze
|
|
19
|
+
GIF_MAGICS = %w[GIF87a GIF89a].freeze
|
|
20
|
+
PDF_MAGIC = "%PDF-"
|
|
21
|
+
# Sizes of the known BMP DIB headers (BITMAPCOREHEADER, OS/2 v2, BITMAPINFOHEADER .. BITMAPV5HEADER).
|
|
22
|
+
BMP_HEADER_SIZES = [12, 16, 40, 52, 56, 64, 108, 124].freeze
|
|
23
|
+
AVIF_BRANDS = %w[avif avis].freeze
|
|
24
|
+
HEIF_BRANDS = %w[heic heix heim heis hevc hevx].freeze
|
|
25
|
+
# Generic HEIF image / image-sequence brands: AVIF when an AVIF brand is also listed, HEIF otherwise.
|
|
26
|
+
MIAF_BRANDS = %w[mif1 msf1].freeze
|
|
27
|
+
# P1-P6 followed by whitespace or a comment. P7 (PAM) is not supported.
|
|
28
|
+
PNM_MAGIC = /\AP[1-6][ \t\n\v\f\r#]/n
|
|
29
|
+
NON_PRINTABLE = /[^\x20-\x7E]/n
|
|
30
|
+
private_constant :PNG_MAGIC, :JPEG_MAGIC, :TIFF_MAGICS, :GIF_MAGICS, :PDF_MAGIC, :BMP_HEADER_SIZES,
|
|
31
|
+
:AVIF_BRANDS, :HEIF_BRANDS, :MIAF_BRANDS, :PNM_MAGIC, :NON_PRINTABLE
|
|
32
|
+
|
|
33
|
+
class << self
|
|
34
|
+
# Detects the kind of data from its first bytes.
|
|
35
|
+
#
|
|
36
|
+
# Signatures at offset 0 are checked first. A PDF is recognized by +%PDF-+ anywhere in the first
|
|
37
|
+
# {HEAD_SIZE} bytes, because PDF readers accept leading junk.
|
|
38
|
+
#
|
|
39
|
+
# @param head [String, nil] the first bytes of the input, in any encoding (always treated as bytes);
|
|
40
|
+
# only the first {HEAD_SIZE} bytes are examined
|
|
41
|
+
# @return [Symbol, nil] one of {KINDS}, or nil if the format is not recognized
|
|
42
|
+
def detect(head)
|
|
43
|
+
return nil if head.nil?
|
|
44
|
+
raise TypeError, "expected a String, got #{head.class}" unless head.is_a?(String)
|
|
45
|
+
|
|
46
|
+
bytes = head.b.byteslice(0, HEAD_SIZE)
|
|
47
|
+
signature_kind(bytes) || (bytes.include?(PDF_MAGIC) ? :pdf : nil)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Detects the kind of a file or IO from its content.
|
|
51
|
+
#
|
|
52
|
+
# @param input [String, Pathname, IO] a path, or an IO read from its current position. At most
|
|
53
|
+
# {HEAD_SIZE} bytes are read, and a seekable IO is moved back to where it was (bytes read from a pipe
|
|
54
|
+
# stay consumed).
|
|
55
|
+
# @return [Symbol] one of {KINDS}
|
|
56
|
+
# @raise [UnsupportedInput] if the input is empty or its format is not recognized
|
|
57
|
+
# @raise [SystemCallError] if the file cannot be read (e.g. +Errno::ENOENT+)
|
|
58
|
+
# @raise [TypeError] if +input+ is neither a path nor an IO
|
|
59
|
+
def sniff(input)
|
|
60
|
+
head, label = read_head(input)
|
|
61
|
+
raise UnsupportedInput, "empty input: #{label} contains no data" if head.empty?
|
|
62
|
+
|
|
63
|
+
detect(head) || raise(UnsupportedInput, unrecognized_message(head, label))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def signature_kind(bytes)
|
|
69
|
+
if bytes.start_with?(PNG_MAGIC) then :png
|
|
70
|
+
elsif bytes.start_with?(JPEG_MAGIC) then :jpeg
|
|
71
|
+
elsif bytes.start_with?(*TIFF_MAGICS) then :tiff
|
|
72
|
+
elsif bytes.start_with?(*GIF_MAGICS) then :gif
|
|
73
|
+
elsif bmp?(bytes) then :bmp
|
|
74
|
+
elsif bytes.start_with?("RIFF") && bytes.byteslice(8, 4) == "WEBP" then :webp
|
|
75
|
+
elsif bytes.byteslice(4, 4) == "ftyp" then iso_bmff_kind(bytes)
|
|
76
|
+
elsif bytes.match?(PNM_MAGIC) then :pnm
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# "BM" alone is too weak (plain text can start with it): also require a known DIB header size.
|
|
81
|
+
def bmp?(bytes)
|
|
82
|
+
bytes.start_with?("BM") && bytes.bytesize >= 18 &&
|
|
83
|
+
BMP_HEADER_SIZES.include?(bytes.unpack1("V", offset: 14))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# HEIF and AVIF files start with an ISO-BMFF ftyp box: size (uint32 BE, 0 = to the end of the file),
|
|
87
|
+
# "ftyp", major brand, minor version, then compatible brands up to the end of the box.
|
|
88
|
+
def iso_bmff_kind(bytes)
|
|
89
|
+
return nil if bytes.bytesize < 12
|
|
90
|
+
|
|
91
|
+
size = bytes.unpack1("N")
|
|
92
|
+
return nil if size.between?(1, 15) # 1 = 64-bit size (never used for ftyp); 2..15 can't hold the brands
|
|
93
|
+
|
|
94
|
+
box_end = size.zero? ? bytes.bytesize : [size, bytes.bytesize].min
|
|
95
|
+
compatible = (16..box_end - 4).step(4).map { |offset| bytes.byteslice(offset, 4) }
|
|
96
|
+
brand_kind(bytes.byteslice(8, 4), compatible)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def brand_kind(major, compatible)
|
|
100
|
+
return :avif if AVIF_BRANDS.include?(major)
|
|
101
|
+
return :heif if HEIF_BRANDS.include?(major)
|
|
102
|
+
return nil unless MIAF_BRANDS.include?(major)
|
|
103
|
+
|
|
104
|
+
compatible.intersect?(AVIF_BRANDS) ? :avif : :heif
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Returns the first bytes (BINARY) and a label for messages.
|
|
108
|
+
def read_head(input)
|
|
109
|
+
if input.is_a?(String) || (defined?(::Pathname) && input.is_a?(::Pathname))
|
|
110
|
+
path = input.to_s
|
|
111
|
+
[File.open(path, "rb") { |file| file.read(HEAD_SIZE) }.to_s, path]
|
|
112
|
+
elsif input.respond_to?(:read)
|
|
113
|
+
[read_io_head(input), io_label(input)]
|
|
114
|
+
else
|
|
115
|
+
raise TypeError, "expected a path (String or Pathname) or an IO, got #{input.class}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def read_io_head(io)
|
|
120
|
+
position = io_position(io)
|
|
121
|
+
begin
|
|
122
|
+
io.read(HEAD_SIZE).to_s.b
|
|
123
|
+
ensure
|
|
124
|
+
io.seek(position) if position
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def io_position(io)
|
|
129
|
+
return nil unless io.respond_to?(:pos) && io.respond_to?(:seek)
|
|
130
|
+
|
|
131
|
+
io.pos
|
|
132
|
+
rescue SystemCallError, IOError # pipes, sockets and other unseekable streams
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def io_label(io)
|
|
137
|
+
path = io.path if io.respond_to?(:path)
|
|
138
|
+
path ? path.to_s : io.class.to_s
|
|
139
|
+
rescue IOError
|
|
140
|
+
io.class.to_s
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def unrecognized_message(head, label)
|
|
144
|
+
"unrecognized input format: #{label} is not a PDF, PNG, JPEG, TIFF, GIF, BMP, WebP, HEIF, AVIF or " \
|
|
145
|
+
"PNM file (first bytes: #{hex_dump(head)}); the format is detected from the content, not the file name"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# "25 50 44 46 |%PDF|": hex and printable ASCII of the first 16 bytes.
|
|
149
|
+
def hex_dump(bytes)
|
|
150
|
+
sample = bytes.byteslice(0, 16)
|
|
151
|
+
"#{sample.unpack1("H*").scan(/../).join(" ")} |#{sample.gsub(NON_PRINTABLE, ".")}|"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module ZXingFFI
|
|
6
|
+
# A scan input normalized to an absolute file path plus its sniffed kind.
|
|
7
|
+
#
|
|
8
|
+
# Paths are made absolute so a leading "-" can never be read as a CLI option. IO inputs are spooled to a
|
|
9
|
+
# 0600 Tempfile that {.open} always removes.
|
|
10
|
+
class Source
|
|
11
|
+
# @return [String] absolute path
|
|
12
|
+
attr_reader :path
|
|
13
|
+
# @return [Symbol] sniffed kind (see {Sniffer::KINDS})
|
|
14
|
+
attr_reader :kind
|
|
15
|
+
# @return [String, nil] a display name (original path, or the IO's path if it has one)
|
|
16
|
+
attr_reader :name
|
|
17
|
+
|
|
18
|
+
# Yields a Source for +input+ and cleans up any temp file afterwards.
|
|
19
|
+
#
|
|
20
|
+
# @param input [String, Pathname, IO, #read] a path or a readable IO (read from its current position)
|
|
21
|
+
# @yieldparam source [Source]
|
|
22
|
+
# @raise [UnsupportedInput] for unknown file types or unsupported input objects
|
|
23
|
+
def self.open(input)
|
|
24
|
+
tempfile = nil
|
|
25
|
+
path, name =
|
|
26
|
+
# Pathname responds to #read too, so check it before the IO branch (it must not be spooled).
|
|
27
|
+
if input.is_a?(String) || (defined?(::Pathname) && input.is_a?(::Pathname)) || (input.respond_to?(:to_path) && !input.respond_to?(:read))
|
|
28
|
+
full = File.expand_path(input.respond_to?(:to_path) ? input.to_path : input)
|
|
29
|
+
raise Errno::ENOENT, full unless File.file?(full)
|
|
30
|
+
|
|
31
|
+
[full, input.to_s]
|
|
32
|
+
elsif input.respond_to?(:read)
|
|
33
|
+
tempfile = spool(input)
|
|
34
|
+
[tempfile.path, (input.respond_to?(:path) ? input.path : nil)]
|
|
35
|
+
else
|
|
36
|
+
raise UnsupportedInput, "expected a path, an IO or a ZXingFFI::Image, got #{input.class}"
|
|
37
|
+
end
|
|
38
|
+
yield new(path, Sniffer.sniff(path), name)
|
|
39
|
+
ensure
|
|
40
|
+
if tempfile
|
|
41
|
+
tempfile.close
|
|
42
|
+
File.unlink(tempfile.path) if File.exist?(tempfile.path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Copies an IO into a private temp file.
|
|
47
|
+
# @return [File] closed-for-writing temp file (caller removes it)
|
|
48
|
+
def self.spool(io)
|
|
49
|
+
file = Tempfile.create(["zxing_ffi", ".input"], binmode: true) # created 0600
|
|
50
|
+
IO.copy_stream(io, file)
|
|
51
|
+
file.flush
|
|
52
|
+
file
|
|
53
|
+
rescue
|
|
54
|
+
if file
|
|
55
|
+
file.close
|
|
56
|
+
File.unlink(file.path) if File.exist?(file.path)
|
|
57
|
+
end
|
|
58
|
+
raise
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(path, kind, name = nil)
|
|
62
|
+
@path = path
|
|
63
|
+
@kind = kind
|
|
64
|
+
@name = name || path
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Integer] file size in bytes
|
|
68
|
+
def size
|
|
69
|
+
File.size(path)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [String]
|
|
73
|
+
def inspect
|
|
74
|
+
"#<#{self.class.name} #{kind} #{name}>"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# The escalating pass ladder run on one page.
|
|
5
|
+
#
|
|
6
|
+
# Every pass decodes the page's base image or a derivative of it (a re-render, an upscale, tiles, a rotation)
|
|
7
|
+
# and maps its results back to base-image coordinates with an {Geometry::Affine}. Results are filtered,
|
|
8
|
+
# deduplicated against earlier passes (the earliest pass wins) and the ladder stops according to +stop+.
|
|
9
|
+
class Strategy
|
|
10
|
+
# Pass names in ladder order.
|
|
11
|
+
PASSES = %i[base inverted global_binarizer high_res tiles rotated_45 denoise].freeze
|
|
12
|
+
|
|
13
|
+
# Passes per effort level.
|
|
14
|
+
EFFORTS = {
|
|
15
|
+
fast: %i[base],
|
|
16
|
+
normal: %i[base inverted global_binarizer high_res],
|
|
17
|
+
thorough: PASSES
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Raster images whose longest side is below this get a 2× upscale in the high_res pass.
|
|
21
|
+
UPSCALE_BELOW = 2000
|
|
22
|
+
|
|
23
|
+
# Tile geometry for the tiles pass.
|
|
24
|
+
TILES = {min_tile: 1024, max_tile: 1536, overlap: 0.2}.freeze
|
|
25
|
+
|
|
26
|
+
# Rotation of the rotated pass (with try_rotate it also covers 135°, 225° and 315°).
|
|
27
|
+
ROTATED_DEGREES = 45
|
|
28
|
+
|
|
29
|
+
# @!attribute barcodes [Array<Barcode>] positions in base-image pixels, +pass+ set
|
|
30
|
+
# @!attribute passes_run [Array<Symbol>]
|
|
31
|
+
# @!attribute skipped_passes [Hash{Symbol => String}] passes that could not run, with the reason
|
|
32
|
+
Result = Data.define(:barcodes, :passes_run, :skipped_passes)
|
|
33
|
+
|
|
34
|
+
# An image (or a crop of it) to decode plus the transform from its pixels to the base image.
|
|
35
|
+
# +negative+ marks a pixel-inverted copy: its results are reported as inverted.
|
|
36
|
+
View = Data.define(:image, :to_base, :crop, :negative) do
|
|
37
|
+
def initialize(image:, to_base:, crop: nil, negative: false)
|
|
38
|
+
super
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
# @param effort [Symbol] +:fast+, +:normal+, +:thorough+
|
|
44
|
+
# @param passes [Array<Symbol>, nil] explicit ladder, overrides +effort+
|
|
45
|
+
# @return [Array<Symbol>]
|
|
46
|
+
# @raise [ArgumentError]
|
|
47
|
+
def passes_for(effort: :normal, passes: nil)
|
|
48
|
+
if passes
|
|
49
|
+
list = Array(passes).map(&:to_sym)
|
|
50
|
+
unknown = list - PASSES
|
|
51
|
+
raise ArgumentError, "unknown pass(es) #{unknown.map(&:inspect).join(", ")}; valid: #{PASSES.join(", ")}" if unknown.any?
|
|
52
|
+
raise ArgumentError, "passes must not be empty" if list.empty?
|
|
53
|
+
|
|
54
|
+
return list.uniq
|
|
55
|
+
end
|
|
56
|
+
EFFORTS.fetch(effort) { raise ArgumentError, "effort must be one of #{EFFORTS.keys.join(", ")}, got #{effort.inspect}" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @param stop [Symbol, Integer] +:found+, +:exhaustive+ or a positive Integer
|
|
60
|
+
# @return [Symbol, Integer]
|
|
61
|
+
# @raise [ArgumentError]
|
|
62
|
+
def validate_stop(stop)
|
|
63
|
+
return stop if stop == :found || stop == :exhaustive || (stop.is_a?(Integer) && stop.positive?)
|
|
64
|
+
|
|
65
|
+
raise ArgumentError, "stop must be :found, :exhaustive or a positive Integer, got #{stop.inspect}"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @param passes [Array<Symbol>]
|
|
70
|
+
# @param stop [Symbol, Integer]
|
|
71
|
+
# @param options [Reader::Options] options of the base pass (library defaults + gem defaults + user options)
|
|
72
|
+
# @param config [Config] effective configuration
|
|
73
|
+
# @param min_length [Hash{Symbol => Integer}] minimum text length per format or symbology
|
|
74
|
+
# @param transformer [#call, nil] returns a {Transformers::Base} or nil, called only when a pass needs one
|
|
75
|
+
def initialize(passes:, stop:, options:, config:, min_length: {}, transformer: nil)
|
|
76
|
+
@passes = passes
|
|
77
|
+
@stop = stop
|
|
78
|
+
@options = options
|
|
79
|
+
@config = config
|
|
80
|
+
@min_length = min_length
|
|
81
|
+
@transformer_factory = transformer
|
|
82
|
+
@pass_options = {}
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Runs the ladder on one page.
|
|
86
|
+
#
|
|
87
|
+
# @param page [Loaders::Page] the base page (not released here)
|
|
88
|
+
# @param rerender [#call, nil] +->(dpi) { Loaders::Page }+ for PDF pages; nil for rasters
|
|
89
|
+
# @param page_info [Loaders::PageInfo, nil] page size in points, for the PDF high_res pixel cap
|
|
90
|
+
# @param deadline [Float, nil] monotonic clock time after which no further pass starts (the first pass always runs)
|
|
91
|
+
# @param notify [#call, nil] +->(event, payload)+, receives +:pass_completed+
|
|
92
|
+
# @return [Result]
|
|
93
|
+
def run(page, rerender: nil, page_info: nil, deadline: nil, notify: nil)
|
|
94
|
+
base = View.new(page.image, Geometry::Affine.identity, nil)
|
|
95
|
+
found = []
|
|
96
|
+
passes_run = []
|
|
97
|
+
skipped = {}
|
|
98
|
+
best = base # highest-resolution image so far (tiles pass)
|
|
99
|
+
owned = [] # derived images to release
|
|
100
|
+
|
|
101
|
+
@passes.each do |name|
|
|
102
|
+
break if stop?(found, passes_run)
|
|
103
|
+
if deadline && passes_run.any? && monotonic > deadline # the first pass always runs
|
|
104
|
+
skipped[name] = "page timeout"
|
|
105
|
+
next
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
started = monotonic
|
|
109
|
+
begin
|
|
110
|
+
views, options, reason = prepare(name, page, base, best, rerender, page_info, owned)
|
|
111
|
+
if reason
|
|
112
|
+
skipped[name] = reason
|
|
113
|
+
next
|
|
114
|
+
end
|
|
115
|
+
results = views.flat_map { |view| decode(name, view, options) }
|
|
116
|
+
rescue Error => e
|
|
117
|
+
raise if passes_run.empty? # the first pass failing is the page failing
|
|
118
|
+
# an escalation pass failing (re-render timeout, transformer error, …) must not lose earlier results
|
|
119
|
+
skipped[name] = "failed: #{e.class.name.split("::").last}: #{e.message.lines.first&.strip}"
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
best = views.first if name == :high_res
|
|
123
|
+
added = merge!(found, filter(results))
|
|
124
|
+
passes_run << name
|
|
125
|
+
release!(owned.pop) if %i[inverted rotated_45].include?(name) # single-use derived images
|
|
126
|
+
notify&.call(:pass_completed, {pass: name, found: results.size, added: added, duration: monotonic - started})
|
|
127
|
+
end
|
|
128
|
+
Result.new(barcodes: found, passes_run: passes_run, skipped_passes: skipped)
|
|
129
|
+
ensure
|
|
130
|
+
owned&.each { |image| release!(image) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def stop?(found, passes_run)
|
|
136
|
+
return false if passes_run.empty?
|
|
137
|
+
|
|
138
|
+
case @stop
|
|
139
|
+
when :found then found.any?
|
|
140
|
+
when :exhaustive then false
|
|
141
|
+
else found.size >= @stop
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# @return [Array(Array<View>, Reader::Options, nil), Array(nil, nil, String)] views + options, or a skip reason
|
|
146
|
+
def prepare(name, page, base, best, rerender, page_info, owned)
|
|
147
|
+
case name
|
|
148
|
+
when :base
|
|
149
|
+
[[base], @options]
|
|
150
|
+
when :inverted
|
|
151
|
+
prepare_inverted(base, owned)
|
|
152
|
+
when :global_binarizer
|
|
153
|
+
return [nil, nil, "binarizer is already :global_histogram"] if @options[:binarizer] == :global_histogram
|
|
154
|
+
|
|
155
|
+
[[base], options_for(name) { @options.merge(binarizer: :global_histogram) }]
|
|
156
|
+
when :high_res
|
|
157
|
+
prepare_high_res(page, base, rerender, page_info, owned)
|
|
158
|
+
when :tiles
|
|
159
|
+
grid = Geometry.tile_grid(best.image.width, best.image.height, **TILES)
|
|
160
|
+
views = grid.map do |x, y, w, h|
|
|
161
|
+
View.new(best.image, best.to_base.compose(Geometry::Affine.translation(x, y)), [x, y, w, h])
|
|
162
|
+
end
|
|
163
|
+
[views, options_for(name) { @options.merge(try_downscale: false) }]
|
|
164
|
+
when :rotated_45
|
|
165
|
+
prepare_rotated(base, owned)
|
|
166
|
+
when :denoise
|
|
167
|
+
return [nil, nil, "try_denoise is not supported by this libZXing (needs ZXING_EXPERIMENTAL_API)"] unless Native.supports?(:try_denoise)
|
|
168
|
+
|
|
169
|
+
[[base], options_for(name) { @options.merge(try_denoise: true) }]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def prepare_high_res(page, base, rerender, page_info, owned)
|
|
174
|
+
image, reason = (rerender && page.dpi) ? rerender_high_res(page, rerender, page_info) : upscale(base)
|
|
175
|
+
return [nil, nil, reason] unless image
|
|
176
|
+
|
|
177
|
+
owned << image
|
|
178
|
+
scale = Geometry::Affine.scale(base.image.width.to_f / image.width, base.image.height.to_f / image.height)
|
|
179
|
+
[[View.new(image, scale, nil)], @options]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# PDF pages: re-render at 2x the base DPI, within max_dpi and the pixel cap.
|
|
183
|
+
def rerender_high_res(page, rerender, page_info)
|
|
184
|
+
return [nil, "no page size to compute the high-res DPI"] unless page_info
|
|
185
|
+
|
|
186
|
+
dpi = Dpi.high_res(page.dpi, width_pt: page_info.width, height_pt: page_info.height,
|
|
187
|
+
max_dpi: @config.max_dpi, max_pixels: @config.max_pixels)
|
|
188
|
+
return [nil, "already at max_dpi or the pixel cap (#{page.dpi} DPI)"] unless dpi
|
|
189
|
+
|
|
190
|
+
[rerender.call(dpi).image, nil]
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Rasters: 2x upscale when the longest side is below UPSCALE_BELOW.
|
|
194
|
+
def upscale(base)
|
|
195
|
+
return [nil, "the upscale needs a :lum image"] unless base.image.format == :lum
|
|
196
|
+
|
|
197
|
+
longest = [base.image.width, base.image.height].max
|
|
198
|
+
return [nil, "image is already #{longest} px (>= #{UPSCALE_BELOW})"] if longest >= UPSCALE_BELOW
|
|
199
|
+
return [nil, "upscaled image would exceed max_pixels"] if @config.max_pixels && base.image.width * base.image.height * 4 > @config.max_pixels
|
|
200
|
+
|
|
201
|
+
transformer = self.transformer or return [nil, "no transformer available"]
|
|
202
|
+
[transformer.resize(base.image, 2), nil]
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# White-on-black codes. zxing-cpp's try_invert only helps its 2D readers (linear readers scan raw rows), so this
|
|
206
|
+
# pass decodes a pixel-inverted copy: restricted to linear formats when try_invert already covers 2D, all
|
|
207
|
+
# selected formats otherwise.
|
|
208
|
+
def prepare_inverted(base, owned)
|
|
209
|
+
return [nil, nil, "the inverted pass needs a :lum image"] unless base.image.format == :lum
|
|
210
|
+
|
|
211
|
+
linear_only = effective(:try_invert)
|
|
212
|
+
formats = linear_only ? Formats.linear_subset(@options[:formats]) : nil
|
|
213
|
+
return [nil, nil, "try_invert already covers the selected formats (no linear format selected)"] if linear_only && formats.empty?
|
|
214
|
+
|
|
215
|
+
image = base.image.inverted
|
|
216
|
+
owned << image
|
|
217
|
+
options = options_for(:inverted) do
|
|
218
|
+
linear_only ? @options.merge(formats: formats, try_invert: false) : @options.merge(try_invert: false)
|
|
219
|
+
end
|
|
220
|
+
[[View.new(image, Geometry::Affine.identity, nil, true)], options]
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def prepare_rotated(base, owned)
|
|
224
|
+
return [nil, nil, "the rotated pass needs a :lum image"] unless base.image.format == :lum
|
|
225
|
+
|
|
226
|
+
formats = Formats.linear_subset(@options[:formats])
|
|
227
|
+
return [nil, nil, "no linear formats selected"] if formats.empty?
|
|
228
|
+
|
|
229
|
+
to_canvas, width, height = Geometry.rotation_canvas(base.image.width, base.image.height, ROTATED_DEGREES)
|
|
230
|
+
if @config.max_pixels && width * height > @config.max_pixels
|
|
231
|
+
return [nil, nil, "rotated canvas #{width}x#{height} would exceed max_pixels"]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
transformer = self.transformer or return [nil, nil, "no transformer available"]
|
|
235
|
+
image = transformer.rotate(base.image, ROTATED_DEGREES, background: 255)
|
|
236
|
+
owned << image
|
|
237
|
+
# transformers may round the expanded canvas differently (±1 px): keep the image centered on the actual canvas
|
|
238
|
+
to_canvas = Geometry::Affine.translation((image.width - width) / 2.0, (image.height - height) / 2.0).compose(to_canvas)
|
|
239
|
+
[[View.new(image, to_canvas.invert, nil)], options_for(:rotated_45) { @options.merge(formats: formats) }]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def transformer
|
|
243
|
+
return @transformer if defined?(@transformer)
|
|
244
|
+
|
|
245
|
+
@transformer = @transformer_factory&.call
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def options_for(name)
|
|
249
|
+
@pass_options[name] ||= yield
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Effective value of a reader option: the caller's, else the library default.
|
|
253
|
+
def effective(key)
|
|
254
|
+
@options[key].nil? ? LIBRARY_DEFAULTS[key] : @options[key]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def decode(pass, view, options)
|
|
258
|
+
rotation = view.to_base.rotation_degrees
|
|
259
|
+
Reader.read(view.image, options, crop: view.crop).map do |barcode|
|
|
260
|
+
barcode.with(
|
|
261
|
+
position: view.to_base.apply_quad(barcode.position).round,
|
|
262
|
+
rotation: (barcode.rotation + rotation).round % 360,
|
|
263
|
+
inverted: barcode.inverted ^ view.negative,
|
|
264
|
+
pass: pass
|
|
265
|
+
)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def filter(barcodes)
|
|
270
|
+
barcodes.select do |barcode|
|
|
271
|
+
next false unless barcode.valid? || @options[:return_errors]
|
|
272
|
+
|
|
273
|
+
minimum = @min_length[barcode.format] || @min_length[barcode.symbology]
|
|
274
|
+
minimum.nil? || barcode.text.length >= minimum
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Adds results that are not duplicates of earlier ones; returns how many were added.
|
|
279
|
+
def merge!(found, results)
|
|
280
|
+
before = found.size
|
|
281
|
+
results.each { |barcode| found << barcode unless found.any? { |kept| Dedupe.duplicate?(kept, barcode) } }
|
|
282
|
+
found.size - before
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def release!(image)
|
|
286
|
+
image&.release!
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def monotonic
|
|
290
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|