// 配色引擎:把底图 + 二维码合成为 MARD 色号网格 // 性能要点: // 1. 5bit 量化桶 → 候选 Top-K 查找表(懒加载),每格只精算约 10 个候选,而非整套 291 色 // 2. OKLab 感知距离 + 色间距离矩阵,全部 Typed Array,无对象分配 // 3. “颜色连贯度”参考已选中的左/上邻居色号,减少零碎杂色,拼起来更省事 // 4. 定位/时序/对齐等功能区使用更严格的深浅阈值,显著提升扫码成功率 const { rgbToOklabInto } = require('./color') const { MATCH_MODES } = require('./palette') const { PAPER } = require('./imaging') const TOP_K = 10 const C_ANY = 0 const C_DARK = 1 const C_LIGHT = 2 const C_FDARK = 3 const C_FLIGHT = 4 const C_QUIET = 5 const C_EMPTY = 6 function subsetFor(pal, mode, kind) { const key = `${mode.id}:${kind}` if (pal.subsets[key]) return pal.subsets[key] const { n, lum } = pal let test if (kind === C_DARK) test = (i) => lum[i] <= mode.darkMax else if (kind === C_LIGHT) test = (i) => lum[i] >= mode.lightMin else if (kind === C_FDARK) test = (i) => lum[i] <= Math.min(mode.darkMax, 0.3) else if (kind === C_FLIGHT) test = (i) => lum[i] >= Math.max(mode.lightMin, 0.82) else test = () => true let list = [] for (let i = 0; i < n; i += 1) if (test(i)) list.push(i) if (!list.length) { // 色库太小时退化为最深/最浅的 3 个颜色 const order = Array.from({ length: n }, (_, i) => i).sort((a, b) => lum[a] - lum[b]) list = kind === C_DARK || kind === C_FDARK ? order.slice(0, 3) : order.slice(-3) } const member = new Uint8Array(n) list.forEach((i) => { member[i] = 1 }) const subset = { key, list: Int16Array.from(list), member, lut: new Map() } pal.subsets[key] = subset return subset } const tmpLab = new Float32Array(3) function topKForBucket(pal, subset, bucket) { let hit = subset.lut.get(bucket) if (hit) return hit const r = ((bucket >> 10) & 31) * 8 + 4 const g = ((bucket >> 5) & 31) * 8 + 4 const b = (bucket & 31) * 8 + 4 rgbToOklabInto(r, g, b, tmpLab, 0) const { lab } = pal const list = subset.list const k = Math.min(TOP_K, list.length) const bestIdx = new Int16Array(k).fill(-1) const bestDist = new Float32Array(k).fill(Infinity) for (let t = 0; t < list.length; t += 1) { const i = list[t] const dl = lab[i * 3] - tmpLab[0] const da = lab[i * 3 + 1] - tmpLab[1] const db = lab[i * 3 + 2] - tmpLab[2] const d = dl * dl + da * da + db * db if (d >= bestDist[k - 1]) continue let p = k - 1 while (p > 0 && bestDist[p - 1] > d) { bestDist[p] = bestDist[p - 1]; bestIdx[p] = bestIdx[p - 1]; p -= 1 } bestDist[p] = d bestIdx[p] = i } hit = bestIdx subset.lut.set(bucket, hit) return hit } /** * @param base { size, rgb: Uint8Array, mask?: Uint8Array } 或 null(无底图) * @param qr makeQr() 结果 * @param placement createPlacement() 结果 * @param pal preparePalette() 结果 * @param options { modeId, coherence(0-100), cleanup(bool) } */ function computeArtwork(base, qr, placement, pal, options = {}) { const mode = MATCH_MODES.find((m) => m.id === options.modeId) || MATCH_MODES[1] const size = placement.size const total = size * size const lab = new Float32Array(total * 3) const bucket = new Int32Array(total) const kind = new Uint8Array(total) const { left, top, quiet } = placement for (let y = 0; y < size; y += 1) { for (let x = 0; x < size; x += 1) { const i = y * size + x const qx = x - left const qy = y - top const inQr = qx >= 0 && qy >= 0 && qx < qr.size && qy < qr.size const inQuiet = !inQr && x >= left - quiet && x < left + qr.size + quiet && y >= top - quiet && y < top + qr.size + quiet let r; let g; let b if (inQr) { const q = qy * qr.size + qx const isDark = qr.dark[q] const isFunc = qr.func[q] kind[i] = isFunc ? (isDark ? C_FDARK : C_FLIGHT) : (isDark ? C_DARK : C_LIGHT) if (base && !(base.mask && base.mask[i])) { r = base.rgb[i * 3]; g = base.rgb[i * 3 + 1]; b = base.rgb[i * 3 + 2] } else if (base) { r = PAPER[0]; g = PAPER[1]; b = PAPER[2] } else { r = g = b = isDark ? 0 : 255 } } else if (inQuiet || !base) { kind[i] = C_QUIET continue } else if (base.mask && base.mask[i]) { kind[i] = C_EMPTY continue } else { kind[i] = C_ANY r = base.rgb[i * 3]; g = base.rgb[i * 3 + 1]; b = base.rgb[i * 3 + 2] } rgbToOklabInto(r, g, b, lab, i * 3) bucket[i] = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3) } } const n = pal.n const plab = pal.lab const pair = pal.pair const weight = (Math.max(0, Math.min(100, options.coherence == null ? 40 : options.coherence)) / 100) * 0.6 const subsets = [C_ANY, C_DARK, C_LIGHT, C_FDARK, C_FLIGHT].map((k) => subsetFor(pal, mode, k)) const cells = new Int16Array(total).fill(-1) const distTo = (c, i) => { const dl = plab[c * 3] - lab[i * 3] const da = plab[c * 3 + 1] - lab[i * 3 + 1] const db = plab[c * 3 + 2] - lab[i * 3 + 2] return Math.sqrt(dl * dl + da * da + db * db) } for (let y = 0; y < size; y += 1) { for (let x = 0; x < size; x += 1) { const i = y * size + x const k = kind[i] if (k >= C_QUIET) continue const subset = subsets[k] const cands = topKForBucket(pal, subset, bucket[i]) const nl = x > 0 ? cells[i - 1] : -1 const nu = y > 0 ? cells[i - size] : -1 const nbCount = (nl >= 0 ? 1 : 0) + (nu >= 0 ? 1 : 0) let best = -1 let bestScore = Infinity const consider = (c) => { let score = distTo(c, i) if (weight > 0 && nbCount) { let s = 0 if (nl >= 0) s += pair[c * n + nl] if (nu >= 0) s += pair[c * n + nu] score += weight * (s / nbCount) } if (score < bestScore) { bestScore = score; best = c } } for (let t = 0; t < cands.length; t += 1) if (cands[t] >= 0) consider(cands[t]) if (weight > 0) { if (nl >= 0 && subset.member[nl]) consider(nl) if (nu >= 0 && nu !== nl && subset.member[nu]) consider(nu) } cells[i] = best } } // 合并零散色:只用了 1~2 颗的色号并入最接近的常用色,减少需要购买的色号 const counts = new Int32Array(n) for (let i = 0; i < total; i += 1) if (cells[i] >= 0) counts[cells[i]] += 1 if (options.cleanup !== false) { const kept = [] for (let c = 0; c < n; c += 1) if (counts[c] >= 3) kept.push(c) if (kept.length) { for (let i = 0; i < total; i += 1) { const c = cells[i] if (c < 0 || counts[c] >= 3) continue const member = subsets[kind[i]].member let best = -1 let bestD = Infinity for (let t = 0; t < kept.length; t += 1) { const kc = kept[t] if (!member[kc]) continue const d = distTo(kc, i) if (d < bestD) { bestD = d; best = kc } } // 与原色差别过大时保留原色,避免破坏画面 if (best >= 0 && bestD <= distTo(c, i) + 12) cells[i] = best } counts.fill(0) for (let i = 0; i < total; i += 1) if (cells[i] >= 0) counts[cells[i]] += 1 } } // 二维码留白区:使用画面中最常用的浅色;没有则用色库里最浅的颜色 let quietColor = -1 let quietCount = -1 for (let c = 0; c < n; c += 1) { if (pal.lum[c] >= Math.max(0.8, mode.lightMin) && counts[c] > quietCount) { quietColor = c; quietCount = counts[c] } } if (quietCount <= 0) { quietColor = 0 for (let c = 1; c < n; c += 1) if (pal.lum[c] > pal.lum[quietColor]) quietColor = c } for (let i = 0; i < total; i += 1) { if (kind[i] === C_QUIET) { cells[i] = quietColor; counts[quietColor] += 1 } } const usage = [] let beads = 0 for (let c = 0; c < n; c += 1) { if (counts[c] > 0) { usage.push({ index: c, id: pal.ids[c], hex: pal.hexes[c], count: counts[c] }) beads += counts[c] } } usage.sort((a, b) => b.count - a.count) return { size, cells, usage, beads, pal, qr, placement } } module.exports = { computeArtwork }