qr 0.5.4 → 0.6.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/README.md +43 -36
- package/decode.d.ts +100 -13
- package/decode.d.ts.map +1 -1
- package/decode.js +623 -153
- package/decode.js.map +1 -1
- package/dom.d.ts +110 -13
- package/dom.d.ts.map +1 -1
- package/dom.js +138 -18
- package/dom.js.map +1 -1
- package/index.d.ts +175 -35
- package/index.d.ts.map +1 -1
- package/index.js +276 -83
- package/index.js.map +1 -1
- package/package.json +8 -4
- package/src/decode.ts +648 -177
- package/src/dom.ts +169 -31
- package/src/index.ts +500 -129
package/src/decode.ts
CHANGED
|
@@ -17,30 +17,43 @@ limitations under the License.
|
|
|
17
17
|
/**
|
|
18
18
|
* Methods for decoding (reading) QR code patterns.
|
|
19
19
|
* @module
|
|
20
|
-
* @example
|
|
21
|
-
```js
|
|
22
|
-
|
|
23
|
-
```
|
|
24
20
|
*/
|
|
25
21
|
|
|
26
|
-
import type { EncodingType, ErrorCorrection, Image, Mask, Point } from './index.ts';
|
|
22
|
+
import type { EncodingType, ErrorCorrection, Image, Mask, Point, TArg, TRet } from './index.ts';
|
|
27
23
|
import { Bitmap, utils } from './index.ts';
|
|
28
|
-
const { best, bin, drawTemplate, fillArr, info, interleave, validateVersion, zigzag, popcnt } =
|
|
29
|
-
utils;
|
|
30
24
|
|
|
31
25
|
// Constants
|
|
32
26
|
const MAX_BITS_ERROR = 3; // Up to 3 bit errors in version/format
|
|
27
|
+
// Kept at 8: the block-stat fast path reads two u32 words per row and the
|
|
28
|
+
// average uses `sum >>> 6`, so the current binarizer assumes 8x8 = 64 pixels.
|
|
33
29
|
const GRAYSCALE_BLOCK_SIZE = 8;
|
|
34
30
|
const GRAYSCALE_RANGE = 24;
|
|
35
31
|
const PATTERN_VARIANCE = 2;
|
|
32
|
+
// Diagonal finder scans are noisier under blur/perspective than horizontal or
|
|
33
|
+
// vertical runs, so they use a looser ratio tolerance.
|
|
36
34
|
const PATTERN_VARIANCE_DIAGONAL = 1.333;
|
|
37
35
|
const PATTERN_MIN_CONFIRMATIONS = 2;
|
|
38
36
|
const DETECT_MIN_ROW_SKIP = 3;
|
|
37
|
+
// Pair LUTs for the 8x8 block-stat fast path: each 16-bit lane holds two
|
|
38
|
+
// brightness bytes, so we can accumulate sum/min/max four pixels at a time.
|
|
39
|
+
const SUM16 = new Uint16Array(1 << 16);
|
|
40
|
+
const MIN16 = new Uint8Array(1 << 16);
|
|
41
|
+
const MAX16 = new Uint8Array(1 << 16);
|
|
42
|
+
for (let i = 0; i < SUM16.length; i++) {
|
|
43
|
+
const lo = i & 0xff;
|
|
44
|
+
const hi = i >>> 8;
|
|
45
|
+
SUM16[i] = lo + hi;
|
|
46
|
+
MIN16[i] = lo < hi ? lo : hi;
|
|
47
|
+
MAX16[i] = lo > hi ? lo : hi;
|
|
48
|
+
}
|
|
39
49
|
|
|
40
50
|
// TODO: move to index, nearby with bitmap and other graph related stuff?
|
|
51
|
+
// Fast truncation for values expected to be non-negative; negatives would wrap
|
|
52
|
+
// through uint32 because this uses `>>> 0`, not `Math.floor()`.
|
|
41
53
|
const int = (n: number) => n >>> 0;
|
|
42
54
|
|
|
43
55
|
type Point4 = [Point, Point, Point, Point];
|
|
56
|
+
/** Finder and alignment points returned by the detector. */
|
|
44
57
|
export type FinderPoints = [Pattern, Pattern, Point, Pattern];
|
|
45
58
|
// distance ^ 2
|
|
46
59
|
const distance2 = (p1: Point, p2: Point) => {
|
|
@@ -59,36 +72,142 @@ const pointMirror = (p: Point) => ({ x: p.y, y: p.x });
|
|
|
59
72
|
const pointClone = (p: Point) => ({ x: p.x, y: p.y });
|
|
60
73
|
const pointInt = (p: Point) => ({ x: int(p.x), y: int(p.y) });
|
|
61
74
|
const pointAdd = (a: Point, b: Point) => ({ x: a.x + b.x, y: a.y + b.y });
|
|
62
|
-
|
|
63
|
-
|
|
75
|
+
// Count trailing zeroes in a packed bitmap word so scanLine can skip whole
|
|
76
|
+
// runs instead of testing one bit at a time.
|
|
77
|
+
const ctz32 = (v: number) => {
|
|
78
|
+
v = v >>> 0;
|
|
79
|
+
if (v === 0) return 32;
|
|
80
|
+
return 31 - Math.clz32((v & -v) >>> 0);
|
|
81
|
+
};
|
|
82
|
+
function cap(value: number, min?: number, max?: number): number {
|
|
83
|
+
// ISO/IEC 18004:2024 §12 h) builds the sampling grid from "module centres";
|
|
84
|
+
// detector callers pass `0` as a real image edge when clipping those samples
|
|
85
|
+
// and search windows. `|| value` treats that bound as absent.
|
|
86
|
+
let res = value;
|
|
87
|
+
if (max !== undefined) res = Math.min(res, max);
|
|
88
|
+
if (min !== undefined) res = Math.max(res, min);
|
|
89
|
+
return res;
|
|
64
90
|
}
|
|
65
|
-
|
|
66
|
-
const
|
|
91
|
+
function getBytesPerPixel(img: TArg<Image>): number {
|
|
92
|
+
const image = img as Image;
|
|
93
|
+
const perPixel = image.data.length / (image.width * image.height);
|
|
67
94
|
if (perPixel === 3 || perPixel === 4) return perPixel; // RGB or RGBA
|
|
68
95
|
throw new Error(`Unknown image format, bytes per pixel=${perPixel}`);
|
|
69
|
-
}
|
|
70
|
-
|
|
96
|
+
}
|
|
97
|
+
function isBytes(data: unknown): data is Uint8Array {
|
|
98
|
+
return data instanceof Uint8Array || data instanceof Uint8ClampedArray;
|
|
99
|
+
}
|
|
71
100
|
/**
|
|
72
101
|
* Convert to grayscale. The function is the most expensive part of decoding:
|
|
73
|
-
* it takes up to 90% of time.
|
|
102
|
+
* it takes up to 90% of time.
|
|
103
|
+
*
|
|
104
|
+
* Binarization pipeline:
|
|
105
|
+
* 1. Convert RGB/RGBA image to one luma byte per pixel.
|
|
106
|
+
* 2. Split the image into 8x8 blocks and collect per-block mean/min/max.
|
|
107
|
+
* 3. Build a 5x5 neighborhood mean over those block means.
|
|
108
|
+
* 4. Turn each 8x8 block into bitmap bits using a local cut derived from:
|
|
109
|
+
* - the neighborhood mean,
|
|
110
|
+
* - the current block statistics,
|
|
111
|
+
* - a cheap whole-image color-spread estimate,
|
|
112
|
+
* - and, on risky scenes, a local variance field over block means.
|
|
113
|
+
*
|
|
114
|
+
* Instead of producing "best looking" thresholding: we produce a
|
|
115
|
+
* bitmap where finder patterns survive perspective / blur / highlights while
|
|
116
|
+
* keeping false dark regions low enough for downstream finder selection.
|
|
74
117
|
*/
|
|
75
|
-
function toBitmap(img: Image): Bitmap {
|
|
118
|
+
function toBitmap(img: TArg<Image>): TRet<Bitmap> {
|
|
119
|
+
const image = img as Image;
|
|
120
|
+
const width = image.width;
|
|
121
|
+
const height = image.height;
|
|
122
|
+
const data = image.data;
|
|
76
123
|
const bytesPerPixel = getBytesPerPixel(img);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
124
|
+
const pixLen = height * width;
|
|
125
|
+
const brightness = new Uint8Array(pixLen);
|
|
126
|
+
if (bytesPerPixel === 4 && isBytes(data) && (data.byteOffset & 3) === 0) {
|
|
127
|
+
// Little-endian RGBA: compute four grayscale bytes and commit as one u32 store.
|
|
128
|
+
// Unaligned RGBA subarray views are still valid inputs; they fall back to
|
|
129
|
+
// the scalar path because Uint32Array would throw on a misaligned offset.
|
|
130
|
+
const pixels = new Uint32Array(data.buffer, data.byteOffset, pixLen);
|
|
131
|
+
const bright32 = new Uint32Array(
|
|
132
|
+
brightness.buffer,
|
|
133
|
+
brightness.byteOffset,
|
|
134
|
+
brightness.length >>> 2
|
|
135
|
+
);
|
|
136
|
+
const n4 = pixels.length & ~3;
|
|
137
|
+
for (let i = 0, j = 0; i < n4; i += 4, j++) {
|
|
138
|
+
const v0 = pixels[i] >>> 0;
|
|
139
|
+
const v1 = pixels[i + 1] >>> 0;
|
|
140
|
+
const v2 = pixels[i + 2] >>> 0;
|
|
141
|
+
const v3 = pixels[i + 3] >>> 0;
|
|
142
|
+
// RGBA words are little-endian here, so this is `(r + 2*g + b) / 4`
|
|
143
|
+
// computed from the packed byte lanes for four pixels at once.
|
|
144
|
+
const b0 = ((v0 & 0xff) + (((v0 >>> 8) & 0xff) << 1) + ((v0 >>> 16) & 0xff)) >>> 2;
|
|
145
|
+
const b1 = ((v1 & 0xff) + (((v1 >>> 8) & 0xff) << 1) + ((v1 >>> 16) & 0xff)) >>> 2;
|
|
146
|
+
const b2 = ((v2 & 0xff) + (((v2 >>> 8) & 0xff) << 1) + ((v2 >>> 16) & 0xff)) >>> 2;
|
|
147
|
+
const b3 = ((v3 & 0xff) + (((v3 >>> 8) & 0xff) << 1) + ((v3 >>> 16) & 0xff)) >>> 2;
|
|
148
|
+
bright32[j] = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
|
|
149
|
+
}
|
|
150
|
+
for (let i = n4; i < pixels.length; i++) {
|
|
151
|
+
const v = pixels[i] >>> 0;
|
|
152
|
+
brightness[i] = ((v & 0xff) + (((v >>> 8) & 0xff) << 1) + ((v >>> 16) & 0xff)) >>> 2;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
for (let i = 0, j = 0, d = data; i < d.length; i += bytesPerPixel) {
|
|
156
|
+
const r = d[i];
|
|
157
|
+
const g = d[i + 1];
|
|
158
|
+
const b = d[i + 2];
|
|
159
|
+
brightness[j++] = int((r + 2 * g + b) / 4) & 0xff;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Sampled color spread is a cheap "scene type" signal:
|
|
163
|
+
// grayscale / flat lighting scenes want conservative cuts, while colorful or
|
|
164
|
+
// high-spread scenes benefit from a slightly darker threshold.
|
|
165
|
+
let spreadSum = 0;
|
|
166
|
+
let spreadCnt = 0;
|
|
167
|
+
const spreadStep = bytesPerPixel * 16;
|
|
168
|
+
for (let i = 0; i < data.length; i += spreadStep) {
|
|
169
|
+
const r = data[i];
|
|
170
|
+
const g = data[i + 1];
|
|
171
|
+
const b = data[i + 2];
|
|
172
|
+
// hi=max(r,g,b), lo=min(r,g,b): this sampled channel spread is a cheap
|
|
173
|
+
// scene-level proxy for "how colorful / highlighty is this frame?".
|
|
174
|
+
const hi = r > g ? (r > b ? r : b) : g > b ? g : b;
|
|
175
|
+
const lo = r < g ? (r < b ? r : b) : g < b ? g : b;
|
|
176
|
+
spreadSum += hi - lo;
|
|
177
|
+
spreadCnt++;
|
|
83
178
|
}
|
|
179
|
+
const spreadMean = spreadSum / spreadCnt;
|
|
84
180
|
// Convert to bitmap
|
|
85
181
|
const block = GRAYSCALE_BLOCK_SIZE;
|
|
86
|
-
if (
|
|
87
|
-
const bWidth = Math.ceil(
|
|
88
|
-
const bHeight = Math.ceil(
|
|
89
|
-
const maxY =
|
|
90
|
-
const maxX =
|
|
91
|
-
const
|
|
182
|
+
if (width < block * 5 || height < block * 5) throw new Error('image too small');
|
|
183
|
+
const bWidth = Math.ceil(width / block);
|
|
184
|
+
const bHeight = Math.ceil(height / block);
|
|
185
|
+
const maxY = height - block;
|
|
186
|
+
const maxX = width - block;
|
|
187
|
+
const blockLen = bWidth * bHeight;
|
|
188
|
+
const blockState = new Uint32Array(blockLen);
|
|
189
|
+
// Each 8x8 block stores packed:
|
|
190
|
+
// - bits 0..7: block baseline brightness used by the threshold field
|
|
191
|
+
// - bits 8..15: block min
|
|
192
|
+
// - bits 16..23: block max
|
|
193
|
+
let hiRangeCnt = 0;
|
|
194
|
+
let veryLowCnt = 0;
|
|
195
|
+
const padW = (width + 3) & ~3;
|
|
196
|
+
let statStride = width;
|
|
197
|
+
let stat32: Uint32Array;
|
|
198
|
+
if ((width & 3) !== 0) {
|
|
199
|
+
const padLen = padW * height;
|
|
200
|
+
const brightPad = new Uint8Array(padLen);
|
|
201
|
+
for (let y = 0; y < height; y++) {
|
|
202
|
+
const src = y * width;
|
|
203
|
+
const dst = y * padW;
|
|
204
|
+
brightPad.set(brightness.subarray(src, src + width), dst);
|
|
205
|
+
}
|
|
206
|
+
// Misaligned widths are padded only for the block-stat fast path.
|
|
207
|
+
statStride = padW;
|
|
208
|
+
stat32 = new Uint32Array(brightPad.buffer, brightPad.byteOffset, (padW * height) >>> 2);
|
|
209
|
+
} else
|
|
210
|
+
stat32 = new Uint32Array(brightness.buffer, brightness.byteOffset, brightness.length >>> 2);
|
|
92
211
|
for (let y = 0; y < bHeight; y++) {
|
|
93
212
|
const yPos = cap(y * block, 0, maxY);
|
|
94
213
|
for (let x = 0; x < bWidth; x++) {
|
|
@@ -96,54 +215,222 @@ function toBitmap(img: Image): Bitmap {
|
|
|
96
215
|
let sum = 0;
|
|
97
216
|
let min = 0xff;
|
|
98
217
|
let max = 0;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
218
|
+
// The stat-LUT fast path needs the 8-pixel row start to be 32-bit aligned
|
|
219
|
+
// so each row can be read as two full u32 words without any shifts.
|
|
220
|
+
if ((xPos & 3) === 0) {
|
|
221
|
+
for (let yy = 0, pos = yPos * statStride + xPos; yy < block; yy++, pos += statStride) {
|
|
222
|
+
const p = pos >>> 2;
|
|
223
|
+
const w0 = stat32[p] >>> 0;
|
|
224
|
+
const w1 = stat32[p + 1] >>> 0;
|
|
225
|
+
const a0 = w0 & 0xffff;
|
|
226
|
+
const a1 = w0 >>> 16;
|
|
227
|
+
const b0 = w1 & 0xffff;
|
|
228
|
+
const b1 = w1 >>> 16;
|
|
229
|
+
sum += SUM16[a0] + SUM16[a1] + SUM16[b0] + SUM16[b1];
|
|
230
|
+
const min0 = MIN16[a0];
|
|
231
|
+
const min1 = MIN16[a1];
|
|
232
|
+
const min2 = MIN16[b0];
|
|
233
|
+
const min3 = MIN16[b1];
|
|
234
|
+
if (min0 < min) min = min0;
|
|
235
|
+
if (min1 < min) min = min1;
|
|
236
|
+
if (min2 < min) min = min2;
|
|
237
|
+
if (min3 < min) min = min3;
|
|
238
|
+
const max0 = MAX16[a0];
|
|
239
|
+
const max1 = MAX16[a1];
|
|
240
|
+
const max2 = MAX16[b0];
|
|
241
|
+
const max3 = MAX16[b1];
|
|
242
|
+
if (max0 > max) max = max0;
|
|
243
|
+
if (max1 > max) max = max1;
|
|
244
|
+
if (max2 > max) max = max2;
|
|
245
|
+
if (max3 > max) max = max3;
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
for (let yy = 0, pos = yPos * width + xPos; yy < block; yy++, pos += width) {
|
|
249
|
+
for (let xx = 0; xx < block; xx++) {
|
|
250
|
+
const pixel = brightness[pos + xx];
|
|
251
|
+
sum += pixel;
|
|
252
|
+
if (pixel < min) min = pixel;
|
|
253
|
+
if (pixel > max) max = pixel;
|
|
254
|
+
}
|
|
109
255
|
}
|
|
110
256
|
}
|
|
257
|
+
const bIdx = bWidth * y + x;
|
|
258
|
+
const range = max - min;
|
|
111
259
|
// Average brightness of block
|
|
112
|
-
let average =
|
|
113
|
-
if (
|
|
260
|
+
let average = sum >>> 6;
|
|
261
|
+
if (range <= GRAYSCALE_RANGE) {
|
|
262
|
+
// Low-contrast blocks are unstable if we threshold from their raw mean.
|
|
263
|
+
// Bias toward the local dark floor, then smooth with already-seen
|
|
264
|
+
// neighbors so finder rings don't disappear in washed-out regions.
|
|
114
265
|
average = min / 2;
|
|
115
266
|
if (y > 0 && x > 0) {
|
|
116
267
|
const idx = (x: number, y: number) => y * bWidth + x;
|
|
117
|
-
const
|
|
118
|
-
(
|
|
119
|
-
|
|
268
|
+
const neighborNumerator =
|
|
269
|
+
(blockState[idx(x, y - 1)] & 0xff) +
|
|
270
|
+
2 * (blockState[idx(x - 1, y)] & 0xff) +
|
|
271
|
+
(blockState[idx(x - 1, y - 1)] & 0xff);
|
|
272
|
+
if (min * 4 < neighborNumerator) average = neighborNumerator / 4;
|
|
120
273
|
}
|
|
121
274
|
}
|
|
122
|
-
|
|
275
|
+
blockState[bIdx] = int(average) | (min << 8) | (max << 16);
|
|
276
|
+
if (range > 40 && average < 224) hiRangeCnt++;
|
|
277
|
+
if (range <= 10) veryLowCnt++;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const hiRangeFrac = hiRangeCnt / blockLen;
|
|
281
|
+
const veryLowFrac = veryLowCnt / blockLen;
|
|
282
|
+
// These two scene gates are the main "policy" layer on top of the local cut:
|
|
283
|
+
// - `spotBias` darkens globally flat, slightly colorful scenes that otherwise
|
|
284
|
+
// miss bright-spot / washed-out QR modules.
|
|
285
|
+
// - `useVarField` avoids paying the variance-field cost on scenes where the
|
|
286
|
+
// plain 5x5 mean is already stable enough.
|
|
287
|
+
const spotBias =
|
|
288
|
+
veryLowFrac > 0.55 &&
|
|
289
|
+
veryLowFrac < 0.66 &&
|
|
290
|
+
hiRangeFrac < 0.02 &&
|
|
291
|
+
spreadMean > 10 &&
|
|
292
|
+
spreadMean < 20
|
|
293
|
+
? -1
|
|
294
|
+
: 0;
|
|
295
|
+
const useVarField = veryLowFrac < 0.62 || spreadMean > 30;
|
|
296
|
+
const iWidth = bWidth + 1;
|
|
297
|
+
const iHeight = bHeight + 1;
|
|
298
|
+
const integLen = iHeight * iWidth;
|
|
299
|
+
// `integ` is the standard summed-area table of block means.
|
|
300
|
+
const integ = new Uint32Array(integLen);
|
|
301
|
+
// `integSqr` is the square-integral / summed-area table of `v * v` over the
|
|
302
|
+
// same block means, not a u8 pixel buffer. Those prefix sums can overflow
|
|
303
|
+
// 32-bit integer storage on large images, and Float32 was the measured
|
|
304
|
+
// faster compromise vs Float64 for this heuristic field.
|
|
305
|
+
const integSqr = useVarField ? new Float32Array(integLen) : undefined;
|
|
306
|
+
for (let y = 0; y < bHeight; y++) {
|
|
307
|
+
let rowSum = 0;
|
|
308
|
+
let rowSq = 0;
|
|
309
|
+
const bRow = y * bWidth;
|
|
310
|
+
const iRow = (y + 1) * iWidth;
|
|
311
|
+
const iPrev = y * iWidth;
|
|
312
|
+
for (let x = 0; x < bWidth; x++) {
|
|
313
|
+
const v = blockState[bRow + x] & 0xff;
|
|
314
|
+
rowSum += v;
|
|
315
|
+
if (integSqr) rowSq += v * v;
|
|
316
|
+
integ[iRow + x + 1] = integ[iPrev + x + 1] + rowSum;
|
|
317
|
+
if (integSqr) integSqr[iRow + x + 1] = integSqr[iPrev + x + 1] + rowSq;
|
|
123
318
|
}
|
|
124
319
|
}
|
|
125
|
-
const matrix = new Bitmap({ width
|
|
320
|
+
const matrix = new Bitmap({ width, height });
|
|
321
|
+
const rows = Math.ceil(width / 32);
|
|
322
|
+
// Decode intentionally writes the packed bitmap words directly here. The
|
|
323
|
+
// per-pixel Bitmap API is too expensive on this hot path, so this must stay
|
|
324
|
+
// in sync with Bitmap's internal `value` layout.
|
|
325
|
+
const bm = (matrix as unknown as { value: Uint32Array }).value;
|
|
326
|
+
const rad = 2;
|
|
327
|
+
const win = rad * 2 + 1;
|
|
328
|
+
const area = win * win;
|
|
126
329
|
for (let y = 0; y < bHeight; y++) {
|
|
127
330
|
const yPos = cap(y * block, 0, maxY);
|
|
128
|
-
const top = cap(y,
|
|
331
|
+
const top = cap(y, rad, bHeight - rad - 1);
|
|
332
|
+
const y0 = top - rad;
|
|
333
|
+
const y1 = top + rad;
|
|
334
|
+
const r0 = y0 * iWidth;
|
|
335
|
+
const r1 = (y1 + 1) * iWidth;
|
|
129
336
|
for (let x = 0; x < bWidth; x++) {
|
|
130
337
|
const xPos = cap(x * block, 0, maxX);
|
|
131
|
-
const
|
|
338
|
+
const shift = xPos & 31;
|
|
339
|
+
const col = xPos >>> 5;
|
|
340
|
+
const left = cap(x, rad, bWidth - rad - 1);
|
|
341
|
+
const x0 = left - rad;
|
|
342
|
+
const x1 = left + rad;
|
|
132
343
|
// 5x5 blocks average
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
344
|
+
const sum = integ[r1 + (x1 + 1)] - integ[r0 + (x1 + 1)] - integ[r1 + x0] + integ[r0 + x0];
|
|
345
|
+
// `average` is the coarse threshold surface: a 5x5 neighborhood mean of
|
|
346
|
+
// the 8x8 block means. The adjustments below decide when to move away
|
|
347
|
+
// from that surface for the current block.
|
|
348
|
+
const average = (sum / area) | 0;
|
|
349
|
+
let cut = average;
|
|
350
|
+
const bIdx = bWidth * y + x;
|
|
351
|
+
const blk = blockState[bIdx];
|
|
352
|
+
const blockAvg = blk & 0xff;
|
|
353
|
+
const min = (blk >>> 8) & 0xff;
|
|
354
|
+
const max = blk >>> 16;
|
|
355
|
+
const range = max - min;
|
|
356
|
+
if (average < min) continue;
|
|
357
|
+
if (average >= max) {
|
|
358
|
+
const m = 0xff;
|
|
359
|
+
for (let yy = 0, row = yPos * rows + col; yy < block; yy++, row += rows) {
|
|
360
|
+
const lo = (m << shift) >>> 0;
|
|
361
|
+
bm[row] |= lo;
|
|
362
|
+
if (shift > 24) bm[row + 1] |= m >>> (32 - shift);
|
|
142
363
|
}
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
// `localAdj`: nudge toward the current block when it is darker than
|
|
367
|
+
// its neighborhood. This helps preserve dark rings / modules that are
|
|
368
|
+
// locally meaningful but diluted by the 5x5 field.
|
|
369
|
+
let localAdj = (blockAvg - average) >> 4;
|
|
370
|
+
if (localAdj < 0) localAdj = 0;
|
|
371
|
+
if (localAdj > 1) localAdj = 1;
|
|
372
|
+
// `chromaAdj`: in colorful, mid-tone blocks, slight extra darkening
|
|
373
|
+
// helps where luma alone underestimates QR structure.
|
|
374
|
+
let chromaAdj = 0;
|
|
375
|
+
if (range > 6 && average > 48 && average < 232) {
|
|
376
|
+
const spreadBoost = spreadMean > 8 ? spreadMean - 8 : 0;
|
|
377
|
+
const mid = 128 - Math.abs(average - 128);
|
|
378
|
+
chromaAdj = int((spreadBoost * (range - 6) * mid) / 2200000);
|
|
379
|
+
if (chromaAdj > 1) chromaAdj = 1;
|
|
380
|
+
}
|
|
381
|
+
// `varAdj`: if the surrounding block field has real variance, darken
|
|
382
|
+
// more aggressively when the local mean still sits far above the
|
|
383
|
+
// block minimum. This is what rescues many weak finder cases.
|
|
384
|
+
let varAdj = 0;
|
|
385
|
+
if (integSqr && range >= 6 && range <= 128) {
|
|
386
|
+
const sq =
|
|
387
|
+
integSqr[r1 + (x1 + 1)] - integSqr[r0 + (x1 + 1)] - integSqr[r1 + x0] + integSqr[r0 + x0];
|
|
388
|
+
const meanSq = sq / area;
|
|
389
|
+
let variance = meanSq - average * average;
|
|
390
|
+
if (variance < 0) variance = 0;
|
|
391
|
+
const gap = average - min;
|
|
392
|
+
const num = gap * (variance - 196);
|
|
393
|
+
const den = (variance + 832) * 9;
|
|
394
|
+
// `variance` is clamped non-negative, so `den` stays strictly positive.
|
|
395
|
+
varAdj = int(num / den);
|
|
396
|
+
if (varAdj < -1) varAdj = -1;
|
|
397
|
+
if (varAdj > 4) varAdj = 4;
|
|
398
|
+
}
|
|
399
|
+
cut = average + localAdj + chromaAdj + varAdj;
|
|
400
|
+
// Small scene-level nudges are intentionally separate from the three
|
|
401
|
+
// local terms above: they are cheap and only target known whole-scene
|
|
402
|
+
// failure modes such as washed-out bright-spot images.
|
|
403
|
+
if (spreadMean > 10 && range >= 8 && range <= 96 && average > min + 8 && average < 192) cut++;
|
|
404
|
+
if (veryLowFrac > 0.68 && veryLowFrac < 0.86 && range >= 6 && range <= 20 && average < 196)
|
|
405
|
+
cut++;
|
|
406
|
+
cut += spotBias;
|
|
407
|
+
if (cut < min) cut = min;
|
|
408
|
+
if (cut > max) cut = max;
|
|
409
|
+
// Emit one 8-pixel row of the current 8x8 block: compare against the
|
|
410
|
+
// block cut, pack the 8 black/white decisions into one byte, then OR
|
|
411
|
+
// that byte into the bitmap word(s) at the current x-bit offset.
|
|
412
|
+
for (
|
|
413
|
+
let yy = 0, pos = yPos * width + xPos, row = yPos * rows + col;
|
|
414
|
+
yy < block;
|
|
415
|
+
yy++, pos += width, row += rows
|
|
416
|
+
) {
|
|
417
|
+
let m = 0;
|
|
418
|
+
if (brightness[pos] <= cut) m |= 1;
|
|
419
|
+
if (brightness[pos + 1] <= cut) m |= 2;
|
|
420
|
+
if (brightness[pos + 2] <= cut) m |= 4;
|
|
421
|
+
if (brightness[pos + 3] <= cut) m |= 8;
|
|
422
|
+
if (brightness[pos + 4] <= cut) m |= 16;
|
|
423
|
+
if (brightness[pos + 5] <= cut) m |= 32;
|
|
424
|
+
if (brightness[pos + 6] <= cut) m |= 64;
|
|
425
|
+
if (brightness[pos + 7] <= cut) m |= 128;
|
|
426
|
+
if (m === 0) continue;
|
|
427
|
+
const lo = (m << shift) >>> 0;
|
|
428
|
+
bm[row] |= lo;
|
|
429
|
+
if (shift > 24) bm[row + 1] |= m >>> (32 - shift);
|
|
143
430
|
}
|
|
144
431
|
}
|
|
145
432
|
}
|
|
146
|
-
return matrix
|
|
433
|
+
return matrix as TRet<Bitmap>;
|
|
147
434
|
}
|
|
148
435
|
|
|
149
436
|
// Various utilities for pattern
|
|
@@ -178,7 +465,7 @@ type Runs = number[];
|
|
|
178
465
|
* @returns
|
|
179
466
|
*/
|
|
180
467
|
function pattern(p: boolean[], size?: number[]) {
|
|
181
|
-
const _size = size || fillArr(p.length, 1);
|
|
468
|
+
const _size = size || utils.fillArr(p.length, 1);
|
|
182
469
|
if (p.length !== _size.length) throw new Error('invalid pattern');
|
|
183
470
|
if (!(p.length & 1)) throw new Error('invalid pattern, length should be odd');
|
|
184
471
|
const res = {
|
|
@@ -186,7 +473,7 @@ function pattern(p: boolean[], size?: number[]) {
|
|
|
186
473
|
length: p.length,
|
|
187
474
|
pattern: p,
|
|
188
475
|
size: _size,
|
|
189
|
-
runs: () => fillArr(p.length, 0),
|
|
476
|
+
runs: () => utils.fillArr(p.length, 0),
|
|
190
477
|
totalSize: sum(_size),
|
|
191
478
|
total: (runs: Runs) => runs.reduce((acc, i) => acc + i),
|
|
192
479
|
shift: (runs: Runs, n: number) => {
|
|
@@ -201,7 +488,11 @@ function pattern(p: boolean[], size?: number[]) {
|
|
|
201
488
|
return true;
|
|
202
489
|
},
|
|
203
490
|
add(out: Pattern[], x: number, y: number, total: number) {
|
|
204
|
-
|
|
491
|
+
// ISO/IEC 18004:2024 §5.3.3.1 gives finder runs as "1:1:3:1:1";
|
|
492
|
+
// §5.3.6 defines alignment as "5 x 5 dark modules", "3 x 3 light
|
|
493
|
+
// modules", and a central dark module. Use this pattern's own run width
|
|
494
|
+
// so alignment candidates are not divided by finder width 7.
|
|
495
|
+
const moduleSize = total / res.totalSize;
|
|
205
496
|
const cur = { x, y, moduleSize, count: 1 };
|
|
206
497
|
for (let idx = 0; idx < out.length; idx++) {
|
|
207
498
|
const f = out[idx];
|
|
@@ -216,12 +507,13 @@ function pattern(p: boolean[], size?: number[]) {
|
|
|
216
507
|
end -= runs[res.center] / 2;
|
|
217
508
|
return end;
|
|
218
509
|
},
|
|
219
|
-
check(b: Bitmap
|
|
510
|
+
check(b: TArg<Bitmap>, runs: Runs, center: Point, incr: Point, maxCount?: number) {
|
|
511
|
+
const bm = b as Bitmap;
|
|
220
512
|
let j = 0;
|
|
221
513
|
let i = pointClone(center);
|
|
222
514
|
const neg = pointNeg(incr);
|
|
223
515
|
const check = (p: number, step: Point) => {
|
|
224
|
-
for (;
|
|
516
|
+
for (; bm.isInside(i) && !!bm.point(i) === res.pattern[p]; pointIncr(i, step)) {
|
|
225
517
|
runs[p]++;
|
|
226
518
|
j++;
|
|
227
519
|
}
|
|
@@ -238,24 +530,58 @@ function pattern(p: boolean[], size?: number[]) {
|
|
|
238
530
|
return j;
|
|
239
531
|
},
|
|
240
532
|
scanLine(
|
|
241
|
-
b: Bitmap
|
|
533
|
+
b: TArg<Bitmap>,
|
|
242
534
|
y: number,
|
|
243
535
|
xStart: number,
|
|
244
536
|
xEnd: number,
|
|
245
537
|
fn: (runs: Runs, x: number) => boolean | void
|
|
246
538
|
) {
|
|
539
|
+
const bm = b as Bitmap;
|
|
247
540
|
const runs = res.runs();
|
|
541
|
+
// Finder scanning also couples to Bitmap internals so it can scan packed
|
|
542
|
+
// 32-bit words directly instead of re-reading one pixel bit at a time.
|
|
543
|
+
const words = (bm as unknown as { words: number }).words;
|
|
544
|
+
const vals = (bm as unknown as { value: Uint32Array }).value;
|
|
545
|
+
const row = y * words;
|
|
546
|
+
const pattern = res.pattern;
|
|
547
|
+
// Scan one packed bitmap row by jumping whole equal-bit runs inside 32-bit
|
|
548
|
+
// words; this keeps finder scanning from degenerating into per-pixel work.
|
|
549
|
+
const bitAt = (x: number) => ((vals[row + (x >>> 5)] >>> (x & 31)) & 1) === 1;
|
|
550
|
+
const runLen = (x: number, want: boolean) => {
|
|
551
|
+
let wi = row + (x >>> 5);
|
|
552
|
+
let bit = x & 31;
|
|
553
|
+
let w = (vals[wi] >>> bit) >>> 0;
|
|
554
|
+
let left = xEnd - x;
|
|
555
|
+
let len = 0;
|
|
556
|
+
while (left > 0) {
|
|
557
|
+
const room = 32 - bit;
|
|
558
|
+
let n = want ? ctz32(~w >>> 0) : ctz32(w);
|
|
559
|
+
if (n > room) n = room;
|
|
560
|
+
if (n > left) n = left;
|
|
561
|
+
len += n;
|
|
562
|
+
if (n < room && n < left) break;
|
|
563
|
+
left -= n;
|
|
564
|
+
if (left <= 0) break;
|
|
565
|
+
wi++;
|
|
566
|
+
bit = 0;
|
|
567
|
+
w = vals[wi] >>> 0;
|
|
568
|
+
}
|
|
569
|
+
return len;
|
|
570
|
+
};
|
|
248
571
|
let pos = 0;
|
|
249
572
|
let x = xStart;
|
|
250
573
|
// If we start in middle of an image, skip first pattern run,
|
|
251
574
|
// since we don't know run length of pixels from left side
|
|
252
|
-
if (xStart)
|
|
575
|
+
if (xStart) x += runLen(x, pattern[0]);
|
|
253
576
|
for (; x < xEnd; x++) {
|
|
577
|
+
const cur = bitAt(x);
|
|
254
578
|
// Same run, continue counting
|
|
255
|
-
if (
|
|
256
|
-
|
|
579
|
+
if (cur === pattern[pos]) {
|
|
580
|
+
const n = runLen(x, cur);
|
|
581
|
+
runs[pos] += n;
|
|
582
|
+
x += n - 1;
|
|
257
583
|
// If not last element - continue counting
|
|
258
|
-
if (x !==
|
|
584
|
+
if (x !== bm.width - 1) continue;
|
|
259
585
|
// Last element finishes run, set x outside of run
|
|
260
586
|
x++;
|
|
261
587
|
}
|
|
@@ -283,16 +609,17 @@ function pattern(p: boolean[], size?: number[]) {
|
|
|
283
609
|
};
|
|
284
610
|
return res;
|
|
285
611
|
}
|
|
286
|
-
// light/dark/light/dark
|
|
287
|
-
const FINDER = pattern([true, false, true, false, true], [1, 1, 3, 1, 1]);
|
|
288
|
-
// dark/light
|
|
289
|
-
const ALIGNMENT = pattern([false, true, false]);
|
|
612
|
+
// dark/light/dark/light/dark in 1:1:3:1:1 ratio
|
|
613
|
+
const FINDER = /* @__PURE__ */ pattern([true, false, true, false, true], [1, 1, 3, 1, 1]);
|
|
614
|
+
// central light/dark/light runs of an alignment pattern in 1:1:1 ratio
|
|
615
|
+
const ALIGNMENT = /* @__PURE__ */ pattern([false, true, false]);
|
|
290
616
|
|
|
291
|
-
function findFinder(b: Bitmap): {
|
|
617
|
+
function findFinder(b: TArg<Bitmap>): {
|
|
292
618
|
bl: Pattern;
|
|
293
619
|
tl: Pattern;
|
|
294
620
|
tr: Pattern;
|
|
295
621
|
} {
|
|
622
|
+
const bm = b as Bitmap;
|
|
296
623
|
let found: Pattern[] = [];
|
|
297
624
|
function checkRuns(runs: Runs, v = 2) {
|
|
298
625
|
const total = sum(runs);
|
|
@@ -303,7 +630,7 @@ function findFinder(b: Bitmap): {
|
|
|
303
630
|
// Non-diagonal line (horizontal or vertical)
|
|
304
631
|
function checkLine(center: Point, maxCount: number, total: number, incr: Point) {
|
|
305
632
|
const runs = FINDER.runs();
|
|
306
|
-
let i = FINDER.check(
|
|
633
|
+
let i = FINDER.check(bm, runs, center, incr, maxCount);
|
|
307
634
|
if (i === false) return false;
|
|
308
635
|
const runsTotal = sum(runs);
|
|
309
636
|
if (5 * Math.abs(runsTotal - total) >= 2 * total) return false;
|
|
@@ -325,17 +652,17 @@ function findFinder(b: Bitmap): {
|
|
|
325
652
|
x = xx + int(x);
|
|
326
653
|
// Diagonal
|
|
327
654
|
const dRuns = FINDER.runs();
|
|
328
|
-
if (!FINDER.check(
|
|
655
|
+
if (!FINDER.check(bm, dRuns, { x: int(x), y: int(y) }, { x: 1, y: 1 })) return false;
|
|
329
656
|
if (!checkRuns(dRuns, PATTERN_VARIANCE_DIAGONAL)) return false;
|
|
330
657
|
FINDER.add(found, x, y, total);
|
|
331
658
|
return true;
|
|
332
659
|
}
|
|
333
660
|
let skipped = false;
|
|
334
661
|
// Start with high skip lines count until we find first pattern
|
|
335
|
-
let ySkip = cap(int((3 *
|
|
662
|
+
let ySkip = cap(int((3 * bm.height) / (4 * 97)), DETECT_MIN_ROW_SKIP);
|
|
336
663
|
let done = false;
|
|
337
|
-
for (let y = ySkip - 1; y <
|
|
338
|
-
FINDER.scanLine(
|
|
664
|
+
for (let y = ySkip - 1; y < bm.height && !done; y += ySkip) {
|
|
665
|
+
FINDER.scanLine(bm, y, 0, bm.width, (runs, x) => {
|
|
339
666
|
if (!check(runs, y, x)) return;
|
|
340
667
|
// Found pattern
|
|
341
668
|
// Reduce row skip, since we found pattern and qr code is nearby
|
|
@@ -373,7 +700,7 @@ function findFinder(b: Bitmap): {
|
|
|
373
700
|
const flen = found.length;
|
|
374
701
|
if (flen < 3) throw new Error(`Finder: len(found) = ${flen}`);
|
|
375
702
|
found.sort((i, j) => i.moduleSize - j.moduleSize);
|
|
376
|
-
const pBest = best<[Pattern, Pattern, Pattern]>();
|
|
703
|
+
const pBest = utils.best<[Pattern, Pattern, Pattern]>();
|
|
377
704
|
// Qubic complexity, but we stop search when we found 3 patterns, so not a problem
|
|
378
705
|
for (let i = 0; i < flen - 2; i++) {
|
|
379
706
|
const fi = found[i];
|
|
@@ -420,21 +747,26 @@ function findFinder(b: Bitmap): {
|
|
|
420
747
|
return { bl, tl, tr };
|
|
421
748
|
}
|
|
422
749
|
|
|
423
|
-
function findAlignment(b: Bitmap
|
|
750
|
+
function findAlignment(b: TArg<Bitmap>, est: Pattern, allowanceFactor: number): Pattern {
|
|
751
|
+
const bm = b as Bitmap;
|
|
424
752
|
const { moduleSize } = est;
|
|
425
753
|
const allowance = int(allowanceFactor * moduleSize);
|
|
426
754
|
const leftX = cap(est.x - allowance, 0);
|
|
427
|
-
const rightX = cap(est.x + allowance, undefined,
|
|
755
|
+
const rightX = cap(est.x + allowance, undefined, bm.width - 1);
|
|
428
756
|
const x = rightX - leftX;
|
|
429
757
|
const topY = cap(est.y - allowance, 0);
|
|
430
|
-
const bottomY = cap(est.y + allowance, undefined,
|
|
758
|
+
const bottomY = cap(est.y + allowance, undefined, bm.height - 1);
|
|
431
759
|
const y = bottomY - topY;
|
|
432
760
|
if (x < moduleSize * 3 || y < moduleSize * 3)
|
|
433
761
|
throw new Error(`x = ${x}, y=${y} moduleSize = ${moduleSize}`);
|
|
434
762
|
const xStart = leftX;
|
|
435
763
|
const yStart = topY;
|
|
436
|
-
|
|
437
|
-
|
|
764
|
+
// ISO/IEC 18004:2024 §12 h)3 scans the alignment pattern's white-square
|
|
765
|
+
// outline from the provisional centre. `rightX` / `bottomY` are inclusive
|
|
766
|
+
// clipped image coordinates; convert them to exclusive scan bounds below so
|
|
767
|
+
// an exact 5x5 search window still includes its final row and column.
|
|
768
|
+
const width = rightX - leftX + 1;
|
|
769
|
+
const height = bottomY - topY + 1;
|
|
438
770
|
const found: Pattern[] = [];
|
|
439
771
|
const xEnd = xStart + width;
|
|
440
772
|
const middleY = int(yStart + height / 2);
|
|
@@ -442,13 +774,13 @@ function findAlignment(b: Bitmap, est: Pattern, allowanceFactor: number) {
|
|
|
442
774
|
const diff = int((yGen + 1) / 2);
|
|
443
775
|
const y = middleY + (yGen & 1 ? -diff : diff);
|
|
444
776
|
let res;
|
|
445
|
-
ALIGNMENT.scanLine(
|
|
777
|
+
ALIGNMENT.scanLine(bm, y, xStart, xEnd, (runs, x) => {
|
|
446
778
|
if (!ALIGNMENT.checkSize(runs, moduleSize)) return;
|
|
447
779
|
const total = sum(runs);
|
|
448
780
|
const xx = ALIGNMENT.toCenter(runs, x);
|
|
449
781
|
// Vertical
|
|
450
782
|
const rVert = ALIGNMENT.runs();
|
|
451
|
-
let v = ALIGNMENT.check(
|
|
783
|
+
let v = ALIGNMENT.check(bm, rVert, { x: int(xx), y }, { y: 1, x: 0 }, 2 * runs[1]);
|
|
452
784
|
if (v === false) return;
|
|
453
785
|
v += y;
|
|
454
786
|
const vTotal = sum(rVert);
|
|
@@ -465,7 +797,8 @@ function findAlignment(b: Bitmap, est: Pattern, allowanceFactor: number) {
|
|
|
465
797
|
throw new Error('Alignment pattern not found');
|
|
466
798
|
}
|
|
467
799
|
|
|
468
|
-
function _single(b: Bitmap
|
|
800
|
+
function _single(b: TArg<Bitmap>, from: Point, to: Point) {
|
|
801
|
+
const bm = b as Bitmap;
|
|
469
802
|
// http://en.wikipedia.org/wiki/Bresenham's_line_algorithm
|
|
470
803
|
let steep = false;
|
|
471
804
|
let d = { x: Math.abs(to.x - from.x), y: Math.abs(to.y - from.y) };
|
|
@@ -483,8 +816,10 @@ function _single(b: Bitmap, from: Point, to: Point) {
|
|
|
483
816
|
for (let x = from.x, y = from.y; x !== xLimit; x += step.x) {
|
|
484
817
|
let real = { x, y };
|
|
485
818
|
if (steep) real = pointMirror(real);
|
|
486
|
-
//
|
|
487
|
-
|
|
819
|
+
// Starting from a dark finder center, walk until the ray crosses
|
|
820
|
+
// light -> dark -> light; `BWBRunLength()` mirrors this to recover the
|
|
821
|
+
// full finder width around the center point.
|
|
822
|
+
if ((runPos === 1) === !!bm.point(real)) {
|
|
488
823
|
if (runPos === 2) return distance({ x, y }, from);
|
|
489
824
|
runPos++;
|
|
490
825
|
}
|
|
@@ -498,12 +833,13 @@ function _single(b: Bitmap, from: Point, to: Point) {
|
|
|
498
833
|
return NaN;
|
|
499
834
|
}
|
|
500
835
|
|
|
501
|
-
function BWBRunLength(b: Bitmap
|
|
502
|
-
|
|
836
|
+
function BWBRunLength(b: TArg<Bitmap>, from: Point, to: Point) {
|
|
837
|
+
const bm = b as Bitmap;
|
|
838
|
+
let result = _single(bm, from, to);
|
|
503
839
|
let scaleY = 1.0;
|
|
504
840
|
const { x: fx, y: fy } = from;
|
|
505
841
|
let otherToX = fx - (to.x - fx);
|
|
506
|
-
const bw =
|
|
842
|
+
const bw = bm.width;
|
|
507
843
|
if (otherToX < 0) {
|
|
508
844
|
scaleY = fx / (fx - otherToX);
|
|
509
845
|
otherToX = 0;
|
|
@@ -511,9 +847,15 @@ function BWBRunLength(b: Bitmap, from: Point, to: Point) {
|
|
|
511
847
|
scaleY = (bw - 1 - fx) / (otherToX - fx);
|
|
512
848
|
otherToX = bw - 1;
|
|
513
849
|
}
|
|
850
|
+
// ISO/IEC 18004:2024 §12 b) uses finder runs in the 1:1:3:1:1 ratio,
|
|
851
|
+
// and §12 h)1 derives module size from finder pattern width. Clipping the
|
|
852
|
+
// reflected ray before `int()` would avoid `>>> 0` wrapping negative near-edge
|
|
853
|
+
// coordinates to a huge uint32 and clamping to the wrong edge. Policy: keep
|
|
854
|
+
// the existing heuristic because the direct fix degraded decode performance
|
|
855
|
+
// on current vectors (BoofCV sweep 134/485 -> 132/485).
|
|
514
856
|
let otherToY = int(fy - (to.y - fy) * scaleY);
|
|
515
857
|
let scaleX = 1.0;
|
|
516
|
-
const bh =
|
|
858
|
+
const bh = bm.height;
|
|
517
859
|
if (otherToY < 0) {
|
|
518
860
|
scaleX = fy / (fy - otherToY);
|
|
519
861
|
otherToY = 0;
|
|
@@ -522,53 +864,64 @@ function BWBRunLength(b: Bitmap, from: Point, to: Point) {
|
|
|
522
864
|
otherToY = bh - 1;
|
|
523
865
|
}
|
|
524
866
|
otherToX = int(fx + (otherToX - fx) * scaleX);
|
|
525
|
-
result += _single(
|
|
867
|
+
result += _single(bm, from, { x: otherToX, y: otherToY });
|
|
868
|
+
// Both mirrored rays include the center module once, so drop one module
|
|
869
|
+
// after summing them into the full finder-width estimate.
|
|
526
870
|
return result - 1.0;
|
|
527
871
|
}
|
|
528
872
|
|
|
529
|
-
function moduleSizeAvg(b: Bitmap
|
|
873
|
+
function moduleSizeAvg(b: TArg<Bitmap>, p1: Point, p2: Point) {
|
|
530
874
|
const est1 = BWBRunLength(b, pointInt(p1), pointInt(p2));
|
|
531
875
|
const est2 = BWBRunLength(b, pointInt(p2), pointInt(p1));
|
|
876
|
+
// One ray can fail near image edges, so keep the surviving estimate
|
|
877
|
+
// instead of discarding the finder-width measurement outright.
|
|
532
878
|
if (Number.isNaN(est1)) return est2 / FINDER.totalSize;
|
|
533
879
|
if (Number.isNaN(est2)) return est1 / FINDER.totalSize;
|
|
534
880
|
return (est1 + est2) / (2 * FINDER.totalSize);
|
|
535
881
|
}
|
|
536
882
|
|
|
537
|
-
function detect(b: Bitmap): {
|
|
883
|
+
function detect(b: TArg<Bitmap>): TRet<{
|
|
538
884
|
bits: Bitmap;
|
|
539
885
|
points: FinderPoints;
|
|
540
|
-
} {
|
|
886
|
+
}> {
|
|
887
|
+
const bm = b as Bitmap;
|
|
541
888
|
let bl, tl, tr;
|
|
542
889
|
try {
|
|
543
|
-
({ bl, tl, tr } = findFinder(
|
|
890
|
+
({ bl, tl, tr } = findFinder(bm));
|
|
544
891
|
} catch (e) {
|
|
545
892
|
try {
|
|
546
|
-
b
|
|
547
|
-
|
|
893
|
+
// ISO/IEC 18004:2024 §12 b)5 says to "reverse the colouring of the
|
|
894
|
+
// light and dark pixels" for reflectance reversal. `detect()` works on
|
|
895
|
+
// the `decodeQR()`-owned scratch bitmap, so keep the retry in-place for
|
|
896
|
+
// performance instead of cloning before this private/test-only helper.
|
|
897
|
+
bm.negate();
|
|
898
|
+
({ bl, tl, tr } = findFinder(bm));
|
|
548
899
|
} catch (e) {
|
|
549
|
-
|
|
900
|
+
bm.negate(); // undo negate
|
|
550
901
|
throw e;
|
|
551
902
|
}
|
|
552
903
|
}
|
|
553
|
-
const moduleSize = (moduleSizeAvg(
|
|
904
|
+
const moduleSize = (moduleSizeAvg(bm, tl, tr) + moduleSizeAvg(bm, tl, bl)) / 2;
|
|
554
905
|
if (moduleSize < 1.0) throw new Error(`invalid moduleSize = ${moduleSize}`);
|
|
555
906
|
// Estimate size
|
|
556
907
|
const tltr = int(distance(tl, tr) / moduleSize + 0.5);
|
|
557
908
|
const tlbl = int(distance(tl, bl) / moduleSize + 0.5);
|
|
558
909
|
let size = int((tltr + tlbl) / 2 + 7);
|
|
910
|
+
// QR side lengths are 21 + 4 * (version - 1), so normalize the estimate
|
|
911
|
+
// to the nearest size that is 1 modulo 4 before decoding the version.
|
|
559
912
|
const rem = size % 4;
|
|
560
913
|
if (rem === 0)
|
|
561
914
|
size++; // -> 1
|
|
562
915
|
else if (rem === 2)
|
|
563
916
|
size--; // -> 1
|
|
564
917
|
else if (rem === 3) size -= 2;
|
|
565
|
-
const version = info.size.decode(size);
|
|
566
|
-
validateVersion(version);
|
|
918
|
+
const version = utils.info.size.decode(size);
|
|
919
|
+
utils.validateVersion(version);
|
|
567
920
|
let alignmentPattern;
|
|
568
|
-
if (info.alignmentPatterns(version).length > 0) {
|
|
921
|
+
if (utils.info.alignmentPatterns(version).length > 0) {
|
|
569
922
|
// Bottom right estimate
|
|
570
923
|
const br = { x: tr.x - tl.x + bl.x, y: tr.y - tl.y + bl.y };
|
|
571
|
-
const c = 1.0 - 3.0 / (info.size.encode(version) - 7);
|
|
924
|
+
const c = 1.0 - 3.0 / (utils.info.size.encode(version) - 7);
|
|
572
925
|
// Estimated alignment pattern position
|
|
573
926
|
const est = {
|
|
574
927
|
x: int(tl.x + c * (br.x - tl.x)),
|
|
@@ -578,7 +931,7 @@ function detect(b: Bitmap): {
|
|
|
578
931
|
};
|
|
579
932
|
for (let i = 4; i <= 16; i <<= 1) {
|
|
580
933
|
try {
|
|
581
|
-
alignmentPattern = findAlignment(
|
|
934
|
+
alignmentPattern = findAlignment(bm, est, i);
|
|
582
935
|
break;
|
|
583
936
|
} catch (e) {}
|
|
584
937
|
}
|
|
@@ -596,14 +949,16 @@ function detect(b: Bitmap): {
|
|
|
596
949
|
toBR = { x: size - 3.5, y: size - 3.5 };
|
|
597
950
|
}
|
|
598
951
|
const from: FinderPoints = [tl, tr, br, bl];
|
|
599
|
-
const bits = transform(
|
|
600
|
-
return { bits: bits, points: from };
|
|
952
|
+
const bits = transform(bm, size, from, [toTL, toTR, toBR, toBL]);
|
|
953
|
+
return { bits: bits as Bitmap, points: from } as TRet<{ bits: Bitmap; points: FinderPoints }>;
|
|
601
954
|
}
|
|
602
955
|
|
|
603
956
|
// Perspective transform by 4 points
|
|
604
957
|
function squareToQuadrilateral(p: Point4) {
|
|
605
958
|
const d3 = { x: p[0].x - p[1].x + p[2].x - p[3].x, y: p[0].y - p[1].y + p[2].y - p[3].y };
|
|
606
959
|
if (d3.x === 0.0 && d3.y === 0.0) {
|
|
960
|
+
// Parallelogram fast path: perspective terms vanish, so the homography
|
|
961
|
+
// reduces to an affine transform.
|
|
607
962
|
return [
|
|
608
963
|
[p[1].x - p[0].x, p[2].x - p[1].x, p[0].x],
|
|
609
964
|
[p[1].y - p[0].y, p[2].y - p[1].y, p[0].y],
|
|
@@ -624,10 +979,13 @@ function squareToQuadrilateral(p: Point4) {
|
|
|
624
979
|
}
|
|
625
980
|
|
|
626
981
|
// Transform quadrilateral to square by 4 points
|
|
627
|
-
function transform(b: Bitmap
|
|
982
|
+
function transform(b: TArg<Bitmap>, size: number, from: Point4, to: Point4): TRet<Bitmap> {
|
|
983
|
+
const bm = b as Bitmap;
|
|
628
984
|
// TODO: check
|
|
629
985
|
// https://math.stackexchange.com/questions/13404/mapping-irregular-quadrilateral-to-a-rectangle
|
|
630
986
|
const p = squareToQuadrilateral(to);
|
|
987
|
+
// Homographies are scale-invariant, so the adjugate is enough here;
|
|
988
|
+
// there is no need to divide by the determinant when inverting `p`.
|
|
631
989
|
const qToS = [
|
|
632
990
|
[
|
|
633
991
|
p[1][1] * p[2][2] - p[2][1] * p[1][2],
|
|
@@ -651,7 +1009,7 @@ function transform(b: Bitmap, size: number, from: Point4, to: Point4): Bitmap {
|
|
|
651
1009
|
);
|
|
652
1010
|
|
|
653
1011
|
const res = new Bitmap(size);
|
|
654
|
-
const points = fillArr(2 * size, 0);
|
|
1012
|
+
const points = utils.fillArr(2 * size, 0);
|
|
655
1013
|
const pointsLength = points.length;
|
|
656
1014
|
for (let y = 0; y < size; y++) {
|
|
657
1015
|
const p = transform;
|
|
@@ -659,23 +1017,28 @@ function transform(b: Bitmap, size: number, from: Point4, to: Point4): Bitmap {
|
|
|
659
1017
|
const x = i / 2 + 0.5;
|
|
660
1018
|
const y2 = y + 0.5;
|
|
661
1019
|
const den = p[2][0] * x + p[2][1] * y2 + p[2][2];
|
|
662
|
-
|
|
663
|
-
|
|
1020
|
+
// ISO/IEC 18004:2024 §12 h) maps sampling grid intersections back to
|
|
1021
|
+
// image coordinates before deciding dark/light state. Clip projected
|
|
1022
|
+
// coordinates before `int()` because `>>> 0` wraps negative samples to a
|
|
1023
|
+
// huge uint32 and would clamp them to the far image edge.
|
|
1024
|
+
points[i] = int(cap((p[0][0] * x + p[0][1] * y2 + p[0][2]) / den, 0, bm.width - 1));
|
|
1025
|
+
points[i + 1] = int(cap((p[1][0] * x + p[1][1] * y2 + p[1][2]) / den, 0, bm.height - 1));
|
|
664
1026
|
}
|
|
665
1027
|
for (let i = 0; i < pointsLength; i += 2) {
|
|
666
|
-
|
|
667
|
-
const py = cap(points[i + 1], 0, b.height - 1);
|
|
668
|
-
if (b.get(px, py)) res.set((i / 2) | 0, y, true);
|
|
1028
|
+
if (bm.get(points[i], points[i + 1])) res.set((i / 2) | 0, y, true);
|
|
669
1029
|
}
|
|
670
1030
|
}
|
|
671
|
-
return res
|
|
1031
|
+
return res as TRet<Bitmap>;
|
|
672
1032
|
}
|
|
673
1033
|
|
|
674
1034
|
// Same as in drawTemplate, but reading
|
|
675
1035
|
// TODO: merge in CoderType?
|
|
676
|
-
function readInfoBits(b: Bitmap) {
|
|
677
|
-
const
|
|
678
|
-
|
|
1036
|
+
function readInfoBits(b: TArg<Bitmap>) {
|
|
1037
|
+
const bm = b as Bitmap;
|
|
1038
|
+
// Walk each reserved copy from the highest-numbered module back to module 0
|
|
1039
|
+
// so the shift accumulator rebuilds the canonical bit string from MSB to LSB.
|
|
1040
|
+
const readBit = (x: number, y: number, out: number) => (out << 1) | (bm.get(x, y) ? 1 : 0);
|
|
1041
|
+
const size = bm.height;
|
|
679
1042
|
// Version information
|
|
680
1043
|
let version1 = 0;
|
|
681
1044
|
for (let y = 5; y >= 0; y--)
|
|
@@ -696,45 +1059,48 @@ function readInfoBits(b: Bitmap) {
|
|
|
696
1059
|
return { version1, version2, format1, format2 };
|
|
697
1060
|
}
|
|
698
1061
|
|
|
699
|
-
function parseInfo(b: Bitmap) {
|
|
1062
|
+
function parseInfo(b: TArg<Bitmap>) {
|
|
1063
|
+
const bm = b as Bitmap;
|
|
700
1064
|
// Population count over xor -> hamming distance
|
|
701
|
-
const size =
|
|
702
|
-
const { version1, version2, format1, format2 } = readInfoBits(
|
|
1065
|
+
const size = bm.height;
|
|
1066
|
+
const { version1, version2, format1, format2 } = readInfoBits(bm);
|
|
703
1067
|
// Guess format
|
|
704
1068
|
let format;
|
|
705
|
-
const bestFormat = best<{ ecc: ErrorCorrection; mask: Mask }>();
|
|
1069
|
+
const bestFormat = utils.best<{ ecc: ErrorCorrection; mask: Mask }>();
|
|
706
1070
|
for (const ecc of ['medium', 'low', 'high', 'quartile'] as const) {
|
|
707
1071
|
for (let mask: Mask = 0; mask < 8; mask++) {
|
|
708
|
-
const bits = info.formatBits(ecc, mask as Mask);
|
|
1072
|
+
const bits = utils.info.formatBits(ecc, mask as Mask);
|
|
709
1073
|
const cur = { ecc, mask: mask as Mask };
|
|
710
1074
|
if (bits === format1 || bits === format2) {
|
|
711
1075
|
format = cur;
|
|
712
1076
|
break;
|
|
713
1077
|
}
|
|
714
|
-
bestFormat.add(popcnt(format1 ^ bits), cur);
|
|
715
|
-
if (format1 !== format2) bestFormat.add(popcnt(format2 ^ bits), cur);
|
|
1078
|
+
bestFormat.add(utils.popcnt(format1 ^ bits), cur);
|
|
1079
|
+
if (format1 !== format2) bestFormat.add(utils.popcnt(format2 ^ bits), cur);
|
|
716
1080
|
}
|
|
717
1081
|
}
|
|
718
1082
|
if (format === undefined && bestFormat.score() <= MAX_BITS_ERROR) format = bestFormat.get();
|
|
719
1083
|
if (format === undefined) throw new Error('invalid format pattern');
|
|
720
|
-
let version: number | undefined = info.size.decode(size); // Guess version based on bitmap size
|
|
721
|
-
|
|
1084
|
+
let version: number | undefined = utils.info.size.decode(size); // Guess version based on bitmap size
|
|
1085
|
+
// Versions 1-6 do not carry version-information words, so side length is
|
|
1086
|
+
// the only authoritative version source until version 7 adds those fields.
|
|
1087
|
+
if (version < 7) utils.validateVersion(version);
|
|
722
1088
|
else {
|
|
723
1089
|
version = undefined;
|
|
724
1090
|
// Guess version
|
|
725
|
-
const bestVer = best<number>();
|
|
1091
|
+
const bestVer = utils.best<number>();
|
|
726
1092
|
for (let ver = 7; ver <= 40; ver++) {
|
|
727
|
-
const bits = info.versionBits(ver);
|
|
1093
|
+
const bits = utils.info.versionBits(ver);
|
|
728
1094
|
if (bits === version1 || bits === version2) {
|
|
729
1095
|
version = ver;
|
|
730
1096
|
break;
|
|
731
1097
|
}
|
|
732
|
-
bestVer.add(popcnt(version1 ^ bits), ver);
|
|
733
|
-
if (version1 !== version2) bestVer.add(popcnt(version2 ^ bits), ver);
|
|
1098
|
+
bestVer.add(utils.popcnt(version1 ^ bits), ver);
|
|
1099
|
+
if (version1 !== version2) bestVer.add(utils.popcnt(version2 ^ bits), ver);
|
|
734
1100
|
}
|
|
735
1101
|
if (version === undefined && bestVer.score() <= MAX_BITS_ERROR) version = bestVer.get();
|
|
736
1102
|
if (version === undefined) throw new Error('invalid version pattern');
|
|
737
|
-
if (info.size.encode(version) !== size) throw new Error('invalid version size');
|
|
1103
|
+
if (utils.info.size.encode(version) !== size) throw new Error('invalid version size');
|
|
738
1104
|
}
|
|
739
1105
|
return { version, ...format };
|
|
740
1106
|
}
|
|
@@ -743,7 +1109,10 @@ function parseInfo(b: Bitmap) {
|
|
|
743
1109
|
// See https://github.com/microsoft/TypeScript/issues/31535
|
|
744
1110
|
declare const TextDecoder: any;
|
|
745
1111
|
|
|
746
|
-
//
|
|
1112
|
+
// ISO/IEC 18004:2024 §7.4.3.2 says each ECI is a "6-digit assignment
|
|
1113
|
+
// number"; §7.4.3.4 says invoked ECIs apply until "a change of ECI".
|
|
1114
|
+
// Decode through platform TextDecoder only: unsupported labels may throw on
|
|
1115
|
+
// some runtimes, and callers needing them can provide a custom textDecoder.
|
|
747
1116
|
const eciToEncoding: Record<number, string> = {
|
|
748
1117
|
1: 'iso-8859-1',
|
|
749
1118
|
2: 'ibm437',
|
|
@@ -773,38 +1142,42 @@ const eciToEncoding: Record<number, string> = {
|
|
|
773
1142
|
30: 'euc-kr',
|
|
774
1143
|
};
|
|
775
1144
|
|
|
776
|
-
function decodeWithEci(bytes: Uint8Array
|
|
1145
|
+
function decodeWithEci(bytes: TArg<Uint8Array>, eci: number = 26): string {
|
|
1146
|
+
// ISO/IEC 18004:2024 §7.3.2 says QR's "default interpretation" is
|
|
1147
|
+
// "ECI 000003 representing the ISO/IEC 8859-1 character set". Keep UTF-8
|
|
1148
|
+
// here so this library's UTF-8 byte-mode encoder round-trips without ECI.
|
|
777
1149
|
const encoding = eciToEncoding[eci];
|
|
778
1150
|
if (!encoding) throw new Error(`Unsupported ECI: ${eci}`);
|
|
779
1151
|
return new TextDecoder(encoding).decode(bytes);
|
|
780
1152
|
}
|
|
781
1153
|
|
|
782
1154
|
function decodeBitmap(
|
|
783
|
-
b: Bitmap
|
|
784
|
-
decoder: (bytes: Uint8Array, eci: number) => string = decodeWithEci
|
|
1155
|
+
b: TArg<Bitmap>,
|
|
1156
|
+
decoder: TArg<(bytes: Uint8Array, eci: number) => string> = decodeWithEci
|
|
785
1157
|
): string {
|
|
786
|
-
const
|
|
787
|
-
|
|
1158
|
+
const bm = b as Bitmap;
|
|
1159
|
+
const size = bm.height;
|
|
1160
|
+
if (size < 21 || (size & 0b11) !== 1 || size !== bm.width)
|
|
788
1161
|
throw new Error(`decode: invalid size=${size}`);
|
|
789
|
-
const { version, mask, ecc } = parseInfo(
|
|
790
|
-
const tpl = drawTemplate(version, ecc, mask);
|
|
791
|
-
const { total } = info.capacity(version, ecc);
|
|
1162
|
+
const { version, mask, ecc } = parseInfo(bm);
|
|
1163
|
+
const tpl = utils.drawTemplate(version, ecc, mask);
|
|
1164
|
+
const { total } = utils.info.capacity(version, ecc);
|
|
792
1165
|
const bytes = new Uint8Array(total);
|
|
793
1166
|
let pos = 0;
|
|
794
1167
|
let buf = 0;
|
|
795
1168
|
let bitPos = 0;
|
|
796
|
-
zigzag(tpl, mask, (x, y, m) => {
|
|
1169
|
+
utils.zigzag(tpl, mask, (x, y, m) => {
|
|
797
1170
|
bitPos++;
|
|
798
1171
|
buf <<= 1;
|
|
799
|
-
buf |= +(!!
|
|
1172
|
+
buf |= +(!!bm.get(x, y) !== m);
|
|
800
1173
|
if (bitPos !== 8) return;
|
|
801
1174
|
bytes[pos++] = buf;
|
|
802
1175
|
bitPos = 0;
|
|
803
1176
|
buf = 0;
|
|
804
1177
|
});
|
|
805
1178
|
if (pos !== total) throw new Error(`decode: pos=${pos}, total=${total}`);
|
|
806
|
-
let bits = Array.from(interleave(version, ecc).decode(bytes))
|
|
807
|
-
.map((i) => bin(i, 8))
|
|
1179
|
+
let bits = Array.from(utils.interleave(version, ecc).decode(bytes))
|
|
1180
|
+
.map((i) => utils.bin(i, 8))
|
|
808
1181
|
.join('');
|
|
809
1182
|
// Reverse operation of index.ts/encode working on bits
|
|
810
1183
|
const readBits = (n: number) => {
|
|
@@ -824,6 +1197,9 @@ function decodeBitmap(
|
|
|
824
1197
|
'1000': 'kanji',
|
|
825
1198
|
};
|
|
826
1199
|
let res = '';
|
|
1200
|
+
// ISO/IEC 18004:2024 §7.3.2 defines no-ECI byte data as ECI 000003
|
|
1201
|
+
// / ISO-8859-1. Keep ECI 26 for compatibility with legacy no-ECI UTF-8
|
|
1202
|
+
// payloads and with encodeQR's UTF-8 byte-mode default.
|
|
827
1203
|
let eci: number = 26; // Default to utf-8 for compat with old behavior
|
|
828
1204
|
while (true) {
|
|
829
1205
|
if (bits.length < 4) break;
|
|
@@ -831,7 +1207,7 @@ function decodeBitmap(
|
|
|
831
1207
|
const mode = modes[modeBits];
|
|
832
1208
|
if (mode === undefined) throw new Error(`Unknown modeBits=${modeBits} res="${res}"`);
|
|
833
1209
|
if (mode === 'terminator') break;
|
|
834
|
-
const countBits = info.lengthBits(version, mode);
|
|
1210
|
+
const countBits = utils.info.lengthBits(version, mode);
|
|
835
1211
|
let count = toNum(readBits(countBits));
|
|
836
1212
|
if (mode === 'numeric') {
|
|
837
1213
|
while (count >= 3) {
|
|
@@ -852,10 +1228,10 @@ function decodeBitmap(
|
|
|
852
1228
|
} else if (mode === 'alphanumeric') {
|
|
853
1229
|
while (count >= 2) {
|
|
854
1230
|
const v = toNum(readBits(11));
|
|
855
|
-
res += info.alphabet.alphanumerc.encode([Math.floor(v / 45), v % 45]).join('');
|
|
1231
|
+
res += utils.info.alphabet.alphanumerc.encode([Math.floor(v / 45), v % 45]).join('');
|
|
856
1232
|
count -= 2;
|
|
857
1233
|
}
|
|
858
|
-
if (count === 1) res += info.alphabet.alphanumerc.encode([toNum(readBits(6))]).join('');
|
|
1234
|
+
if (count === 1) res += utils.info.alphabet.alphanumerc.encode([toNum(readBits(6))]).join('');
|
|
859
1235
|
} else if (mode === 'eci') {
|
|
860
1236
|
const first = toNum(readBits(8));
|
|
861
1237
|
if ((first & 0x80) === 0) eci = first;
|
|
@@ -866,24 +1242,69 @@ function decodeBitmap(
|
|
|
866
1242
|
const data = new Uint8Array(count);
|
|
867
1243
|
for (let i = 0; i < count; i++) data[i] = toNum(readBits(8));
|
|
868
1244
|
res += decoder(data, eci);
|
|
1245
|
+
} else if (mode === 'kanji') {
|
|
1246
|
+
// ISO/IEC 18004:2024 §7.3.6 says Kanji mode uses Shift JIS and
|
|
1247
|
+
// "Each two-byte character value is compacted to a 13-bit binary
|
|
1248
|
+
// codeword." Keep it unsupported until a real interop fixture can verify
|
|
1249
|
+
// the 13-bit-to-Shift-JIS mapping rather than adding untested decoding.
|
|
1250
|
+
throw new Error('Kanji mode is not supported');
|
|
869
1251
|
} else throw new Error(`Unknown mode=${mode}`);
|
|
870
1252
|
}
|
|
871
1253
|
return res;
|
|
872
1254
|
}
|
|
873
1255
|
|
|
1256
|
+
/** QR decoding hooks and image preprocessing options. */
|
|
874
1257
|
export type DecodeOpts = {
|
|
1258
|
+
/** Crop rectangular inputs to a centered square before decoding. */
|
|
875
1259
|
cropToSquare?: boolean;
|
|
876
|
-
|
|
1260
|
+
/**
|
|
1261
|
+
* Custom byte-to-text decoder used for byte segments.
|
|
1262
|
+
*
|
|
1263
|
+
* Receives the byte segment and, when needed, the active ECI designator.
|
|
1264
|
+
* ISO/IEC 18004:2024 §7.4.3.4 keeps ECIs active "until the end of
|
|
1265
|
+
* the encoded data or a change of ECI", so callers need the active
|
|
1266
|
+
* designator to apply their own charset policy.
|
|
1267
|
+
* @param bytes - Byte segment payload to decode.
|
|
1268
|
+
* @param eci - Active ECI designator for the byte segment.
|
|
1269
|
+
* @returns Decoded text for the byte segment.
|
|
1270
|
+
*/
|
|
1271
|
+
textDecoder?: TArg<(bytes: Uint8Array, eci?: number) => string>;
|
|
1272
|
+
/**
|
|
1273
|
+
* Callback invoked with finder/alignment points after detection succeeds.
|
|
1274
|
+
*
|
|
1275
|
+
* Receives finder and alignment points returned by the detector.
|
|
1276
|
+
* @param points - Finder and alignment points returned by the detector.
|
|
1277
|
+
*/
|
|
877
1278
|
pointsOnDetect?: (points: FinderPoints) => void;
|
|
1279
|
+
/**
|
|
1280
|
+
* Callback invoked with the grayscale bitmap that the detector sees.
|
|
1281
|
+
*
|
|
1282
|
+
* Receives the grayscale image generated during bitmap conversion.
|
|
1283
|
+
* @param img - Grayscale image generated during bitmap conversion.
|
|
1284
|
+
*/
|
|
878
1285
|
imageOnBitmap?: (img: Image) => void;
|
|
1286
|
+
/**
|
|
1287
|
+
* Callback invoked with the perspective-corrected QR image.
|
|
1288
|
+
*
|
|
1289
|
+
* Receives the perspective-corrected QR image.
|
|
1290
|
+
* @param img - Perspective-corrected QR image.
|
|
1291
|
+
*/
|
|
879
1292
|
imageOnDetect?: (img: Image) => void;
|
|
1293
|
+
/**
|
|
1294
|
+
* Callback invoked with the final decoded QR image.
|
|
1295
|
+
*
|
|
1296
|
+
* Receives the final QR image used to decode the payload.
|
|
1297
|
+
* @param img - Final QR image used to decode the payload.
|
|
1298
|
+
*/
|
|
880
1299
|
imageOnResult?: (img: Image) => void;
|
|
881
1300
|
};
|
|
882
1301
|
|
|
883
|
-
//
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
const
|
|
1302
|
+
// Center-crop to a square and return the original-image offset so
|
|
1303
|
+
// `decodeQR()` can map detected points back into caller coordinates.
|
|
1304
|
+
function cropToSquare(img: TArg<Image>) {
|
|
1305
|
+
const image = img as Image;
|
|
1306
|
+
const data = Array.isArray(image.data) ? new Uint8Array(image.data) : image.data;
|
|
1307
|
+
const { height, width } = image;
|
|
887
1308
|
const squareSize = Math.min(height, width);
|
|
888
1309
|
const offset = {
|
|
889
1310
|
x: Math.floor((width - squareSize) / 2),
|
|
@@ -900,49 +1321,99 @@ function cropToSquare(img: Image) {
|
|
|
900
1321
|
return { offset, img: { height: squareSize, width: squareSize, data: croppedData } };
|
|
901
1322
|
}
|
|
902
1323
|
|
|
903
|
-
|
|
1324
|
+
/**
|
|
1325
|
+
* Decode text from a QR image.
|
|
1326
|
+
* @param img - RGB or RGBA image data that contains a QR code.
|
|
1327
|
+
* @param opts - Decoder hooks and image preprocessing options. See {@link DecodeOpts}.
|
|
1328
|
+
* @returns Decoded QR payload as a string.
|
|
1329
|
+
* @throws If the image, decoder options, or QR contents are invalid. {@link Error}
|
|
1330
|
+
* @example
|
|
1331
|
+
* Decode text from a QR image.
|
|
1332
|
+
* ```ts
|
|
1333
|
+
* import encodeQR, { Bitmap } from 'qr';
|
|
1334
|
+
* import decodeQR from 'qr/decode.js';
|
|
1335
|
+
* const bits = encodeQR('Hello world', 'raw', { scale: 4 });
|
|
1336
|
+
* const bm = new Bitmap({ width: bits[0].length, height: bits.length }, bits);
|
|
1337
|
+
* const text = decodeQR(bm.toImage());
|
|
1338
|
+
* ```
|
|
1339
|
+
*/
|
|
1340
|
+
export function decodeQR(img: TArg<Image>, opts: TArg<DecodeOpts> = {}): string {
|
|
1341
|
+
let image = img as Image;
|
|
1342
|
+
const options = opts as DecodeOpts;
|
|
904
1343
|
for (const field of ['height', 'width'] as const) {
|
|
905
|
-
if (!Number.isSafeInteger(
|
|
906
|
-
throw new Error(`invalid img.${field}=${
|
|
1344
|
+
if (!Number.isSafeInteger(image[field]) || image[field] <= 0)
|
|
1345
|
+
throw new Error(`invalid img.${field}=${image[field]} (${typeof image[field]})`);
|
|
907
1346
|
}
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
1347
|
+
const { data } = image;
|
|
1348
|
+
if (!Array.isArray(data) && !isBytes(data))
|
|
1349
|
+
throw new Error(`invalid image.data=${data} (${typeof data})`);
|
|
1350
|
+
if (options.cropToSquare !== undefined && typeof options.cropToSquare !== 'boolean')
|
|
1351
|
+
throw new Error(`invalid opts.cropToSquare=${options.cropToSquare}`);
|
|
1352
|
+
// Validate callbacks before decoding so payload mode does not decide whether
|
|
1353
|
+
// an invalid public option is accepted.
|
|
1354
|
+
for (const fn of [
|
|
1355
|
+
'textDecoder',
|
|
1356
|
+
'pointsOnDetect',
|
|
1357
|
+
'imageOnBitmap',
|
|
1358
|
+
'imageOnDetect',
|
|
1359
|
+
'imageOnResult',
|
|
1360
|
+
] as const) {
|
|
1361
|
+
if (options[fn] !== undefined && typeof options[fn] !== 'function')
|
|
1362
|
+
throw new Error(`invalid opts.${fn}=${options[fn]} (${typeof options[fn]})`);
|
|
919
1363
|
}
|
|
920
1364
|
let offset = { x: 0, y: 0 };
|
|
921
|
-
if (
|
|
922
|
-
const bmp = toBitmap(
|
|
923
|
-
if (
|
|
1365
|
+
if (options.cropToSquare) ({ img: image, offset } = cropToSquare(image));
|
|
1366
|
+
const bmp = toBitmap(image) as Bitmap;
|
|
1367
|
+
if (options.imageOnBitmap) options.imageOnBitmap(bmp.toImage());
|
|
924
1368
|
const { bits, points } = detect(bmp);
|
|
925
|
-
if (
|
|
1369
|
+
if (options.pointsOnDetect) {
|
|
1370
|
+
// Report finder points in the caller's original coordinate space after any center-crop.
|
|
926
1371
|
const p = points.map((i) => ({ ...i, ...pointAdd(i, offset) })) as FinderPoints;
|
|
927
|
-
|
|
1372
|
+
options.pointsOnDetect(p);
|
|
928
1373
|
}
|
|
929
|
-
if (
|
|
930
|
-
const res = decodeBitmap(bits,
|
|
931
|
-
if (
|
|
1374
|
+
if (options.imageOnDetect) options.imageOnDetect(bits.toImage());
|
|
1375
|
+
const res = decodeBitmap(bits, options.textDecoder);
|
|
1376
|
+
if (options.imageOnResult) options.imageOnResult(bits.toImage());
|
|
932
1377
|
return res;
|
|
933
1378
|
}
|
|
934
1379
|
|
|
1380
|
+
/**
|
|
1381
|
+
* Default export alias for {@link decodeQR}.
|
|
1382
|
+
* @param img - RGB or RGBA image data that contains a QR code.
|
|
1383
|
+
* @param opts - Decoder hooks and image preprocessing options. See {@link DecodeOpts}.
|
|
1384
|
+
* @returns Decoded QR payload as a string.
|
|
1385
|
+
* @throws If the image, decoder options, or QR contents are invalid. {@link Error}
|
|
1386
|
+
* @example
|
|
1387
|
+
* Decode a rendered QR image via the default decoder export.
|
|
1388
|
+
* ```ts
|
|
1389
|
+
* import encodeQR, { Bitmap } from 'qr';
|
|
1390
|
+
* import decodeQR from 'qr/decode.js';
|
|
1391
|
+
* const bits = encodeQR('Hello world', 'raw', { scale: 4 });
|
|
1392
|
+
* const bm = new Bitmap({ width: bits[0].length, height: bits.length }, bits);
|
|
1393
|
+
* decodeQR(bm.toImage());
|
|
1394
|
+
* ```
|
|
1395
|
+
*/
|
|
935
1396
|
export default decodeQR;
|
|
936
1397
|
|
|
1398
|
+
// Additional private helpers for focused regression tests; separate from the
|
|
1399
|
+
// existing `_tests` object so its current keys remain stable.
|
|
1400
|
+
export const _TESTS: {
|
|
1401
|
+
findAlignment: typeof findAlignment;
|
|
1402
|
+
transform: typeof transform;
|
|
1403
|
+
} = /* @__PURE__ */ Object.freeze({
|
|
1404
|
+
findAlignment: findAlignment,
|
|
1405
|
+
transform: transform,
|
|
1406
|
+
});
|
|
1407
|
+
|
|
937
1408
|
// Unsafe API utils, exported only for tests
|
|
938
1409
|
export const _tests: {
|
|
939
1410
|
toBitmap: typeof toBitmap;
|
|
940
1411
|
decodeBitmap: typeof decodeBitmap;
|
|
941
1412
|
findFinder: typeof findFinder;
|
|
942
1413
|
detect: typeof detect;
|
|
943
|
-
} = {
|
|
1414
|
+
} = /* @__PURE__ */ Object.freeze({
|
|
944
1415
|
toBitmap,
|
|
945
1416
|
decodeBitmap,
|
|
946
1417
|
findFinder,
|
|
947
1418
|
detect,
|
|
948
|
-
};
|
|
1419
|
+
});
|