cut / BeadQR /miniprogram /utils /engine.js
Ethscriptions's picture
上传 BeadQR 微信小程序项目源码
9de7b70 verified
Raw History Blame Contribute Delete
8.27 kB
// 配色引擎:把底图 + 二维码合成为 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 }