File size: 8,273 Bytes
9de7b70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
// 配色引擎:把底图 + 二维码合成为 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 }