@sythos/js_barcode_universal 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.
- package/LICENSE +215 -0
- package/NOTICE.md +106 -0
- package/README.md +433 -0
- package/bundle/sythos-barcode.esm.js +7998 -0
- package/bundle/sythos-barcode.js +7948 -0
- package/examples/create.html +731 -0
- package/examples/read.html +341 -0
- package/licenses/README.md +42 -0
- package/licenses/codabar.license +74 -0
- package/licenses/code-11.license +69 -0
- package/licenses/code-128.license +69 -0
- package/licenses/code-39.license +70 -0
- package/licenses/code-93.license +71 -0
- package/licenses/ean-13.license +70 -0
- package/licenses/ean-8.license +70 -0
- package/licenses/gs1-128.license +71 -0
- package/licenses/isbn.license +76 -0
- package/licenses/itf-14.license +69 -0
- package/licenses/itf.license +70 -0
- package/licenses/msi-plessey.license +72 -0
- package/licenses/pharmacode.license +71 -0
- package/licenses/qr-code.license +75 -0
- package/licenses/upc-a.license +72 -0
- package/licenses/upc-e.license +69 -0
- package/package.json +89 -0
- package/src/core/bit-buffer.js +174 -0
- package/src/core/bit-matrix.js +241 -0
- package/src/core/errors.js +61 -0
- package/src/core/galois-field.js +204 -0
- package/src/core/index.js +56 -0
- package/src/core/reed-solomon.js +313 -0
- package/src/image/binarizer.js +270 -0
- package/src/image/grid-sampler.js +164 -0
- package/src/image/index.js +40 -0
- package/src/image/luminance.js +196 -0
- package/src/image/perspective.js +195 -0
- package/src/index.js +240 -0
- package/src/oned/index.js +89 -0
- package/src/oned/patterns.js +384 -0
- package/src/oned/reader.js +918 -0
- package/src/oned/writers.js +741 -0
- package/src/qr/decoder.js +575 -0
- package/src/qr/detector.js +630 -0
- package/src/qr/encoder.js +958 -0
- package/src/qr/index.js +44 -0
- package/src/qr/tables.js +737 -0
- package/src/render/image-data.js +125 -0
- package/src/render/index.js +130 -0
- package/src/render/options.js +160 -0
- package/src/render/png.js +295 -0
- package/src/render/svg.js +120 -0
- package/src/render/webgl.js +206 -0
- package/src/render/webgpu.js +369 -0
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/*!
|
|
2
|
+
* Sythos Barcode Suite
|
|
3
|
+
*
|
|
4
|
+
* MIT License
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) 2026 Sythos
|
|
7
|
+
*
|
|
8
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
9
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
10
|
+
* in the Software without restriction, including without limitation the rights
|
|
11
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
12
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
13
|
+
* furnished to do so, subject to the following conditions:
|
|
14
|
+
*
|
|
15
|
+
* The above copyright notice and this permission notice shall be included in all
|
|
16
|
+
* copies or substantial portions of the Software.
|
|
17
|
+
*
|
|
18
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
19
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
20
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
21
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
22
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
23
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
24
|
+
* SOFTWARE.
|
|
25
|
+
*
|
|
26
|
+
* SPDX-License-Identifier: MIT
|
|
27
|
+
*
|
|
28
|
+
* Original work. No code from any other barcode implementation.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* QR Code detection — finding symbols in a binarized image.
|
|
33
|
+
*
|
|
34
|
+
* The whole thing hangs off one property of the finder pattern: along any line
|
|
35
|
+
* through its centre, in any direction, the dark and light runs are in the
|
|
36
|
+
* ratio 1:1:3:1:1. That is true horizontally, vertically and diagonally, it is
|
|
37
|
+
* scale-independent, and no ordinary printed matter reproduces it by accident.
|
|
38
|
+
* So the search is: scan rows for that ratio, confirm each hit by scanning the
|
|
39
|
+
* column through it, cluster what survives, and look for three clusters
|
|
40
|
+
* arranged in a right isoceles triangle.
|
|
41
|
+
*
|
|
42
|
+
* Three finders give three corners. The fourth is the problem: extrapolating
|
|
43
|
+
* `topRight + bottomLeft - topLeft` assumes the symbol is a parallelogram,
|
|
44
|
+
* which is only true if it was photographed square-on. For version 2 and up the
|
|
45
|
+
* bottom-right alignment pattern pins that corner down properly, which is what
|
|
46
|
+
* makes a tilted symbol readable. When the alignment pattern cannot be found,
|
|
47
|
+
* detection degrades to the parallelogram estimate rather than failing — a
|
|
48
|
+
* slightly wrong corner still decodes on a flat image, and Reed-Solomon absorbs
|
|
49
|
+
* the rest.
|
|
50
|
+
*
|
|
51
|
+
* @module qr/detector
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
import { NotFoundError } from '../core/errors.js';
|
|
55
|
+
import { PerspectiveTransform } from '../image/perspective.js';
|
|
56
|
+
import { sampleQuad } from '../image/grid-sampler.js';
|
|
57
|
+
import { decodeQR } from './decoder.js';
|
|
58
|
+
|
|
59
|
+
/** Finder pattern run ratios, centre run first in the array's own order. */
|
|
60
|
+
const FINDER_RATIOS = [1, 1, 3, 1, 1];
|
|
61
|
+
|
|
62
|
+
/** Alignment pattern run ratios. */
|
|
63
|
+
const ALIGNMENT_RATIOS = [1, 1, 1, 1, 1];
|
|
64
|
+
|
|
65
|
+
/** Smallest and largest legal symbol dimensions, in modules. */
|
|
66
|
+
const MIN_DIMENSION = 21;
|
|
67
|
+
const MAX_DIMENSION = 177;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Do five alternating runs match the expected ratios?
|
|
71
|
+
*
|
|
72
|
+
* Tolerance is half a module, scaled by the ratio, which is the widest band
|
|
73
|
+
* that still rejects ordinary text and rules while accepting the blur and
|
|
74
|
+
* rounding of a real scan.
|
|
75
|
+
*
|
|
76
|
+
* @param {number[]} counts Five run lengths, dark first.
|
|
77
|
+
* @param {number[]} ratios
|
|
78
|
+
* @returns {number} The implied module size, or 0 if the ratios do not match.
|
|
79
|
+
*/
|
|
80
|
+
function matchRatios(counts, ratios) {
|
|
81
|
+
let total = 0;
|
|
82
|
+
let units = 0;
|
|
83
|
+
for (let i = 0; i < 5; i++) {
|
|
84
|
+
if (counts[i] === 0) return 0;
|
|
85
|
+
total += counts[i];
|
|
86
|
+
units += ratios[i];
|
|
87
|
+
}
|
|
88
|
+
if (total < units) return 0;
|
|
89
|
+
|
|
90
|
+
const moduleSize = total / units;
|
|
91
|
+
const tolerance = moduleSize / 2;
|
|
92
|
+
for (let i = 0; i < 5; i++) {
|
|
93
|
+
if (Math.abs(counts[i] - moduleSize * ratios[i]) > tolerance * ratios[i]) return 0;
|
|
94
|
+
}
|
|
95
|
+
return moduleSize;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Scan one row for the finder run ratio.
|
|
100
|
+
*
|
|
101
|
+
* @param {import('../core/bit-matrix.js').BitMatrix} image
|
|
102
|
+
* @param {number} y
|
|
103
|
+
* @param {number[]} ratios
|
|
104
|
+
* @param {(centreX: number, moduleSize: number) => void} onHit
|
|
105
|
+
*/
|
|
106
|
+
function scanRow(image, y, ratios, onHit) {
|
|
107
|
+
const width = image.width;
|
|
108
|
+
const counts = [0, 0, 0, 0, 0];
|
|
109
|
+
let state = 0;
|
|
110
|
+
|
|
111
|
+
for (let x = 0; x < width; x++) {
|
|
112
|
+
const dark = image.get(x, y);
|
|
113
|
+
|
|
114
|
+
// Even states are dark runs, odd states light.
|
|
115
|
+
if (dark === ((state & 1) === 0)) {
|
|
116
|
+
counts[state]++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A leading light margin is not part of any pattern.
|
|
121
|
+
if (state === 0 && counts[0] === 0) continue;
|
|
122
|
+
|
|
123
|
+
if (state < 4) {
|
|
124
|
+
state++;
|
|
125
|
+
counts[state] = 1;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Five runs complete, and the sixth has begun.
|
|
130
|
+
const moduleSize = matchRatios(counts, ratios);
|
|
131
|
+
if (moduleSize > 0) {
|
|
132
|
+
onHit(x - counts[4] - counts[3] - counts[2] / 2, moduleSize);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Slide the window on by two runs: the trailing dark run of a rejected
|
|
136
|
+
// candidate is often the leading dark run of the real one.
|
|
137
|
+
counts[0] = counts[2];
|
|
138
|
+
counts[1] = counts[3];
|
|
139
|
+
counts[2] = counts[4];
|
|
140
|
+
counts[3] = 1;
|
|
141
|
+
counts[4] = 0;
|
|
142
|
+
state = 3;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (state === 4) {
|
|
146
|
+
const moduleSize = matchRatios(counts, ratios);
|
|
147
|
+
if (moduleSize > 0) {
|
|
148
|
+
onHit(width - counts[4] - counts[3] - counts[2] / 2, moduleSize);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Walk a line through a candidate centre and confirm the ratio holds there too.
|
|
155
|
+
*
|
|
156
|
+
* @param {import('../core/bit-matrix.js').BitMatrix} image
|
|
157
|
+
* @param {number} x @param {number} y
|
|
158
|
+
* @param {number} dx @param {number} dy Unit step defining the line.
|
|
159
|
+
* @param {number[]} ratios
|
|
160
|
+
* @param {number} maxRun Guard against running the length of a dark image.
|
|
161
|
+
* @returns {number} Refined centre offset along the line, or NaN.
|
|
162
|
+
*/
|
|
163
|
+
function crossCheck(image, x, y, dx, dy, ratios, maxRun) {
|
|
164
|
+
const width = image.width;
|
|
165
|
+
const height = image.height;
|
|
166
|
+
|
|
167
|
+
/** @returns {boolean | null} Null when the sample falls outside the image. */
|
|
168
|
+
const at = (i) => {
|
|
169
|
+
const px = x + dx * i;
|
|
170
|
+
const py = y + dy * i;
|
|
171
|
+
if (px < 0 || py < 0 || px >= width || py >= height) return null;
|
|
172
|
+
return image.get(px, py);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (at(0) !== true) return NaN;
|
|
176
|
+
|
|
177
|
+
const counts = [0, 0, 0, 0, 0];
|
|
178
|
+
let i = 0;
|
|
179
|
+
|
|
180
|
+
// Forward from the centre: rest of the centre run, then light, then dark.
|
|
181
|
+
while (at(i) === true && counts[2] < maxRun) { counts[2]++; i++; }
|
|
182
|
+
if (at(i) === null) return NaN;
|
|
183
|
+
const centreForward = counts[2];
|
|
184
|
+
|
|
185
|
+
while (at(i) === false && counts[3] < maxRun) { counts[3]++; i++; }
|
|
186
|
+
if (at(i) === null || counts[3] === 0) return NaN;
|
|
187
|
+
|
|
188
|
+
while (at(i) === true && counts[4] < maxRun) { counts[4]++; i++; }
|
|
189
|
+
if (counts[4] === 0) return NaN;
|
|
190
|
+
|
|
191
|
+
// Backward from the centre.
|
|
192
|
+
i = -1;
|
|
193
|
+
while (at(i) === true && counts[2] < maxRun * 2) { counts[2]++; i--; }
|
|
194
|
+
if (at(i) === null) return NaN;
|
|
195
|
+
const centreBackward = counts[2] - centreForward;
|
|
196
|
+
|
|
197
|
+
while (at(i) === false && counts[1] < maxRun) { counts[1]++; i--; }
|
|
198
|
+
if (at(i) === null || counts[1] === 0) return NaN;
|
|
199
|
+
|
|
200
|
+
while (at(i) === true && counts[0] < maxRun) { counts[0]++; i--; }
|
|
201
|
+
if (counts[0] === 0) return NaN;
|
|
202
|
+
|
|
203
|
+
if (matchRatios(counts, ratios) === 0) return NaN;
|
|
204
|
+
|
|
205
|
+
// The centre run spans offsets [-centreBackward, centreForward - 1].
|
|
206
|
+
return (centreForward - 1 - centreBackward) / 2;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* @typedef {object} Candidate
|
|
211
|
+
* @property {number} x
|
|
212
|
+
* @property {number} y
|
|
213
|
+
* @property {number} moduleSize
|
|
214
|
+
* @property {number} hits
|
|
215
|
+
*/
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Locate pattern centres of a given run ratio.
|
|
219
|
+
*
|
|
220
|
+
* @param {import('../core/bit-matrix.js').BitMatrix} image
|
|
221
|
+
* @param {number[]} ratios
|
|
222
|
+
* @param {number} unitsWide Modules the pattern spans (7 or 5).
|
|
223
|
+
* @param {{x0: number, y0: number, x1: number, y1: number}} [region]
|
|
224
|
+
* @returns {Candidate[]}
|
|
225
|
+
*/
|
|
226
|
+
function findPatterns(image, ratios, unitsWide, region) {
|
|
227
|
+
/** @type {Candidate[]} */
|
|
228
|
+
const found = [];
|
|
229
|
+
|
|
230
|
+
const y0 = region ? Math.max(0, region.y0) : 0;
|
|
231
|
+
const y1 = region ? Math.min(image.height, region.y1) : image.height;
|
|
232
|
+
|
|
233
|
+
for (let y = y0; y < y1; y++) {
|
|
234
|
+
scanRow(image, y, ratios, (centreX, moduleSize) => {
|
|
235
|
+
if (region && (centreX < region.x0 || centreX > region.x1)) return;
|
|
236
|
+
|
|
237
|
+
const px = Math.floor(centreX);
|
|
238
|
+
const maxRun = Math.ceil(moduleSize * unitsWide);
|
|
239
|
+
const offsetY = crossCheck(image, px, y, 0, 1, ratios, maxRun);
|
|
240
|
+
if (Number.isNaN(offsetY)) return;
|
|
241
|
+
|
|
242
|
+
const cy = y + offsetY;
|
|
243
|
+
// Re-check horizontally at the refined row, which both confirms the hit
|
|
244
|
+
// and gives a better x than the original scan line did.
|
|
245
|
+
const offsetX = crossCheck(image, px, Math.round(cy), 1, 0, ratios, maxRun);
|
|
246
|
+
if (Number.isNaN(offsetX)) return;
|
|
247
|
+
|
|
248
|
+
const cx = px + offsetX;
|
|
249
|
+
|
|
250
|
+
// Merge with an existing centre when they describe the same pattern.
|
|
251
|
+
for (let i = 0; i < found.length; i++) {
|
|
252
|
+
const c = found[i];
|
|
253
|
+
if (
|
|
254
|
+
Math.abs(c.x - cx) <= c.moduleSize &&
|
|
255
|
+
Math.abs(c.y - cy) <= c.moduleSize &&
|
|
256
|
+
Math.abs(c.moduleSize - moduleSize) <= Math.max(1, c.moduleSize / 2)
|
|
257
|
+
) {
|
|
258
|
+
const n = c.hits + 1;
|
|
259
|
+
c.x = (c.x * c.hits + cx) / n;
|
|
260
|
+
c.y = (c.y * c.hits + cy) / n;
|
|
261
|
+
c.moduleSize = (c.moduleSize * c.hits + moduleSize) / n;
|
|
262
|
+
c.hits = n;
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
found.push({ x: cx, y: cy, moduleSize, hits: 1 });
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return found;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* @param {{x: number, y: number}} a @param {{x: number, y: number}} b
|
|
276
|
+
* @returns {number}
|
|
277
|
+
*/
|
|
278
|
+
function distance(a, b) {
|
|
279
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Order three finder centres as top-left, top-right, bottom-left.
|
|
284
|
+
*
|
|
285
|
+
* The corner is the centre opposite the longest side. Which of the remaining
|
|
286
|
+
* two is "top right" follows from the sign of the cross product: in image
|
|
287
|
+
* coordinates, with y increasing downward, a symbol the right way round has
|
|
288
|
+
* (topRight - topLeft) x (bottomLeft - topLeft) positive.
|
|
289
|
+
*
|
|
290
|
+
* @param {Candidate[]} three
|
|
291
|
+
* @returns {{tl: Candidate, tr: Candidate, bl: Candidate} | null}
|
|
292
|
+
*/
|
|
293
|
+
function orientFinders(three) {
|
|
294
|
+
const [a, b, c] = three;
|
|
295
|
+
const ab = distance(a, b);
|
|
296
|
+
const bc = distance(b, c);
|
|
297
|
+
const ca = distance(c, a);
|
|
298
|
+
|
|
299
|
+
let tl, p, q, hypotenuse, leg1, leg2;
|
|
300
|
+
if (ab >= bc && ab >= ca) {
|
|
301
|
+
tl = c; p = a; q = b; hypotenuse = ab; leg1 = ca; leg2 = bc;
|
|
302
|
+
} else if (bc >= ab && bc >= ca) {
|
|
303
|
+
tl = a; p = b; q = c; hypotenuse = bc; leg1 = ab; leg2 = ca;
|
|
304
|
+
} else {
|
|
305
|
+
tl = b; p = c; q = a; hypotenuse = ca; leg1 = bc; leg2 = ab;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (leg1 === 0 || leg2 === 0) return null;
|
|
309
|
+
|
|
310
|
+
// The two legs must be near enough equal, and Pythagoras must hold: this is
|
|
311
|
+
// what rejects three unrelated finder-lookalikes that happen to co-occur.
|
|
312
|
+
const ratio = leg1 / leg2;
|
|
313
|
+
if (ratio < 0.7 || ratio > 1.4) return null;
|
|
314
|
+
const expected = Math.hypot(leg1, leg2);
|
|
315
|
+
if (Math.abs(hypotenuse - expected) > expected * 0.25) return null;
|
|
316
|
+
|
|
317
|
+
const cross = (p.x - tl.x) * (q.y - tl.y) - (p.y - tl.y) * (q.x - tl.x);
|
|
318
|
+
return cross >= 0 ? { tl, tr: p, bl: q } : { tl, tr: q, bl: p };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Snap a measured dimension to a legal symbol size.
|
|
323
|
+
*
|
|
324
|
+
* Every QR dimension is 17 + 4v, so `dimension % 4 === 1`. A measurement one
|
|
325
|
+
* off is rounding; two off means the module size estimate is wrong and the
|
|
326
|
+
* candidate is not worth pursuing.
|
|
327
|
+
*
|
|
328
|
+
* @param {number} raw
|
|
329
|
+
* @returns {number} 0 if it cannot be reconciled.
|
|
330
|
+
*/
|
|
331
|
+
function snapDimension(raw) {
|
|
332
|
+
let d = Math.round(raw);
|
|
333
|
+
switch (d & 3) {
|
|
334
|
+
case 0: d--; break;
|
|
335
|
+
case 2: d++; break;
|
|
336
|
+
case 3: return 0;
|
|
337
|
+
default: break;
|
|
338
|
+
}
|
|
339
|
+
if (d < MIN_DIMENSION || d > MAX_DIMENSION) return 0;
|
|
340
|
+
return d;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The alignment pattern, as modules. 1 is dark. */
|
|
344
|
+
const ALIGNMENT_MODULES = [
|
|
345
|
+
[1, 1, 1, 1, 1],
|
|
346
|
+
[1, 0, 0, 0, 1],
|
|
347
|
+
[1, 0, 1, 0, 1],
|
|
348
|
+
[1, 0, 0, 0, 1],
|
|
349
|
+
[1, 1, 1, 1, 1],
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Confirm a candidate by reading the 5x5 module block it claims to be.
|
|
354
|
+
*
|
|
355
|
+
* The run-ratio scan alone is not enough here. A finder pattern's 1:1:3:1:1 is
|
|
356
|
+
* rare enough to stand on its own, but an alignment pattern's 1:1:1:1:1 occurs
|
|
357
|
+
* constantly in ordinary data modules, so an unverified match near the expected
|
|
358
|
+
* position is more likely to be payload than pattern — and a false match drags
|
|
359
|
+
* the fourth corner off by several modules, which is worse than having no
|
|
360
|
+
* alignment pattern at all.
|
|
361
|
+
*
|
|
362
|
+
* @param {import('../core/bit-matrix.js').BitMatrix} image
|
|
363
|
+
* @param {number} cx @param {number} cy @param {number} moduleSize
|
|
364
|
+
* @returns {boolean}
|
|
365
|
+
*/
|
|
366
|
+
function verifyAlignment(image, cx, cy, moduleSize) {
|
|
367
|
+
let good = 0;
|
|
368
|
+
for (let j = 0; j < 5; j++) {
|
|
369
|
+
for (let i = 0; i < 5; i++) {
|
|
370
|
+
const px = Math.round(cx + (i - 2) * moduleSize);
|
|
371
|
+
const py = Math.round(cy + (j - 2) * moduleSize);
|
|
372
|
+
if (px < 0 || py < 0 || px >= image.width || py >= image.height) return false;
|
|
373
|
+
if (image.get(px, py) === (ALIGNMENT_MODULES[j][i] === 1)) good++;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Allow two modules of slop for blur and sampling, but no more.
|
|
377
|
+
return good >= 23;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Look for the bottom-right alignment pattern near where the geometry predicts.
|
|
382
|
+
*
|
|
383
|
+
* @param {import('../core/bit-matrix.js').BitMatrix} image
|
|
384
|
+
* @param {{x: number, y: number}} expected
|
|
385
|
+
* @param {number} moduleSize
|
|
386
|
+
* @returns {{x: number, y: number} | null}
|
|
387
|
+
*/
|
|
388
|
+
function findAlignment(image, expected, moduleSize) {
|
|
389
|
+
// Three modules of slack. The parallelogram estimate is good to well under
|
|
390
|
+
// that on any symbol flat enough to decode, and a wider net only admits more
|
|
391
|
+
// data modules as candidates.
|
|
392
|
+
const radius = Math.max(3, Math.ceil(moduleSize * 3));
|
|
393
|
+
const region = {
|
|
394
|
+
x0: expected.x - radius,
|
|
395
|
+
x1: expected.x + radius,
|
|
396
|
+
y0: Math.floor(expected.y - radius),
|
|
397
|
+
y1: Math.ceil(expected.y + radius),
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const found = findPatterns(image, ALIGNMENT_RATIOS, 5, region);
|
|
401
|
+
|
|
402
|
+
let best = null;
|
|
403
|
+
let bestDistance = Infinity;
|
|
404
|
+
for (let i = 0; i < found.length; i++) {
|
|
405
|
+
// The alignment pattern is 5 modules across, so its implied module size
|
|
406
|
+
// should agree with the one the finders reported.
|
|
407
|
+
if (found[i].moduleSize > moduleSize * 1.5 || found[i].moduleSize < moduleSize / 1.5) continue;
|
|
408
|
+
if (!verifyAlignment(image, found[i].x, found[i].y, moduleSize)) continue;
|
|
409
|
+
const d = distance(found[i], expected);
|
|
410
|
+
if (d < bestDistance) {
|
|
411
|
+
bestDistance = d;
|
|
412
|
+
best = found[i];
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Last resort: the pattern is exactly where predicted but its runs were
|
|
417
|
+
// mangled by blur. Reading the modules directly still confirms it.
|
|
418
|
+
if (!best && verifyAlignment(image, expected.x, expected.y, moduleSize)) {
|
|
419
|
+
best = { x: expected.x, y: expected.y };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return best;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* @typedef {object} Detection
|
|
427
|
+
* @property {Array<{x: number, y: number}>} corners Outer corners of the
|
|
428
|
+
* symbol, ordered top-left, top-right, bottom-right, bottom-left.
|
|
429
|
+
* @property {number} dimension Modules per side.
|
|
430
|
+
* @property {number} version
|
|
431
|
+
* @property {number} moduleSize Estimated pixels per module.
|
|
432
|
+
* @property {boolean} alignmentFound
|
|
433
|
+
*/
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Find QR Code symbols in a binarized image.
|
|
437
|
+
*
|
|
438
|
+
* @param {import('../core/bit-matrix.js').BitMatrix} binaryImage Set bit = dark.
|
|
439
|
+
* @returns {Detection[]} Possibly empty; ordered by descending module size, so
|
|
440
|
+
* the most prominent symbol comes first.
|
|
441
|
+
*/
|
|
442
|
+
export function detectQR(binaryImage) {
|
|
443
|
+
if (!binaryImage || !binaryImage.width) {
|
|
444
|
+
throw new NotFoundError('detectQR: no image supplied');
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const finders = findPatterns(binaryImage, FINDER_RATIOS, 7);
|
|
448
|
+
// A single stray row hit is noise; a real finder is crossed many times.
|
|
449
|
+
const solid = finders.filter((f) => f.hits >= 2);
|
|
450
|
+
const pool = solid.length >= 3 ? solid : finders;
|
|
451
|
+
if (pool.length < 3) return [];
|
|
452
|
+
|
|
453
|
+
// Prefer the largest patterns, and cap the combinatorics on noisy images.
|
|
454
|
+
pool.sort((a, b) => b.moduleSize - a.moduleSize || b.hits - a.hits);
|
|
455
|
+
const limit = Math.min(pool.length, 12);
|
|
456
|
+
|
|
457
|
+
/** @type {Detection[]} */
|
|
458
|
+
const detections = [];
|
|
459
|
+
const used = new Set();
|
|
460
|
+
|
|
461
|
+
for (let i = 0; i < limit; i++) {
|
|
462
|
+
for (let j = i + 1; j < limit; j++) {
|
|
463
|
+
for (let k = j + 1; k < limit; k++) {
|
|
464
|
+
const three = [pool[i], pool[j], pool[k]];
|
|
465
|
+
|
|
466
|
+
// All three finders belong to one symbol, so they share a module size.
|
|
467
|
+
const sizes = three.map((f) => f.moduleSize);
|
|
468
|
+
if (Math.max(...sizes) > Math.min(...sizes) * 1.6) continue;
|
|
469
|
+
|
|
470
|
+
const oriented = orientFinders(three);
|
|
471
|
+
if (!oriented) continue;
|
|
472
|
+
|
|
473
|
+
const { tl, tr, bl } = oriented;
|
|
474
|
+
const moduleSize = (tl.moduleSize + tr.moduleSize + bl.moduleSize) / 3;
|
|
475
|
+
if (moduleSize <= 0) continue;
|
|
476
|
+
|
|
477
|
+
// Centre-to-centre spans dimension - 7 modules.
|
|
478
|
+
const across = (distance(tl, tr) + distance(tl, bl)) / 2;
|
|
479
|
+
const dimension = snapDimension(across / moduleSize + 7);
|
|
480
|
+
if (dimension === 0) continue;
|
|
481
|
+
|
|
482
|
+
const version = (dimension - 17) / 4;
|
|
483
|
+
if (version < 1 || version > 40) continue;
|
|
484
|
+
|
|
485
|
+
const key = `${Math.round(tl.x)},${Math.round(tl.y)},${dimension}`;
|
|
486
|
+
if (used.has(key)) continue;
|
|
487
|
+
used.add(key);
|
|
488
|
+
|
|
489
|
+
detections.push(
|
|
490
|
+
buildDetection(binaryImage, tl, tr, bl, dimension, version, moduleSize)
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
detections.sort((a, b) => b.moduleSize - a.moduleSize);
|
|
497
|
+
return detections;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Turn three finder centres into four symbol corners.
|
|
502
|
+
*
|
|
503
|
+
* @param {import('../core/bit-matrix.js').BitMatrix} image
|
|
504
|
+
* @param {Candidate} tl @param {Candidate} tr @param {Candidate} bl
|
|
505
|
+
* @param {number} dimension @param {number} version @param {number} moduleSize
|
|
506
|
+
* @returns {Detection}
|
|
507
|
+
*/
|
|
508
|
+
function buildDetection(image, tl, tr, bl, dimension, version, moduleSize) {
|
|
509
|
+
const d = dimension;
|
|
510
|
+
// Finder centres sit on module (3, 3) and friends, so at grid coordinate 3.5.
|
|
511
|
+
const gridTl = [3.5, 3.5];
|
|
512
|
+
const gridTr = [d - 3.5, 3.5];
|
|
513
|
+
const gridBl = [3.5, d - 3.5];
|
|
514
|
+
|
|
515
|
+
// Parallelogram estimate of the far corner, used both as the search seed for
|
|
516
|
+
// the alignment pattern and as the fallback when it is not there.
|
|
517
|
+
const guess = { x: tr.x + bl.x - tl.x, y: tr.y + bl.y - tl.y };
|
|
518
|
+
|
|
519
|
+
/** @type {PerspectiveTransform | null} */
|
|
520
|
+
let transform = null;
|
|
521
|
+
let alignmentFound = false;
|
|
522
|
+
|
|
523
|
+
if (version >= 2) {
|
|
524
|
+
// The bottom-right alignment pattern is centred on module (d - 7, d - 7).
|
|
525
|
+
const gridAlign = [d - 6.5, d - 6.5];
|
|
526
|
+
// Where that module lands under the parallelogram assumption.
|
|
527
|
+
const seed = {
|
|
528
|
+
x: tl.x + ((gridAlign[0] - 3.5) / (d - 7)) * (tr.x - tl.x) +
|
|
529
|
+
((gridAlign[1] - 3.5) / (d - 7)) * (bl.x - tl.x),
|
|
530
|
+
y: tl.y + ((gridAlign[0] - 3.5) / (d - 7)) * (tr.y - tl.y) +
|
|
531
|
+
((gridAlign[1] - 3.5) / (d - 7)) * (bl.y - tl.y),
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
const align = findAlignment(image, seed, moduleSize);
|
|
535
|
+
if (align) {
|
|
536
|
+
alignmentFound = true;
|
|
537
|
+
transform = PerspectiveTransform.quadToQuad(
|
|
538
|
+
gridTl[0], gridTl[1], gridTr[0], gridTr[1], gridAlign[0], gridAlign[1], gridBl[0], gridBl[1],
|
|
539
|
+
tl.x, tl.y, tr.x, tr.y, align.x, align.y, bl.x, bl.y
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const plain = PerspectiveTransform.quadToQuad(
|
|
545
|
+
gridTl[0], gridTl[1], gridTr[0], gridTr[1], d - 3.5, d - 3.5, gridBl[0], gridBl[1],
|
|
546
|
+
tl.x, tl.y, tr.x, tr.y, guess.x, guess.y, bl.x, bl.y
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const corners = cornersOf(transform ?? plain, d);
|
|
550
|
+
// Keep the parallelogram corners as a second opinion whenever an alignment
|
|
551
|
+
// pattern steered the first set. Verification makes a false match unlikely,
|
|
552
|
+
// not impossible, and one extra sampling attempt is far cheaper than losing
|
|
553
|
+
// a symbol to it.
|
|
554
|
+
const altCorners = transform ? cornersOf(plain, d) : null;
|
|
555
|
+
|
|
556
|
+
return { corners, altCorners, dimension, version, moduleSize, alignmentFound };
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* The four outer corners of the symbol under a grid-to-image transform.
|
|
561
|
+
*
|
|
562
|
+
* @param {PerspectiveTransform} transform @param {number} d
|
|
563
|
+
* @returns {Array<{x: number, y: number}>}
|
|
564
|
+
*/
|
|
565
|
+
function cornersOf(transform, d) {
|
|
566
|
+
return [
|
|
567
|
+
transform.transformPoint(0, 0),
|
|
568
|
+
transform.transformPoint(d, 0),
|
|
569
|
+
transform.transformPoint(d, d),
|
|
570
|
+
transform.transformPoint(0, d),
|
|
571
|
+
];
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Find and decode every QR Code in a binarized image.
|
|
576
|
+
*
|
|
577
|
+
* Each candidate gets up to four attempts: a plain centre sample, a 3x3
|
|
578
|
+
* majority vote for noisy input, and both of those rotated 180 degrees. The
|
|
579
|
+
* rotation retry matters because three finders in a right isoceles triangle
|
|
580
|
+
* look identical to the same three rotated half a turn — the orientation is
|
|
581
|
+
* only settled once the format information reads cleanly, which is to say once
|
|
582
|
+
* the decode succeeds.
|
|
583
|
+
*
|
|
584
|
+
* @param {import('../core/bit-matrix.js').BitMatrix} binaryImage
|
|
585
|
+
* @returns {Array<import('./decoder.js').DecodeResult & {corners: Array<{x: number, y: number}>}>}
|
|
586
|
+
* Empty when nothing decodes; never throws for "no symbol here".
|
|
587
|
+
*/
|
|
588
|
+
export function detectAndDecodeQR(binaryImage) {
|
|
589
|
+
let detections;
|
|
590
|
+
try {
|
|
591
|
+
detections = detectQR(binaryImage);
|
|
592
|
+
} catch (e) {
|
|
593
|
+
return [];
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const results = [];
|
|
597
|
+
const seen = new Set();
|
|
598
|
+
|
|
599
|
+
for (let i = 0; i < detections.length; i++) {
|
|
600
|
+
const det = detections[i];
|
|
601
|
+
|
|
602
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
603
|
+
const voting = attempt === 1 || attempt === 3;
|
|
604
|
+
const rotated = attempt >= 2;
|
|
605
|
+
|
|
606
|
+
let matrix;
|
|
607
|
+
try {
|
|
608
|
+
matrix = sampleQuad(binaryImage, det.dimension, det.corners, voting);
|
|
609
|
+
} catch (e) {
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
if (rotated) matrix.rotate180();
|
|
613
|
+
|
|
614
|
+
try {
|
|
615
|
+
const result = decodeQR(matrix);
|
|
616
|
+
// The same symbol can be detected through more than one finder triple.
|
|
617
|
+
const key = `${result.version}|${result.text}`;
|
|
618
|
+
if (!seen.has(key)) {
|
|
619
|
+
seen.add(key);
|
|
620
|
+
results.push(Object.assign({ corners: det.corners }, result));
|
|
621
|
+
}
|
|
622
|
+
break;
|
|
623
|
+
} catch (e) {
|
|
624
|
+
/* Try the next sampling strategy. */
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return results;
|
|
630
|
+
}
|