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/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
- function cap(value: number, min?: number, max?: number) {
63
- return Math.max(Math.min(value, max || value), min || value);
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
- const getBytesPerPixel = (img: Image) => {
66
- const perPixel = img.data.length / (img.width * img.height);
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. TODO: check gamma correction / sqr.
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 brightness = new Uint8Array(img.height * img.width);
78
- for (let i = 0, j = 0, d = img.data; i < d.length; i += bytesPerPixel) {
79
- const r = d[i];
80
- const g = d[i + 1];
81
- const b = d[i + 2];
82
- brightness[j++] = int((r + 2 * g + b) / 4) & 0xff;
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 (img.width < block * 5 || img.height < block * 5) throw new Error('image too small');
87
- const bWidth = Math.ceil(img.width / block);
88
- const bHeight = Math.ceil(img.height / block);
89
- const maxY = img.height - block;
90
- const maxX = img.width - block;
91
- const blocks = new Uint8Array(bWidth * bHeight);
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
- for (
100
- let yy = 0, pos = yPos * img.width + xPos;
101
- yy < block;
102
- yy = yy + 1, pos = pos + img.width
103
- ) {
104
- for (let xx = 0; xx < block; xx++) {
105
- const pixel = brightness[pos + xx];
106
- sum += pixel;
107
- min = Math.min(min, pixel);
108
- max = Math.max(max, pixel);
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 = Math.floor(sum / block ** 2);
113
- if (max - min <= GRAYSCALE_RANGE) {
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 prev =
118
- (blocks[idx(x, y - 1)] + 2 * blocks[idx(x - 1, y)] + blocks[idx(x - 1, y - 1)]) / 4;
119
- if (min < prev) average = prev;
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
- blocks[bWidth * y + x] = int(average);
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: img.width, height: img.height });
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, 2, bHeight - 3);
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 left = cap(x, 2, bWidth - 3);
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
- let sum = 0;
134
- for (let yy = -2; yy <= 2; yy++) {
135
- const y2 = bWidth * (top + yy) + left;
136
- for (let xx = -2; xx <= 2; xx++) sum += blocks[y2 + xx];
137
- }
138
- const average = sum / 25;
139
- for (let y = 0, pos = yPos * img.width + xPos; y < block; y += 1, pos += img.width) {
140
- for (let x = 0; x < block; x++) {
141
- if (brightness[pos + x] <= average) matrix.set(xPos + x, yPos + y, true);
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
- const moduleSize = total / FINDER.totalSize;
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, runs: Runs, center: Point, incr: Point, maxCount?: number) {
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 (; b.isInside(i) && !!b.point(i) === res.pattern[p]; pointIncr(i, step)) {
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) while (x < xEnd && !!b.get(x, y) === res.pattern[0]) x++;
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 (!!b.get(x, y) === res.pattern[pos]) {
256
- runs[pos]++;
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 !== b.width - 1) continue;
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/light in 1:1:3:1:1 ratio
287
- const FINDER = pattern([true, false, true, false, true], [1, 1, 3, 1, 1]);
288
- // dark/light/dark in 1:1:1 ratio
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(b, runs, center, incr, maxCount);
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(b, dRuns, { x: int(x), y: int(y) }, { x: 1, y: 1 })) return false;
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 * b.height) / (4 * 97)), DETECT_MIN_ROW_SKIP);
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 < b.height && !done; y += ySkip) {
338
- FINDER.scanLine(b, y, 0, b.width, (runs, x) => {
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, est: Pattern, allowanceFactor: number) {
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, b.width - 1);
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, b.height - 1);
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
- const width = rightX - leftX;
437
- const height = bottomY - topY;
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(b, y, xStart, xEnd, (runs, x) => {
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(b, rVert, { x: int(xx), y }, { y: 1, x: 0 }, 2 * runs[1]);
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, from: Point, to: Point) {
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
- // Same as alignment pattern ([true, false, true])
487
- if ((runPos === 1) === !!b.point(real)) {
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, from: Point, to: Point) {
502
- let result = _single(b, from, to);
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 = b.width;
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 = b.height;
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(b, from, { x: otherToX, y: otherToY });
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, p1: Point, p2: Point) {
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(b));
890
+ ({ bl, tl, tr } = findFinder(bm));
544
891
  } catch (e) {
545
892
  try {
546
- b.negate();
547
- ({ bl, tl, tr } = findFinder(b));
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
- b.negate(); // undo negate
900
+ bm.negate(); // undo negate
550
901
  throw e;
551
902
  }
552
903
  }
553
- const moduleSize = (moduleSizeAvg(b, tl, tr) + moduleSizeAvg(b, tl, bl)) / 2;
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(b, est, i);
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(b, size, from, [toTL, toTR, toBR, toBL]);
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, size: number, from: Point4, to: Point4): 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
- points[i] = int((p[0][0] * x + p[0][1] * y2 + p[0][2]) / den);
663
- points[i + 1] = int((p[1][0] * x + p[1][1] * y2 + p[1][2]) / den);
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
- const px = cap(points[i], 0, b.width - 1);
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 readBit = (x: number, y: number, out: number) => (out << 1) | (b.get(x, y) ? 1 : 0);
678
- const size = b.height;
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 = b.height;
702
- const { version1, version2, format1, format2 } = readInfoBits(b);
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
- if (version < 7) validateVersion(version);
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
- // Common encodings, please open issue if something popular missing
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, eci: number = 26): string {
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 size = b.height;
787
- if (size < 21 || (size & 0b11) !== 1 || size !== b.width)
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(b);
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 |= +(!!b.get(x, y) !== m);
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
- textDecoder?: (bytes: Uint8Array) => string;
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
- // Creates square from rectangle
884
- function cropToSquare(img: Image) {
885
- const data = Array.isArray(img.data) ? new Uint8Array(img.data) : img.data;
886
- const { height, width } = img;
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
- export function decodeQR(img: Image, opts: DecodeOpts = {}): string {
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(img[field]) || img[field] <= 0)
906
- throw new Error(`invalid img.${field}=${img[field]} (${typeof 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
- if (
909
- !Array.isArray(img.data) &&
910
- !(img.data instanceof Uint8Array) &&
911
- !(img.data instanceof Uint8ClampedArray)
912
- )
913
- throw new Error(`invalid image.data=${img.data} (${typeof img.data})`);
914
- if (opts.cropToSquare !== undefined && typeof opts.cropToSquare !== 'boolean')
915
- throw new Error(`invalid opts.cropToSquare=${opts.cropToSquare}`);
916
- for (const fn of ['pointsOnDetect', 'imageOnBitmap', 'imageOnDetect', 'imageOnResult'] as const) {
917
- if (opts[fn] !== undefined && typeof opts[fn] !== 'function')
918
- throw new Error(`invalid opts.${fn}=${opts[fn]} (${typeof opts[fn]})`);
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 (opts.cropToSquare) ({ img, offset } = cropToSquare(img));
922
- const bmp = toBitmap(img);
923
- if (opts.imageOnBitmap) opts.imageOnBitmap(bmp.toImage());
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 (opts.pointsOnDetect) {
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
- opts.pointsOnDetect(p);
1372
+ options.pointsOnDetect(p);
928
1373
  }
929
- if (opts.imageOnDetect) opts.imageOnDetect(bits.toImage());
930
- const res = decodeBitmap(bits, opts.textDecoder);
931
- if (opts.imageOnResult) opts.imageOnResult(bits.toImage());
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
+ });