quadqr-js 1.0.0 → 1.0.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.
@@ -56,7 +56,7 @@ function luminance(rgb) {
56
56
  return 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b;
57
57
  }
58
58
 
59
- function buildGray(imageData) {
59
+ function buildGray(imageData, mode = "luminance") {
60
60
  const gray = new Uint8Array(imageData.width * imageData.height);
61
61
  for (let i = 0; i < gray.length; i++) {
62
62
  const p = i * 4;
@@ -64,7 +64,15 @@ function buildGray(imageData) {
64
64
  const r = imageData.data[p] * a + 255 * (1 - a);
65
65
  const g = imageData.data[p + 1] * a + 255 * (1 - a);
66
66
  const b = imageData.data[p + 2] * a + 255 * (1 - a);
67
- gray[i] = Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b);
67
+
68
+ // Finder detection is special for QuadQR. Saturated red/green/blue data
69
+ // cells can look very dark in normal luminance, especially blue under a
70
+ // warm phone camera. The HSV "value" channel keeps any cell with one
71
+ // strong RGB component bright while structural black stays dark in all
72
+ // channels. That makes the black/white finder rings far easier to isolate.
73
+ gray[i] = Math.round(mode === "value"
74
+ ? Math.max(r, g, b)
75
+ : 0.2126 * r + 0.7152 * g + 0.0722 * b);
68
76
  }
69
77
  return gray;
70
78
  }
@@ -100,12 +108,28 @@ function otsuThreshold(gray) {
100
108
  return threshold;
101
109
  }
102
110
 
103
- function buildBinary(imageData) {
104
- const gray = buildGray(imageData);
105
- const threshold = otsuThreshold(gray);
111
+ function binaryAtThreshold(gray, threshold) {
106
112
  const binary = new Uint8Array(gray.length);
107
113
  for (let i = 0; i < gray.length; i++) binary[i] = gray[i] <= threshold ? 1 : 0;
108
- return { gray, binary, threshold };
114
+ return binary;
115
+ }
116
+
117
+ function buildBinary(imageData, options = {}) {
118
+ const grayMode = options.grayMode ?? "luminance";
119
+ const gray = buildGray(imageData, grayMode);
120
+ const baseThreshold = otsuThreshold(gray);
121
+ const threshold = clamp(
122
+ Math.round(baseThreshold + (options.thresholdOffset ?? 0)),
123
+ 8,
124
+ 247
125
+ );
126
+ return {
127
+ gray,
128
+ binary: binaryAtThreshold(gray, threshold),
129
+ threshold,
130
+ baseThreshold,
131
+ grayMode
132
+ };
109
133
  }
110
134
 
111
135
  function runsForRow(binary, width, row) {
@@ -140,14 +164,14 @@ function runsForColumn(binary, width, height, col) {
140
164
  return runs;
141
165
  }
142
166
 
143
- function finderRatioScore(lengths) {
167
+ function finderRatioScore(lengths, toleranceScale = 1) {
144
168
  const total = lengths.reduce((sum, value) => sum + value, 0);
145
169
  if (total < 7) return Infinity;
146
170
  const module = total / 7;
147
171
  const expected = [module, module, 3 * module, module, module];
148
172
  let score = 0;
149
173
  for (let i = 0; i < 5; i++) {
150
- const tolerance = i === 2 ? module * 1.25 : module * 0.8;
174
+ const tolerance = (i === 2 ? module * 1.25 : module * 0.8) * toleranceScale;
151
175
  const diff = Math.abs(lengths[i] - expected[i]);
152
176
  if (diff > tolerance) return Infinity;
153
177
  score += diff / Math.max(1, expected[i]);
@@ -155,14 +179,14 @@ function finderRatioScore(lengths) {
155
179
  return score;
156
180
  }
157
181
 
158
- function findWindowContainingCoordinate(runs, coordinate) {
182
+ function findWindowContainingCoordinate(runs, coordinate, toleranceScale = 1) {
159
183
  for (let i = 2; i < runs.length - 2; i++) {
160
184
  const centerRun = runs[i];
161
185
  if (centerRun.color !== 1) continue;
162
186
  if (coordinate < centerRun.start || coordinate >= centerRun.start + centerRun.length) continue;
163
187
  const window = runs.slice(i - 2, i + 3);
164
188
  if (window.map((run) => run.color).join("") !== "10101") continue;
165
- const score = finderRatioScore(window.map((run) => run.length));
189
+ const score = finderRatioScore(window.map((run) => run.length), toleranceScale);
166
190
  if (!Number.isFinite(score)) continue;
167
191
  const first = window[0].start;
168
192
  const total = window.reduce((sum, run) => sum + run.length, 0);
@@ -175,17 +199,17 @@ function findWindowContainingCoordinate(runs, coordinate) {
175
199
  return null;
176
200
  }
177
201
 
178
- function crossCheckVertical(binary, width, height, x, y) {
202
+ function crossCheckVertical(binary, width, height, x, y, toleranceScale = 1) {
179
203
  const col = clamp(Math.round(x), 0, width - 1);
180
- return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y);
204
+ return findWindowContainingCoordinate(runsForColumn(binary, width, height, col), y, toleranceScale);
181
205
  }
182
206
 
183
- function crossCheckHorizontal(binary, width, height, x, y) {
207
+ function crossCheckHorizontal(binary, width, height, x, y, toleranceScale = 1) {
184
208
  const row = clamp(Math.round(y), 0, height - 1);
185
- return findWindowContainingCoordinate(runsForRow(binary, width, row), x);
209
+ return findWindowContainingCoordinate(runsForRow(binary, width, row), x, toleranceScale);
186
210
  }
187
211
 
188
- function clusterFinderCandidates(raw) {
212
+ function clusterFinderCandidates(raw, minConfirmations = 2) {
189
213
  const clusters = [];
190
214
  raw.sort((a, b) => a.moduleSize - b.moduleSize);
191
215
 
@@ -218,7 +242,7 @@ function clusterFinderCandidates(raw) {
218
242
  }
219
243
 
220
244
  return clusters
221
- .filter((candidate) => candidate.confirmations >= 2)
245
+ .filter((candidate) => candidate.confirmations >= minConfirmations)
222
246
  .sort((a, b) =>
223
247
  (b.confirmations - a.confirmations) ||
224
248
  (a.score - b.score) ||
@@ -226,9 +250,12 @@ function clusterFinderCandidates(raw) {
226
250
  );
227
251
  }
228
252
 
229
- function detectFinderCandidates(binary, width, height) {
253
+ function detectFinderCandidates(binary, width, height, options = {}) {
230
254
  const raw = [];
231
255
  const rowStep = height > 1200 ? 2 : 1;
256
+ const toleranceScale = options.toleranceScale ?? 1;
257
+ const moduleSpreadLimit = options.moduleSpreadLimit ?? 0.45;
258
+ const minConfirmations = options.minConfirmations ?? 2;
232
259
 
233
260
  for (let y = 0; y < height; y += rowStep) {
234
261
  const runs = runsForRow(binary, width, y);
@@ -236,13 +263,13 @@ function detectFinderCandidates(binary, width, height) {
236
263
  const window = runs.slice(i, i + 5);
237
264
  if (window.map((run) => run.color).join("") !== "10101") continue;
238
265
  const lengths = window.map((run) => run.length);
239
- const ratioScore = finderRatioScore(lengths);
266
+ const ratioScore = finderRatioScore(lengths, toleranceScale);
240
267
  if (!Number.isFinite(ratioScore)) continue;
241
268
  const total = lengths.reduce((sum, value) => sum + value, 0);
242
269
  const centerX = window[0].start + total / 2;
243
- const vertical = crossCheckVertical(binary, width, height, centerX, y);
270
+ const vertical = crossCheckVertical(binary, width, height, centerX, y, toleranceScale);
244
271
  if (!vertical) continue;
245
- const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center);
272
+ const horizontal = crossCheckHorizontal(binary, width, height, centerX, vertical.center, toleranceScale);
246
273
  if (!horizontal) continue;
247
274
 
248
275
  const moduleSize = (total / 7 + vertical.moduleSize + horizontal.moduleSize) / 3;
@@ -251,7 +278,7 @@ function detectFinderCandidates(binary, width, height) {
251
278
  Math.abs(moduleSize - vertical.moduleSize),
252
279
  Math.abs(moduleSize - horizontal.moduleSize)
253
280
  ) / moduleSize;
254
- if (moduleSpread > 0.45) continue;
281
+ if (moduleSpread > moduleSpreadLimit) continue;
255
282
 
256
283
  raw.push({
257
284
  x: horizontal.center,
@@ -262,7 +289,7 @@ function detectFinderCandidates(binary, width, height) {
262
289
  }
263
290
  }
264
291
 
265
- return clusterFinderCandidates(raw);
292
+ return clusterFinderCandidates(raw, minConfirmations);
266
293
  }
267
294
 
268
295
  function dot(a, b) {
@@ -486,15 +513,11 @@ export function projectPoint(h, u, v) {
486
513
  };
487
514
  }
488
515
 
489
- export function detectCodeGeometry(imageData, options = {}) {
490
- assert(imageData?.data && imageData.width && imageData.height, "Valid image data is required.");
516
+ function geometryCandidatesFromBinary(binary, width, height, finders, threshold, options = {}) {
517
+ if (finders.length < 3) return [];
491
518
  const minVersion = options.minVersion ?? 1;
492
519
  const maxVersion = options.maxVersion ?? 40;
493
520
  const maxCandidates = options.maxCandidates ?? 8;
494
- const { binary, threshold } = buildBinary(imageData);
495
- const finders = detectFinderCandidates(binary, imageData.width, imageData.height);
496
- if (finders.length < 3) return [];
497
-
498
521
  const triples = chooseFinderTriples(finders, 20);
499
522
  const geometries = [];
500
523
 
@@ -507,8 +530,8 @@ export function detectCodeGeometry(imageData, options = {}) {
507
530
  for (const item of versions) {
508
531
  const version = item.version;
509
532
  const size = sizeForVersion(version);
510
- const alignment = searchAlignment(binary, imageData.width, imageData.height, triple, version);
511
- if (alignment.score < 0.72) continue;
533
+ const alignment = searchAlignment(binary, width, height, triple, version);
534
+ if (alignment.score < (options.alignmentThreshold ?? 0.72)) continue;
512
535
 
513
536
  const alignmentTarget = alignment.target;
514
537
  const dest = [
@@ -531,8 +554,8 @@ export function detectCodeGeometry(imageData, options = {}) {
531
554
  continue;
532
555
  }
533
556
 
534
- const alignmentGrid = scoreAlignmentGrid(binary, imageData.width, imageData.height, homography, version);
535
- if (alignmentGrid.score < 0.68) continue;
557
+ const alignmentGrid = scoreAlignmentGrid(binary, width, height, homography, version);
558
+ if (alignmentGrid.score < (options.alignmentGridThreshold ?? 0.68)) continue;
536
559
 
537
560
  const versionPenalty = item.error / 4;
538
561
  const alignmentConfidence = 0.55 * alignment.score + 0.45 * alignmentGrid.score;
@@ -554,13 +577,13 @@ export function detectCodeGeometry(imageData, options = {}) {
554
577
  patternScores: alignmentGrid.patternScores
555
578
  },
556
579
  threshold,
580
+ finderMethod: options.finderMethod,
557
581
  estimatedSize,
558
582
  score
559
583
  });
560
584
  }
561
585
  }
562
586
 
563
- // Deduplicate roughly identical geometry/version hypotheses.
564
587
  const deduped = [];
565
588
  geometries.sort((a, b) => b.score - a.score);
566
589
  for (const geometry of geometries) {
@@ -577,33 +600,489 @@ export function detectCodeGeometry(imageData, options = {}) {
577
600
  return deduped;
578
601
  }
579
602
 
603
+ function pushFinderDiagnostic(options, finderMethod, threshold, finders, geometries) {
604
+ if (!options.diagnostics || typeof options.diagnostics !== "object") return;
605
+ const passes = Array.isArray(options.diagnostics.passes)
606
+ ? options.diagnostics.passes
607
+ : (options.diagnostics.passes = []);
608
+ passes.push({
609
+ label: options.diagnosticLabel ?? "perspective",
610
+ finderMethod,
611
+ width: options.width,
612
+ height: options.height,
613
+ threshold,
614
+ finderCount: finders.length,
615
+ finders: finders.map((finder) => ({
616
+ x: finder.x,
617
+ y: finder.y,
618
+ moduleSize: finder.moduleSize,
619
+ confirmations: finder.confirmations,
620
+ score: finder.score
621
+ })),
622
+ geometries: geometries.map((geometry) => ({
623
+ version: geometry.version,
624
+ size: geometry.size,
625
+ score: geometry.score,
626
+ estimatedSize: geometry.estimatedSize,
627
+ alignmentScore: geometry.alignment.score,
628
+ alignmentGridScore: geometry.alignment.gridScore,
629
+ finderMethod: geometry.finderMethod,
630
+ finders: {
631
+ topLeft: { ...geometry.finders.topLeft },
632
+ topRight: { ...geometry.finders.topRight },
633
+ bottomLeft: { ...geometry.finders.bottomLeft }
634
+ },
635
+ alignmentCenter: { ...geometry.alignment.center },
636
+ sourcePoints: geometry.sourcePoints.map((point) => ({ ...point }))
637
+ }))
638
+ });
639
+ }
640
+
641
+ export function detectCodeGeometry(imageData, options = {}) {
642
+ assert(imageData?.data && imageData.width && imageData.height, "Valid image data is required.");
643
+ const width = imageData.width;
644
+ const height = imageData.height;
645
+ const maxCandidates = options.maxCandidates ?? 8;
646
+
647
+ const evaluatePass = (pass, recovery = false) => {
648
+ const finders = detectFinderCandidates(pass.binary, width, height, pass.detector ?? {});
649
+ const geometries = geometryCandidatesFromBinary(
650
+ pass.binary,
651
+ width,
652
+ height,
653
+ finders,
654
+ pass.threshold,
655
+ {
656
+ ...options,
657
+ finderMethod: pass.finderMethod,
658
+ alignmentThreshold: recovery ? 0.68 : 0.72,
659
+ alignmentGridThreshold: recovery ? 0.64 : 0.68
660
+ }
661
+ );
662
+
663
+ pushFinderDiagnostic(
664
+ { ...options, width, height },
665
+ pass.finderMethod,
666
+ pass.threshold,
667
+ finders,
668
+ geometries
669
+ );
670
+ return { finders, geometries };
671
+ };
672
+
673
+ // Fast path stays exactly one grayscale + finder pass. No Auto Color, extra
674
+ // thresholding, or luminance image is computed when a normal frame works.
675
+ const valueInfo = buildBinary(imageData, { grayMode: "value" });
676
+ const fast = evaluatePass({
677
+ finderMethod: "rgb-value-otsu",
678
+ binary: valueInfo.binary,
679
+ threshold: valueInfo.threshold,
680
+ detector: {}
681
+ }, false);
682
+ if (fast.geometries.length) return fast.geometries.slice(0, maxCandidates);
683
+ if (options.finderRecovery === false) return [];
684
+
685
+ // Camera recovery #1: Photoshop Auto Color-style per-channel levels before
686
+ // finder thresholding. Live camera frames are usually much larger than the
687
+ // QR itself, so a single global histogram can be dominated by dark room/UI
688
+ // pixels around the guide. Photoshop looked strong in the user's cropped
689
+ // sample because its statistics were effectively code-centric. We emulate
690
+ // that by trying a few center-weighted analysis windows, while still applying
691
+ // each correction to the full frame so finder coordinates never move.
692
+ const requestedInsets = Array.isArray(options.finderAutoColorAnalysisInsets)
693
+ ? options.finderAutoColorAnalysisInsets
694
+ : (Number.isFinite(options.finderAutoColorAnalysisInset)
695
+ ? [options.finderAutoColorAnalysisInset]
696
+ : [0.10, 0.20, 0.04]);
697
+ const autoColorInsets = [];
698
+ for (const value of requestedInsets) {
699
+ const inset = clamp(Number(value), 0, 0.30);
700
+ if (!autoColorInsets.some((item) => Math.abs(item - inset) < 0.001)) autoColorInsets.push(inset);
701
+ }
702
+
703
+ for (const analysisInset of autoColorInsets) {
704
+ try {
705
+ const autoColorGray = buildAutoColorValueGray(imageData, {
706
+ blackClip: options.finderAutoColorBlackClip ?? 0.0001,
707
+ whiteClip: options.finderAutoColorWhiteClip,
708
+ highlightPercentile: options.finderAutoColorHighlightPercentile ?? 0.95,
709
+ outputHighlight: options.finderAutoColorOutputHighlight ?? 190,
710
+ analysisInset,
711
+ minimumInputRange: options.finderAutoColorMinimumInputRange ?? 72,
712
+ targetSamples: options.finderAutoColorTargetSamples
713
+ });
714
+ const base = otsuThreshold(autoColorGray);
715
+ const insetLabel = String(Math.round(analysisInset * 100)).padStart(2, "0");
716
+ const thresholds = [
717
+ { suffix: "otsu", value: base },
718
+ { suffix: "high", value: clamp(base + 12, 8, 247) }
719
+ ];
720
+ for (const thresholdInfo of thresholds) {
721
+ const autoColor = evaluatePass({
722
+ finderMethod: `auto-color-center${insetLabel}-${thresholdInfo.suffix}`,
723
+ binary: binaryAtThreshold(autoColorGray, thresholdInfo.value),
724
+ threshold: thresholdInfo.value,
725
+ detector: { toleranceScale: 1.22, moduleSpreadLimit: 0.60 }
726
+ }, true);
727
+ if (autoColor.geometries.length) return autoColor.geometries.slice(0, maxCandidates);
728
+ }
729
+ } catch {
730
+ // Try the next center weighting, then continue with raw threshold bracketing.
731
+ }
732
+ }
733
+
734
+ // Camera recovery #2: bracket the raw value-channel threshold, then retain
735
+ // the legacy luminance pass for unusual captures. These are only built after
736
+ // both the normal and Auto Color finder passes fail.
737
+ const highThreshold = clamp(valueInfo.baseThreshold + 18, 8, 247);
738
+ const lowThreshold = clamp(valueInfo.baseThreshold - 14, 8, 247);
739
+ const recoveryPasses = [];
740
+ if (highThreshold !== valueInfo.threshold) {
741
+ recoveryPasses.push({
742
+ finderMethod: "rgb-value-high-threshold",
743
+ binary: binaryAtThreshold(valueInfo.gray, highThreshold),
744
+ threshold: highThreshold,
745
+ detector: { toleranceScale: 1.18, moduleSpreadLimit: 0.56 }
746
+ });
747
+ }
748
+ if (lowThreshold !== valueInfo.threshold) {
749
+ recoveryPasses.push({
750
+ finderMethod: "rgb-value-low-threshold",
751
+ binary: binaryAtThreshold(valueInfo.gray, lowThreshold),
752
+ threshold: lowThreshold,
753
+ detector: { toleranceScale: 1.14, moduleSpreadLimit: 0.54 }
754
+ });
755
+ }
756
+ const lumaInfo = buildBinary(imageData, { grayMode: "luminance" });
757
+ recoveryPasses.push({
758
+ finderMethod: "luminance-otsu",
759
+ binary: lumaInfo.binary,
760
+ threshold: lumaInfo.threshold,
761
+ detector: { toleranceScale: 1.10, moduleSpreadLimit: 0.52 }
762
+ });
763
+
764
+ for (const pass of recoveryPasses) {
765
+ const recovery = evaluatePass(pass, true);
766
+ if (recovery.geometries.length) return recovery.geometries.slice(0, maxCandidates);
767
+ }
768
+
769
+ return [];
770
+ }
771
+
772
+ function median(values) {
773
+ if (!values.length) return 0;
774
+ const sorted = values.slice().sort((a, b) => a - b);
775
+ const middle = Math.floor(sorted.length / 2);
776
+ return sorted.length % 2
777
+ ? sorted[middle]
778
+ : (sorted[middle - 1] + sorted[middle]) / 2;
779
+ }
780
+
781
+ function robustRgb(values) {
782
+ if (!values.length) throw new Error("No RGB samples available.");
783
+ return {
784
+ r: median(values.map((rgb) => rgb.r)),
785
+ g: median(values.map((rgb) => rgb.g)),
786
+ b: median(values.map((rgb) => rgb.b))
787
+ };
788
+ }
789
+
580
790
  function averageProjectedSample(imageData, homography, moduleX, moduleY, radius = 0.16) {
581
791
  const offsets = radius > 0
582
792
  ? [[0, 0], [-radius, 0], [radius, 0], [0, -radius], [0, radius]]
583
793
  : [[0, 0]];
584
- let r = 0;
585
- let g = 0;
586
- let b = 0;
587
- let count = 0;
794
+ const values = [];
588
795
  for (const [dx, dy] of offsets) {
589
796
  const point = projectPoint(homography, moduleX + dx, moduleY + dy);
590
797
  if (point.x < 0 || point.y < 0 || point.x >= imageData.width || point.y >= imageData.height) continue;
591
- const rgb = bilinearRgb(imageData, point.x, point.y);
592
- r += rgb.r;
593
- g += rgb.g;
594
- b += rgb.b;
595
- count++;
798
+ values.push(bilinearRgb(imageData, point.x, point.y));
799
+ }
800
+ if (!values.length) return { r: 255, g: 255, b: 255 };
801
+ return averageRgb(values);
802
+ }
803
+
804
+ function robustProjectedSample(imageData, homography, moduleX, moduleY, radius = 0.12) {
805
+ // Fallback for dirty/soft-focus camera frames. A compact 3x3 patch stays
806
+ // away from module edges and median aggregation rejects glare/speckles.
807
+ const offsets = radius > 0 ? [-radius, 0, radius] : [0];
808
+ const values = [];
809
+ for (const dy of offsets) {
810
+ for (const dx of offsets) {
811
+ const point = projectPoint(homography, moduleX + dx, moduleY + dy);
812
+ if (point.x < 0 || point.y < 0 || point.x >= imageData.width || point.y >= imageData.height) continue;
813
+ values.push(bilinearRgb(imageData, point.x, point.y));
814
+ }
815
+ }
816
+ return values.length ? robustRgb(values) : { r: 255, g: 255, b: 255 };
817
+ }
818
+
819
+
820
+
821
+ function histogramPercentile(histogram, total, fraction) {
822
+ if (!total) return 0;
823
+ const target = Math.max(1, Math.ceil(total * clamp(fraction, 0, 1)));
824
+ let seen = 0;
825
+ for (let i = 0; i < histogram.length; i++) {
826
+ seen += histogram[i];
827
+ if (seen >= target) return i;
828
+ }
829
+ return histogram.length - 1;
830
+ }
831
+
832
+
833
+ function buildAutoColorLevels(imageData, options = {}) {
834
+ const analysisInset = clamp(options.analysisInset ?? 0, 0, 0.30);
835
+ const x0 = Math.floor(imageData.width * analysisInset);
836
+ const y0 = Math.floor(imageData.height * analysisInset);
837
+ const x1 = Math.max(x0 + 1, Math.ceil(imageData.width * (1 - analysisInset)));
838
+ const y1 = Math.max(y0 + 1, Math.ceil(imageData.height * (1 - analysisInset)));
839
+ const analysisWidth = Math.max(1, x1 - x0);
840
+ const analysisHeight = Math.max(1, y1 - y0);
841
+ const pixelCount = analysisWidth * analysisHeight;
842
+ const targetSamples = Math.max(4000, Math.round(options.targetSamples ?? 90000));
843
+ const step = Math.max(1, Math.floor(pixelCount / targetSamples));
844
+ const histograms = [new Uint32Array(256), new Uint32Array(256), new Uint32Array(256)];
845
+ let samples = 0;
846
+
847
+ // Camera recovery deliberately supports analysing only the central part of
848
+ // the visible frame. A phone preview often contains very dark UI/screen
849
+ // edges outside the code; letting those pixels define the black point makes
850
+ // an otherwise useful Auto Color pass far too weak.
851
+ for (let index = 0; index < pixelCount; index += step) {
852
+ const x = x0 + (index % analysisWidth);
853
+ const y = y0 + Math.floor(index / analysisWidth);
854
+ const p = (y * imageData.width + x) * 4;
855
+ const a = imageData.data[p + 3] / 255;
856
+ const rgb = [
857
+ imageData.data[p] * a + 255 * (1 - a),
858
+ imageData.data[p + 1] * a + 255 * (1 - a),
859
+ imageData.data[p + 2] * a + 255 * (1 - a)
860
+ ];
861
+ for (let channel = 0; channel < 3; channel++) {
862
+ histograms[channel][clamp(Math.round(rgb[channel]), 0, 255)]++;
863
+ }
864
+ samples++;
865
+ }
866
+
867
+ const blackClip = clamp(options.blackClip ?? 0.004, 0, 0.06);
868
+ const whiteClip = clamp(options.whiteClip ?? 0.004, 0, 0.06);
869
+ const lows = histograms.map((histogram) => histogramPercentile(histogram, samples, blackClip));
870
+
871
+ let references;
872
+ let highs;
873
+ const outputHighlight = Number(options.outputHighlight);
874
+ if (Number.isFinite(outputHighlight)) {
875
+ // Strong Photoshop-like camera mode. Photoshop Auto Color on the supplied
876
+ // warm camera sample does not stretch the brightest observed paper/white
877
+ // cells all the way to 255. Instead it anchors the per-channel shadow
878
+ // points close to black while keeping the observed highlight around a
879
+ // neutral mid-high value. That produces much darker structural black and
880
+ // much stronger RGB separation without washing the whole code out.
881
+ const highlightPercentile = clamp(options.highlightPercentile ?? 0.95, 0.70, 0.9999);
882
+ const target = clamp(outputHighlight, 96, 250);
883
+ references = histograms.map((histogram) => histogramPercentile(histogram, samples, highlightPercentile));
884
+ const minimumInputRange = Math.max(32, Math.round(options.minimumInputRange ?? 72));
885
+ highs = references.map((reference, channel) => {
886
+ const observedRange = Math.max(minimumInputRange, reference - lows[channel]);
887
+ return lows[channel] + observedRange * 255 / target;
888
+ });
889
+ } else {
890
+ references = histograms.map((histogram) => histogramPercentile(histogram, samples, 1 - whiteClip));
891
+ highs = references.slice();
892
+ for (let channel = 0; channel < 3; channel++) {
893
+ highs[channel] = Math.max(lows[channel] + 24, highs[channel]);
894
+ }
596
895
  }
597
- if (!count) return { r: 255, g: 255, b: 255 };
598
- return { r: r / count, g: g / count, b: b / count };
896
+
897
+ const mapChannel = (value, channel) => clamp(
898
+ (value - lows[channel]) * 255 / Math.max(1, highs[channel] - lows[channel]),
899
+ 0,
900
+ 255
901
+ );
902
+
903
+ return { lows, highs, references, mapChannel };
904
+ }
905
+
906
+ export function autoColorImageData(imageData, options = {}) {
907
+ const levels = buildAutoColorLevels(imageData, options);
908
+ const pixelCount = imageData.width * imageData.height;
909
+ const data = new Uint8ClampedArray(imageData.data.length);
910
+ for (let index = 0; index < pixelCount; index++) {
911
+ const p = index * 4;
912
+ const a = imageData.data[p + 3] / 255;
913
+ const r = imageData.data[p] * a + 255 * (1 - a);
914
+ const g = imageData.data[p + 1] * a + 255 * (1 - a);
915
+ const b = imageData.data[p + 2] * a + 255 * (1 - a);
916
+ data[p] = levels.mapChannel(r, 0);
917
+ data[p + 1] = levels.mapChannel(g, 1);
918
+ data[p + 2] = levels.mapChannel(b, 2);
919
+ data[p + 3] = 255;
920
+ }
921
+ return { width: imageData.width, height: imageData.height, data };
922
+ }
923
+
924
+ function buildAutoColorValueGray(imageData, options = {}) {
925
+ const levels = buildAutoColorLevels(imageData, options);
926
+ const gray = new Uint8Array(imageData.width * imageData.height);
927
+ for (let i = 0; i < gray.length; i++) {
928
+ const p = i * 4;
929
+ const a = imageData.data[p + 3] / 255;
930
+ const r = imageData.data[p] * a + 255 * (1 - a);
931
+ const g = imageData.data[p + 1] * a + 255 * (1 - a);
932
+ const b = imageData.data[p + 2] * a + 255 * (1 - a);
933
+ gray[i] = Math.round(Math.max(
934
+ levels.mapChannel(r, 0),
935
+ levels.mapChannel(g, 1),
936
+ levels.mapChannel(b, 2)
937
+ ));
938
+ }
939
+ return gray;
940
+ }
941
+
942
+ function buildAutoToneContrastColorTransform(samples, options = {}) {
943
+ if (!samples?.length) return (rgb) => rgb;
944
+ const blackClip = clamp(options.blackClip ?? 0.006, 0, 0.08);
945
+ const whiteClip = clamp(options.whiteClip ?? 0.004, 0, 0.08);
946
+ const highlightFraction = clamp(options.highlightFraction ?? 0.14, 0.04, 0.35);
947
+ const saturation = clamp(options.saturation ?? 1.12, 1, 1.5);
948
+
949
+ // Auto Color-style neutralization: use the brightest portion of the frame as
950
+ // a likely white reference. This is especially effective on warm/yellow
951
+ // phone-camera frames where the blue channel is suppressed.
952
+ const luminanceHistogram = new Uint32Array(256);
953
+ for (const rgb of samples) {
954
+ const y = clamp(Math.round(luminance(rgb)), 0, 255);
955
+ luminanceHistogram[y]++;
956
+ }
957
+ const highlightThreshold = histogramPercentile(
958
+ luminanceHistogram,
959
+ samples.length,
960
+ 1 - highlightFraction
961
+ );
962
+ let highlightR = 0;
963
+ let highlightG = 0;
964
+ let highlightB = 0;
965
+ let highlightCount = 0;
966
+ for (const rgb of samples) {
967
+ if (luminance(rgb) < highlightThreshold) continue;
968
+ highlightR += rgb.r;
969
+ highlightG += rgb.g;
970
+ highlightB += rgb.b;
971
+ highlightCount++;
972
+ }
973
+ const highlightMean = highlightCount
974
+ ? { r: highlightR / highlightCount, g: highlightG / highlightCount, b: highlightB / highlightCount }
975
+ : { r: 255, g: 255, b: 255 };
976
+ const neutralTarget = (highlightMean.r + highlightMean.g + highlightMean.b) / 3;
977
+ const gains = {
978
+ r: clamp(neutralTarget / Math.max(24, highlightMean.r), 0.72, 1.42),
979
+ g: clamp(neutralTarget / Math.max(24, highlightMean.g), 0.72, 1.42),
980
+ b: clamp(neutralTarget / Math.max(24, highlightMean.b), 0.72, 1.42)
981
+ };
982
+
983
+ // Auto Tone-style per-channel levels after the neutralization above.
984
+ const histograms = [new Uint32Array(256), new Uint32Array(256), new Uint32Array(256)];
985
+ for (const rgb of samples) {
986
+ const corrected = [rgb.r * gains.r, rgb.g * gains.g, rgb.b * gains.b];
987
+ for (let channel = 0; channel < 3; channel++) {
988
+ histograms[channel][clamp(Math.round(corrected[channel]), 0, 255)]++;
989
+ }
990
+ }
991
+ const lows = histograms.map((histogram) => histogramPercentile(histogram, samples.length, blackClip));
992
+ const highs = histograms.map((histogram) => histogramPercentile(histogram, samples.length, 1 - whiteClip));
993
+ for (let channel = 0; channel < 3; channel++) {
994
+ highs[channel] = Math.max(lows[channel] + 20, highs[channel]);
995
+ }
996
+
997
+ const tone = (value, channel) => clamp(
998
+ (value * [gains.r, gains.g, gains.b][channel] - lows[channel]) * 255 / (highs[channel] - lows[channel]),
999
+ 0,
1000
+ 255
1001
+ );
1002
+
1003
+ // Auto Contrast-style common tonal expansion is calculated after the
1004
+ // per-channel levels so it does not undo white-balance correction.
1005
+ const toneLuminanceHistogram = new Uint32Array(256);
1006
+ for (const rgb of samples) {
1007
+ const corrected = { r: tone(rgb.r, 0), g: tone(rgb.g, 1), b: tone(rgb.b, 2) };
1008
+ toneLuminanceHistogram[clamp(Math.round(luminance(corrected)), 0, 255)]++;
1009
+ }
1010
+ const contrastLow = histogramPercentile(toneLuminanceHistogram, samples.length, blackClip);
1011
+ const contrastHigh = Math.max(
1012
+ contrastLow + 28,
1013
+ histogramPercentile(toneLuminanceHistogram, samples.length, 1 - whiteClip)
1014
+ );
1015
+ const contrast = (value) => clamp((value - contrastLow) * 255 / (contrastHigh - contrastLow), 0, 255);
1016
+
1017
+ return (rgb) => {
1018
+ let r = contrast(tone(rgb.r, 0));
1019
+ let g = contrast(tone(rgb.g, 1));
1020
+ let b = contrast(tone(rgb.b, 2));
1021
+
1022
+ // A small saturation recovery restores separation lost to lens haze and
1023
+ // bilinear camera scaling. It is intentionally conservative because this
1024
+ // path runs only after the normal scanner has failed.
1025
+ const y = luminance({ r, g, b });
1026
+ r = clamp(y + (r - y) * saturation, 0, 255);
1027
+ g = clamp(y + (g - y) * saturation, 0, 255);
1028
+ b = clamp(y + (b - y) * saturation, 0, 255);
1029
+ return { r, g, b };
1030
+ };
1031
+ }
1032
+
1033
+ export function autoToneContrastColorRgbGrid(rgbGrid, options = {}) {
1034
+ const samples = [];
1035
+ for (const row of rgbGrid) for (const rgb of row) samples.push(rgb);
1036
+ const transform = buildAutoToneContrastColorTransform(samples, options);
1037
+ return rgbGrid.map((row) => row.map((rgb) => transform(rgb)));
1038
+ }
1039
+
1040
+ export function autoToneContrastColorImageData(imageData, options = {}) {
1041
+ const pixelCount = imageData.width * imageData.height;
1042
+ const targetSamples = Math.max(8000, Math.round(options.targetSamples ?? 160000));
1043
+ const step = Math.max(1, Math.floor(pixelCount / targetSamples));
1044
+ const samples = [];
1045
+ for (let index = 0; index < pixelCount; index += step) {
1046
+ const p = index * 4;
1047
+ const a = imageData.data[p + 3] / 255;
1048
+ samples.push({
1049
+ r: imageData.data[p] * a + 255 * (1 - a),
1050
+ g: imageData.data[p + 1] * a + 255 * (1 - a),
1051
+ b: imageData.data[p + 2] * a + 255 * (1 - a)
1052
+ });
1053
+ }
1054
+
1055
+ const transform = buildAutoToneContrastColorTransform(samples, options);
1056
+ const data = new Uint8ClampedArray(imageData.data.length);
1057
+ for (let index = 0; index < pixelCount; index++) {
1058
+ const p = index * 4;
1059
+ const a = imageData.data[p + 3] / 255;
1060
+ const corrected = transform({
1061
+ r: imageData.data[p] * a + 255 * (1 - a),
1062
+ g: imageData.data[p + 1] * a + 255 * (1 - a),
1063
+ b: imageData.data[p + 2] * a + 255 * (1 - a)
1064
+ });
1065
+ data[p] = corrected.r;
1066
+ data[p + 1] = corrected.g;
1067
+ data[p + 2] = corrected.b;
1068
+ data[p + 3] = 255;
1069
+ }
1070
+ return { width: imageData.width, height: imageData.height, data };
599
1071
  }
600
1072
 
601
1073
  export function samplePerspectiveMatrix(imageData, homography, size, options = {}) {
602
1074
  const rgbGrid = Array.from({ length: size }, () => new Array(size));
603
- const radius = options.sampleRadius ?? 0.16;
1075
+ const mode = options.sampleMode ?? "cross";
1076
+ const radius = options.sampleRadius ?? (mode === "median" ? 0.12 : 0.16);
1077
+ const offsetX = Number.isFinite(options.sampleOffsetX) ? options.sampleOffsetX : 0;
1078
+ const offsetY = Number.isFinite(options.sampleOffsetY) ? options.sampleOffsetY : 0;
604
1079
  for (let r = 0; r < size; r++) {
605
1080
  for (let c = 0; c < size; c++) {
606
- rgbGrid[r][c] = averageProjectedSample(imageData, homography, c + 0.5, r + 0.5, radius);
1081
+ const moduleX = c + 0.5 + offsetX;
1082
+ const moduleY = r + 0.5 + offsetY;
1083
+ rgbGrid[r][c] = mode === "median"
1084
+ ? robustProjectedSample(imageData, homography, moduleX, moduleY, radius)
1085
+ : averageProjectedSample(imageData, homography, moduleX, moduleY, radius);
607
1086
  }
608
1087
  }
609
1088
  return { rgbGrid };
@@ -634,13 +1113,14 @@ function pickCalibrationSamples(rgbGrid, positions, limit = 24) {
634
1113
  return out;
635
1114
  }
636
1115
 
637
- export function sampleObservedPalette(rgbGrid, calibration) {
1116
+ export function sampleObservedPalette(rgbGrid, calibration, options = {}) {
1117
+ const aggregate = options.robust ? robustRgb : averageRgb;
638
1118
  const palette = {
639
- black: averageRgb(pickCalibrationSamples(rgbGrid, calibration.black, 36)),
640
- white: averageRgb(pickCalibrationSamples(rgbGrid, calibration.white, 36)),
641
- red: averageRgb(pickCalibrationSamples(rgbGrid, calibration.red, 12)),
642
- green: averageRgb(pickCalibrationSamples(rgbGrid, calibration.green, 12)),
643
- blue: averageRgb(pickCalibrationSamples(rgbGrid, calibration.blue, 12))
1119
+ black: aggregate(pickCalibrationSamples(rgbGrid, calibration.black, 36)),
1120
+ white: aggregate(pickCalibrationSamples(rgbGrid, calibration.white, 36)),
1121
+ red: aggregate(pickCalibrationSamples(rgbGrid, calibration.red, 12)),
1122
+ green: aggregate(pickCalibrationSamples(rgbGrid, calibration.green, 12)),
1123
+ blue: aggregate(pickCalibrationSamples(rgbGrid, calibration.blue, 12))
644
1124
  };
645
1125
 
646
1126
  // All five observed classes must remain separated enough for reliable RGBW
@@ -660,6 +1140,71 @@ export function sampleObservedPalette(rgbGrid, calibration) {
660
1140
  return palette;
661
1141
  }
662
1142
 
1143
+ function calibrationReferencePoints(rgbGrid, positions, limit = 64) {
1144
+ if (!positions?.length) return [];
1145
+ const step = Math.max(1, Math.floor(positions.length / limit));
1146
+ const refs = [];
1147
+ for (let i = 0; i < positions.length; i += step) {
1148
+ const [row, col] = positions[i];
1149
+ refs.push({ row, col, rgb: rgbGrid[row][col] });
1150
+ if (refs.length >= limit) break;
1151
+ }
1152
+ return refs;
1153
+ }
1154
+
1155
+ function estimateLocalReference(refs, row, col) {
1156
+ if (!refs.length) return { r: 0, g: 0, b: 0 };
1157
+ let wr = 0;
1158
+ let wg = 0;
1159
+ let wb = 0;
1160
+ let total = 0;
1161
+ for (const ref of refs) {
1162
+ const dr = row - ref.row;
1163
+ const dc = col - ref.col;
1164
+ const distanceSq = dr * dr + dc * dc;
1165
+ if (distanceSq < 1e-9) return ref.rgb;
1166
+ // Smooth inverse-distance weighting. The +4 prevents one slightly blurred
1167
+ // structural cell from dominating the local estimate.
1168
+ const weight = 1 / (distanceSq + 4);
1169
+ wr += ref.rgb.r * weight;
1170
+ wg += ref.rgb.g * weight;
1171
+ wb += ref.rgb.b * weight;
1172
+ total += weight;
1173
+ }
1174
+ return {
1175
+ r: wr / total,
1176
+ g: wg / total,
1177
+ b: wb / total
1178
+ };
1179
+ }
1180
+
1181
+ export function spatiallyNormalizeRgbGrid(rgbGrid, calibration, options = {}) {
1182
+ const size = rgbGrid.length;
1183
+ const limit = options.referenceLimit ?? 64;
1184
+ const blackRefs = calibrationReferencePoints(rgbGrid, calibration.black, limit);
1185
+ const whiteRefs = calibrationReferencePoints(rgbGrid, calibration.white, limit);
1186
+ if (!blackRefs.length || !whiteRefs.length) throw new Error("Black/white references are required for spatial color normalization.");
1187
+
1188
+ const normalized = Array.from({ length: size }, () => new Array(size));
1189
+ for (let row = 0; row < size; row++) {
1190
+ for (let col = 0; col < size; col++) {
1191
+ const rgb = rgbGrid[row][col];
1192
+ const black = estimateLocalReference(blackRefs, row, col);
1193
+ const white = estimateLocalReference(whiteRefs, row, col);
1194
+ const normalizeChannel = (value, low, high) => {
1195
+ const range = Math.max(28, high - low);
1196
+ return clamp((value - low) * 255 / range, -96, 384);
1197
+ };
1198
+ normalized[row][col] = {
1199
+ r: normalizeChannel(rgb.r, black.r, white.r),
1200
+ g: normalizeChannel(rgb.g, black.g, white.g),
1201
+ b: normalizeChannel(rgb.b, black.b, white.b)
1202
+ };
1203
+ }
1204
+ }
1205
+ return normalized;
1206
+ }
1207
+
663
1208
  export function rectifyImageData(imageData, homography, size, moduleSize = 8) {
664
1209
  const outputSize = Math.max(1, Math.round(size * moduleSize));
665
1210
  const data = new Uint8ClampedArray(outputSize * outputSize * 4);