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.
- package/README.md +1068 -1057
- package/dist/esm/quadqr.js +1270 -99
- package/dist/esm/vision.js +597 -52
- package/dist/quadqr.js +1865 -153
- package/dist/quadqr.min.js +1881 -153
- package/docs/API.md +329 -309
- package/docs/BROWSER_CDN.md +142 -137
- package/docs/GETTING_STARTED.md +174 -173
- package/docs/WASM.md +67 -67
- package/package.json +95 -95
- package/types/index.d.ts +123 -1
- package/docs/PUBLISHING.md +0 -109
package/dist/esm/vision.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 >=
|
|
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 >
|
|
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
|
-
|
|
490
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
598
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
640
|
-
white:
|
|
641
|
-
red:
|
|
642
|
-
green:
|
|
643
|
-
blue:
|
|
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);
|