intellifont-engine 1.2.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1182 @@
1
+ (() => {
2
+ if (window.IntellifontImagePipeline) return;
3
+ 'use strict';
4
+ /**
5
+ * Intellifont Image Pipeline (browser)
6
+ *
7
+ * Identifies fonts from arbitrary raster images: screenshots, photos, PDFs,
8
+ * scanned documents. Runs entirely client-side — no uploads, no server needed
9
+ * for the analysis step.
10
+ *
11
+ * Pipeline stages:
12
+ * [1] Load image onto canvas
13
+ * [2] Quality check (Laplacian variance) → optional bicubic upscale
14
+ * [3] Deskew (projection profile method)
15
+ * [4] Text region detection (connected-component heuristic)
16
+ * [5] Crop + normalize each character region to GLYPH_SIZE × GLYPH_SIZE
17
+ * [6] Extract pixel metrics via CanvasDNA.analyzeImageData()
18
+ * Assign character label heuristically from shape
19
+ *
20
+ * Output: JsPixelMetrics[] ready for identifyFromPixelMetrics()
21
+ *
22
+ * Usage:
23
+ * const pipeline = new ImagePipeline();
24
+ * const { metrics, quality, deskewAngle } = await pipeline.analyze(imageSource);
25
+ * const matches = identifyFromPixelMetrics(metrics, 8);
26
+ */
27
+
28
+ // Size to normalize each glyph crop to before metric extraction
29
+ const GLYPH_SIZE = 128;
30
+ // Pixel brightness threshold for "ink" (0 = black, 255 = white)
31
+ const DARK_THRESHOLD = 140;
32
+ // Minimum and maximum character height in pixels (on the deskewed image)
33
+ const MIN_GLYPH_H = 12;
34
+ const MAX_GLYPH_H = 600;
35
+ // Acceptable aspect ratio range for a character crop (w/h)
36
+ const MIN_CHAR_RATIO = 0.15;
37
+ const MAX_CHAR_RATIO = 3.5;
38
+ // How many glyph crops to extract and analyze per image
39
+ const MAX_GLYPHS = 12;
40
+ // Sharpness score below this triggers a 2× upscale attempt
41
+ const SHARP_THRESHOLD = 80;
42
+
43
+ // =============================================================================
44
+ // Helpers
45
+ // =============================================================================
46
+
47
+ function _createCanvas(w, h) {
48
+ const c = document.createElement('canvas');
49
+ c.width = w;
50
+ c.height = h;
51
+ return c;
52
+ }
53
+
54
+ /**
55
+ * Canonicalises image polarity so downstream code ALWAYS sees ink-dark / bg-light
56
+ * — eliminates the need for per-call invert detection (which broke for white-on-dark
57
+ * via the multi-threshold sweep, dropping geometric inverted to ~6%).
58
+ *
59
+ * Wraps the internal grayscale producer. If mean intensity is below 128 (dark-
60
+ * dominated image, e.g. white text on dark bg), inverts so it becomes light-
61
+ * dominated. Highlight/glow/colored paths already produce light-dominated output,
62
+ * so the check is a no-op for them.
63
+ */
64
+ function _toGrayscaleData(rgba, w, h) {
65
+ const gray = _toGrayscaleDataRaw(rgba, w, h);
66
+ // Compute mean intensity from a downsampled stride (cheap on big images)
67
+ const STRIDE = Math.max(1, Math.floor(gray.length / 20000));
68
+ let sum = 0, n = 0;
69
+ for (let i = 0; i < gray.length; i += STRIDE) { sum += gray[i]; n++; }
70
+ const mean = sum / n;
71
+ if (mean < 128) {
72
+ for (let i = 0; i < gray.length; i++) gray[i] = 255 - gray[i];
73
+ console.log(`[Intellifont ALSC] polarity-canon: inverted (mean=${mean.toFixed(0)} < 128)`);
74
+ }
75
+ return gray;
76
+ }
77
+
78
+ function _toGrayscaleDataRaw(rgba, w, h) {
79
+ const gray = new Uint8Array(w * h);
80
+ const total = w * h;
81
+
82
+ // Classify image: B&W, clean colored, or glow colored.
83
+ //
84
+ // Glow images have a halo of medium-saturation pixels (sat 50–160) around
85
+ // each letter. Clean colored images are bimodal: near-zero sat (dark bg) or
86
+ // very high sat (colored text). We count medium-sat pixels to distinguish.
87
+ let saturatedCount = 0, glowCount = 0, highlightCount = 0;
88
+ for (let i = 0; i < total; i++) {
89
+ const r = rgba[i * 4], g = rgba[i * 4 + 1], b = rgba[i * 4 + 2];
90
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
91
+ const s = (max - min);
92
+ if (max > 60 && s > 80) saturatedCount++;
93
+ if (max > 40 && s > 40 && s < 130) glowCount++; // medium-sat = halo pixels
94
+ // High-luminance + medium-saturation = highlighter fill (yellow/green/pink bg)
95
+ if (max > 160 && s > 40 && s < 160) highlightCount++;
96
+ }
97
+ const useSaturation = saturatedCount > total * 0.05;
98
+ const hasHighlight = highlightCount > total * 0.25;
99
+ const hasGlow = glowCount > total * 0.20; // >20% medium-sat pixels = glow (8% was too low — AA edges triggered it)
100
+
101
+ if (!useSaturation) {
102
+ // Standard ITU-R BT.601 luminance for B&W / non-colored images
103
+ for (let i = 0; i < total; i++)
104
+ gray[i] = (rgba[i*4]*77 + rgba[i*4+1]*150 + rgba[i*4+2]*29) >> 8;
105
+ console.log(`[Intellifont ALSC] path=luminance imageSize=${w}×${h}`);
106
+ return gray;
107
+ }
108
+
109
+ // Compute raw saturation array (needed by multiple paths)
110
+ const sat = new Uint8Array(total);
111
+ for (let i = 0; i < total; i++) {
112
+ const r = rgba[i*4], g = rgba[i*4+1], b = rgba[i*4+2];
113
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
114
+ sat[i] = max === 0 ? 0 : Math.round(((max - min) / max) * 255);
115
+ }
116
+
117
+ if (hasHighlight) {
118
+ // Highlighter bg (yellow/green/pink): dark text ink on bright colored background.
119
+ // ALSC fails here because the BACKGROUND is the high-saturation region — it would
120
+ // amplify background pixels as ink. Instead: suppress high-luminance medium-saturation
121
+ // pixels to white (background), keep dark pixels as-is via standard luminance.
122
+ for (let i = 0; i < total; i++) {
123
+ const r = rgba[i*4], g = rgba[i*4+1], b = rgba[i*4+2];
124
+ const max = Math.max(r, g, b), min = Math.min(r, g, b);
125
+ const s = max - min;
126
+ gray[i] = (max > 160 && s > 40 && s < 160)
127
+ ? 255 // suppress highlighter fill → white (background)
128
+ : (r*77 + g*150 + b*29) >> 8; // keep ink pixels via luminance
129
+ }
130
+ console.log(`[Intellifont ALSC] path=highlight-suppress imageSize=${w}×${h} highlight=${((highlightCount/total)*100).toFixed(1)}%`);
131
+ return gray;
132
+ }
133
+
134
+ if (!hasGlow) {
135
+ // Clean colored text — squared saturation: gray = 255 - sat²/255.
136
+ // Squaring heavily penalises JPEG artifacts (sat≈80-130 → gray≈215+, white)
137
+ // while keeping true colored text dark (sat≈200 → gray≈98).
138
+ // Linear 255-sat was too sensitive: artifact pixels (gray≈127) fell below
139
+ // DARK_THRESHOLD=140 and were mistaken for ink.
140
+ for (let i = 0; i < total; i++)
141
+ gray[i] = 255 - Math.round((sat[i] * sat[i]) / 255);
142
+ console.log(`[Intellifont ALSC] path=clean-color imageSize=${w}×${h} saturated=${((saturatedCount/total)*100).toFixed(1)}%`);
143
+ return gray;
144
+ }
145
+
146
+ // Glow colored text — ALSC (Adaptive Local Saturation Contrast).
147
+ //
148
+ // Global thresholds fail because the glow halo is the same hue as the letter
149
+ // core — just less saturated. ALSC measures each pixel's saturation RELATIVE
150
+ // to its local neighbourhood via an integral image:
151
+ // excess = max(0, S_pixel − 0.80 × S_localMean)
152
+ // Letter cores stand above their local mean → dark. Glow pixels are at or
153
+ // below their local mean → white.
154
+ console.log(`[Intellifont ALSC] path=glow-alsc imageSize=${w}×${h} glowFrac=${((glowCount/total)*100).toFixed(1)}%`);
155
+
156
+ // Integral image (summed area table) over saturation
157
+ const intg = new Float64Array((w + 1) * (h + 1));
158
+ for (let y = 0; y < h; y++) {
159
+ for (let x = 0; x < w; x++) {
160
+ intg[(y+1)*(w+1)+(x+1)] =
161
+ sat[y*w+x]
162
+ + intg[y*(w+1)+(x+1)]
163
+ + intg[(y+1)*(w+1)+x]
164
+ - intg[y*(w+1)+x];
165
+ }
166
+ }
167
+
168
+ const R = Math.min(60, Math.max(20, Math.round(h * 0.25)));
169
+ for (let y = 0; y < h; y++) {
170
+ for (let x = 0; x < w; x++) {
171
+ const x0 = Math.max(0, x - R), y0 = Math.max(0, y - R);
172
+ const x1 = Math.min(w, x + R + 1), y1 = Math.min(h, y + R + 1);
173
+ const area = (x1 - x0) * (y1 - y0);
174
+ const sum = intg[y1*(w+1)+x1] - intg[y0*(w+1)+x1]
175
+ - intg[y1*(w+1)+x0] + intg[y0*(w+1)+x0];
176
+ const localMean = sum / area;
177
+ const excess = Math.max(0, sat[y*w+x] - 0.80 * localMean);
178
+ gray[y*w+x] = Math.max(0, 255 - Math.round(excess * 3.0));
179
+ }
180
+ }
181
+ return gray;
182
+ }
183
+
184
+ // Laplacian variance — high = sharp, low = blurry
185
+ function _computeSharpness(gray, w, h) {
186
+ let sum = 0, sumSq = 0, n = 0;
187
+ for (let y = 1; y < h - 1; y++) {
188
+ for (let x = 1; x < w - 1; x++) {
189
+ const lap =
190
+ Math.abs(gray[y * w + x] * 4
191
+ - gray[(y - 1) * w + x]
192
+ - gray[(y + 1) * w + x]
193
+ - gray[y * w + (x - 1)]
194
+ - gray[y * w + (x + 1)]);
195
+ sum += lap;
196
+ sumSq += lap * lap;
197
+ n++;
198
+ }
199
+ }
200
+ const mean = sum / n;
201
+ return sumSq / n - mean * mean; // variance
202
+ }
203
+
204
+ // Otsu's method — returns the optimal binary threshold
205
+ function _otsuThreshold(gray) {
206
+ const hist = new Int32Array(256);
207
+ for (const v of gray) hist[v]++;
208
+ const total = gray.length;
209
+ let sum = 0;
210
+ for (let i = 0; i < 256; i++) sum += i * hist[i];
211
+
212
+ let sumB = 0, wB = 0, best = 0, thresh = DARK_THRESHOLD;
213
+ for (let t = 0; t < 256; t++) {
214
+ wB += hist[t];
215
+ if (!wB) continue;
216
+ const wF = total - wB;
217
+ if (!wF) break;
218
+ sumB += t * hist[t];
219
+ const mB = sumB / wB;
220
+ const mF = (sum - sumB) / wF;
221
+ const between = wB * wF * (mB - mF) ** 2;
222
+ if (between > best) { best = between; thresh = t; }
223
+ }
224
+ return thresh;
225
+ }
226
+
227
+ // Tile-based Otsu binarization — applies per-tile thresholds for images where
228
+ // global Otsu fails (colored text on colored backgrounds, mixed lighting).
229
+ // Returns a binary gray image (0 = ink, 255 = background).
230
+ function _tileOtsuGray(gray, w, h, tileSize = 64) {
231
+ const out = new Uint8Array(w * h);
232
+ const tilesX = Math.ceil(w / tileSize);
233
+ const tilesY = Math.ceil(h / tileSize);
234
+ for (let ty = 0; ty < tilesY; ty++) {
235
+ for (let tx = 0; tx < tilesX; tx++) {
236
+ const x0 = tx * tileSize, x1 = Math.min(w, x0 + tileSize);
237
+ const y0 = ty * tileSize, y1 = Math.min(h, y0 + tileSize);
238
+ const tileLen = (x1 - x0) * (y1 - y0);
239
+ if (!tileLen) continue;
240
+ const tile = new Uint8Array(tileLen);
241
+ let idx = 0;
242
+ for (let y = y0; y < y1; y++)
243
+ for (let x = x0; x < x1; x++)
244
+ tile[idx++] = gray[y * w + x];
245
+ const thresh = _otsuThreshold(tile);
246
+ for (let y = y0; y < y1; y++)
247
+ for (let x = x0; x < x1; x++)
248
+ out[y * w + x] = gray[y * w + x] <= thresh ? 0 : 255;
249
+ }
250
+ }
251
+ return out;
252
+ }
253
+
254
+ // =============================================================================
255
+ // Stage 3 — Deskew via projection profile
256
+ // Searches angles [-15°, +15°] in 1° steps on a downsampled image.
257
+ // Returns the angle (degrees) that maximises horizontal line variance.
258
+ // =============================================================================
259
+ function _computeDeskewAngle(gray, w, h) {
260
+ // Downsample to speed up search
261
+ const SCALE = 0.25;
262
+ const sw = Math.max(1, Math.round(w * SCALE));
263
+ const sh = Math.max(1, Math.round(h * SCALE));
264
+ const small = new Uint8Array(sw * sh);
265
+ for (let y = 0; y < sh; y++) {
266
+ for (let x = 0; x < sw; x++) {
267
+ small[y * sw + x] = gray[Math.round(y / SCALE) * w + Math.round(x / SCALE)];
268
+ }
269
+ }
270
+
271
+ const thresh = _otsuThreshold(small);
272
+ let bestVariance = -1, bestAngle = 0;
273
+
274
+ for (let deg = -15; deg <= 15; deg++) {
275
+ const rad = (deg * Math.PI) / 180;
276
+ const cos = Math.cos(rad), sin = Math.sin(rad);
277
+ const cx = sw / 2, cy = sh / 2;
278
+ const profile = new Int32Array(sh);
279
+
280
+ for (let y = 0; y < sh; y++) {
281
+ for (let x = 0; x < sw; x++) {
282
+ if (small[y * sw + x] > thresh) continue; // skip light pixels
283
+ const ry = Math.round((x - cx) * sin + (y - cy) * cos + cy);
284
+ if (ry >= 0 && ry < sh) profile[ry]++;
285
+ }
286
+ }
287
+
288
+ const mean = profile.reduce((a, b) => a + b, 0) / sh;
289
+ const variance = profile.reduce((s, v) => s + (v - mean) ** 2, 0) / sh;
290
+ if (variance > bestVariance) { bestVariance = variance; bestAngle = deg; }
291
+ }
292
+
293
+ return bestAngle;
294
+ }
295
+
296
+ function _rotateCanvas(src, angleDeg) {
297
+ if (angleDeg === 0) return src;
298
+ const rad = (angleDeg * Math.PI) / 180;
299
+ const cos = Math.abs(Math.cos(rad)), sin = Math.abs(Math.sin(rad));
300
+ const nw = Math.ceil(src.width * cos + src.height * sin);
301
+ const nh = Math.ceil(src.width * sin + src.height * cos);
302
+ const dst = _createCanvas(nw, nh);
303
+ const ctx = dst.getContext('2d');
304
+ ctx.translate(nw / 2, nh / 2);
305
+ ctx.rotate(rad);
306
+ ctx.drawImage(src, -src.width / 2, -src.height / 2);
307
+ return dst;
308
+ }
309
+
310
+ // =============================================================================
311
+ // BFS connected-component labeler for binarized (0=ink, 255=bg) images.
312
+ // Returns [{minX, maxX, minY, maxY, pixels}] — one entry per ink blob.
313
+ // Used by _detectTextRegions to isolate whole character shapes; avoids the
314
+ // column-projection split that breaks round letters (o, e, a, n) at counters.
315
+ // =============================================================================
316
+ function _labelComponents(binary, w, h) {
317
+ const visited = new Uint8Array(w * h);
318
+ const comps = [];
319
+ const queue = new Int32Array(w * h); // pre-allocated — avoids GC churn
320
+
321
+ for (let start = 0; start < binary.length; start++) {
322
+ if (binary[start] !== 0 || visited[start]) continue; // bg or already labeled
323
+
324
+ let head = 0, tail = 0;
325
+ queue[tail++] = start;
326
+ visited[start] = 1;
327
+ let minX = w, maxX = 0, minY = h, maxY = 0, pixels = 0;
328
+
329
+ while (head < tail) {
330
+ const idx = queue[head++];
331
+ const x = idx % w, y = (idx / w) | 0;
332
+ if (x < minX) minX = x; if (x > maxX) maxX = x;
333
+ if (y < minY) minY = y; if (y > maxY) maxY = y;
334
+ pixels++;
335
+ if (x > 0 && binary[idx-1] === 0 && !visited[idx-1]) { visited[idx-1]=1; queue[tail++]=idx-1; }
336
+ if (x < w-1 && binary[idx+1] === 0 && !visited[idx+1]) { visited[idx+1]=1; queue[tail++]=idx+1; }
337
+ if (y > 0 && binary[idx-w] === 0 && !visited[idx-w]) { visited[idx-w]=1; queue[tail++]=idx-w; }
338
+ if (y < h-1 && binary[idx+w] === 0 && !visited[idx+w]) { visited[idx+w]=1; queue[tail++]=idx+w; }
339
+ }
340
+ comps.push({ minX, maxX, minY, maxY, pixels });
341
+ }
342
+ return comps;
343
+ }
344
+
345
+ // Split a region likely containing 2+ merged glyphs using vertical projection valleys.
346
+ // Improvements vs the original (which only fired at rW/rH > 2.5 with absolute threshold):
347
+ // • Fires at rW/rH > 1.15 — catches 2-letter merges (was missing them entirely).
348
+ // • Cuts at columns where ink < 30% of the LOCAL PEAK (relative criterion) — robust
349
+ // to stroke-width kerning bridges that absolute 5%-of-height misses.
350
+ // • Requires a valley to be persistent (2+ consecutive low-ink columns) to avoid
351
+ // splitting on noise inside a single glyph.
352
+ // • Enforces minimum part width ≈ 0.3 × rH so we don't shave off tiny fragments.
353
+ function _splitWideRegion(region, binary, w) {
354
+ const rW = region.x1 - region.x0;
355
+ const rH = region.y1 - region.y0;
356
+ if (rW / rH <= 1.15) return [region];
357
+
358
+ // Column-wise ink count within the region
359
+ const vProj = new Int32Array(rW);
360
+ for (let y = region.y0; y < region.y1; y++)
361
+ for (let x = region.x0; x < region.x1; x++)
362
+ if (binary[y * w + x] === 0) vProj[x - region.x0]++;
363
+
364
+ // Local peak (sliding-max) so a global peak in one glyph doesn't suppress the
365
+ // valley between two equally-tall glyphs. Window ≈ rH (one-letter-width-ish).
366
+ const win = Math.max(8, Math.round(rH * 0.6));
367
+ const peak = new Int32Array(rW);
368
+ for (let x = 0; x < rW; x++) {
369
+ let mx = 0;
370
+ for (let k = Math.max(0, x - win); k < Math.min(rW, x + win); k++)
371
+ if (vProj[k] > mx) mx = vProj[k];
372
+ peak[x] = mx;
373
+ }
374
+
375
+ // Find persistent valleys (≥2 consecutive cols where ink < 30% of local peak,
376
+ // AND below 25% of region height). Cut at the centre of each valley run.
377
+ const RUN_MIN = 2;
378
+ const MIN_PART = Math.max(4, Math.round(rH * 0.30));
379
+ const valleyCols = [];
380
+ let runStart = -1;
381
+ for (let x = 1; x < rW - 1; x++) {
382
+ const pk = peak[x];
383
+ const ink = vProj[x];
384
+ const isValley = pk > 0
385
+ && ink <= Math.max(1, Math.round(pk * 0.30))
386
+ && ink <= Math.round(rH * 0.25);
387
+ if (isValley) {
388
+ if (runStart < 0) runStart = x;
389
+ } else if (runStart >= 0) {
390
+ if (x - runStart >= RUN_MIN) valleyCols.push(Math.round((runStart + x - 1) / 2));
391
+ runStart = -1;
392
+ }
393
+ }
394
+ if (runStart >= 0 && rW - 1 - runStart >= RUN_MIN)
395
+ valleyCols.push(Math.round((runStart + rW - 1) / 2));
396
+
397
+ if (!valleyCols.length) return [region];
398
+
399
+ // Build candidate parts; reject any narrower than MIN_PART.
400
+ const bounds = [0, ...valleyCols, rW];
401
+ const parts = [];
402
+ for (let i = 0; i < bounds.length - 1; i++) {
403
+ const x0 = bounds[i], x1 = bounds[i + 1];
404
+ const segW = x1 - x0;
405
+ if (segW < MIN_PART) continue;
406
+ let dark = 0;
407
+ for (let y = region.y0; y < region.y1; y++)
408
+ for (let x = region.x0 + x0; x < region.x0 + x1; x++)
409
+ if (binary[y * w + x] === 0) dark++;
410
+ const density = dark / (segW * rH);
411
+ parts.push({ x0: region.x0 + x0, y0: region.y0,
412
+ x1: region.x0 + x1, y1: region.y1, density });
413
+ }
414
+ // If splitting produced fewer than 2 valid parts, return the original (don't
415
+ // shave the region) — preserves robustness when valleys are spurious.
416
+ return parts.length >= 2 ? parts : [region];
417
+ }
418
+
419
+ // =============================================================================
420
+ // Stage 4 — Text region detection (connected-component based)
421
+ //
422
+ // Algorithm:
423
+ // a. Binarise with Otsu threshold
424
+ // b. Horizontal projection profile → locate text line bands
425
+ // c. Within each band, BFS connected-component labeling → one blob per char
426
+ // d. Filter blobs by aspect ratio and density; split wide (merged) blobs
427
+ // e. Return up to MAX_GLYPHS bounding boxes, sorted by quality score
428
+ // =============================================================================
429
+ function _detectTextRegions(gray, w, h, threshMult = 1.0) {
430
+ // ── VERSION MARKER — confirms CC-v3 code is running ──────────────────────
431
+ console.log(`[CC-v3] _detectTextRegions mult=${threshMult} imageSize=${w}×${h}`);
432
+
433
+ const otsu = _otsuThreshold(gray);
434
+ const thresh = Math.min(254, Math.max(1, Math.round(otsu * threshMult)));
435
+ let darkCount = 0;
436
+ for (let i = 0; i < gray.length; i++) if (gray[i] < thresh) darkCount++;
437
+ const invert = darkCount > gray.length * 0.5;
438
+ console.log(`[CC-v3] otsu=${otsu} thresh=${thresh} invert=${invert} darkFrac=${(darkCount/gray.length).toFixed(3)}`);
439
+
440
+ const binary = new Uint8Array(w * h);
441
+ for (let i = 0; i < gray.length; i++) {
442
+ const isDark = invert ? gray[i] >= thresh : gray[i] < thresh;
443
+ binary[i] = isDark ? 0 : 255;
444
+ }
445
+
446
+ // Horizontal projection: count dark pixels per row
447
+ const hProj = new Int32Array(h);
448
+ for (let y = 0; y < h; y++)
449
+ for (let x = 0; x < w; x++)
450
+ if (binary[y * w + x] === 0) hProj[y]++;
451
+
452
+ // Find text line bands (rows with ≥ 1% dark pixels)
453
+ const darkPerRow = Math.max(1, w * 0.01);
454
+ const bands = [];
455
+ let inBand = false, bandStart = 0;
456
+ for (let y = 0; y < h; y++) {
457
+ const isDark = hProj[y] >= darkPerRow;
458
+ if (isDark && !inBand) { inBand = true; bandStart = y; }
459
+ if (!isDark && inBand) {
460
+ inBand = false;
461
+ const bandH = y - bandStart;
462
+ const kept = bandH >= MIN_GLYPH_H && bandH <= MAX_GLYPH_H;
463
+ console.log(`[CC-v3] band y=${bandStart}-${y} h=${bandH} ${kept ? '✓ KEPT' : `✗ SKIP (${bandH < MIN_GLYPH_H ? 'too short' : 'too tall'})`}`);
464
+ if (kept) bands.push({ y0: bandStart, y1: y });
465
+ }
466
+ }
467
+ if (inBand) {
468
+ const bandH = h - bandStart;
469
+ const kept = bandH >= MIN_GLYPH_H && bandH <= MAX_GLYPH_H;
470
+ console.log(`[CC-v3] band y=${bandStart}-${h} h=${bandH} ${kept ? '✓ KEPT (edge)' : '✗ SKIP (edge)'}`);
471
+ if (kept) bands.push({ y0: bandStart, y1: h });
472
+ }
473
+ console.log(`[CC-v3] total bands: ${bands.length} MIN_GLYPH_H=${MIN_GLYPH_H} MAX_GLYPH_H=${MAX_GLYPH_H} darkPerRow=${darkPerRow}`);
474
+
475
+ const regions = [];
476
+
477
+ for (let bi = 0; bi < bands.length; bi++) {
478
+ const band = bands[bi];
479
+ const bandH = band.y1 - band.y0;
480
+
481
+ const bandBin = new Uint8Array(w * bandH);
482
+ for (let y = 0; y < bandH; y++)
483
+ for (let x = 0; x < w; x++)
484
+ bandBin[y * w + x] = binary[(band.y0 + y) * w + x];
485
+
486
+ const comps = _labelComponents(bandBin, w, bandH);
487
+ console.log(`[CC-v3] band[${bi}] y=${band.y0}-${band.y1} h=${bandH}: ${comps.length} CC components`);
488
+
489
+ let passedInBand = 0;
490
+ for (const c of comps) {
491
+ const cW = c.maxX - c.minX + 1;
492
+ const cH = c.maxY - c.minY + 1;
493
+ const region = {
494
+ x0: c.minX,
495
+ y0: band.y0 + c.minY,
496
+ x1: c.minX + cW,
497
+ y1: band.y0 + c.minY + cH,
498
+ density: c.pixels / (cW * cH),
499
+ };
500
+
501
+ for (const r of _splitWideRegion(region, binary, w)) {
502
+ const rW = r.x1 - r.x0, rH = r.y1 - r.y0;
503
+ const nat = rW / rH;
504
+ let skipReason = null;
505
+ if (nat < MIN_CHAR_RATIO) skipReason = `nat=${nat.toFixed(3)}<MIN(${MIN_CHAR_RATIO})`;
506
+ else if (nat > MAX_CHAR_RATIO) skipReason = `nat=${nat.toFixed(3)}>MAX(${MAX_CHAR_RATIO})`;
507
+ else if (r.density < 0.10) skipReason = `density=${r.density.toFixed(3)}<0.10`;
508
+ else if (r.density > 0.85) skipReason = `density=${r.density.toFixed(3)}>0.85`;
509
+
510
+ if (skipReason) {
511
+ console.log(` [CC-v3] ✗ SKIP [${r.x0},${r.y0}→${r.x1},${r.y1}] w=${rW} h=${rH} ${skipReason}`);
512
+ } else {
513
+ console.log(` [CC-v3] ✓ PASS [${r.x0},${r.y0}→${r.x1},${r.y1}] w=${rW} h=${rH} nat=${nat.toFixed(3)} density=${r.density.toFixed(3)}`);
514
+ regions.push(r);
515
+ passedInBand++;
516
+ }
517
+ }
518
+ }
519
+ console.log(`[CC-v3] band[${bi}] passed filter: ${passedInBand}/${comps.length} components`);
520
+ }
521
+
522
+ console.log(`[CC-v3] total regions before dedup: ${regions.length}`);
523
+
524
+ // Sort by bounding-box area (largest = most likely a real character).
525
+ // Area-based sort is weight-agnostic: Bold (density 0.65) and Light (density 0.20) both
526
+ // win over sub-character fragments regardless of weight. Old density-proximity sort
527
+ // penalised Bold/Black by always ranking them behind Regular-weight noise.
528
+ regions.sort((a, b) => {
529
+ const aArea = (a.x1 - a.x0) * (a.y1 - a.y0);
530
+ const bArea = (b.x1 - b.x0) * (b.y1 - b.y0);
531
+ return bArea - aArea;
532
+ });
533
+ const picked = [];
534
+ outer: for (const r of regions) {
535
+ for (const p of picked) {
536
+ const ox = Math.min(r.x1, p.x1) - Math.max(r.x0, p.x0);
537
+ const oy = Math.min(r.y1, p.y1) - Math.max(r.y0, p.y0);
538
+ if (ox > 0 && oy > 0) {
539
+ console.log(` [CC-v3] dedup skip [${r.x0},${r.y0}→${r.x1},${r.y1}] overlaps [${p.x0},${p.y0}→${p.x1},${p.y1}]`);
540
+ continue outer;
541
+ }
542
+ }
543
+ picked.push(r);
544
+ if (picked.length >= MAX_GLYPHS) break;
545
+ }
546
+
547
+ console.log(`[CC-v3] final picked: ${picked.length} regions (MAX_GLYPHS=${MAX_GLYPHS})`);
548
+ for (const r of picked) {
549
+ const nat = (r.x1-r.x0)/(r.y1-r.y0);
550
+ console.log(` [CC-v3] → [${r.x0},${r.y0}→${r.x1},${r.y1}] w=${r.x1-r.x0} h=${r.y1-r.y0} nat=${nat.toFixed(3)} density=${r.density.toFixed(3)}`);
551
+ }
552
+
553
+ const avgDensity = picked.length
554
+ ? picked.reduce((s, r) => s + r.density, 0) / picked.length
555
+ : 0;
556
+ return { regions: picked, invert, avgDensity, binary };
557
+ }
558
+
559
+ // =============================================================================
560
+ // Stage 5 — Crop region from canvas, normalize to GLYPH_SIZE × GLYPH_SIZE
561
+ // Returns an ImageData at GLYPH_SIZE resolution (always dark-ink on white bg)
562
+ // =============================================================================
563
+ function _cropAndNormalize(region, binary, srcW) {
564
+ const { x0, y0, x1, y1 } = region;
565
+ const rw = x1 - x0, rh = y1 - y0;
566
+
567
+ // Render binary mask as clean black-on-white at native resolution.
568
+ // This strips ALL source image artifacts (color, glow, shadow, compression)
569
+ // so CanvasDNA receives the same kind of image the database was built from.
570
+ const tmp = _createCanvas(rw, rh);
571
+ const tCtx = tmp.getContext('2d', { willReadFrequently: true });
572
+ tCtx.fillStyle = '#ffffff';
573
+ tCtx.fillRect(0, 0, rw, rh);
574
+ const tId = tCtx.getImageData(0, 0, rw, rh);
575
+ for (let y = 0; y < rh; y++) {
576
+ for (let x = 0; x < rw; x++) {
577
+ if (binary[(y0 + y) * srcW + (x0 + x)] === 0) {
578
+ const i = (y * rw + x) * 4;
579
+ tId.data[i] = tId.data[i + 1] = tId.data[i + 2] = 0;
580
+ tId.data[i + 3] = 255;
581
+ }
582
+ }
583
+ }
584
+ tCtx.putImageData(tId, 0, 0);
585
+
586
+ // Scale to GLYPH_SIZE with padding, using canvas smoothing for antialiasing
587
+ const dst = _createCanvas(GLYPH_SIZE, GLYPH_SIZE);
588
+ const ctx = dst.getContext('2d', { willReadFrequently: true });
589
+ ctx.fillStyle = '#ffffff';
590
+ ctx.fillRect(0, 0, GLYPH_SIZE, GLYPH_SIZE);
591
+ const pad = Math.floor(GLYPH_SIZE * 0.08);
592
+ const inner = GLYPH_SIZE - pad * 2;
593
+ ctx.imageSmoothingEnabled = true;
594
+ ctx.imageSmoothingQuality = 'high';
595
+ ctx.drawImage(tmp, pad, pad, inner, inner);
596
+ return ctx.getImageData(0, 0, GLYPH_SIZE, GLYPH_SIZE);
597
+ }
598
+
599
+ // =============================================================================
600
+ // Stage 6 — Heuristic character label assignment
601
+ // Assigns a character label to a glyph based on its shape metrics.
602
+ // Imperfect but sufficient to route into the right LSH bucket for matching.
603
+ // =============================================================================
604
+ // Extract a tight-cropped 64×64 binarized thumbnail from a glyph region.
605
+ // Uses area averaging (same as rebuild-pixel-db.js) so browser-extracted thumbnails
606
+ // match the IMGDB2 reference database built from font renders.
607
+ // Returns Uint8Array(4096): 0=ink, 255=background — IMGDB2 convention.
608
+ const _THUMB_DIM = 64;
609
+ const _THUMB_MARGIN = 2; // blank border pixels inside 64×64 frame
610
+ function _glyphToThumbnail(binary, region, w) {
611
+ const rW = region.x1 - region.x0;
612
+ const rH = region.y1 - region.y0;
613
+ const BYTES = _THUMB_DIM * _THUMB_DIM; // 4096
614
+ const thumb = new Uint8Array(BYTES).fill(255);
615
+ if (rW <= 0 || rH <= 0) return thumb;
616
+
617
+ const drawW = _THUMB_DIM - 2 * _THUMB_MARGIN;
618
+ const drawH = _THUMB_DIM - 2 * _THUMB_MARGIN;
619
+
620
+ for (let ty = 0; ty < drawH; ty++) {
621
+ for (let tx = 0; tx < drawW; tx++) {
622
+ // Source area for this output pixel
623
+ const sx0 = region.x0 + (tx / drawW) * rW;
624
+ const sy0 = region.y0 + (ty / drawH) * rH;
625
+ const sx1 = region.x0 + ((tx + 1) / drawW) * rW;
626
+ const sy1 = region.y0 + ((ty + 1) / drawH) * rH;
627
+
628
+ // Area average — count ink pixels in source area
629
+ let ink = 0, total = 0;
630
+ for (let sy = Math.floor(sy0); sy < Math.ceil(sy1); sy++) {
631
+ for (let sx = Math.floor(sx0); sx < Math.ceil(sx1); sx++) {
632
+ if (sx >= 0 && sx < w && sy >= 0) {
633
+ ink += binary[sy * w + sx] === 0 ? 1 : 0;
634
+ total++;
635
+ }
636
+ }
637
+ }
638
+ // ≥50% ink in area → ink pixel
639
+ const isInk = total > 0 && (ink / total) >= 0.5;
640
+ thumb[(ty + _THUMB_MARGIN) * _THUMB_DIM + (tx + _THUMB_MARGIN)] = isInk ? 0 : 255;
641
+ }
642
+ }
643
+ return thumb;
644
+ }
645
+
646
+ // Build the ML-input glyph: 64×64, ASPECT-PRESERVED, ink bbox scaled to ~52px and
647
+ // centered — matching the CosFace training renderer (scripts/ml/glyph_dataset.py).
648
+ // CRITICAL: samples the ORIGINAL grayscale (not binary), preserving anti-aliasing
649
+ // the model trained on. The `invert` flag flips ink/bg so the output is always
650
+ // ink=dark / bg=light (training convention), regardless of source image polarity.
651
+ // Geometric uses stretch-to-fill of binary (above); ML needs aspect-preserved gray.
652
+ // Returns Uint8Array(4096): 0=ink, 255=background.
653
+ const _ML_DIM = 64, _ML_TARGET = 52;
654
+ function _glyphToMlGray(gray, region, w, invert = false) {
655
+ const out = new Uint8Array(_ML_DIM * _ML_DIM).fill(255);
656
+ const rW = region.x1 - region.x0, rH = region.y1 - region.y0;
657
+ if (rW <= 0 || rH <= 0) return out;
658
+ const scale = _ML_TARGET / Math.max(rW, rH);
659
+ const dW = Math.max(1, Math.round(rW * scale));
660
+ const dH = Math.max(1, Math.round(rH * scale));
661
+ const offX = Math.floor((_ML_DIM - dW) / 2);
662
+ const offY = Math.floor((_ML_DIM - dH) / 2);
663
+ for (let ty = 0; ty < dH; ty++) {
664
+ for (let tx = 0; tx < dW; tx++) {
665
+ const sx0 = region.x0 + (tx / dW) * rW;
666
+ const sy0 = region.y0 + (ty / dH) * rH;
667
+ const sx1 = region.x0 + ((tx + 1) / dW) * rW;
668
+ const sy1 = region.y0 + ((ty + 1) / dH) * rH;
669
+ // Area-average the GRAYSCALE values (smooth anti-aliasing preserved).
670
+ let sum = 0, total = 0;
671
+ for (let sy = Math.floor(sy0); sy < Math.ceil(sy1); sy++) {
672
+ for (let sx = Math.floor(sx0); sx < Math.ceil(sx1); sx++) {
673
+ if (sx >= 0 && sx < w && sy >= 0) {
674
+ const v = gray[sy * w + sx];
675
+ // Canonicalise to ink=dark / bg=light irrespective of source polarity.
676
+ sum += invert ? (255 - v) : v;
677
+ total++;
678
+ }
679
+ }
680
+ }
681
+ out[(ty + offY) * _ML_DIM + (tx + offX)] = total > 0 ? Math.round(sum / total) : 255;
682
+ }
683
+ }
684
+ return out;
685
+ }
686
+
687
+ function _guessCharacter(preMetrics, region) {
688
+ const { yBalance, density, quadrantSw, quadrantSe,
689
+ quadrantNw, quadrantNe } = preMetrics;
690
+
691
+ // nat = natural region aspect ratio (width/height BEFORE 128×128 normalisation).
692
+ // inkRatio = aspect of ink bbox INSIDE the normalised square — distorted by stretch,
693
+ // so it cannot be used as a primary shape discriminator.
694
+ const natW = region ? (region.x1 - region.x0) : 0;
695
+ const natH = region ? (region.y1 - region.y0) : 1;
696
+ const nat = natW / natH;
697
+
698
+ const symH = Math.abs((quadrantNw + quadrantSw) - (quadrantNe + quadrantSe));
699
+ const symV = Math.abs((quadrantNw + quadrantNe) - (quadrantSw + quadrantSe));
700
+
701
+ // ── Stems: I, l, 1 ──────────────────────────────────────────────────────
702
+ if (nat < 0.22) return 'I';
703
+
704
+ // ── Narrow (t, f, i, r) ─────────────────────────────────────────────────
705
+ if (nat < 0.45) {
706
+ if (yBalance < 100) return 'f'; // top-heavy narrow → f or t
707
+ return 'I'; // i, r, or thin serif stem
708
+ }
709
+
710
+ // ── Wide: W, M ───────────────────────────────────────────────────────────
711
+ if (nat > 1.60) return 'W';
712
+ if (nat > 1.25) return 'M';
713
+
714
+ // ── Medium-narrow (0.45–0.75): c, e, s, n, d, b, h ─────────────────────
715
+
716
+ // Descender below baseline → g, y, p, q
717
+ if (yBalance > 155) return 'g';
718
+
719
+ // Upper-only strokes: top-heavy, empty lower half → P, F, E
720
+ if (yBalance < 108 && quadrantSe < 25 && nat < 0.80) return 'P';
721
+
722
+ // Strong bilateral asymmetry + narrow → stem+bowl pair (d, b, h)
723
+ if (symH > 55 && nat < 0.85) {
724
+ if (quadrantNw < quadrantNe) return 'd';
725
+ // b: bowl sits in lower half → ink centroid below midpoint (yBalance > 128)
726
+ // h: arch connects at mid-height → centroid near/above midpoint (yBalance ≤ 128)
727
+ return yBalance > 128 ? 'b' : 'h';
728
+ }
729
+
730
+ // Two-legged arch (h, n) — ceiling raised to 70 to catch bold strokes with higher symH
731
+ if (symH < 70 && symV < 45 && yBalance > 112 && yBalance < 155 && quadrantNw > quadrantNe) {
732
+ return nat < 0.68 ? 'h' : 'n';
733
+ }
734
+
735
+ // ── Squarish (0.45–1.25): O, C, a, e, R ─────────────────────────────────
736
+
737
+ // Bilaterally symmetric → round bowl
738
+ if (symH < 45 && symV < 50 && nat > 0.55) {
739
+ // Fully closed (O, 0) vs open on right (C, c, e)
740
+ return (quadrantNe > 45 && quadrantSe > 35) ? 'O' : 'C';
741
+ }
742
+
743
+ // Dense compact strokes → a
744
+ if (density > 130 && nat < 0.90) return 'a';
745
+
746
+ // ── Additional discriminators to reduce the 'R' catch-all ────────────────
747
+
748
+ // Top-bar only + wide → T (strong top, thin bottom stem)
749
+ if (symH < 40 && symV > 55 && yBalance < 120 && nat > 0.70) return 'T';
750
+
751
+ // Bottom-convergence → V (more ink in top half than bottom)
752
+ if (symH < 35 && symV > 40 && yBalance < 118 && nat > 0.80 && nat < 1.40) return 'V';
753
+
754
+ // Strong left-heavy asymmetry + squarish → E or F (horizontal bars left side)
755
+ if (symH > 50 && nat > 0.55 && nat < 1.1 && quadrantNe < 30 && quadrantSe < 30) return 'E';
756
+
757
+ // Rightward lean with crossing diagonal → X or K
758
+ if (symH > 35 && symV > 35 && nat > 0.65 && nat < 1.15 && density > 80) return 'X';
759
+
760
+ return 'R';
761
+ }
762
+
763
+ // =============================================================================
764
+ // Aggregate style results across multiple glyph crops.
765
+ // Majority vote for italic; median CSS weight; median italic angle.
766
+ // =============================================================================
767
+ function _aggregateStyle(styles) {
768
+ if (!styles.length) return { italic: false, italicAngle: 0, cssWeight: 400, weightLabel: 'Regular' };
769
+
770
+ const italicVotes = styles.filter(s => s.italic).length;
771
+ const italic = italicVotes > styles.length / 2;
772
+
773
+ const angles = styles.map(s => s.italicAngle).sort((a, b) => a - b);
774
+ const weights = styles.map(s => s.cssWeight).sort((a, b) => a - b);
775
+ const mid = Math.floor(styles.length / 2);
776
+
777
+ const italicAngle = angles[mid];
778
+ const cssWeight = weights[mid];
779
+
780
+ const WEIGHT_LABELS = {
781
+ 100: 'Thin', 200: 'ExtraLight', 300: 'Light', 400: 'Regular',
782
+ 500: 'Medium', 600: 'SemiBold', 700: 'Bold', 800: 'ExtraBold', 900: 'Black',
783
+ };
784
+
785
+ return { italic, italicAngle, cssWeight, weightLabel: WEIGHT_LABELS[cssWeight] };
786
+ }
787
+
788
+ // =============================================================================
789
+ // Main pipeline class
790
+ // =============================================================================
791
+ class ImagePipeline {
792
+ /**
793
+ * Analyze an image and extract font identification metrics.
794
+ *
795
+ * @param {string|File|Blob|HTMLImageElement|HTMLCanvasElement} imageSource
796
+ * @returns {Promise<{
797
+ * metrics: JsPixelMetrics[], // feed into identifyFromPixelMetrics()
798
+ * quality: number, // sharpness score (higher = better)
799
+ * deskewAngle: number, // degrees the image was rotated
800
+ * regionCount: number, // text regions found before capping
801
+ * warning: string|null // user-facing quality warning if any
802
+ * }>}
803
+ */
804
+ async analyze(imageSource) {
805
+ // ── Stage 1: load image ──────────────────────────────────────────────
806
+ const canvas = await this._loadToCanvas(imageSource);
807
+ const w = canvas.width, h = canvas.height;
808
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
809
+
810
+ // ── Stage 2: quality check + upscale if needed ───────────────────────
811
+ let imgData = ctx.getImageData(0, 0, w, h);
812
+ let gray = _toGrayscaleData(imgData.data, w, h);
813
+ const quality = _computeSharpness(gray, w, h);
814
+ let warning = null;
815
+
816
+ let workCanvas = canvas;
817
+ let workW = w, workH = h;
818
+
819
+ if (quality < SHARP_THRESHOLD && (w < 800 || h < 600)) {
820
+ // Bicubic 2× upscale via canvas (no model needed, fast)
821
+ workCanvas = _createCanvas(w * 2, h * 2);
822
+ const upCtx = workCanvas.getContext('2d', { willReadFrequently: true });
823
+ upCtx.imageSmoothingEnabled = true;
824
+ upCtx.imageSmoothingQuality = 'high';
825
+ upCtx.drawImage(canvas, 0, 0, w * 2, h * 2);
826
+ workW = w * 2; workH = h * 2;
827
+ imgData = upCtx.getImageData(0, 0, workW, workH);
828
+ gray = _toGrayscaleData(imgData.data, workW, workH);
829
+ const qualityAfter = _computeSharpness(gray, workW, workH);
830
+ if (qualityAfter < SHARP_THRESHOLD * 0.5) {
831
+ warning = 'Image quality is low — results may be inaccurate. Try a higher-resolution source.';
832
+ }
833
+ }
834
+
835
+ // ── Stage 3: deskew ──────────────────────────────────────────────────
836
+ const deskewAngle = _computeDeskewAngle(gray, workW, workH);
837
+ let straightCanvas = workCanvas;
838
+ if (Math.abs(deskewAngle) >= 1) {
839
+ straightCanvas = _rotateCanvas(workCanvas, -deskewAngle);
840
+ workW = straightCanvas.width;
841
+ workH = straightCanvas.height;
842
+ const sCtx = straightCanvas.getContext('2d', { willReadFrequently: true });
843
+ imgData = sCtx.getImageData(0, 0, workW, workH);
844
+ gray = _toGrayscaleData(imgData.data, workW, workH);
845
+ }
846
+
847
+ // ── Stage 4: multi-threshold text region detection ───────────────────
848
+ // Two-pass selection:
849
+ // Pass 1 (preferred): levels where avgDensity ≤ 0.52 (glow not included).
850
+ // Score = goodAR fraction × 10 − |density−0.35|.
851
+ // Pass 2 (fallback): if pass 1 finds nothing, use pass-1 formula without cap.
852
+ // "goodAR" = regions whose width/height ratio is in [0.35, 1.50] (single char).
853
+ const THRESH_LEVELS = [0.20, 0.28, 0.38, 0.50, 0.65, 0.80, 1.00, 1.20];
854
+ const DENSITY_CAP = 0.88; // ink > 88% of bbox = solid block, not a glyph (ExtraBold ≈ 45–60%, Black ≈ 65–82%)
855
+ let bestRegions = null, bestBinary = null, bestScore = -Infinity, bestMult = 1.00;
856
+ let _bestDensity = 0, bestInvert = false;
857
+
858
+ const _otsuVal = _otsuThreshold(gray);
859
+ const _attempts = [];
860
+
861
+ for (const mult of THRESH_LEVELS) {
862
+ const attempt = _detectTextRegions(gray, workW, workH, mult);
863
+ _attempts.push({ mult, attempt });
864
+ let goodAR = 0;
865
+ for (const r of attempt.regions) {
866
+ const ar = (r.x1 - r.x0) / (r.y1 - r.y0);
867
+ if (ar >= 0.12 && ar <= 3.0) goodAR++;
868
+ }
869
+ console.log(`[Intellifont SWEEP] mult=${mult} otsu=${_otsuVal} thresh=${Math.round(_otsuVal*mult)} regions=${attempt.regions.length} density=${attempt.avgDensity.toFixed(3)} goodAR=${goodAR}/${attempt.regions.length}`);
870
+ }
871
+
872
+ // Pass 1: density-capped selection (preferred — glow excluded).
873
+ // Score = goodAR fraction × 10 only (no density penalty — bold fonts have density 0.45–0.55
874
+ // and were previously penalised by |density−0.35|, causing wrong threshold selection).
875
+ for (const { mult, attempt } of _attempts) {
876
+ if (!attempt.regions.length || attempt.avgDensity > DENSITY_CAP) continue;
877
+ let goodAR = 0;
878
+ for (const r of attempt.regions) {
879
+ const ar = (r.x1 - r.x0) / (r.y1 - r.y0);
880
+ if (ar >= 0.12 && ar <= 3.0) goodAR++;
881
+ }
882
+ const goodFrac = goodAR / attempt.regions.length;
883
+ const qualScore = goodFrac * 10 + attempt.avgDensity;
884
+ if (qualScore > bestScore) {
885
+ bestScore = qualScore; bestRegions = attempt.regions;
886
+ bestBinary = attempt.binary; bestMult = mult; _bestDensity = attempt.avgDensity;
887
+ bestInvert = attempt.invert;
888
+ }
889
+ }
890
+
891
+ // Pass 2 fallback: if all levels exceeded density cap, use uncapped scoring
892
+ if (!bestRegions) {
893
+ for (const { mult, attempt } of _attempts) {
894
+ if (!attempt.regions.length) continue;
895
+ let goodAR = 0;
896
+ for (const r of attempt.regions) {
897
+ const ar = (r.x1 - r.x0) / (r.y1 - r.y0);
898
+ if (ar >= 0.35 && ar <= 1.50) goodAR++;
899
+ }
900
+ const goodFrac = goodAR / attempt.regions.length;
901
+ const qualScore = goodFrac * 10 + attempt.avgDensity;
902
+ if (qualScore > bestScore) {
903
+ bestScore = qualScore; bestRegions = attempt.regions;
904
+ bestBinary = attempt.binary; bestMult = mult; _bestDensity = attempt.avgDensity;
905
+ bestInvert = attempt.invert;
906
+ }
907
+ }
908
+ }
909
+
910
+ // Pass 3 fallback: tile-based Otsu for colored-bg / mixed-lighting images
911
+ // where global Otsu produces a single-class histogram (no ink/bg separation).
912
+ if (!bestRegions) {
913
+ const adaptGray = _tileOtsuGray(gray, workW, workH, 64);
914
+ const attempt = _detectTextRegions(adaptGray, workW, workH, 1.0);
915
+ if (attempt.regions.length > 0) {
916
+ bestRegions = attempt.regions;
917
+ bestBinary = attempt.binary;
918
+ bestMult = 1.0;
919
+ _bestDensity = attempt.avgDensity;
920
+ bestInvert = attempt.invert;
921
+ console.log(`[Intellifont] Tile-Otsu fallback: ${bestRegions.length} regions found`);
922
+ }
923
+ }
924
+
925
+ if (!bestRegions) {
926
+ return { metrics: [], quality, deskewAngle, regionCount: 0,
927
+ warning: 'No text regions detected. Ensure the image contains printed text.' };
928
+ }
929
+
930
+ console.log(`[Intellifont] Best threshold: Otsu×${bestMult} (thresh=${Math.round(_otsuVal*bestMult)}), density=${_bestDensity.toFixed(3)}, score=${bestScore.toFixed(3)}`);
931
+ const regions = bestRegions;
932
+ const binary = bestBinary;
933
+
934
+ // ── Stage 4b: split merged multi-character regions ───────────────────
935
+ function _splitRegion(region, bin, imgW) {
936
+ const rW = region.x1 - region.x0;
937
+ const rH = region.y1 - region.y0;
938
+
939
+ // Single characters (H, M, W, N) have interior whitespace that mimics a
940
+ // split valley. Only attempt splitting if the region is clearly wider than
941
+ // tall — a real joined pair like "il" or "mn" is at least 1.5× as wide.
942
+ if (rW <= rH * 1.5) return [region];
943
+
944
+ const vProj = new Int32Array(rW);
945
+ for (let y = region.y0; y < region.y1; y++)
946
+ for (let x = region.x0; x < region.x1; x++)
947
+ if (bin[y * imgW + x] === 0) vProj[x - region.x0]++;
948
+
949
+ let peak = 0;
950
+ for (let i = 0; i < rW; i++) if (vProj[i] > peak) peak = vProj[i];
951
+ if (peak < 5) return [region]; // nearly empty — nothing to split
952
+ const valleyThresh = peak * 0.15;
953
+
954
+ const splits = [];
955
+ let inValley = false, valleyStart = 0;
956
+ for (let i = 0; i <= rW; i++) {
957
+ const isValley = i < rW && vProj[i] <= valleyThresh;
958
+ if (isValley && !inValley) { inValley = true; valleyStart = i; }
959
+ if (!isValley && inValley) {
960
+ inValley = false;
961
+ // require gap ≥ 3 columns wide — ignore 1-2 pixel noise
962
+ if (i - valleyStart >= 3)
963
+ splits.push(region.x0 + Math.round((valleyStart + i - 1) / 2));
964
+ }
965
+ }
966
+
967
+ if (!splits.length) return [region];
968
+
969
+ const bounds = [region.x0, ...splits, region.x1];
970
+ const result = [];
971
+ for (let i = 0; i < bounds.length - 1; i++) {
972
+ const x0 = bounds[i], x1 = bounds[i + 1];
973
+ const subW = x1 - x0;
974
+ if (subW < rH * 0.2) continue;
975
+ let dc = 0;
976
+ for (let y = region.y0; y < region.y1; y++)
977
+ for (let x = x0; x < x1; x++)
978
+ if (bin[y * imgW + x] === 0) dc++;
979
+ result.push({ x0, y0: region.y0, x1, y1: region.y1, density: dc / (subW * rH) });
980
+ }
981
+ return result.length ? result : [region];
982
+ }
983
+
984
+ const splitRegions = [];
985
+ for (const r of regions) {
986
+ const parts = _splitRegion(r, binary, workW);
987
+ if (parts.length > 1)
988
+ console.log(`[Intellifont SPLIT] region w=${r.x1-r.x0} h=${r.y1-r.y0} → ${parts.length} parts`);
989
+ for (const p of parts) splitRegions.push(p);
990
+ }
991
+ const finalRegions = splitRegions.slice(0, MAX_GLYPHS);
992
+
993
+ // ── Stage 5 + 6: crop → normalize → extract metrics + style ─────────
994
+ const { analyzeImageData, analyzeStyle } = CanvasDNA;
995
+ const metrics = [];
996
+ const styles = [];
997
+
998
+ for (const region of finalRegions) {
999
+ const croppedData = _cropAndNormalize(region, binary, workW);
1000
+ // Quick pre-pass to guess character label from shape
1001
+ const pre = analyzeImageData(croppedData, GLYPH_SIZE, '?');
1002
+ if (!pre) continue;
1003
+ const character = _guessCharacter(pre, region);
1004
+ const m = analyzeImageData(croppedData, GLYPH_SIZE, character);
1005
+ if (!m) continue;
1006
+
1007
+ // ── DEBUG: show extracted glyph in console ──
1008
+ const _dbgC = _createCanvas(GLYPH_SIZE, GLYPH_SIZE);
1009
+ _dbgC.getContext('2d').putImageData(croppedData, 0, 0);
1010
+ const _dbgUrl = _dbgC.toDataURL();
1011
+ const _qSymH = Math.abs((pre.quadrantNw + pre.quadrantSw) - (pre.quadrantNe + pre.quadrantSe));
1012
+ const _qSymV = Math.abs((pre.quadrantNw + pre.quadrantNe) - (pre.quadrantSw + pre.quadrantSe));
1013
+ const _natW = region.x1 - region.x0, _natH = region.y1 - region.y0;
1014
+ console.log(
1015
+ `[Intellifont DBG] glyph#${metrics.length} → char='${character}' | ` +
1016
+ `nat=${(_natW/_natH).toFixed(3)} (${_natW}×${_natH}px) | ` +
1017
+ `cdnaAR=${(pre.aspectRatio/64).toFixed(2)} density=${pre.density} yBal=${pre.yBalance} | ` +
1018
+ `NW=${pre.quadrantNw} NE=${pre.quadrantNe} SW=${pre.quadrantSw} SE=${pre.quadrantSe} | ` +
1019
+ `symH=${_qSymH} symV=${_qSymV} | region=[${region.x0},${region.y0}→${region.x1},${region.y1}]`
1020
+ );
1021
+ console.log('%c ', `background:url(${_dbgUrl}) no-repeat center/contain;padding:40px 55px;outline:1px solid #555`);
1022
+ // ── END DEBUG ──
1023
+
1024
+ // Attach 64×64 thumbnail for IMGDB2 geometric matching (stretch-to-fill binary)
1025
+ m.thumbnail = Array.from(_glyphToThumbnail(binary, region, workW));
1026
+ // Attach 64×64 ML glyph (aspect-preserved, source = ANTI-ALIASED GRAY w/ invert
1027
+ // canonicalisation) for the CosFace embedding matcher — matches training distribution.
1028
+ m.mlGlyph = Array.from(_glyphToMlGray(gray, region, workW, bestInvert));
1029
+ metrics.push(m);
1030
+ styles.push(analyzeStyle(croppedData, GLYPH_SIZE));
1031
+ }
1032
+
1033
+ // Aggregate style across all glyphs (majority vote for italic, median for weight)
1034
+ const style = _aggregateStyle(styles);
1035
+
1036
+ return { metrics, style, quality, deskewAngle, regionCount: regions.length, warning };
1037
+ }
1038
+
1039
+ // ── Load any image source onto a canvas ─────────────────────────────────
1040
+ async _loadToCanvas(src) {
1041
+ if (src instanceof HTMLCanvasElement) return src;
1042
+
1043
+ const img = await this._toImage(src);
1044
+ const c = _createCanvas(img.naturalWidth || img.width, img.naturalHeight || img.height);
1045
+ c.getContext('2d').drawImage(img, 0, 0);
1046
+ return c;
1047
+ }
1048
+
1049
+ _toImage(src) {
1050
+ return new Promise((resolve, reject) => {
1051
+ if (src instanceof HTMLImageElement && src.complete) return resolve(src);
1052
+ const img = new Image();
1053
+ img.crossOrigin = 'anonymous';
1054
+ img.onload = () => resolve(img);
1055
+ img.onerror = () => {
1056
+ // CORS load failed. Check if the image is accessible without CORS
1057
+ // (e.g. Google Lens, authenticated URLs) to give a helpful message.
1058
+ const probe = new Image();
1059
+ probe.onload = () => reject(new Error(
1060
+ 'Image is blocked by CORS policy. Right-click the image → "Save image as…", open the saved file in a browser tab, then try again.'
1061
+ ));
1062
+ probe.onerror = () => reject(new Error(
1063
+ 'Could not load image. The URL may require a login or the image no longer exists.'
1064
+ ));
1065
+ probe.src = typeof src === 'string' ? src : (src.src || '');
1066
+ };
1067
+ if (typeof src === 'string') {
1068
+ img.src = src;
1069
+ } else if (src instanceof File || src instanceof Blob) {
1070
+ img.src = URL.createObjectURL(src);
1071
+ } else if (src instanceof HTMLImageElement) {
1072
+ img.src = src.src;
1073
+ } else {
1074
+ reject(new Error('Unsupported image source type'));
1075
+ }
1076
+ });
1077
+ }
1078
+ }
1079
+
1080
+ // =============================================================================
1081
+ // AI Text Authenticity Analysis
1082
+ //
1083
+ // Determines whether text in an image was rendered by a real font engine or
1084
+ // generated by an AI image model (Midjourney, DALL-E, Stable Diffusion, etc.).
1085
+ //
1086
+ // Core insight: real fonts are deterministically consistent — stroke widths,
1087
+ // serif structure, and curve ratios are nearly identical across characters in
1088
+ // the same typeface. AI-generated text varies chaotically at the pixel level
1089
+ // even when it looks correct at a glance.
1090
+ //
1091
+ // @param {JsPixelMetrics[]} metrics - from ImagePipeline.analyze()
1092
+ // @param {Array<{name|family, confidence}>} [fontMatches] - from identifyFromPixelMetrics()
1093
+ // @returns {{ isAiGenerated, confidence, deviationScore, indicators, nearestFont }}
1094
+ // =============================================================================
1095
+
1096
+ function _stdev(arr) {
1097
+ if (arr.length < 2) return 0;
1098
+ const mean = arr.reduce((a, b) => a + b, 0) / arr.length;
1099
+ return Math.sqrt(arr.reduce((s, v) => s + (v - mean) ** 2, 0) / arr.length);
1100
+ }
1101
+
1102
+ function analyzeTextAuthenticity(metrics, fontMatches = null) {
1103
+ if (!metrics || metrics.length < 2) {
1104
+ return {
1105
+ isAiGenerated: false, confidence: 0, deviationScore: 0,
1106
+ indicators: ['insufficient_glyphs'],
1107
+ nearestFont: null, nearestFontConfidence: null,
1108
+ };
1109
+ }
1110
+
1111
+ const indicators = [];
1112
+ let aiScore = 0;
1113
+
1114
+ // ── Signal 1: Stroke width consistency ─────────────────────────────────────
1115
+ // Real fonts: stdev < 12 | AI text: stdev often > 20
1116
+ const strokeWidths = metrics.map(m => m.strokeWidth ?? m.stroke_width ?? 0);
1117
+ const strokeStdev = _stdev(strokeWidths);
1118
+ if (strokeStdev > 22) { aiScore += 0.35; indicators.push('inconsistent_stroke_width'); }
1119
+ else if (strokeStdev > 15) { aiScore += 0.15; }
1120
+
1121
+ // ── Signal 2: Serif structure consistency ──────────────────────────────────
1122
+ // Real fonts are uniformly serif OR sans.
1123
+ // AI text mixes serif/sans-like letterforms within a "word".
1124
+ const serifScores = metrics.map(m => m.serifScore ?? m.serif_score ?? 0);
1125
+ const serifStdev = _stdev(serifScores);
1126
+ if (serifStdev > 30) { aiScore += 0.30; indicators.push('inconsistent_serif_structure'); }
1127
+ else if (serifStdev > 20) { aiScore += 0.10; }
1128
+
1129
+ // ── Signal 3: Ink density variance ─────────────────────────────────────────
1130
+ // Real fonts within the same weight: density variance is moderate.
1131
+ // AI text can have extreme density swings across neighbouring characters.
1132
+ const densities = metrics.map(m => m.density ?? 0);
1133
+ const densityStdev = _stdev(densities);
1134
+ if (densityStdev > 40) { aiScore += 0.20; indicators.push('inconsistent_density'); }
1135
+
1136
+ // ── Signal 4: Curve ratio consistency ──────────────────────────────────────
1137
+ const curveRatios = metrics.map(m => m.curveRatio ?? m.curve_ratio ?? 0);
1138
+ const curveStdev = _stdev(curveRatios);
1139
+ if (curveStdev > 35) { aiScore += 0.15; indicators.push('inconsistent_curves'); }
1140
+
1141
+ // ── Signal 5: Font DB match confidence (bonus, if available) ───────────────
1142
+ // Real rendered text matches a known font at > 0.70 confidence.
1143
+ // AI text often < 0.55 — it looks like a font but isn't one.
1144
+ if (fontMatches && fontMatches.length > 0) {
1145
+ const top = fontMatches[0];
1146
+ const topConf = top.confidence ?? 0;
1147
+ if (topConf < 0.50) { aiScore += 0.35; indicators.push('no_font_match'); }
1148
+ else if (topConf < 0.65) { aiScore += 0.15; indicators.push('weak_font_match'); }
1149
+ }
1150
+
1151
+ aiScore = Math.min(1.0, aiScore);
1152
+ const isAiGenerated = aiScore > 0.45;
1153
+
1154
+ const top = fontMatches?.[0];
1155
+ return {
1156
+ isAiGenerated,
1157
+ confidence: isAiGenerated ? aiScore : 1 - aiScore,
1158
+ deviationScore: aiScore,
1159
+ indicators: indicators.length ? indicators : ['consistent_metrics'],
1160
+ nearestFont: top?.family ?? top?.name ?? null,
1161
+ nearestFontConfidence: top?.confidence ?? null,
1162
+ };
1163
+ }
1164
+
1165
+ // =============================================================================
1166
+ // Convenience wrapper — one-call API
1167
+ // =============================================================================
1168
+ async function analyzeImage(imageSource) {
1169
+ return new ImagePipeline().analyze(imageSource);
1170
+ }
1171
+
1172
+ // =============================================================================
1173
+ // Exports
1174
+ // =============================================================================
1175
+ const ImagePipelineModule = { ImagePipeline, analyzeImage, analyzeTextAuthenticity };
1176
+
1177
+ if (typeof module !== 'undefined' && module.exports) {
1178
+ module.exports = ImagePipelineModule;
1179
+ } else if (typeof window !== 'undefined') {
1180
+ window.IntellifontImagePipeline = ImagePipelineModule;
1181
+ }
1182
+ })();