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,508 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# Plane geometry for barcode positions: {Point}, {Quad}, {Affine}, the canvas transform of rotated
|
|
5
|
+
# passes ({.rotation_canvas}) and the tile layout of the tiles pass ({.tile_grid}).
|
|
6
|
+
#
|
|
7
|
+
# Coordinates are image pixels: origin at the top-left corner, x to the right, y down. An image of width w
|
|
8
|
+
# and height h covers the continuous region [0, w] × [0, h]. Angles are in degrees; positive angles turn
|
|
9
|
+
# clockwise as displayed.
|
|
10
|
+
module Geometry
|
|
11
|
+
# [cos, sin] of 0°, 90°, 180° and 270°, exact so that quarter turns map Integer points to Integer points.
|
|
12
|
+
QUARTER_TURNS = [[1, 0], [0, 1], [-1, 0], [0, -1]].freeze
|
|
13
|
+
# Floating-point noise ignored before a rotated canvas extent is rounded up to whole pixels.
|
|
14
|
+
CANVAS_TOLERANCE = 1e-6
|
|
15
|
+
# Floating-point noise ignored when converting the overlap fraction to whole pixels.
|
|
16
|
+
OVERLAP_TOLERANCE = 1e-9
|
|
17
|
+
private_constant :QUARTER_TURNS, :CANVAS_TOLERANCE, :OVERLAP_TOLERANCE
|
|
18
|
+
|
|
19
|
+
# A point in image coordinates. Coordinates keep their numeric type (usually Integer or Float).
|
|
20
|
+
#
|
|
21
|
+
# @!attribute [r] x
|
|
22
|
+
# @return [Numeric] horizontal coordinate, growing to the right
|
|
23
|
+
# @!attribute [r] y
|
|
24
|
+
# @return [Numeric] vertical coordinate, growing downwards
|
|
25
|
+
Point = Data.define(:x, :y) do
|
|
26
|
+
# Converts +value+ to a Point.
|
|
27
|
+
#
|
|
28
|
+
# @param value [Point, Array(Numeric, Numeric)] a Point (returned as is) or an +[x, y]+ pair
|
|
29
|
+
# @return [Point]
|
|
30
|
+
# @raise [ArgumentError] if +value+ is neither
|
|
31
|
+
def self.from(value)
|
|
32
|
+
case value
|
|
33
|
+
when Point then value
|
|
34
|
+
when Array
|
|
35
|
+
raise ArgumentError, "expected an [x, y] pair, got #{value.inspect}" unless value.size == 2
|
|
36
|
+
|
|
37
|
+
new(*value)
|
|
38
|
+
else
|
|
39
|
+
raise ArgumentError, "expected a Point or an [x, y] pair, got #{value.inspect}"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @raise [ArgumentError] if a coordinate is not a finite real number
|
|
44
|
+
def initialize(x:, y:)
|
|
45
|
+
super(x: Geometry.number!(x, :x), y: Geometry.number!(y, :y))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param other [Point, Array(Numeric, Numeric)]
|
|
49
|
+
# @return [Point] the component-wise sum
|
|
50
|
+
def +(other)
|
|
51
|
+
other = Point.from(other)
|
|
52
|
+
with(x: x + other.x, y: y + other.y)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param other [Point, Array(Numeric, Numeric)]
|
|
56
|
+
# @return [Point] the component-wise difference
|
|
57
|
+
def -(other)
|
|
58
|
+
other = Point.from(other)
|
|
59
|
+
with(x: x - other.x, y: y - other.y)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param other [Point, Array(Numeric, Numeric)]
|
|
63
|
+
# @return [Float] the Euclidean distance to +other+
|
|
64
|
+
def distance_to(other)
|
|
65
|
+
other = Point.from(other)
|
|
66
|
+
Math.hypot(x - other.x, y - other.y)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Point] the point with both coordinates rounded to Integers (halves away from zero)
|
|
70
|
+
def round
|
|
71
|
+
with(x: x.round, y: y.round)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Array(Numeric, Numeric)] +[x, y]+
|
|
75
|
+
def to_a
|
|
76
|
+
[x, y]
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The four corners of a barcode, named in the symbol's own orientation: +top_left+ is the corner that is
|
|
81
|
+
# top-left when the symbol is upright, wherever it lies in the image. Corners may be given as {Point}s or
|
|
82
|
+
# +[x, y]+ pairs.
|
|
83
|
+
#
|
|
84
|
+
# @!attribute [r] top_left
|
|
85
|
+
# @return [Point]
|
|
86
|
+
# @!attribute [r] top_right
|
|
87
|
+
# @return [Point]
|
|
88
|
+
# @!attribute [r] bottom_right
|
|
89
|
+
# @return [Point]
|
|
90
|
+
# @!attribute [r] bottom_left
|
|
91
|
+
# @return [Point]
|
|
92
|
+
Quad = Data.define(:top_left, :top_right, :bottom_right, :bottom_left) do
|
|
93
|
+
# @param points [Array<Point, Array(Numeric, Numeric)>] exactly four corners, in the order top-left,
|
|
94
|
+
# top-right, bottom-right, bottom-left
|
|
95
|
+
# @return [Quad]
|
|
96
|
+
# @raise [ArgumentError] unless there are exactly four valid points
|
|
97
|
+
def self.from_points(points)
|
|
98
|
+
points = Array(points)
|
|
99
|
+
raise ArgumentError, "expected 4 points, got #{points.size}" unless points.size == 4
|
|
100
|
+
|
|
101
|
+
new(*points)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# @raise [ArgumentError] if a corner is not a Point or an +[x, y]+ pair
|
|
105
|
+
def initialize(top_left:, top_right:, bottom_right:, bottom_left:)
|
|
106
|
+
super(
|
|
107
|
+
top_left: Point.from(top_left),
|
|
108
|
+
top_right: Point.from(top_right),
|
|
109
|
+
bottom_right: Point.from(bottom_right),
|
|
110
|
+
bottom_left: Point.from(bottom_left)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [Array<Point>] +[top_left, top_right, bottom_right, bottom_left]+
|
|
115
|
+
def points
|
|
116
|
+
[top_left, top_right, bottom_right, bottom_left]
|
|
117
|
+
end
|
|
118
|
+
alias_method :to_a, :points
|
|
119
|
+
|
|
120
|
+
# @return [Point] the mean of the four corners (Float coordinates)
|
|
121
|
+
def center
|
|
122
|
+
corners = points
|
|
123
|
+
Point.new(corners.sum(&:x).fdiv(4), corners.sum(&:y).fdiv(4))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [Array<Float>] lengths of the top, right, bottom and left sides
|
|
127
|
+
def side_lengths
|
|
128
|
+
corners = points
|
|
129
|
+
corners.zip(corners.rotate).map { |from, to| from.distance_to(to) }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Float] length of the shortest side
|
|
133
|
+
def shorter_side
|
|
134
|
+
side_lengths.min
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# @return [Float] length of the longest side
|
|
138
|
+
def longer_side
|
|
139
|
+
side_lengths.max
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Area enclosed by the corners (shoelace formula), whatever their winding order. Only meaningful for a
|
|
143
|
+
# simple (not self-intersecting) quad.
|
|
144
|
+
# @return [Float]
|
|
145
|
+
def area
|
|
146
|
+
corners = points
|
|
147
|
+
corners.zip(corners.rotate).sum { |from, to| from.x * to.y - to.x * from.y }.abs.fdiv(2)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# @return [Array(Numeric, Numeric, Numeric, Numeric)] +[min_x, min_y, max_x, max_y]+
|
|
151
|
+
def bounding_box
|
|
152
|
+
xs = points.map(&:x)
|
|
153
|
+
ys = points.map(&:y)
|
|
154
|
+
[xs.min, ys.min, xs.max, ys.max]
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# @param affine [Affine]
|
|
158
|
+
# @return [Quad] the quad with every corner transformed (see {Affine#apply_quad})
|
|
159
|
+
def transform(affine)
|
|
160
|
+
raise ArgumentError, "expected an Affine, got #{affine.inspect}" unless affine.is_a?(Affine)
|
|
161
|
+
|
|
162
|
+
affine.apply_quad(self)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# @return [Quad] the quad with every corner rounded to Integer coordinates
|
|
166
|
+
def round
|
|
167
|
+
map(&:round)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Builds a new Quad from the block's result for each corner, keeping the corner roles.
|
|
171
|
+
#
|
|
172
|
+
# @yieldparam point [Point]
|
|
173
|
+
# @yieldreturn [Point, Array(Numeric, Numeric)]
|
|
174
|
+
# @return [Quad, Enumerator] an Enumerator without a block
|
|
175
|
+
def map(&block)
|
|
176
|
+
return enum_for(:map) unless block
|
|
177
|
+
|
|
178
|
+
Quad.new(*points.map(&block))
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# @return [Hash{Symbol => Hash{Symbol => Numeric}}] plain nested Hashes (JSON-friendly),
|
|
182
|
+
# e.g. +{top_left: {x: 0, y: 0}, top_right: {x: 10, y: 0}, ...}+
|
|
183
|
+
def to_h(&block)
|
|
184
|
+
hash = members.to_h { |corner| [corner, public_send(corner).to_h] }
|
|
185
|
+
block ? hash.to_h(&block) : hash
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# An immutable 2-D affine transform: the top two rows of a 3×3 homogeneous matrix.
|
|
190
|
+
#
|
|
191
|
+
# | a b c | x' = a·x + b·y + c
|
|
192
|
+
# | d e f | y' = d·x + e·y + f
|
|
193
|
+
#
|
|
194
|
+
# Coefficients keep their numeric type, so transforms built from Integers (translations, quarter turns)
|
|
195
|
+
# map Integer points to Integer points exactly. Value semantics as for Arrays: +==+ compares coefficients
|
|
196
|
+
# numerically, +eql?+ and +hash+ also compare their classes.
|
|
197
|
+
#
|
|
198
|
+
# Pass transforms are chained with {#compose} (mathematical order) or {#then} (pipeline order):
|
|
199
|
+
#
|
|
200
|
+
# @example Tile at (tx, ty) of a 2× upscaled image, back to base-image pixels
|
|
201
|
+
# to_base = Affine.scale(0.5).compose(Affine.translation(tx, ty))
|
|
202
|
+
# to_base = Affine.translation(tx, ty).then(Affine.scale(0.5)) # the same transform
|
|
203
|
+
#
|
|
204
|
+
# @!attribute [r] a
|
|
205
|
+
# @return [Numeric] x' coefficient of x
|
|
206
|
+
# @!attribute [r] b
|
|
207
|
+
# @return [Numeric] x' coefficient of y
|
|
208
|
+
# @!attribute [r] c
|
|
209
|
+
# @return [Numeric] x' translation
|
|
210
|
+
# @!attribute [r] d
|
|
211
|
+
# @return [Numeric] y' coefficient of x
|
|
212
|
+
# @!attribute [r] e
|
|
213
|
+
# @return [Numeric] y' coefficient of y
|
|
214
|
+
# @!attribute [r] f
|
|
215
|
+
# @return [Numeric] y' translation
|
|
216
|
+
Affine = Data.define(:a, :b, :c, :d, :e, :f) do
|
|
217
|
+
class << self
|
|
218
|
+
# @return [Affine] the transform that leaves every point in place
|
|
219
|
+
def identity
|
|
220
|
+
new(1, 0, 0, 0, 1, 0)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# @param dx [Numeric]
|
|
224
|
+
# @param dy [Numeric]
|
|
225
|
+
# @return [Affine] (x, y) → (x + dx, y + dy)
|
|
226
|
+
def translation(dx, dy)
|
|
227
|
+
new(1, 0, dx, 0, 1, dy)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# @param sx [Numeric]
|
|
231
|
+
# @param sy [Numeric] defaults to +sx+ (uniform scale)
|
|
232
|
+
# @return [Affine] (x, y) → (sx·x, sy·y)
|
|
233
|
+
def scale(sx, sy = sx)
|
|
234
|
+
new(sx, 0, 0, 0, sy, 0)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Rotation by +degrees+ about (+cx+, +cy+), clockwise as displayed: the x axis (1, 0) turns towards
|
|
238
|
+
# the y axis (0, 1), which points down. Multiples of 90° have exact Integer cos/sin coefficients.
|
|
239
|
+
#
|
|
240
|
+
# @param degrees [Numeric]
|
|
241
|
+
# @param cx [Numeric]
|
|
242
|
+
# @param cy [Numeric]
|
|
243
|
+
# @return [Affine]
|
|
244
|
+
def rotation(degrees, cx = 0, cy = 0)
|
|
245
|
+
cos, sin = Geometry.cos_sin(degrees)
|
|
246
|
+
Geometry.number!(cx, :cx)
|
|
247
|
+
Geometry.number!(cy, :cy)
|
|
248
|
+
new(cos, -sin, cx - cos * cx + sin * cy, sin, cos, cy - sin * cx - cos * cy)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# @raise [ArgumentError] if a coefficient is not a finite real number
|
|
253
|
+
def initialize(a:, b:, c:, d:, e:, f:)
|
|
254
|
+
super(
|
|
255
|
+
a: Geometry.number!(a, :a), b: Geometry.number!(b, :b), c: Geometry.number!(c, :c),
|
|
256
|
+
d: Geometry.number!(d, :d), e: Geometry.number!(e, :e), f: Geometry.number!(f, :f)
|
|
257
|
+
)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Mathematical composition self ∘ other: the transform that applies +other+ first, then +self+
|
|
261
|
+
# (the matrix product self × other), so +compose(other).apply(p) == apply(other.apply(p))+.
|
|
262
|
+
#
|
|
263
|
+
# @param other [Affine]
|
|
264
|
+
# @return [Affine]
|
|
265
|
+
def compose(other)
|
|
266
|
+
raise ArgumentError, "expected an Affine, got #{other.inspect}" unless other.is_a?(Affine)
|
|
267
|
+
|
|
268
|
+
Affine.new(
|
|
269
|
+
a * other.a + b * other.d, a * other.b + b * other.e, a * other.c + b * other.f + c,
|
|
270
|
+
d * other.a + e * other.d, d * other.b + e * other.e, d * other.c + e * other.f + f
|
|
271
|
+
)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Pipeline order: the transform that applies +self+ first, then +other+, i.e. +other.compose(self)+,
|
|
275
|
+
# so +self.then(other).apply(p) == other.apply(apply(p))+. With a block and no argument this is
|
|
276
|
+
# +Kernel#then+.
|
|
277
|
+
#
|
|
278
|
+
# @param other [Affine]
|
|
279
|
+
# @return [Affine]
|
|
280
|
+
def then(other = nil, &block)
|
|
281
|
+
return super(&block) if other.nil? && block
|
|
282
|
+
raise ArgumentError, "expected an Affine, got #{other.inspect}" unless other.is_a?(Affine)
|
|
283
|
+
|
|
284
|
+
other.compose(self)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# @param point [Point, Array(Numeric, Numeric)]
|
|
288
|
+
# @return [Point] the transformed point
|
|
289
|
+
def apply(point)
|
|
290
|
+
point = Point.from(point)
|
|
291
|
+
Point.new(a * point.x + b * point.y + c, d * point.x + e * point.y + f)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# @param quad [Quad]
|
|
295
|
+
# @return [Quad] the quad with every corner transformed; each corner keeps its role (+top_left+ stays
|
|
296
|
+
# +top_left+), so the result is still named in the symbol's own orientation
|
|
297
|
+
def apply_quad(quad)
|
|
298
|
+
raise ArgumentError, "expected a Quad, got #{quad.inspect}" unless quad.is_a?(Quad)
|
|
299
|
+
|
|
300
|
+
quad.map { |point| apply(point) }
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# @return [Numeric] determinant of the linear part (a·e − b·d): the area scale factor, negative when
|
|
304
|
+
# the transform mirrors
|
|
305
|
+
def determinant
|
|
306
|
+
a * e - b * d
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Coefficients stay Integers where the division is exact (e.g. translations and quarter turns).
|
|
310
|
+
#
|
|
311
|
+
# @return [Affine] the inverse transform
|
|
312
|
+
# @raise [ArgumentError] if the transform is singular or its inverse is not finite
|
|
313
|
+
def invert
|
|
314
|
+
det = determinant
|
|
315
|
+
raise ArgumentError, "cannot invert a singular transform (determinant 0): #{inspect}" if det.zero?
|
|
316
|
+
|
|
317
|
+
inverse = [e, -b, b * f - c * e, -d, a, c * d - a * f].map { |value| Geometry.quotient(value, det) }
|
|
318
|
+
raise ArgumentError, "cannot invert a nearly singular transform: #{inspect}" unless inverse.all?(&:finite?)
|
|
319
|
+
|
|
320
|
+
Affine.new(*inverse)
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Rotation component in degrees, clockwise as displayed, normalized to 0...360. The scanner adds it to a
|
|
324
|
+
# barcode's reported rotation when mapping results back to the base image.
|
|
325
|
+
#
|
|
326
|
+
# This is the rotation of the polar decomposition, atan2(d − b, a + e): exact for any composition of
|
|
327
|
+
# rotations, translations and uniform scales, otherwise the rotation of the closest similarity. The
|
|
328
|
+
# result is rounded to 1e-9° so that quarter turns come out exact.
|
|
329
|
+
#
|
|
330
|
+
# @return [Float]
|
|
331
|
+
# @raise [ArgumentError] if the transform mirrors or collapses the plane (determinant ≤ 0)
|
|
332
|
+
def rotation_degrees
|
|
333
|
+
unless determinant.positive?
|
|
334
|
+
raise ArgumentError, "rotation is undefined for a mirroring or singular transform: #{inspect}"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
degrees = (Math.atan2(d - b, a + e) * 180 / Math::PI).round(9) % 360
|
|
338
|
+
degrees + 0.0 # -0.0 → 0.0
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# @param other [Affine]
|
|
342
|
+
# @param eps [Numeric] absolute tolerance per coefficient
|
|
343
|
+
# @return [Boolean] whether +other+ is an Affine whose coefficients all differ by at most +eps+
|
|
344
|
+
def approx_equal?(other, eps = 1e-9)
|
|
345
|
+
return false unless other.is_a?(Affine)
|
|
346
|
+
|
|
347
|
+
to_a.flatten.zip(other.to_a.flatten).all? { |mine, theirs| (mine - theirs).abs <= eps }
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# @return [Array(Array(Numeric, Numeric, Numeric), Array(Numeric, Numeric, Numeric))] the rows
|
|
351
|
+
# +[[a, b, c], [d, e, f]]+
|
|
352
|
+
def to_a
|
|
353
|
+
[[a, b, c], [d, e, f]]
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
class << self
|
|
358
|
+
# Transform from original image coordinates into the canvas of the image rotated about its center by
|
|
359
|
+
# +degrees+ (clockwise), with the canvas expanded to fit the whole rotated image and starting at (0, 0):
|
|
360
|
+
# the geometry of +Transformers::Base#rotate+. Map results found on the rotated canvas back to the
|
|
361
|
+
# original image with +to_canvas.invert+.
|
|
362
|
+
#
|
|
363
|
+
# Quarter turns are exact: 90° gives an h×w canvas and (x, y) → (h − y, x). Other angles give the
|
|
364
|
+
# rotated bounding box rounded up to whole pixels, with the image centered on the canvas.
|
|
365
|
+
#
|
|
366
|
+
# @param width [Integer] original image width
|
|
367
|
+
# @param height [Integer] original image height
|
|
368
|
+
# @param degrees [Numeric] clockwise rotation
|
|
369
|
+
# @return [Array(Affine, Integer, Integer)] +[to_canvas, canvas_width, canvas_height]+
|
|
370
|
+
# @raise [ArgumentError] on a non-positive or non-Integer size, or a non-finite angle
|
|
371
|
+
def rotation_canvas(width, height, degrees)
|
|
372
|
+
dimension!(width, :width)
|
|
373
|
+
dimension!(height, :height)
|
|
374
|
+
cos, sin = cos_sin(degrees)
|
|
375
|
+
canvas_width, canvas_height = rotation_canvas_size(width, height, cos, sin)
|
|
376
|
+
offset_x, offset_y = rotation_canvas_offset(width, height, canvas_width, canvas_height, cos, sin)
|
|
377
|
+
[Affine.new(cos, -sin, offset_x, sin, cos, offset_y), canvas_width, canvas_height]
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Overlapping tiles covering a width × height image, for the tiles pass.
|
|
381
|
+
#
|
|
382
|
+
# Each axis is split independently. A side of at most +max_tile+ pixels is a single tile spanning it.
|
|
383
|
+
# A longer side gets the fewest tiles of one size within +min_tile+..+max_tile+ such that adjacent
|
|
384
|
+
# tiles overlap by at least +overlap+ × the tile size (rounded up to whole pixels), using the smallest
|
|
385
|
+
# size that achieves that count. The first tile starts at 0, the last one ends at the far edge, and the
|
|
386
|
+
# ones in between are spaced evenly (rounded to whole pixels). The result is deterministic.
|
|
387
|
+
#
|
|
388
|
+
# @param width [Integer]
|
|
389
|
+
# @param height [Integer]
|
|
390
|
+
# @param min_tile [Integer] smallest tile side (unless the image side itself is smaller)
|
|
391
|
+
# @param max_tile [Integer] largest tile side
|
|
392
|
+
# @param overlap [Numeric] minimum overlap of adjacent tiles as a fraction of the tile side, 0...1
|
|
393
|
+
# @return [Array<Array(Integer, Integer, Integer, Integer)>] +[x, y, width, height]+ per tile, row-major
|
|
394
|
+
# (top row first, each row left to right)
|
|
395
|
+
# @raise [ArgumentError] on invalid sizes or parameters
|
|
396
|
+
def tile_grid(width, height, min_tile: 1024, max_tile: 1536, overlap: 0.2)
|
|
397
|
+
dimension!(width, :width)
|
|
398
|
+
dimension!(height, :height)
|
|
399
|
+
validate_tiling!(min_tile, max_tile, overlap)
|
|
400
|
+
columns = tile_spans(width, min_tile, max_tile, overlap)
|
|
401
|
+
rows = tile_spans(height, min_tile, max_tile, overlap)
|
|
402
|
+
rows.flat_map { |y, tile_height| columns.map { |x, tile_width| [x, y, tile_width, tile_height] } }
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# @api private
|
|
406
|
+
# @return [Numeric] +value+ when it is a finite real number
|
|
407
|
+
# @raise [ArgumentError] otherwise
|
|
408
|
+
def number!(value, name)
|
|
409
|
+
return value if value.is_a?(Numeric) && value.real? && value.finite?
|
|
410
|
+
|
|
411
|
+
raise ArgumentError, "#{name} must be a finite real number, got #{value.inspect}"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# @api private
|
|
415
|
+
# @return [Array(Numeric, Numeric)] +[cos, sin]+ of +degrees+; exact Integers for multiples of 90°
|
|
416
|
+
def cos_sin(degrees)
|
|
417
|
+
turn = number!(degrees, :degrees) % 360
|
|
418
|
+
return QUARTER_TURNS.fetch((turn / 90).to_i) if (turn % 90).zero?
|
|
419
|
+
|
|
420
|
+
turn -= 360 if turn > 180 # symmetric range: rotation(-θ) is the exact mirror of rotation(θ)
|
|
421
|
+
radians = turn * Math::PI / 180
|
|
422
|
+
[Math.cos(radians), Math.sin(radians)]
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# @api private
|
|
426
|
+
# @return [Numeric] numerator / denominator: an Integer when both are Integers and the division is
|
|
427
|
+
# exact, a Float otherwise
|
|
428
|
+
def quotient(numerator, denominator)
|
|
429
|
+
if numerator.is_a?(Integer) && denominator.is_a?(Integer) && (numerator % denominator).zero?
|
|
430
|
+
numerator / denominator
|
|
431
|
+
else
|
|
432
|
+
numerator.fdiv(denominator)
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
private
|
|
437
|
+
|
|
438
|
+
# Canvas size for a width × height image rotated by (cos, sin): the rotated bounding box, rounded up
|
|
439
|
+
# to whole pixels (quarter turns are exact).
|
|
440
|
+
def rotation_canvas_size(width, height, cos, sin)
|
|
441
|
+
[width * cos.abs + height * sin.abs, width * sin.abs + height * cos.abs].map do |extent|
|
|
442
|
+
extent.is_a?(Integer) ? extent : [(extent - CANVAS_TOLERANCE).ceil, 1].max
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Translation (c, f) that places the rotated image on its canvas. The single place that decides where
|
|
447
|
+
# the image lands: the image center (w/2, h/2) maps to the canvas center, so the slack from rounding the
|
|
448
|
+
# canvas size up is split evenly between opposite edges. Adjust here if a transformer (libvips +rotate+,
|
|
449
|
+
# ImageMagick +-rotate+) positions the image differently.
|
|
450
|
+
def rotation_canvas_offset(width, height, canvas_width, canvas_height, cos, sin)
|
|
451
|
+
[
|
|
452
|
+
quotient(canvas_width - cos * width + sin * height, 2),
|
|
453
|
+
quotient(canvas_height - sin * width - cos * height, 2)
|
|
454
|
+
]
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
# [[offset, size], ...] of the tiles along one axis of +length+ pixels.
|
|
458
|
+
def tile_spans(length, min_tile, max_tile, overlap)
|
|
459
|
+
return [[0, length]] if length <= max_tile
|
|
460
|
+
|
|
461
|
+
count = 1 + ceil_div(length - max_tile, max_tile - min_overlap(max_tile, overlap))
|
|
462
|
+
# tiles_fit? is monotonic in the size and holds for max_tile, so this finds the smallest fitting size.
|
|
463
|
+
size = (min_tile..max_tile).bsearch { |candidate| tiles_fit?(length, count, candidate, overlap) }
|
|
464
|
+
travel = length - size
|
|
465
|
+
# i × travel / (count − 1), rounded half up in Integer arithmetic
|
|
466
|
+
Array.new(count) { |i| [(2 * i * travel + count - 1) / (2 * (count - 1)), size] }
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Whether +count+ evenly spaced tiles of +size+ (count ≥ 2) span +length+ with the minimum overlap.
|
|
470
|
+
# Rounded offsets advance by at most ceil(travel / (count - 1)) pixels.
|
|
471
|
+
def tiles_fit?(length, count, size, overlap)
|
|
472
|
+
size - ceil_div(length - size, count - 1) >= min_overlap(size, overlap)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Minimum overlap in whole pixels between adjacent tiles of +size+.
|
|
476
|
+
def min_overlap(size, overlap)
|
|
477
|
+
(overlap * size - OVERLAP_TOLERANCE).ceil
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def ceil_div(numerator, denominator)
|
|
481
|
+
(numerator + denominator - 1) / denominator
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def dimension!(value, name)
|
|
485
|
+
return if value.is_a?(Integer) && value.positive?
|
|
486
|
+
|
|
487
|
+
raise ArgumentError, "#{name} must be a positive Integer, got #{value.inspect}"
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def validate_tiling!(min_tile, max_tile, overlap)
|
|
491
|
+
dimension!(min_tile, :min_tile)
|
|
492
|
+
dimension!(max_tile, :max_tile)
|
|
493
|
+
raise ArgumentError, "min_tile (#{min_tile}) must not exceed max_tile (#{max_tile})" if min_tile > max_tile
|
|
494
|
+
unless overlap.is_a?(Numeric) && overlap.real? && overlap.finite? && overlap >= 0 && overlap < 1
|
|
495
|
+
raise ArgumentError, "overlap must be a number in 0...1, got #{overlap.inspect}"
|
|
496
|
+
end
|
|
497
|
+
return if min_overlap(max_tile, overlap) < max_tile
|
|
498
|
+
|
|
499
|
+
raise ArgumentError, "overlap #{overlap} leaves no room to advance between tiles of #{max_tile} px"
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# Shorthand for {Geometry::Point}.
|
|
505
|
+
Point = Geometry::Point
|
|
506
|
+
# Shorthand for {Geometry::Quad}.
|
|
507
|
+
Quad = Geometry::Quad
|
|
508
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ZXingFFI
|
|
4
|
+
# Reads raster dimensions straight from file headers, without decoding anything, for the formats
|
|
5
|
+
# whose headers are simple: PNG, GIF, BMP, PNM and JPEG. The scanner checks them against +max_pixels+ before any
|
|
6
|
+
# loader runs, so a small file declaring a huge image is rejected the same way whichever loader is installed
|
|
7
|
+
# (ImageMagick, for one, refuses to report the size of a truncated BMP/PGM, and Ubuntu's ImageMagick policy rejects
|
|
8
|
+
# large JPEG headers before we see them). Other formats return nil and are checked by the loaders themselves.
|
|
9
|
+
module HeaderProbe
|
|
10
|
+
# Bytes read from the start of the file.
|
|
11
|
+
HEAD_SIZE = 4096
|
|
12
|
+
|
|
13
|
+
# JPEG start-of-frame markers: every SOFn except DHT (C4), JPG (C8) and DAC (CC).
|
|
14
|
+
JPEG_SOF = [0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF].freeze
|
|
15
|
+
|
|
16
|
+
# Marker segments walked before giving up on finding a JPEG frame header.
|
|
17
|
+
JPEG_MAX_SEGMENTS = 256
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# @param path [String]
|
|
21
|
+
# @param kind [Symbol] sniffed kind
|
|
22
|
+
# @return [Array(Integer, Integer), nil] width and height, or nil when unknown or unreadable
|
|
23
|
+
def dimensions(path, kind)
|
|
24
|
+
return jpeg(path) if kind == :jpeg
|
|
25
|
+
|
|
26
|
+
head = File.open(path, "rb") { |file| file.read(HEAD_SIZE) } || "".b
|
|
27
|
+
case kind
|
|
28
|
+
when :png then png(head)
|
|
29
|
+
when :gif then valid(head.unpack("@6v2")) if head.bytesize >= 10
|
|
30
|
+
when :bmp then bmp(head)
|
|
31
|
+
when :pnm then ZXingFFI::Pnm.read_header(head).then { |header| [header.width, header.height] }
|
|
32
|
+
end
|
|
33
|
+
rescue UnsupportedInput, ArgumentError, TypeError, SystemCallError, EOFError
|
|
34
|
+
nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Raises when the header declares more than +max_pixels+ pixels.
|
|
38
|
+
# @raise [LimitExceeded]
|
|
39
|
+
def check!(path, kind, max_pixels)
|
|
40
|
+
return unless max_pixels
|
|
41
|
+
|
|
42
|
+
width, height = dimensions(path, kind)
|
|
43
|
+
return unless width && height && width * height > max_pixels
|
|
44
|
+
|
|
45
|
+
raise LimitExceeded.new("#{File.basename(path)} declares #{width}x#{height} (#{width * height} pixels), " \
|
|
46
|
+
"exceeding max_pixels #{max_pixels}", limit: :max_pixels, value: width * height)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def png(head)
|
|
52
|
+
return nil unless head.bytesize >= 24 && head.byteslice(12, 4) == "IHDR"
|
|
53
|
+
|
|
54
|
+
valid(head.unpack("@16N2"))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# BITMAPCOREHEADER (12 bytes) has 16-bit sizes; later headers 32-bit, with a negative height for top-down.
|
|
58
|
+
def bmp(head)
|
|
59
|
+
return nil unless head.bytesize >= 26
|
|
60
|
+
|
|
61
|
+
if head.unpack1("@14V") == 12
|
|
62
|
+
valid(head.unpack("@18v2"))
|
|
63
|
+
else
|
|
64
|
+
width, height = head.unpack("@18l<2")
|
|
65
|
+
valid([width, height.abs])
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Walks the marker segments to the frame header, seeking past their data: EXIF, ICC and XMP segments (up to
|
|
70
|
+
# 64 KiB each) can put it far beyond HEAD_SIZE. Stops at the first scan (SOS): a frame header must precede it.
|
|
71
|
+
def jpeg(path)
|
|
72
|
+
File.open(path, "rb") do |file|
|
|
73
|
+
return nil unless file.read(2) == "\xFF\xD8".b
|
|
74
|
+
|
|
75
|
+
JPEG_MAX_SEGMENTS.times do
|
|
76
|
+
return nil unless file.readbyte == 0xFF
|
|
77
|
+
|
|
78
|
+
marker = file.readbyte
|
|
79
|
+
marker = file.readbyte while marker == 0xFF # fill bytes
|
|
80
|
+
next if marker == 0x01 || marker.between?(0xD0, 0xD8) # TEM, RSTn, SOI: no length
|
|
81
|
+
return nil if marker.zero? || marker == 0xD9 || marker == 0xDA # not a marker, EOI, or SOS first
|
|
82
|
+
|
|
83
|
+
length = file.read(2)&.unpack1("n")
|
|
84
|
+
return nil unless length && length >= 2
|
|
85
|
+
return valid(file.read(5).to_s.unpack("@1n2").reverse) if JPEG_SOF.include?(marker) # height, then width
|
|
86
|
+
|
|
87
|
+
file.seek(length - 2, IO::SEEK_CUR)
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def valid(dimensions)
|
|
94
|
+
(dimensions.all? { |value| value.is_a?(Integer) && value.positive? }) ? dimensions : nil
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|