mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
From the first human review of sweep-3 builds (pins on 12 samples): fonts at the wrong size, weight, colour, or place; nav bars too tall; kickers and dividers the comp does not have; a footer strip pushed off the frame that read as drift on ground colour alone; a drawing filed as chrome with no note to catch it. - lib/hero-checks.mjs: textRegionCheck (cap height, line count, ink density, ink colour, first-line offset vs the comp crop, measured fresh), chromeStripCheck (first rule row), inventedInk (build energy over a calm comp cell and neighbourhood). Wired into gateHero as reasons; invented ink vetoes at 4% of cells. - comp-diff verdictFor: detailRaw < 0.15 is missing whatever the palette. - comp-spec: every region carries a note. - font-fingerprint: the tall-line filter takes its median over lines with real mass, so two display lines above a small line are not 'tall'. AI-assisted (Claude Code).
565 lines
28 KiB
JavaScript
565 lines
28 KiB
JavaScript
/**
|
|
* font-fingerprint: size-invariant, text-robust shape features for lettering
|
|
* in a raster (a comp crop or a rendered sample). fingerprint(img) returns the
|
|
* feature vector; distance(a, b) compares two vectors over noise-normalized,
|
|
* weighted features. Used by font-match.mjs (comp measurement and ranking)
|
|
* and by the catalog index build (scripts/build-font-index.mjs at the repo root). Depends only on
|
|
* lib/image-metrics.mjs and lib/raster.mjs.
|
|
*
|
|
* Every measure is taken per text line and normalized by R, the line's
|
|
* reference height (median of the tallest column heights above the baseline:
|
|
* the cap line on an all-caps line, the ascender line on a mixed line), so
|
|
* the same face gives the same numbers at any point size; per-glyph measures
|
|
* are medians so the numbers survive a change of text. Small crops are
|
|
* upsampled (bilinear) so R is at least 24px, and stroke runs are measured
|
|
* with antialiased edge pixels counted by coverage, so stem widths do not
|
|
* fatten at small sizes.
|
|
*
|
|
* Features (all in R units unless noted; null when not measurable):
|
|
* advance/advTall/advX median glyph width over baseline glyphs / tall glyphs / x-height glyphs
|
|
* advCV spread of glyph widths (std/median): mono ~0.15, sans ~0.3, script > 0.5
|
|
* gap median inter-glyph gap
|
|
* xRatio x-line / R (null on all-caps lines)
|
|
* descRatio descender depth (90th pct)
|
|
* stemW median horizontal ink run in the x band (stem width)
|
|
* contrast stem width / median thin (vertical) run: didone high, grotesque ~1
|
|
* serif foot width / mid-stem width on stems that reach the baseline
|
|
* roundFrac fraction of glyphs with bbox aspect > 0.9
|
|
* densTall / densX ink / bbox area for tall / x-height glyphs (weight)
|
|
* runDensity horizontal ink runs per row per R of line width (stroke busyness)
|
|
* vprof0..9 normalized vertical ink profile from 0.35R below baseline to 1.05R above
|
|
* hrun25/50/75/90 quantiles of horizontal run lengths over the letter body
|
|
* vrun25/50/75/90 quantiles of vertical run lengths over the whole line
|
|
* colq25/75 quantiles of column heights above the baseline
|
|
* wq25/75 quantiles of glyph widths
|
|
* Also returned: lines, glyphs, capHeightPx (R in source pixels), allCaps, inkIsDark,
|
|
* upsampled, weight (densTall, so v1 callers keep a weight field).
|
|
*/
|
|
import { toGray } from './image-metrics.mjs';
|
|
import { resize } from './raster.mjs';
|
|
|
|
function otsu(gray) {
|
|
const hist = new Float64Array(256);
|
|
for (let i = 0; i < gray.data.length; i++) hist[Math.max(0, Math.min(255, Math.round(gray.data[i])))]++;
|
|
const total = gray.data.length;
|
|
let sum = 0; for (let i = 0; i < 256; i++) sum += i * hist[i];
|
|
let sumB = 0, wB = 0, best = 0, thr = 128;
|
|
for (let t = 0; t < 256; t++) {
|
|
wB += hist[t]; if (!wB) continue;
|
|
const wF = total - wB; if (!wF) break;
|
|
sumB += t * hist[t];
|
|
const mB = sumB / wB, mF = (sum - sumB) / wF;
|
|
const between = wB * wF * (mB - mF) ** 2;
|
|
if (between > best) { best = between; thr = t; }
|
|
}
|
|
return thr;
|
|
}
|
|
|
|
const med = (a) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); const m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
|
const pct = (a, p) => { if (!a.length) return null; const s = [...a].sort((p, q) => p - q); return s[Math.min(s.length - 1, Math.floor(p * s.length))]; };
|
|
const mean = (a) => (a.length ? a.reduce((s, x) => s + x, 0) / a.length : null);
|
|
|
|
/** Binarize; returns { W, H, ink: Uint8Array, inkIsDark }. */
|
|
function binarize(img) {
|
|
const g = toGray(img);
|
|
let thr = otsu(g);
|
|
let dark = 0; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++;
|
|
// a two-level raster (no antialiasing) puts the Otsu threshold on the dark
|
|
// level itself; step it up so that level counts as ink
|
|
if (!dark) { thr += 1; for (let i = 0; i < g.data.length; i++) if (g.data[i] < thr) dark++; }
|
|
const inkIsDark = dark <= g.data.length / 2;
|
|
const ink = new Uint8Array(g.data.length);
|
|
let sI = 0, nI = 0, sG = 0, nG = 0;
|
|
for (let i = 0; i < g.data.length; i++) {
|
|
const on = (inkIsDark ? g.data[i] < thr : g.data[i] >= thr) ? 1 : 0;
|
|
ink[i] = on;
|
|
if (on) { sI += g.data[i]; nI++; } else { sG += g.data[i]; nG++; }
|
|
}
|
|
const inkLevel = nI ? sI / nI : (inkIsDark ? 0 : 255), groundLevel = nG ? sG / nG : (inkIsDark ? 255 : 0);
|
|
// coverage per pixel: 0 = ground, 1 = ink, linear between the two class means, so
|
|
// antialiased edge pixels count fractionally and stroke widths do not fatten at small sizes
|
|
const covA = new Float32Array(g.data.length);
|
|
const den = groundLevel - inkLevel || 1;
|
|
for (let i = 0; i < g.data.length; i++) covA[i] = Math.max(0, Math.min(1, (groundLevel - g.data[i]) / den));
|
|
const cov = (i) => covA[i];
|
|
return { W: g.width, H: g.height, ink, inkIsDark, cov, covA };
|
|
}
|
|
|
|
/** Text lines from the row-ink profile (same rules as font-match v1). */
|
|
function findLines(bin) {
|
|
const { W, H, ink } = bin;
|
|
// Columns inked top to bottom (a rule, a black margin, a page edge) span
|
|
// every line and would fuse them into one run: leave them out of the row
|
|
// profile. Lettering never fills a column for more than ~85% of the crop.
|
|
const colInk = new Uint32Array(W);
|
|
for (let y = 0; y < H; y++) { const o = y * W; for (let x = 0; x < W; x++) colInk[x] += ink[o + x]; }
|
|
const colOk = new Uint8Array(W);
|
|
let okCount = 0;
|
|
for (let x = 0; x < W; x++) { if (colInk[x] < H * 0.85) { colOk[x] = 1; okCount++; } }
|
|
if (!okCount) return { lines: [], rowInk: new Uint32Array(H) };
|
|
const rowInk = new Uint32Array(H);
|
|
for (let y = 0; y < H; y++) { let c = 0; const o = y * W; for (let x = 0; x < W; x++) if (colOk[x]) c += ink[o + x]; rowInk[y] = c; }
|
|
const floor = Math.max(1, W * 0.004);
|
|
const runs = [];
|
|
let y = 0;
|
|
while (y < H) {
|
|
if (rowInk[y] > floor) {
|
|
const y0 = y; while (y < H && (rowInk[y] > floor || (y + 1 < H && rowInk[y + 1] > floor))) y++;
|
|
if (y - y0 >= 4) runs.push({ y0, y1: y });
|
|
} else y++;
|
|
}
|
|
const lines = [];
|
|
for (const run of runs) {
|
|
let peak = 0; for (let yy = run.y0; yy < run.y1; yy++) peak = Math.max(peak, rowInk[yy]);
|
|
const valley = peak * 0.15;
|
|
let start = run.y0, inValley = false, valleyStart = 0;
|
|
for (let yy = run.y0; yy < run.y1; yy++) {
|
|
const low = rowInk[yy] < valley;
|
|
if (low && !inValley) { inValley = true; valleyStart = yy; }
|
|
if (!low && inValley) {
|
|
inValley = false;
|
|
if (yy - valleyStart >= 3 && valleyStart - start >= 4) { lines.push({ y0: start, y1: valleyStart, run }); start = yy; }
|
|
}
|
|
}
|
|
if (run.y1 - start >= 4) lines.push({ y0: start, y1: run.y1, run });
|
|
}
|
|
// A piece split off inside one run with a fraction of the ink of the text
|
|
// lines is not a line: a thin band of ascenders or tittles above the x band
|
|
// (few letters reach it, so the valley rule fires) or a stray rule. Ascender
|
|
// bands merge back into the line below them; anything else is dropped.
|
|
for (const ln of lines) { let m = 0; for (let yy = ln.y0; yy < ln.y1; yy++) m += rowInk[yy]; ln.mass = m; }
|
|
// A drawing or photo sharing the crop with body copy is one tall, massive
|
|
// 'line' that would carry maxMass and drop every real line under the 30%
|
|
// rule (a 461x307 thread crop measured as one 160px 'cap' off a
|
|
// carburetor drawing). When several lines exist, ones far taller than the
|
|
// median are not lettering: leave them out of the mass reference and out
|
|
// of the result.
|
|
// The median is taken over lines carrying real mass (rule slivers and
|
|
// tittles do not vote), and needs three of them: two 145px headline lines
|
|
// above a 26px artist line were dropped as 'tall' against a median pulled
|
|
// to 28 by three slivers.
|
|
const massMax = Math.max(1, ...lines.map((l) => l.mass));
|
|
const real = lines.filter((l) => l.mass >= massMax * 0.05);
|
|
if (real.length >= 3) {
|
|
const hs = real.map((l) => l.y1 - l.y0).sort((a, b) => a - b);
|
|
const medH = hs[Math.floor(hs.length / 2)];
|
|
for (const ln of lines) if (ln.y1 - ln.y0 > medH * 3) ln.tall = true;
|
|
}
|
|
const maxMass = Math.max(0, ...lines.filter((l) => !l.tall).map((l) => l.mass));
|
|
const merged = [];
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const ln = lines[i];
|
|
if (ln.tall) continue;
|
|
if (ln.mass >= maxMass * 0.3) { merged.push({ y0: ln.y0, y1: ln.y1, mass: ln.mass }); continue; }
|
|
const next = lines[i + 1];
|
|
if (next && next.run === ln.run && next.mass >= maxMass * 0.3 && (ln.y1 - ln.y0) <= (next.y1 - next.y0) * 0.5) { next.y0 = ln.y0; }
|
|
}
|
|
return { lines: merged, rowInk };
|
|
}
|
|
|
|
/** Feature names in fingerprint order (used by distance). */
|
|
const VBINS = 10, HQ = [0.25, 0.5, 0.75, 0.9];
|
|
export const FEATURES = ['advance', 'advTall', 'advX', 'advCV', 'gap', 'xRatio', 'descRatio', 'stemW', 'contrast', 'serif', 'roundFrac', 'densTall', 'densX', 'runDensity',
|
|
...Array.from({ length: VBINS }, (_, i) => `vprof${i}`), ...HQ.map((q) => `hrun${Math.round(q * 100)}`), ...HQ.map((q) => `vrun${Math.round(q * 100)}`), 'colq25', 'colq75', 'wq25', 'wq75'];
|
|
|
|
/** Center of the densest window of width tol in a list of values, and its count. */
|
|
function modeOf(vals, tol) {
|
|
let best = null, bestC = -1;
|
|
const s = [...vals].sort((a, b) => a - b);
|
|
let j = 0;
|
|
for (let i = 0; i < s.length; i++) {
|
|
while (s[i] - s[j] > tol) j++;
|
|
const c = i - j + 1;
|
|
if (c > bestC) { bestC = c; best = (s[i] + s[j]) / 2; }
|
|
}
|
|
return { v: best, n: bestC };
|
|
}
|
|
|
|
/**
|
|
* Per-line vertical metrics from column extrema, which do not need glyphs to
|
|
* be separable. baseline = mode of column bottoms. R (the reference height)
|
|
* is the top line of the tallest cluster: the cap line on an all-caps line,
|
|
* the ascender line (or the cap line when caps are taller) on a mixed line.
|
|
* The x-line is a second mode of column heights well below R; when there is
|
|
* none the line is read as all-caps.
|
|
*/
|
|
function lineMetrics(bin, ln) {
|
|
const { W, ink, cov } = bin;
|
|
const cols = [];
|
|
for (let x = 0; x < W; x++) {
|
|
let top = -1, bot = -1;
|
|
for (let yy = ln.y0; yy < ln.y1; yy++) if (ink[yy * W + x]) { if (top < 0) top = yy; bot = yy + 1; }
|
|
if (top < 0) continue;
|
|
// sub-pixel edges from the antialiased boundary pixel's coverage
|
|
const t = top > 0 ? top - cov((top - 1) * W + x) : top;
|
|
const b = bot < bin.H ? bot + cov(bot * W + x) : bot;
|
|
cols.push({ x, top: t, bot: b });
|
|
}
|
|
if (cols.length < 8) return null;
|
|
const roughH = pct(cols.map((c) => c.bot - c.top), 0.9);
|
|
const tol = Math.max(1, Math.round(roughH * 0.04));
|
|
const baseF = modeOf(cols.map((c) => c.bot), tol).v;
|
|
const base = Math.round(baseF);
|
|
const hs = cols.filter((c) => c.bot <= baseF + tol * 1.5).map((c) => baseF - c.top).filter((h) => h > 0);
|
|
if (hs.length < 8) return null;
|
|
const hMaxAbs = pct(hs, 0.995);
|
|
const topCluster = hs.filter((h) => h >= hMaxAbs * 0.94);
|
|
const R = med(topCluster);
|
|
if (!R || R < 4) return null;
|
|
const lowHs = hs.filter((h) => h >= R * 0.3 && h <= R * 0.86);
|
|
let xh = null;
|
|
if (lowHs.length >= Math.max(6, hs.length * 0.12)) {
|
|
const m = modeOf(lowHs, tol);
|
|
if (m.n >= Math.max(4, lowHs.length * 0.25)) xh = m.v;
|
|
}
|
|
const dsc = cols.filter((c) => c.bot > baseF + tol * 1.5 && c.top < baseF - R * 0.3).map((c) => (c.bot - baseF) / R);
|
|
const descRatio = dsc.length >= 4 ? pct(dsc, 0.9) : null;
|
|
return { base, R, cap: R, xh, descRatio, tol, xL: cols[0].x, xR: cols[cols.length - 1].x + 1, hs, ln };
|
|
}
|
|
|
|
/** Glyph boxes: column runs of ink inside the x band, so ascender/descender bridges do not merge letters. */
|
|
function segment(bin, ln, m) {
|
|
const { W, ink } = bin;
|
|
const bandTop = Math.max(ln.y0, Math.round(m.base - (m.xh || m.cap * 0.6)));
|
|
const bandH = m.base - bandTop;
|
|
const thr = 1;
|
|
const colBand = new Uint32Array(W);
|
|
for (let yy = bandTop; yy < m.base; yy++) { const o = yy * W; for (let x = m.xL; x < m.xR; x++) colBand[x] += ink[o + x]; }
|
|
const runs = [];
|
|
let x = m.xL;
|
|
while (x < m.xR) {
|
|
if (colBand[x] >= thr) { const x0 = x; while (x < m.xR && colBand[x] >= thr) x++; runs.push({ x0, x1: x }); } else x++;
|
|
}
|
|
const out = [];
|
|
for (const r of runs) {
|
|
let top = -1, bot = -1, area = 0;
|
|
for (let yy = ln.y0; yy < ln.y1; yy++) {
|
|
let c = 0, cv = 0; const o = yy * W; for (let xx = r.x0; xx < r.x1; xx++) { c += ink[o + xx]; cv += bin.covA[o + xx]; }
|
|
if (c) { if (top < 0) top = yy; bot = yy + 1; }
|
|
area += cv;
|
|
}
|
|
if (top >= 0) out.push({ x0: r.x0, x1: r.x1, w: r.x1 - r.x0, top, bot, h: bot - top, area });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function measure(bin, lines) {
|
|
const { W, H, ink, covA } = bin;
|
|
// run lengths with the antialiased edge pixels counted by coverage
|
|
const hLen = (o, x0, x1) => { let s = 0; for (let x = Math.max(0, x0 - 1); x < Math.min(W, x1 + 1); x++) s += covA[o + x]; return s; };
|
|
const vLen = (x, y0, y1) => { let s = 0; for (let y = Math.max(0, y0 - 1); y < Math.min(H, y1 + 1); y++) s += covA[y * W + x]; return s; };
|
|
let glyphN = 0;
|
|
const per = { xh: [], desc: [], runDensity: [] };
|
|
let allCapsLines = 0;
|
|
const vprof = new Float64Array(VBINS); const hruns = [], vruns = [], colHs = [], widths = [];
|
|
const advTall = [], advAll = [], advX = [], gaps = [], stems = [], thins = [], serifR = [], round = [], densTall = [], densX = [];
|
|
let capSum = 0, capN = 0;
|
|
// One crop, one case. In a multi-line all-caps headline one line can grow a
|
|
// spurious x-height from crossbars (the A and E arms of "JAPANESE" at 0.32R)
|
|
// while its neighbours report none; that line then measures its stems and
|
|
// its x band on the crossbar zone. Lines vote: when most lines see no
|
|
// x-height, none does.
|
|
const metrics = lines.map((ln) => lineMetrics(bin, ln)).filter(Boolean);
|
|
if (metrics.length >= 2) {
|
|
const withX = metrics.filter((m) => m.xh).length;
|
|
if (withX * 2 <= metrics.length) for (const m of metrics) m.xh = null;
|
|
}
|
|
for (const m of metrics) {
|
|
const ln = m.ln;
|
|
const { base, cap, xh, tol, xL, xR } = m;
|
|
capSum += cap; capN++;
|
|
if (xh) per.xh.push(xh / cap);
|
|
if (!xh) allCapsLines++;
|
|
if (m.descRatio != null) per.desc.push(m.descRatio);
|
|
for (const h of m.hs) colHs.push(h / cap);
|
|
// vertical ink profile from 0.35R below the baseline to 1.05R above, VBINS bins
|
|
for (let yy = ln.y0; yy < ln.y1; yy++) {
|
|
const u = (base - yy - 0.5) / cap; // height above baseline in R units
|
|
const bi = Math.floor((u + 0.35) / 1.4 * VBINS);
|
|
if (bi < 0 || bi >= VBINS) continue;
|
|
let c = 0; const o = yy * W; for (let x = xL; x < xR; x++) c += ink[o + x];
|
|
vprof[bi] += c;
|
|
}
|
|
// horizontal run lengths over the whole line body (x band to cap line), vertical run lengths over all columns
|
|
for (let yy = Math.max(ln.y0, Math.round(base - cap)); yy < base; yy++) {
|
|
const o = yy * W; let x = xL;
|
|
while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; hruns.push(hLen(o, x0, x) / cap); } else x++; }
|
|
}
|
|
for (let x = xL; x < xR; x++) {
|
|
let yy = ln.y0;
|
|
while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; vruns.push(vLen(x, y0, yy) / cap); } else yy++; }
|
|
}
|
|
const gl = segment(bin, ln, m);
|
|
const G = gl.filter((g) => g.w >= cap * 0.12 && (base - g.top) >= cap * 0.3);
|
|
glyphN += G.length;
|
|
const onBase = G.filter((g) => Math.abs(g.bot - base) <= tol * 1.5);
|
|
const capG = onBase.filter((g) => base - g.top >= cap * 0.88);
|
|
const xs = xh ? onBase.filter((g) => Math.abs(base - g.top - xh) <= Math.max(tol * 1.5, cap * 0.05)) : [];
|
|
for (const g of capG) { advTall.push(g.w / cap); densTall.push(g.area / (g.w * g.h)); }
|
|
for (const g of xs) { densX.push(g.area / (g.w * g.h)); advX.push(g.w / cap); }
|
|
for (const g of onBase) { advAll.push(g.w / cap); widths.push(g.w / cap); round.push(g.w / (base - g.top) > 0.9 ? 1 : 0); }
|
|
for (let i = 0; i + 1 < G.length; i++) { const gap = G[i + 1].x0 - G[i].x1; if (gap >= 0 && gap < cap * 0.6) gaps.push(gap / cap); }
|
|
const xTop = base - (xh || cap * 0.55);
|
|
const bandTop = Math.round(xTop + (base - xTop) * 0.2), bandBot = Math.round(base - (base - xTop) * 0.2);
|
|
let runCount = 0, runRows = 0;
|
|
for (let yy = bandTop; yy < bandBot; yy++) {
|
|
const o = yy * W; let x = xL; runRows++;
|
|
while (x < xR) { if (ink[o + x]) { const x0 = x; while (x < xR && ink[o + x]) x++; const L = hLen(o, x0, x); runCount++; if (L < cap * 0.5) stems.push(L / cap); } else x++; }
|
|
}
|
|
if (runRows) per.runDensity.push((runCount / runRows) / ((xR - xL) / cap));
|
|
for (let x = xL; x < xR; x++) {
|
|
let yy = ln.y0;
|
|
while (yy < ln.y1) { if (ink[yy * W + x]) { const y0 = yy; while (yy < ln.y1 && ink[yy * W + x]) yy++; const L = vLen(x, y0, yy); if (L < cap * 0.35) thins.push(L / cap); } else yy++; }
|
|
}
|
|
// serif: stems that run straight to the baseline; foot width vs mid-stem width
|
|
const runAt = (yy, x) => { const o = yy * W; if (!ink[o + x]) return 0; let a = x, b = x; while (a > xL && ink[o + a - 1]) a--; while (b + 1 < xR && ink[o + b + 1]) b++; return hLen(o, a, b + 1); };
|
|
const yMid = Math.round(base - cap * 0.4), yHi = Math.round(base - cap * 0.18), yFoot = base - Math.max(1, Math.round(cap * 0.04));
|
|
let x = xL;
|
|
while (x < xR) {
|
|
let yy = base - 1; if (!ink[yy * W + x]) { x++; continue; }
|
|
while (yy > ln.y0 && ink[(yy - 1) * W + x]) yy--;
|
|
if (yy > yMid) { x++; continue; }
|
|
const x0 = x; x++; while (x < xR && ink[(base - 1) * W + x] && ink[yMid * W + x]) x++;
|
|
const xc = Math.round((x0 + x - 1) / 2);
|
|
const wMid = runAt(yMid, xc), wHi = runAt(yHi, xc), wFoot = runAt(yFoot, xc);
|
|
if (wMid > 0 && wMid < cap * 0.5 && wHi <= wMid * 1.3 && wHi >= wMid * 0.7) serifR.push(wFoot / wMid);
|
|
}
|
|
}
|
|
if (!capN) return null;
|
|
const stemW = med(stems), thinW = med(thins);
|
|
const advM = med(advAll);
|
|
const advSd = advAll.length > 3 ? Math.sqrt(advAll.reduce((s, v) => s + (v - advM) ** 2, 0) / advAll.length) : null;
|
|
const vsum = vprof.reduce((s, x) => s + x, 0) || 1;
|
|
const extra = {};
|
|
for (let i = 0; i < VBINS; i++) extra[`vprof${i}`] = vprof[i] / vsum;
|
|
for (const q of HQ) { extra[`hrun${Math.round(q * 100)}`] = pct(hruns, q); extra[`vrun${Math.round(q * 100)}`] = pct(vruns, q); }
|
|
extra.colq25 = pct(colHs, 0.25); extra.colq75 = pct(colHs, 0.75);
|
|
extra.wq25 = pct(widths, 0.25); extra.wq75 = pct(widths, 0.75);
|
|
return {
|
|
...extra,
|
|
capHeightPx: capSum / capN,
|
|
glyphs: glyphN,
|
|
advance: advM,
|
|
advTall: advTall.length ? med(advTall) : null,
|
|
advX: advX.length ? med(advX) : null,
|
|
advCV: advSd != null && advM ? advSd / advM : null,
|
|
gap: gaps.length ? med(gaps) : 0,
|
|
xRatio: per.xh.length ? med(per.xh) : null,
|
|
descRatio: per.desc.length ? med(per.desc) : null,
|
|
allCaps: allCapsLines * 2 > capN,
|
|
runDensity: med(per.runDensity),
|
|
stemW,
|
|
contrast: stemW && thinW ? stemW / thinW : null,
|
|
serif: serifR.length >= 3 ? med(serifR) : null,
|
|
roundFrac: round.length ? mean(round) : null,
|
|
densTall: densTall.length ? med(densTall) : null,
|
|
densX: densX.length ? med(densX) : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* fingerprint(img) -> features, or null when no lettering is found. Upsamples (bilinear) when the
|
|
* cap height is under 24px so runs and edges are measured on finer pixels.
|
|
*/
|
|
/**
|
|
* Keep the dominant lettering in a region crop: the lines whose cap height is
|
|
* within `tol` of the tallest, clipped horizontally to their own ink. A comp
|
|
* region drawn on a 10x10 grid over-covers: the headline crop carries the
|
|
* first line of body copy below it and a slice of the neighbouring column,
|
|
* and every one of those small letters pulls stem width, run lengths and the
|
|
* x-height vote toward a lighter, wider face. Returns { lines, x0, x1 } in
|
|
* the binarized image, or null when nothing survives.
|
|
*/
|
|
export function isolateDominant(bin, lines, { tol = 0.28 } = {}) {
|
|
const ms = lines.map((ln) => ({ ln, m: lineMetrics(bin, ln) })).filter((x) => x.m);
|
|
if (!ms.length) return null;
|
|
// The dominant class is the one holding most of the ink, not the tallest
|
|
// line: a body-copy crop that clips the last line of the headline above it
|
|
// is body copy. Cluster caps within tol of each other and pick the cluster
|
|
// with the most ink mass; the tallest wins only a tie.
|
|
const clusters = [];
|
|
for (const x of [...ms].sort((a, b) => b.m.cap - a.m.cap)) {
|
|
const c = clusters.find((cl) => Math.abs(cl.cap - x.m.cap) <= cl.cap * tol);
|
|
if (c) { c.items.push(x); c.mass += x.ln.mass || 0; } else clusters.push({ cap: x.m.cap, items: [x], mass: x.ln.mass || 0 });
|
|
}
|
|
// Mass per line-height, so one heavy display line does not outvote five
|
|
// lines of body copy; and a cluster of a single clipped line never wins
|
|
// over a cluster of three or more.
|
|
for (const c of clusters) { c.rows = c.items.reduce((n, x) => n + (x.ln.y1 - x.ln.y0), 0); c.density = c.mass / Math.max(1, c.rows); c.n = c.items.length; }
|
|
clusters.sort((a, b) => {
|
|
const aMulti = a.n >= 3, bMulti = b.n >= 3;
|
|
if (aMulti !== bMulti) return aMulti ? -1 : 1;
|
|
return (b.mass - a.mass) || (b.cap - a.cap);
|
|
});
|
|
const keep = clusters[0].items;
|
|
const capMax = Math.max(...keep.map((x) => x.m.cap));
|
|
// horizontal extent of the kept lines' tallest ink columns only: a small
|
|
// column of body text beside the headline shares its rows but not its height
|
|
const { W, ink } = bin;
|
|
let x0 = W, x1 = 0;
|
|
for (const { ln, m } of keep) {
|
|
const top = Math.round(m.base - m.cap * 0.75);
|
|
for (let x = m.xL; x < m.xR; x++) {
|
|
let tall = false;
|
|
for (let y = top; y < m.base && !tall; y++) if (ink[y * W + x]) tall = true;
|
|
if (!tall) continue;
|
|
// a column is headline ink when a run of at least 0.5 cap of ink stands in it
|
|
let run = 0, best = 0;
|
|
for (let y = ln.y0; y < ln.y1; y++) { if (ink[y * W + x]) { run++; if (run > best) best = run; } else run = 0; }
|
|
if (best >= m.cap * 0.5) { if (x < x0) x0 = x; if (x + 1 > x1) x1 = x + 1; }
|
|
}
|
|
}
|
|
if (x1 <= x0) return null;
|
|
// grow the box by half a cap so glyph sides and the tracking gap survive
|
|
const pad = Math.round(capMax * 0.5);
|
|
return { lines: keep.map((x) => x.ln), x0: Math.max(0, x0 - pad), x1: Math.min(W, x1 + pad), dropped: ms.length - keep.length };
|
|
}
|
|
|
|
function maskOutside(bin, x0, x1, lines) {
|
|
const { W, H, ink, covA } = bin;
|
|
const keepRow = new Uint8Array(H);
|
|
for (const ln of lines) for (let y = ln.y0; y < ln.y1; y++) keepRow[y] = 1;
|
|
const ink2 = new Uint8Array(ink.length), cov2 = new Float32Array(covA.length);
|
|
for (let y = 0; y < H; y++) {
|
|
if (!keepRow[y]) continue;
|
|
for (let x = x0; x < x1; x++) { const i = y * W + x; ink2[i] = ink[i]; cov2[i] = covA[i]; }
|
|
}
|
|
return { ...bin, ink: ink2, covA: cov2, cov: (i) => cov2[i] };
|
|
}
|
|
|
|
export function fingerprint(img, { minCap = 24, minGlyphs = 3, isolate = true } = {}) {
|
|
let bin = binarize(img);
|
|
let { lines } = findLines(bin);
|
|
if (!lines.length) return null;
|
|
let isolated = 0;
|
|
if (isolate && lines.length > 1) {
|
|
const iso = isolateDominant(bin, lines);
|
|
if (iso && (iso.dropped > 0 || iso.x1 - iso.x0 < bin.W * 0.9)) {
|
|
bin = maskOutside(bin, iso.x0, iso.x1, iso.lines);
|
|
lines = iso.lines;
|
|
isolated = iso.dropped;
|
|
}
|
|
}
|
|
let f = measure(bin, lines);
|
|
// fewer than minGlyphs separable glyphs is not lettering (a rule, a solid
|
|
// bar, one letterform): callers read null as "no separable lettering"
|
|
if (!f || f.glyphs < minGlyphs) return null;
|
|
let scale = 1;
|
|
if (f.capHeightPx < minCap && f.capHeightPx >= 4) {
|
|
scale = Math.min(4, Math.ceil(minCap / f.capHeightPx));
|
|
const up = resize(img, img.width * scale, img.height * scale);
|
|
bin = binarize(up);
|
|
lines = findLines(bin).lines;
|
|
// the upsample re-reads the whole crop: isolate again so the clipped
|
|
// headline or the drawing does not come back at scale
|
|
if (isolate && lines.length > 1) {
|
|
const iso2 = isolateDominant(bin, lines);
|
|
if (iso2 && (iso2.dropped > 0 || iso2.x1 - iso2.x0 < bin.W * 0.9)) { bin = maskOutside(bin, iso2.x0, iso2.x1, iso2.lines); lines = iso2.lines; isolated = Math.max(isolated, iso2.dropped); }
|
|
}
|
|
const f2 = lines.length ? measure(bin, lines) : null;
|
|
if (f2) f = f2;
|
|
else scale = 1;
|
|
}
|
|
const r = { lines: lines.length, glyphs: f.glyphs, capHeightPx: +(f.capHeightPx / scale).toFixed(1), inkIsDark: bin.inkIsDark, upsampled: scale > 1, allCaps: f.allCaps, isolatedFrom: isolated, weight: f.densTall == null && f.densX == null ? null : +(f.densTall ?? f.densX).toFixed(4) };
|
|
for (const k of FEATURES) r[k] = f[k] == null ? null : +f[k].toFixed(4);
|
|
return r;
|
|
}
|
|
|
|
/**
|
|
* Distance normalization fitted on 299 held-out probes (150 at ~30px cap, 149
|
|
* at ~14px, text different from the index text) against a 3,092-entry Google
|
|
* Fonts index: std = within-family noise (1.4826 x median |probe - own index
|
|
* entry|, floored at 5% of the catalog IQR spread), w = group weight from
|
|
* coordinate descent on top-5 family recall. mean is unused by the distance.
|
|
*/
|
|
export const STATS = {
|
|
advance: { std: 0.07648, w: 0 },
|
|
advTall: { std: 0.25331, w: 0 },
|
|
advX: { std: 0.05144, w: 1.5 },
|
|
advCV: { std: 0.0857, w: 1 },
|
|
gap: { std: 0.02668, w: 1 },
|
|
xRatio: { std: 0.02315, w: 1 },
|
|
descRatio: { std: 0.17831, w: 1 },
|
|
stemW: { std: 0.01922, w: 1 },
|
|
contrast: { std: 0.05969, w: 3 },
|
|
serif: { std: 0.31477, w: 0.5 },
|
|
roundFrac: { std: 0.09341, w: 1 },
|
|
densTall: { std: 0.05708, w: 2 },
|
|
densX: { std: 0.07666, w: 0 },
|
|
runDensity: { std: 0.18199, w: 1 },
|
|
vprof0: { std: 0.01178, w: 1 },
|
|
vprof1: { std: 0.01331, w: 1 },
|
|
vprof2: { std: 0.02745, w: 1 },
|
|
vprof3: { std: 0.03046, w: 1 },
|
|
vprof4: { std: 0.01933, w: 1 },
|
|
vprof5: { std: 0.01737, w: 1 },
|
|
vprof6: { std: 0.0336, w: 1 },
|
|
vprof7: { std: 0.03195, w: 1 },
|
|
vprof8: { std: 0.03271, w: 1 },
|
|
vprof9: { std: 0.02951, w: 1 },
|
|
hrun25: { std: 0.01751, w: 1 },
|
|
hrun50: { std: 0.02124, w: 1 },
|
|
hrun75: { std: 0.04503, w: 1 },
|
|
hrun90: { std: 0.06844, w: 1 },
|
|
vrun25: { std: 0.01895, w: 1 },
|
|
vrun50: { std: 0.02405, w: 1 },
|
|
vrun75: { std: 0.06199, w: 1 },
|
|
vrun90: { std: 0.09486, w: 1 },
|
|
colq25: { std: 0.02906, w: 1 },
|
|
colq75: { std: 0.18204, w: 1 },
|
|
wq25: { std: 0.19862, w: 1 },
|
|
wq75: { std: 0.09687, w: 1 },
|
|
};
|
|
export const Z_CLIP = 3;
|
|
|
|
/** Weighted L1 over z-scored features; a feature missing on either side is skipped and the weight mass renormalized. */
|
|
/**
|
|
* The two readings a designer makes before any detail: how wide, how heavy.
|
|
* Width from the advance of tall glyphs (all-caps crops) or x-height glyphs;
|
|
* weight from ink density of tall glyphs. Both are on the same scale in the
|
|
* comp crop and in a catalog render, so their gap is a plain ratio. Distance
|
|
* adds a penalty that grows with the ratio's log: a face 50% wider or 35%
|
|
* lighter than the comp cannot rank above one that is right on both, whatever
|
|
* its run-length profile says. Weighted like three fine features (the width
|
|
* gap and the weight gap each score up to zClip x 1.5).
|
|
*/
|
|
export function grossGap(a, b) {
|
|
const pick = (f, keys) => { for (const k of keys) if (f[k] != null) return { k, v: f[k] }; return null; };
|
|
const wa = pick(a, ['advX', 'advTall', 'advance']), wb = wa ? (b[wa.k] != null ? { k: wa.k, v: b[wa.k] } : null) : null;
|
|
const ha = pick(a, ['densTall', 'densX', 'stemW']), hb = ha ? (b[ha.k] != null ? { k: ha.k, v: b[ha.k] } : null) : null;
|
|
const gap = (x, y) => (x && y && x.v > 0 && y.v > 0 ? Math.abs(Math.log(y.v / x.v)) : null);
|
|
return { width: gap(wa, wb), weight: gap(ha, hb) };
|
|
}
|
|
|
|
export const GROSS_STD = { width: 0.12, weight: 0.12 }; // one "step" of width class or weight class, in log ratio
|
|
export const GROSS_W = 1.5;
|
|
|
|
export function distance(a, b, stats = STATS, { p = 1, zClip = Z_CLIP, gross = true } = {}) {
|
|
let d = 0, wsum = 0;
|
|
if (gross) {
|
|
const g = grossGap(a, b);
|
|
for (const k of ['width', 'weight']) {
|
|
if (g[k] == null) continue;
|
|
const z = Math.min(zClip, g[k] / GROSS_STD[k]);
|
|
d += GROSS_W * (p === 1 ? z : z * z); wsum += GROSS_W;
|
|
}
|
|
}
|
|
for (const k of FEATURES) {
|
|
const s = stats[k]; if (!s || !s.w) continue;
|
|
const av = a[k], bv = b[k];
|
|
if (av == null || bv == null) continue;
|
|
const z = Math.min(zClip, Math.abs(av - bv) / s.std);
|
|
d += s.w * (p === 1 ? z : z * z); wsum += s.w;
|
|
}
|
|
if (!wsum) return Infinity;
|
|
const v = d / wsum;
|
|
return p === 1 ? v : Math.sqrt(v);
|
|
}
|
|
|
|
/** Debug: per-line metrics (base, R, xh, mode counts) for a raster. */
|
|
export function _debugLines(img) {
|
|
const bin = binarize(img);
|
|
const { lines } = findLines(bin);
|
|
return lines.map((ln) => { const m = lineMetrics(bin, ln); if (!m) return { ln, m: null }; const hs = m.hs.map((h) => +(h / m.R).toFixed(2)).sort((a, b) => a - b); const hist = {}; for (const h of hs) { const b = Math.round(h * 20) / 20; hist[b] = (hist[b] || 0) + 1; } return { y0: ln.y0, y1: ln.y1, base: m.base, R: +m.R.toFixed(1), xh: m.xh && +m.xh.toFixed(1), hist }; });
|
|
}
|