quadqr-js 1.3.0 → 1.4.2

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.
@@ -125,6 +125,25 @@ export function generateRaw256Key() {
125
125
  return key;
126
126
  }
127
127
 
128
+ /**
129
+ * Return the fixed number of bytes added around a plaintext by Secure Payload
130
+ * v1 for the supplied mode. AES-GCM ciphertext length matches plaintext length,
131
+ * so this lets higher layers reason about final QuadQR version boundaries
132
+ * before encryption runs.
133
+ */
134
+ export function estimateSecureEnvelopeOverhead(security = {}) {
135
+ const mode = normalizeMode(security.mode);
136
+ let keyIdLength = 0;
137
+ if (mode === SECURITY_MODES.PASSWORD) {
138
+ if (security.keyId != null && security.keyId !== false) keyIdLength = encodeKeyId(security.keyId).bytes.length;
139
+ return FIXED_HEADER_BYTES + SALT_BYTES + NONCE_BYTES + TAG_BYTES + keyIdLength;
140
+ }
141
+ if (security.keyId === false) keyIdLength = 0;
142
+ else if (security.keyId != null) keyIdLength = encodeKeyId(security.keyId).bytes.length;
143
+ else keyIdLength = AUTO_KEY_ID_BYTES;
144
+ return FIXED_HEADER_BYTES + NONCE_BYTES + TAG_BYTES + keyIdLength;
145
+ }
146
+
128
147
  function randomBytes(length) {
129
148
  const bytes = new Uint8Array(length);
130
149
  cryptoApi().getRandomValues(bytes);
@@ -329,9 +329,15 @@ function cross(a, b) {
329
329
  return a.x * b.y - a.y * b.x;
330
330
  }
331
331
 
332
- function chooseFinderTriples(candidates, maxTriples = 16) {
333
- const top = candidates.slice(0, Math.min(candidates.length, 14));
332
+ function chooseFinderTriples(candidates, maxTriples = 16, options = {}) {
333
+ const perspectiveRecovery = options.perspectiveRecovery === true;
334
+ const topLimit = perspectiveRecovery ? 18 : 14;
335
+ const top = candidates.slice(0, Math.min(candidates.length, topLimit));
334
336
  const triples = [];
337
+ const maxCornerCos = options.maxCornerCos ?? (perspectiveRecovery ? 0.84 : 0.55);
338
+ const maxModuleSpread = options.maxFinderModuleSpread ?? (perspectiveRecovery ? 0.78 : 0.5);
339
+ const maxLegRatio = options.maxFinderLegRatio ?? (perspectiveRecovery ? 3.6 : 2.1);
340
+ const minLegModules = options.minFinderLegModules ?? (perspectiveRecovery ? 7.5 : 10);
335
341
 
336
342
  for (let a = 0; a < top.length - 2; a++) {
337
343
  for (let b = a + 1; b < top.length - 1; b++) {
@@ -346,9 +352,9 @@ function chooseFinderTriples(candidates, maxTriples = 16) {
346
352
  let v = sub(bl, tl);
347
353
  let d1 = Math.hypot(u.x, u.y);
348
354
  let d2 = Math.hypot(v.x, v.y);
349
- if (d1 < tl.moduleSize * 10 || d2 < tl.moduleSize * 10) continue;
355
+ if (d1 < tl.moduleSize * minLegModules || d2 < tl.moduleSize * minLegModules) continue;
350
356
  const cos = Math.abs(dot(u, v) / (d1 * d2));
351
- if (cos > 0.55) continue;
357
+ if (cos > maxCornerCos) continue;
352
358
  if (cross(u, v) < 0) {
353
359
  [tr, bl] = [bl, tr];
354
360
  u = sub(tr, tl);
@@ -362,13 +368,26 @@ function chooseFinderTriples(candidates, maxTriples = 16) {
362
368
  Math.abs(tr.moduleSize - moduleMean),
363
369
  Math.abs(bl.moduleSize - moduleMean)
364
370
  ) / moduleMean;
365
- if (moduleSpread > 0.5) continue;
371
+ if (moduleSpread > maxModuleSpread) continue;
366
372
  const legRatio = Math.max(d1, d2) / Math.min(d1, d2);
367
- if (legRatio > 2.1) continue;
373
+ if (legRatio > maxLegRatio) continue;
368
374
  const area = Math.abs(cross(u, v));
369
375
  const confirmScore = tl.confirmations + tr.confirmations + bl.confirmations;
370
- const score = area / (1 + cos * 8 + moduleSpread * 5 + Math.max(0, legRatio - 1) * 2) + confirmScore * 100;
371
- triples.push({ tl, tr, bl, moduleMean, score, orthogonality: 1 - cos });
376
+ const recoveryPenalty = perspectiveRecovery
377
+ ? 1 + Math.max(0, cos - 0.55) * 7 + Math.max(0, moduleSpread - 0.5) * 5 + Math.max(0, legRatio - 2.1) * 2
378
+ : 1;
379
+ const score = area / ((1 + cos * 8 + moduleSpread * 5 + Math.max(0, legRatio - 1) * 2) * recoveryPenalty) + confirmScore * 100;
380
+ triples.push({
381
+ tl,
382
+ tr,
383
+ bl,
384
+ moduleMean,
385
+ moduleSpread,
386
+ legRatio,
387
+ score,
388
+ orthogonality: 1 - cos,
389
+ perspectiveRecovery
390
+ });
372
391
  }
373
392
  }
374
393
  }
@@ -467,28 +486,158 @@ function searchAlignment(binary, width, height, triple, version, options = {}) {
467
486
  y: triple.tl.y + basisU.y * (targetX - 3.5) + basisV.y * (targetY - 3.5)
468
487
  };
469
488
 
470
- let best = { score: 0, center: predicted, scale: 1 };
471
489
  const precise = options.preciseAlignment === true;
472
- const offsets = precise
490
+ const localScoreFn = precise ? preciseAlignmentScore : alignmentScore;
491
+ const localOffsets = precise
473
492
  ? [-2.5, -1.5, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1.5, 2.5]
474
493
  : [-2.5, -1.5, -0.75, 0, 0.75, 1.5, 2.5];
475
- const scales = [0.72, 0.85, 1, 1.15, 1.3];
476
- const scoreAlignment = precise ? preciseAlignmentScore : alignmentScore;
494
+ const finderSizes = [triple.tl.moduleSize, triple.tr.moduleSize, triple.bl.moduleSize];
495
+ const finderMin = Math.max(0.01, Math.min(...finderSizes));
496
+ const finderMax = Math.max(...finderSizes);
497
+ const finderScaleRatio = finderMax / finderMin;
498
+ const inferredTargetScale = clamp(
499
+ Math.sqrt(
500
+ Math.max(0.2, triple.tr.moduleSize / Math.max(0.01, triple.tl.moduleSize)) *
501
+ Math.max(0.2, triple.bl.moduleSize / Math.max(0.01, triple.tl.moduleSize))
502
+ ),
503
+ 0.58,
504
+ 1.75
505
+ );
506
+ const localScales = [...new Set([
507
+ 0.72, 0.85, 1, 1.15, 1.3,
508
+ Number(inferredTargetScale.toFixed(3)),
509
+ Number((inferredTargetScale * 0.88).toFixed(3)),
510
+ Number((inferredTargetScale * 1.12).toFixed(3))
511
+ ])].filter((scale) => scale >= 0.52 && scale <= 1.9);
512
+
513
+ const centerAt = (offsetU, offsetV) => ({
514
+ x: predicted.x + basisU.x * offsetU + basisV.x * offsetV,
515
+ y: predicted.y + basisU.y * offsetU + basisV.y * offsetV
516
+ });
517
+ let best = { score: 0, center: predicted, scale: 1, offsetU: 0, offsetV: 0, broadSearch: false };
477
518
 
478
- for (const ou of offsets) {
479
- for (const ov of offsets) {
480
- const center = {
481
- x: predicted.x + basisU.x * ou + basisV.x * ov,
482
- y: predicted.y + basisU.y * ou + basisV.y * ov
483
- };
484
- for (const scale of scales) {
485
- const score = scoreAlignment(binary, width, height, center, basisU, basisV, scale, target);
486
- if (score > best.score) best = { score, center, scale };
519
+ const evaluateIntoBest = (offsetU, offsetV, scales, scoreFn, broadSearch = false) => {
520
+ const center = centerAt(offsetU, offsetV);
521
+ for (const scale of scales) {
522
+ const score = scoreFn(binary, width, height, center, basisU, basisV, scale, target);
523
+ if (score > best.score) best = { score, center, scale, offsetU, offsetV, broadSearch };
524
+ }
525
+ };
526
+
527
+ for (const ou of localOffsets) {
528
+ for (const ov of localOffsets) evaluateIntoBest(ou, ov, localScales, localScoreFn, false);
529
+ }
530
+
531
+ // A three-finder affine extrapolation becomes increasingly wrong as the
532
+ // symbol tilts away from the camera. Under strong yaw/pitch the primary
533
+ // bottom-right alignment marker can move 8-15 modules away from the affine
534
+ // prediction even though all three finder patterns remain strong. Use a
535
+ // bounded coarse-to-fine search only when the locator geometry signals that
536
+ // projective foreshortening is present.
537
+ const cornerSkew = Math.max(0, 1 - (triple.orthogonality ?? 1));
538
+ const projectiveSignal = Math.max(
539
+ Math.max(0, finderScaleRatio - 1),
540
+ Math.max(0, (triple.moduleSpread ?? 0) * 1.25),
541
+ cornerSkew * 0.75
542
+ );
543
+ const broadEnabled = options.perspectiveAlignmentRecovery !== false && (
544
+ options.perspectiveRecovery === true ||
545
+ projectiveSignal >= (options.perspectiveAlignmentSignalThreshold ?? 0.32)
546
+ );
547
+
548
+ if (broadEnabled && best.score < (options.perspectiveAlignmentEarlyAccept ?? 0.78)) {
549
+ const radius = clamp(
550
+ Number(options.perspectiveAlignmentRadius ?? (3.5 + projectiveSignal * 17)),
551
+ 4,
552
+ options.perspectiveAlignmentMaxRadius ?? 17
553
+ );
554
+ const coarseStep = radius > 12 ? 3 : radius > 8 ? 2.5 : 2;
555
+ const coarseScales = [...new Set([
556
+ Number((inferredTargetScale * 0.78).toFixed(3)),
557
+ Number((inferredTargetScale * 0.92).toFixed(3)),
558
+ Number(inferredTargetScale.toFixed(3)),
559
+ Number((inferredTargetScale * 1.10).toFixed(3)),
560
+ Number((inferredTargetScale * 1.28).toFixed(3)),
561
+ 0.68, 0.82, 1, 1.18, 1.38, 1.58
562
+ ])].filter((scale) => scale >= 0.48 && scale <= 2.05);
563
+ const coarseOffsets = [0];
564
+ for (let offset = coarseStep; offset <= radius + 0.001; offset += coarseStep) {
565
+ coarseOffsets.push(offset, -offset);
566
+ }
567
+ if (!coarseOffsets.some((value) => Math.abs(Math.abs(value) - radius) < 0.35)) {
568
+ coarseOffsets.push(radius, -radius);
569
+ }
570
+
571
+ const seeds = [];
572
+ for (const ou of coarseOffsets) {
573
+ for (const ov of coarseOffsets) {
574
+ if (Math.abs(ou) <= 2.75 && Math.abs(ov) <= 2.75) continue;
575
+ const center = centerAt(ou, ov);
576
+ let seedScore = 0;
577
+ let seedScale = 1;
578
+ // Keep the coarse pass cheap. Fine refinement below uses the denser
579
+ // sub-cell alignment score to reject data-cell lookalikes.
580
+ for (const scale of coarseScales) {
581
+ const score = alignmentScore(binary, width, height, center, basisU, basisV, scale, target);
582
+ if (score > seedScore) {
583
+ seedScore = score;
584
+ seedScale = scale;
585
+ }
586
+ }
587
+ seeds.push({ score: seedScore, scale: seedScale, offsetU: ou, offsetV: ov });
487
588
  }
488
589
  }
590
+ seeds.sort((a, b) => b.score - a.score);
591
+
592
+ let broadBest = null;
593
+ const fineSeeds = seeds.slice(0, options.perspectiveAlignmentFineSeeds ?? 6);
594
+ const fineOffsets = [-1.25, -0.75, -0.4, 0, 0.4, 0.75, 1.25];
595
+ for (const seed of fineSeeds) {
596
+ const fineScales = [...new Set([
597
+ Number((seed.scale * 0.86).toFixed(3)),
598
+ Number((seed.scale * 0.94).toFixed(3)),
599
+ Number(seed.scale.toFixed(3)),
600
+ Number((seed.scale * 1.06).toFixed(3)),
601
+ Number((seed.scale * 1.16).toFixed(3)),
602
+ Number(inferredTargetScale.toFixed(3)),
603
+ 1.0, 1.15, 1.3
604
+ ])].filter((scale) => scale >= 0.46 && scale <= 2.1);
605
+ for (const du of fineOffsets) {
606
+ for (const dv of fineOffsets) {
607
+ const offsetU = seed.offsetU + du;
608
+ const offsetV = seed.offsetV + dv;
609
+ const center = centerAt(offsetU, offsetV);
610
+ for (const scale of fineScales) {
611
+ const score = preciseAlignmentScore(binary, width, height, center, basisU, basisV, scale, target);
612
+ if (!broadBest || score > broadBest.score) {
613
+ broadBest = { score, center, scale, offsetU, offsetV, broadSearch: true };
614
+ }
615
+ }
616
+ }
617
+ }
618
+ }
619
+
620
+ // The precise score probes each alignment module at several sub-cell
621
+ // positions, making it substantially harder for random payload cells to
622
+ // impersonate the primary reference. Prefer a strong projective candidate
623
+ // even if the cheap centre-only local score happened to be slightly higher.
624
+ if (broadBest && (
625
+ broadBest.score >= (options.perspectiveAlignmentPreciseThreshold ?? 0.80) ||
626
+ broadBest.score > best.score + 0.08
627
+ )) {
628
+ best = broadBest;
629
+ }
489
630
  }
490
631
 
491
- return { ...best, basisU, basisV, predicted, target };
632
+ return {
633
+ ...best,
634
+ basisU,
635
+ basisV,
636
+ predicted,
637
+ target,
638
+ finderScaleRatio,
639
+ projectiveSignal
640
+ };
492
641
  }
493
642
 
494
643
  function projectedAlignmentScore(binary, width, height, homography, pattern) {
@@ -793,7 +942,7 @@ function geometryCandidatesFromBinary(binary, width, height, finders, threshold,
793
942
  const minVersion = options.minVersion ?? 1;
794
943
  const maxVersion = options.maxVersion ?? 40;
795
944
  const maxCandidates = options.maxCandidates ?? 8;
796
- const triples = chooseFinderTriples(finders, 20);
945
+ const triples = chooseFinderTriples(finders, options.perspectiveRecovery ? 30 : 20, options);
797
946
  const geometries = [];
798
947
 
799
948
  for (const triple of triples) {
@@ -954,6 +1103,7 @@ export function detectCodeGeometry(imageData, options = {}) {
954
1103
  const geometryOptions = {
955
1104
  ...options,
956
1105
  finderMethod: pass.finderMethod,
1106
+ perspectiveRecovery: recovery || options.perspectiveRecovery === true,
957
1107
  alignmentThreshold: recovery ? 0.68 : 0.72,
958
1108
  alignmentGridThreshold: recovery ? 0.64 : 0.68
959
1109
  };
@@ -1005,7 +1155,7 @@ export function detectCodeGeometry(imageData, options = {}) {
1005
1155
  return { finders, geometries };
1006
1156
  };
1007
1157
 
1008
- // Fast path stays exactly one grayscale + finder pass. No Auto Color, extra
1158
+ // Fast path stays exactly one grayscale + finder pass. No QuadQR Auto Color, extra
1009
1159
  // thresholding, or luminance image is computed when a normal frame works.
1010
1160
  const valueInfo = buildBinary(imageData, { grayMode: "value" });
1011
1161
  const fast = evaluatePass({
@@ -1019,7 +1169,7 @@ export function detectCodeGeometry(imageData, options = {}) {
1019
1169
 
1020
1170
  // If the clean threshold already found exactly two strong locators, try the
1021
1171
  // bounded perspective-tolerant third-finder pass before any color processing.
1022
- // This is substantially cheaper than Auto Color and targets the dense-code
1172
+ // This is substantially cheaper than QuadQR Auto Color and targets the dense-code
1023
1173
  // projective failure mode directly.
1024
1174
  if (fast.finders.length === 2) {
1025
1175
  const recoveredFinders = recoverFinderSetFromTwo(valueInfo.binary, width, height, fast.finders, {});
@@ -1049,12 +1199,12 @@ export function detectCodeGeometry(imageData, options = {}) {
1049
1199
  }
1050
1200
  }
1051
1201
 
1052
- // Camera recovery #1: Photoshop Auto Color-style per-channel levels before
1202
+ // Camera recovery #1: QuadQR Auto Color per-channel levels before
1053
1203
  // finder thresholding. Live camera frames are usually much larger than the
1054
1204
  // QR itself, so a single global histogram can be dominated by dark room/UI
1055
- // pixels around the guide. Photoshop looked strong in the user's cropped
1056
- // sample because its statistics were effectively code-centric. We emulate
1057
- // that by trying a few center-weighted analysis windows, while still applying
1205
+ // pixels around the guide. QuadQR keeps recovery deliberately code-centric
1206
+ // by using center-weighted analysis windows so surrounding scene pixels do not
1207
+ // dominate the correction, while still applying
1058
1208
  // each correction to the full frame so finder coordinates never move.
1059
1209
  const requestedInsets = Array.isArray(options.finderAutoColorAnalysisInsets)
1060
1210
  ? options.finderAutoColorAnalysisInsets
@@ -1100,7 +1250,7 @@ export function detectCodeGeometry(imageData, options = {}) {
1100
1250
 
1101
1251
  // Camera recovery #2: bracket the raw value-channel threshold, then retain
1102
1252
  // the legacy luminance pass for unusual captures. These are only built after
1103
- // both the normal and Auto Color finder passes fail.
1253
+ // both the normal and QuadQR Auto Color finder passes fail.
1104
1254
  const highThreshold = clamp(valueInfo.baseThreshold + 18, 8, 247);
1105
1255
  const lowThreshold = clamp(valueInfo.baseThreshold - 14, 8, 247);
1106
1256
  const recoveryPasses = [];
@@ -1214,7 +1364,7 @@ function buildAutoColorLevels(imageData, options = {}) {
1214
1364
  // Camera recovery deliberately supports analysing only the central part of
1215
1365
  // the visible frame. A phone preview often contains very dark UI/screen
1216
1366
  // edges outside the code; letting those pixels define the black point makes
1217
- // an otherwise useful Auto Color pass far too weak.
1367
+ // an otherwise useful QuadQR Auto Color pass far too weak.
1218
1368
  for (let index = 0; index < pixelCount; index += step) {
1219
1369
  const x = x0 + (index % analysisWidth);
1220
1370
  const y = y0 + Math.floor(index / analysisWidth);
@@ -1239,8 +1389,8 @@ function buildAutoColorLevels(imageData, options = {}) {
1239
1389
  let highs;
1240
1390
  const outputHighlight = Number(options.outputHighlight);
1241
1391
  if (Number.isFinite(outputHighlight)) {
1242
- // Strong Photoshop-like camera mode. Photoshop Auto Color on the supplied
1243
- // warm camera sample does not stretch the brightest observed paper/white
1392
+ // Strong QuadQR camera color-recovery mode. QuadQR Auto Color does not
1393
+ // stretch the brightest observed paper/white
1244
1394
  // cells all the way to 255. Instead it anchors the per-channel shadow
1245
1395
  // points close to black while keeping the observed highlight around a
1246
1396
  // neutral mid-high value. That produces much darker structural black and
@@ -1313,7 +1463,7 @@ function buildAutoToneContrastColorTransform(samples, options = {}) {
1313
1463
  const highlightFraction = clamp(options.highlightFraction ?? 0.14, 0.04, 0.35);
1314
1464
  const saturation = clamp(options.saturation ?? 1.12, 1, 1.5);
1315
1465
 
1316
- // Auto Color-style neutralization: use the brightest portion of the frame as
1466
+ // QuadQR Auto Color-style neutralization: use the brightest portion of the frame as
1317
1467
  // a likely white reference. This is especially effective on warm/yellow
1318
1468
  // phone-camera frames where the blue channel is suppressed.
1319
1469
  const luminanceHistogram = new Uint32Array(256);
@@ -1463,35 +1613,54 @@ export function samplePerspectiveMatrix(imageData, homography, size, options = {
1463
1613
  * module borders. This makes the decoder much less sensitive to antialiasing,
1464
1614
  * blur, resampling and small homography errors than center sampling.
1465
1615
  */
1616
+ function rgbSampleSpread(values, center) {
1617
+ if (!values.length) return 0;
1618
+ return values.reduce((sum, rgb) => sum + Math.hypot(
1619
+ rgb.r - center.r,
1620
+ rgb.g - center.g,
1621
+ rgb.b - center.b
1622
+ ), 0) / values.length;
1623
+ }
1624
+
1466
1625
  export function samplePerspectiveTriangleMatrix(imageData, homography, size, options = {}) {
1467
1626
  const triangleGrid = Array.from({ length: size }, () => new Array(size));
1468
1627
  const mode = options.sampleMode ?? "cross";
1469
1628
  const radius = options.highDensitySampleRadius ?? options.triangleSampleRadius ??
1470
- (mode === "median" ? 0.075 : 0.085);
1471
- const inset = Math.max(0.18, Math.min(0.36, Number(options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.28)));
1472
- const firstOffset = inset;
1473
- const secondOffset = 1 - inset;
1629
+ (mode === "median" ? 0.055 : 0.065);
1630
+ const inset = Math.max(0.18, Math.min(0.34, Number(options.highDensitySampleInset ?? options.triangleSampleInset ?? 0.27)));
1474
1631
  const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
1475
1632
  const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
1476
1633
  const sampler = mode === "median" ? robustProjectedSample : averageProjectedSample;
1634
+ const side = Math.max(0.18, inset - 0.06);
1635
+ const middle = Math.min(0.52, 0.50 - inset * 0.04);
1636
+ const firstAnchors = [
1637
+ [inset, inset],
1638
+ [side, middle],
1639
+ [middle, side]
1640
+ ];
1641
+ const secondAnchors = firstAnchors.map(([x, y]) => [1 - x, 1 - y]);
1642
+
1643
+ const sampleRegion = (c, r, anchors) => {
1644
+ const values = anchors.map(([x, y]) => sampler(
1645
+ imageData,
1646
+ homography,
1647
+ c + x + offsetX,
1648
+ r + y + offsetY,
1649
+ radius
1650
+ ));
1651
+ const rgb = robustRgb(values);
1652
+ return { rgb, spread: rgbSampleSpread(values, rgb) };
1653
+ };
1477
1654
 
1478
1655
  for (let r = 0; r < size; r++) {
1479
1656
  for (let c = 0; c < size; c++) {
1657
+ const first = sampleRegion(c, r, firstAnchors);
1658
+ const second = sampleRegion(c, r, secondAnchors);
1480
1659
  triangleGrid[r][c] = {
1481
- first: sampler(
1482
- imageData,
1483
- homography,
1484
- c + firstOffset + offsetX,
1485
- r + firstOffset + offsetY,
1486
- radius
1487
- ),
1488
- second: sampler(
1489
- imageData,
1490
- homography,
1491
- c + secondOffset + offsetX,
1492
- r + secondOffset + offsetY,
1493
- radius
1494
- )
1660
+ first: first.rgb,
1661
+ second: second.rgb,
1662
+ firstSpread: first.spread,
1663
+ secondSpread: second.spread
1495
1664
  };
1496
1665
  }
1497
1666
  }
@@ -1694,28 +1863,37 @@ export function sampleAxisAlignedGrid(imageData, bounds, size, radiusRatio = 0.1
1694
1863
  return { rgbGrid, moduleWidth: moduleW, moduleHeight: moduleH };
1695
1864
  }
1696
1865
 
1697
- export function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.085, insetRatio = 0.28) {
1866
+ export function sampleAxisAlignedTriangleGrid(imageData, bounds, size, radiusRatio = 0.065, insetRatio = 0.27) {
1698
1867
  const moduleW = bounds.width / size;
1699
1868
  const moduleH = bounds.height / size;
1700
- const inset = Math.max(0.18, Math.min(0.36, insetRatio));
1869
+ const inset = Math.max(0.18, Math.min(0.34, insetRatio));
1701
1870
  const radius = Math.max(0, Math.min(moduleW, moduleH) * radiusRatio);
1702
1871
  const triangleGrid = Array.from({ length: size }, () => new Array(size));
1872
+ const side = Math.max(0.18, inset - 0.06);
1873
+ const middle = Math.min(0.52, 0.50 - inset * 0.04);
1874
+ const firstAnchors = [[inset, inset], [side, middle], [middle, side]];
1875
+ const secondAnchors = firstAnchors.map(([x, y]) => [1 - x, 1 - y]);
1876
+
1877
+ const sampleRegion = (r, c, anchors) => {
1878
+ const values = anchors.map(([x, y]) => sampleAverageAxis(
1879
+ imageData,
1880
+ bounds.x + (c + x) * moduleW,
1881
+ bounds.y + (r + y) * moduleH,
1882
+ radius
1883
+ ));
1884
+ const rgb = robustRgb(values);
1885
+ return { rgb, spread: rgbSampleSpread(values, rgb) };
1886
+ };
1703
1887
 
1704
1888
  for (let r = 0; r < size; r++) {
1705
1889
  for (let c = 0; c < size; c++) {
1890
+ const first = sampleRegion(r, c, firstAnchors);
1891
+ const second = sampleRegion(r, c, secondAnchors);
1706
1892
  triangleGrid[r][c] = {
1707
- first: sampleAverageAxis(
1708
- imageData,
1709
- bounds.x + (c + inset) * moduleW,
1710
- bounds.y + (r + inset) * moduleH,
1711
- radius
1712
- ),
1713
- second: sampleAverageAxis(
1714
- imageData,
1715
- bounds.x + (c + 1 - inset) * moduleW,
1716
- bounds.y + (r + 1 - inset) * moduleH,
1717
- radius
1718
- )
1893
+ first: first.rgb,
1894
+ second: second.rgb,
1895
+ firstSpread: first.spread,
1896
+ secondSpread: second.spread
1719
1897
  };
1720
1898
  }
1721
1899
  }