dsh-drawai 0.2.6 → 0.3.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.
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * 管线(每一步都能在返回的 metrics 里看到数字):
8
8
  * ① 工作图尺寸 = src/scale,再套 grid(宽)/ gridH(高)上限;透明像素合成到 bgColor
9
- * ② 四边泛洪判背景(近白 / 近灰 / 全透明)→ 不画截图底色
9
+ * ② 去背景:由调用方显式传 removeBg true/false(无 auto)。true 时先识纯色底再泛洪,识不到走近白回退
10
10
  * ③ 分层 k-means(colorsDark 暗色 + colorsFill 填充)定调色板;暗像素进保护区
11
11
  * ④ 3× 超采样归类 → 条件众数(高对比跳过)→ despeckle / minRegionArea(保护区不并)
12
12
  * ⑤ 每色按连通域取闭合轮廓:像素**格边**上的有向图 + 左转跟随(区域恒在左侧)
@@ -29,35 +29,158 @@ function lumaRgb(r, g, b) {
29
29
 
30
30
  /* ────────────────────────── ① 背景 ────────────────────────── */
31
31
 
32
+ /** 收集四边像素下标(角不重复)。 */
33
+ function borderIndices(width, height) {
34
+ const out = []
35
+ if (width <= 0 || height <= 0) return out
36
+ for (let x = 0; x < width; x += 1) {
37
+ out.push(x)
38
+ if (height > 1) out.push((height - 1) * width + x)
39
+ }
40
+ for (let y = 1; y < height - 1; y += 1) {
41
+ out.push(y * width)
42
+ if (width > 1) out.push(y * width + (width - 1))
43
+ }
44
+ return out
45
+ }
46
+
47
+ /**
48
+ * 识图:四边是否被同一种颜色(或透明)主导 = 有纯色背景。
49
+ *
50
+ * 返回 null = 没有纯色底(风景/照片边缘杂色)→ 调用方应保留背景、不抠。
51
+ * 返回对象 = 有纯色底,随后用 markBackground 按该色泛洪。
52
+ */
53
+ function detectSolidBackground(rgba, width, height, options) {
54
+ const solidTol = Math.max(0, Math.round(Number(options.bgSolidTol)))
55
+ const minRatio = clamp(Number(options.bgSolidBorderRatio), 0, 1)
56
+ const border = borderIndices(width, height)
57
+ const n = border.length
58
+ if (n === 0) return null
59
+
60
+ let transparent = 0
61
+ const opaque = []
62
+ for (let bi = 0; bi < n; bi += 1) {
63
+ const i = border[bi]
64
+ const o = i * 4
65
+ if (rgba[o + 3] < 16) {
66
+ transparent += 1
67
+ continue
68
+ }
69
+ opaque.push(rgba[o], rgba[o + 1], rgba[o + 2])
70
+ }
71
+
72
+ if (transparent / n >= minRatio) {
73
+ return { kind: 'transparent', matchRatio: transparent / n, color: null }
74
+ }
75
+ if (opaque.length === 0) {
76
+ return { kind: 'transparent', matchRatio: 1, color: null }
77
+ }
78
+
79
+ // 粗分箱找众数色(箱宽 ≈ solidTol),再按箱心做一次精确匹配计数
80
+ const bin = Math.max(1, solidTol)
81
+ const hist = new Map()
82
+ for (let p = 0; p < opaque.length; p += 3) {
83
+ const key =
84
+ Math.round(opaque[p] / bin) * bin +
85
+ ',' +
86
+ Math.round(opaque[p + 1] / bin) * bin +
87
+ ',' +
88
+ Math.round(opaque[p + 2] / bin) * bin
89
+ hist.set(key, (hist.get(key) || 0) + 1)
90
+ }
91
+ let bestKey = null
92
+ let bestCount = 0
93
+ for (const entry of hist) {
94
+ if (entry[1] > bestCount) {
95
+ bestKey = entry[0]
96
+ bestCount = entry[1]
97
+ }
98
+ }
99
+ const mode = bestKey.split(',').map(Number)
100
+ // 众数箱内像素的均值当代表色(比箱角更稳)
101
+ let sr = 0
102
+ let sg = 0
103
+ let sb = 0
104
+ let sn = 0
105
+ for (let p = 0; p < opaque.length; p += 3) {
106
+ if (
107
+ Math.abs(opaque[p] - mode[0]) <= bin &&
108
+ Math.abs(opaque[p + 1] - mode[1]) <= bin &&
109
+ Math.abs(opaque[p + 2] - mode[2]) <= bin
110
+ ) {
111
+ sr += opaque[p]
112
+ sg += opaque[p + 1]
113
+ sb += opaque[p + 2]
114
+ sn += 1
115
+ }
116
+ }
117
+ const color = sn > 0
118
+ ? [Math.round(sr / sn), Math.round(sg / sn), Math.round(sb / sn)]
119
+ : mode
120
+
121
+ let match = transparent
122
+ for (let p = 0; p < opaque.length; p += 3) {
123
+ if (
124
+ Math.abs(opaque[p] - color[0]) <= solidTol &&
125
+ Math.abs(opaque[p + 1] - color[1]) <= solidTol &&
126
+ Math.abs(opaque[p + 2] - color[2]) <= solidTol
127
+ ) {
128
+ match += 1
129
+ }
130
+ }
131
+ const matchRatio = match / n
132
+ if (matchRatio < minRatio) return null
133
+ return { kind: 'color', matchRatio: matchRatio, color: color }
134
+ }
135
+
32
136
  /**
33
137
  * 从四边泛洪标出背景。
34
138
  *
35
- * 判据是"近白/近灰"而不是"等于纯白":截图和导出图的白底常带一点点色偏(#fdfdfc 这种),
36
- * 用纯白判会漏掉大片底色。泛洪(而不是全局阈值)保证只吃掉**与边界连通**的那一片 ——
37
- * 角色内部的白围裙不连边界,不会被误删。
139
+ * solid 描述要抠的底:
140
+ * · legacy-nearwhite —— 旧行为:近白/近灰/透明(removeBg:true 强制)
141
+ * · transparent —— 透明主导的边,顺带吃近白(常见 PNG 导出)
142
+ * · color —— 与代表色在 solidTol 内(或透明)
143
+ *
144
+ * 泛洪(而不是全局阈值)只吃掉**与边界连通**的那一片 —— 角色内部的白围裙不连边界,不会被误删。
38
145
  */
39
- function markBackground(rgba, width, height, options) {
40
- const bright = options.bgBright
41
- const tol = options.bgTol
146
+ function markBackground(rgba, width, height, options, solid) {
42
147
  const isBg = new Uint8Array(width * height)
148
+ if (solid === null || solid === undefined) return isBg
149
+
150
+ const bright = options.bgBright
151
+ const chromaTol = options.bgTol
152
+ const solidTol = Math.max(0, Math.round(Number(options.bgSolidTol)))
43
153
  const stack = []
154
+ const target = solid.color
44
155
 
45
156
  function nearWhite(i) {
46
157
  const o = i * 4
47
158
  const a = rgba[o + 3]
48
- if (a < 16) return true // 全透明也算底色
159
+ if (a < 16) return true
49
160
  const r = rgba[o]
50
161
  const g = rgba[o + 1]
51
162
  const b = rgba[o + 2]
52
163
  const mx = r > g ? (r > b ? r : b) : g > b ? g : b
53
164
  const mn = r < g ? (r < b ? r : b) : g < b ? g : b
54
- return mx >= bright && mx - mn <= tol
165
+ return mx >= bright && mx - mn <= chromaTol
166
+ }
167
+ function matches(i) {
168
+ const o = i * 4
169
+ const a = rgba[o + 3]
170
+ if (a < 16) return true
171
+ if (solid.kind === 'legacy-nearwhite' || solid.kind === 'transparent') return nearWhite(i)
172
+ if (target === null || target === undefined) return false
173
+ return (
174
+ Math.abs(rgba[o] - target[0]) <= solidTol &&
175
+ Math.abs(rgba[o + 1] - target[1]) <= solidTol &&
176
+ Math.abs(rgba[o + 2] - target[2]) <= solidTol
177
+ )
55
178
  }
56
179
  function push(x, y) {
57
180
  if (x < 0 || y < 0 || x >= width || y >= height) return
58
181
  const i = y * width + x
59
182
  if (isBg[i] === 1) return
60
- if (!nearWhite(i)) return
183
+ if (!matches(i)) return
61
184
  isBg[i] = 1
62
185
  stack.push(i)
63
186
  }
@@ -81,6 +204,19 @@ function markBackground(rgba, width, height, options) {
81
204
  return isBg
82
205
  }
83
206
 
207
+ /**
208
+ * 解析 removeBg:必须由调用方显式传入 true|always(抠)/ false|never(保留)。
209
+ * 不再支持 auto(是否抠由 AI/调用方判断,不由管线猜)。
210
+ * @returns {'always'|'never'}
211
+ */
212
+ function normalizeRemoveBg(raw) {
213
+ if (raw === true || raw === 'always' || raw === 'true' || raw === 1 || raw === '1') return 'always'
214
+ if (raw === false || raw === 'never' || raw === 'false' || raw === 0 || raw === '0') return 'never'
215
+ throw new Error(
216
+ 'removeBg 必填:true(抠背景)或 false(保留背景);不再支持 auto / 省略',
217
+ )
218
+ }
219
+
84
220
  /* ────────────────────────── ② 调色板 ────────────────────────── */
85
221
 
86
222
  /** 线性同余随机数(自带,避免依赖 Math.random 的可重复性问题)。 */
@@ -136,7 +272,20 @@ function kmeans(samples, k, iterations, seed) {
136
272
  const sum = new Float64Array(k * 3)
137
273
  const count = new Int32Array(k)
138
274
  let inertia = 0
275
+ const seen = []
139
276
  for (let iter = 0; iter < iterations; iter += 1) {
277
+ // Empty clusters can alternate between identical states instead of converging.
278
+ // Skip only complete, exactly repeated cycles, always executing the final
279
+ // iteration so assign/inertia retain the original fixed-iteration semantics.
280
+ const values = cent.flat()
281
+ for (let at = seen.length - 1; at >= 0; at--) {
282
+ const previous = seen[at]
283
+ if (!values.every((value, index) => value === previous.values[index])) continue
284
+ const period = iter - previous.iteration
285
+ iter += Math.floor((iterations - iter - 1) / period) * period
286
+ break
287
+ }
288
+ seen.push({ iteration: iter, values })
140
289
  sum.fill(0)
141
290
  count.fill(0)
142
291
  inertia = 0
@@ -163,7 +312,9 @@ function kmeans(samples, k, iterations, seed) {
163
312
  sum[best * 3 + 2] += b
164
313
  count[best] += 1
165
314
  }
315
+ let unchanged = true
166
316
  for (let c = 0; c < k; c += 1) {
317
+ const previous = cent[c]
167
318
  if (count[c] === 0) {
168
319
  let far = 0
169
320
  let farD = -1
@@ -181,7 +332,11 @@ function kmeans(samples, k, iterations, seed) {
181
332
  } else {
182
333
  cent[c] = [sum[c * 3] / count[c], sum[c * 3 + 1] / count[c], sum[c * 3 + 2] / count[c]]
183
334
  }
335
+ if (cent[c][0] !== previous[0] || cent[c][1] !== previous[1] || cent[c][2] !== previous[2]) unchanged = false
184
336
  }
337
+ // An exact fixed point (including empty-cluster reseeding) repeats identically.
338
+ // Keep the original arithmetic/order/seeds; no tolerance-based approximation.
339
+ if (unchanged) break
185
340
  }
186
341
  return { cent: cent, assign: assign, inertia: inertia }
187
342
  }
@@ -197,6 +352,9 @@ function kmeans(samples, k, iterations, seed) {
197
352
  *
198
353
  * 边的方向是**区域恒在左侧**:右边界朝上、下边界朝左。方向给错的话整张图的入度就不等于
199
354
  * 出度,走一步就断在空气里(这个坑真实踩过:外环整圈变成一堆 3~4 个点的碎片)。
355
+ *
356
+ * 邻接只扫右/下时,右框与底框会因"邻 = 画外(-1)"自动出现;左框与顶框必须另补,
357
+ * 否则贴左边/顶边的连通域外环不闭合(照片左侧透出画布网格就是这个)。
200
358
  */
201
359
  function traceLoops(comp, width, height, regionCount) {
202
360
  const stride = width + 1
@@ -217,6 +375,9 @@ function traceLoops(comp, width, height, regionCount) {
217
375
  for (let y = 0; y < height; y += 1) {
218
376
  for (let x = 0; x < width; x += 1) {
219
377
  const id = comp[y * width + x]
378
+ // 只查右 / 下邻:与"邻 = 画外(-1)"合在一起,已经覆盖了右框与底框。
379
+ // 左框与顶框没有对应的"从画外指进来"的邻接扫描,必须单独补,否则贴左边 /
380
+ // 顶边的连通域轮廓不闭合——复杂照片左侧会出现大块透明缺口(画布网格露出来)。
220
381
  const right = at(x + 1, y)
221
382
  if (right !== id) {
222
383
  if (id >= 0) addEdge(x + 1, y + 1, 0, -1, id)
@@ -229,6 +390,15 @@ function traceLoops(comp, width, height, regionCount) {
229
390
  }
230
391
  }
231
392
  }
393
+ // 补左框(x=0)与顶框(y=0):区域在东 / 南侧 → 走下 / 走左 时区域在左手边。
394
+ for (let y = 0; y < height; y += 1) {
395
+ const id = comp[y * width]
396
+ if (id >= 0) addEdge(0, y, 0, 1, id)
397
+ }
398
+ for (let x = 0; x < width; x += 1) {
399
+ const id = comp[x]
400
+ if (id >= 0) addEdge(x + 1, 0, -1, 0, id)
401
+ }
232
402
 
233
403
  // 左转优先:向上→左、向左→下、向下→右、向右→上(区域始终在左手边)
234
404
  function turnOrder(dx, dy) {
@@ -254,13 +424,14 @@ function traceLoops(comp, width, height, regionCount) {
254
424
  cx += edge.dx
255
425
  cy += edge.dy
256
426
  const key = cy * stride + cx
257
- const list = outMap.get(key)
258
- if (list === undefined || list.length === 0) {
427
+ // 回到起点即闭合。不能再用 list.indexOf(first):first 已从起点弹出,
428
+ // 引用永远不在 list 里,外环与洞在共用角点处会被串成一条错环。
429
+ if (key === startKey && points.length > 1) {
259
430
  edge = null
260
431
  break
261
432
  }
262
- if (key === startKey && list.indexOf(first) >= 0) {
263
- list.splice(list.indexOf(first), 1)
433
+ const list = outMap.get(key)
434
+ if (list === undefined || list.length === 0) {
264
435
  edge = null
265
436
  break
266
437
  }
@@ -316,7 +487,7 @@ function traceLoops(comp, width, height, regionCount) {
316
487
  * @param {Uint8Array|null} protectMask 非空时:块内受保护像素过半,或该 label 本身是保护色 → 不合并
317
488
  * @param {Uint8Array|null} protectLabels 长度为 palette.length;1 = 该色永不被合并
318
489
  */
319
- function despeckle(labels, width, height, palette, minArea, protectMask, protectLabels) {
490
+ function despeckle(labels, width, height, palette, minArea, protectMask, protectLabels, backgroundLabel) {
320
491
  if (!(minArea > 0)) {
321
492
  return { mergedPixels: 0, blocksMerged: 0, blocks: 0, protectedSkipped: 0 }
322
493
  }
@@ -366,8 +537,8 @@ function despeckle(labels, width, height, palette, minArea, protectMask, protect
366
537
  for (let b = 0; b < blocks.length; b += 1) {
367
538
  const block = blocks[b]
368
539
  if (block.pixels.length >= minArea) continue
369
- // 背景(label 0)不参与:它是"没画东西的地方",并它等于把图啃出洞
370
- if (palette[block.label] === undefined) continue
540
+ // 仅抠底模式的专用背景标签不参与;保留背景时 label 0 也是普通颜色。
541
+ if (block.label === backgroundLabel || palette[block.label] === undefined) continue
371
542
  if (protectLabels !== null && protectLabels !== undefined && protectLabels[block.label] === 1) {
372
543
  protectedSkipped += 1
373
544
  continue
@@ -401,7 +572,7 @@ function despeckle(labels, width, height, palette, minArea, protectMask, protect
401
572
  let bestScore = null
402
573
  const src = palette[block.label]
403
574
  for (let l = 0; l < colorCount; l += 1) {
404
- if (hist[l] === 0 || l === 0 || l === block.label) continue
575
+ if (hist[l] === 0 || l === backgroundLabel || l === block.label) continue
405
576
  const p = palette[l]
406
577
  const d = (src[0] - p[0]) ** 2 + (src[1] - p[1]) ** 2 + (src[2] - p[2]) ** 2
407
578
  const score = d - Math.min(hist[l], 60) // 出现得多算一点加成,但不至于压过颜色差异
@@ -431,7 +602,7 @@ function despeckle(labels, width, height, palette, minArea, protectMask, protect
431
602
  * 分层调色板:暗像素单独 k-means(colorsDark),其余走 colorsFill。
432
603
  * colorsDark=0 时退回单次 k-means(与旧行为一致)。
433
604
  */
434
- function buildPalette(work, fgIndex, colors, colorsDark, colorsFill, protectLuma, seeds) {
605
+ function buildPalette(work, fgIndex, colors, colorsDark, colorsFill, protectLuma, seeds, adaptive) {
435
606
  const darkK = Math.max(0, Math.min(colors - 1, colorsDark))
436
607
  const fillK = Math.max(1, colors - darkK)
437
608
  // 显式 colorsFill 时以它为准(仍保证总和 = colors)
@@ -472,6 +643,36 @@ function buildPalette(work, fgIndex, colors, colorsDark, colorsFill, protectLuma
472
643
  // 某一侧为空时,把配额并给另一侧,避免空簇
473
644
  let kDark = useDark
474
645
  let kFill = useFill
646
+ if (adaptive && darkIdx.length > 0 && fillIdx.length > 0) {
647
+ // Coarse RGB bins suppress single-pixel noise. Effective diversity gives
648
+ // small but distinct hues a voice without letting pixel area decide alone.
649
+ const richness = (indexes) => {
650
+ const bins = new Uint32Array(512)
651
+ for (const p of indexes) {
652
+ const o = p * 4
653
+ bins[(work[o] >> 5) * 64 + (work[o + 1] >> 5) * 8 + (work[o + 2] >> 5)]++
654
+ }
655
+ const minimum = Math.max(1, Math.floor(indexes.length * 0.0005))
656
+ let roots = 0
657
+ let count = 0
658
+ for (const n of bins) if (n >= minimum) { roots += Math.sqrt(n); count += n }
659
+ return Math.max(1, roots * roots / Math.max(1, count))
660
+ }
661
+ const darkRichness = richness(darkIdx)
662
+ const fillRichness = richness(fillIdx)
663
+ const darkWeight = darkRichness * Math.cbrt(darkIdx.length / fgIndex.length)
664
+ const fillWeight = fillRichness * Math.cbrt(fillIdx.length / fgIndex.length)
665
+ kDark = Math.max(1, Math.min(colors - 1, Math.round(colors * darkWeight / (darkWeight + fillWeight))))
666
+ // A flat region needs one slot, regardless of how large it is.
667
+ if (Math.ceil(darkRichness) + Math.ceil(fillRichness) >= colors) {
668
+ kDark = Math.min(kDark, Math.ceil(darkRichness))
669
+ kDark = Math.max(kDark, colors - Math.ceil(fillRichness))
670
+ }
671
+ if (darkRichness < 1.000001) kDark = 1
672
+ else if (fillRichness < 1.000001) kDark = colors - 1
673
+ kDark = Math.max(1, Math.min(colors - 1, kDark))
674
+ kFill = colors - kDark
675
+ }
475
676
  if (darkIdx.length === 0) {
476
677
  kFill = colors
477
678
  kDark = 0
@@ -557,6 +758,31 @@ function simplifyClosed(points, epsilon) {
557
758
  return simplifyOpen(segA, epsilon).slice(0, -1).concat(simplifyOpen(segB, epsilon).slice(0, -1))
558
759
  }
559
760
 
761
+ /** 仅删除直线上的中间点才算几何无损;指标相对量化标签图,不代表原图保真。 */
762
+ function sameLoopGeometry(original, simplified) {
763
+ if (simplified.length < 3) return false
764
+ const start = original.findIndex((p) => p[0] === simplified[0][0] && p[1] === simplified[0][1])
765
+ if (start < 0) return false
766
+ let cursor = start
767
+ let walked = 0
768
+ for (let i = 0; i < simplified.length; i += 1) {
769
+ const a = simplified[i]
770
+ const b = simplified[(i + 1) % simplified.length]
771
+ const dx = b[0] - a[0]
772
+ const dy = b[1] - a[1]
773
+ do {
774
+ cursor = (cursor + 1) % original.length
775
+ walked += 1
776
+ if (walked > original.length) return false
777
+ const p = original[cursor]
778
+ const px = p[0] - a[0]
779
+ const py = p[1] - a[1]
780
+ if (px * dy !== py * dx || px * dx + py * dy < 0 || px * dx + py * dy > dx * dx + dy * dy) return false
781
+ } while (original[cursor][0] !== b[0] || original[cursor][1] !== b[1])
782
+ }
783
+ return walked === original.length
784
+ }
785
+
560
786
  function simplifyOpen(points, epsilon) {
561
787
  if (points.length < 3) return points.slice()
562
788
  const keep = new Uint8Array(points.length)
@@ -697,8 +923,8 @@ function resolveWorkSize(srcWidth, srcHeight, options) {
697
923
  const capH = gridH > 0 ? gridH : Infinity
698
924
  if (width > grid || height > capH) {
699
925
  const factor = Math.min(grid / width, capH / height)
700
- const nextW = Math.max(16, Math.round(width * factor))
701
- const nextH = Math.max(16, Math.round(height * factor))
926
+ const nextW = Math.max(1, Math.round(width * factor))
927
+ const nextH = Math.max(1, Math.round(height * factor))
702
928
  const parts = []
703
929
  if (width > grid) parts.push('width')
704
930
  if (height > capH) parts.push('height')
@@ -722,7 +948,7 @@ function resolveWorkSize(srcWidth, srcHeight, options) {
722
948
  export const TRACE_DEFAULTS = {
723
949
  preset: 'default', // 命名预设,见 TRACE_PRESETS;显式字段覆盖预设
724
950
  colors: 12, // 前景调色板色数(背景另占 1 色)= colorsDark + colorsFill(auto)
725
- colorsDark: 3, // 暗色配额(线稿);0 = 不分层,全体一次 k-means
951
+ colorsDark: 3, // 固定分配的默认配额;未显式传入配额时按丰富度自适应。0 = 不分层
726
952
  colorsFill: 0, // 填充色配额;0 = 自动 colors - colorsDark
727
953
  grid: 499, // 工作图宽度上限(px);与 gridH 一起做等比封顶
728
954
  gridH: 0, // 工作图高度上限(px);0 = 不限高(只卡宽,兼容旧行为)
@@ -733,9 +959,13 @@ export const TRACE_DEFAULTS = {
733
959
  minRegionArea: 0, // despeckle 之后再按面积并区域;0 = 关(与 minArea 分开调)
734
960
  protectLuma: 110, // 亮度 ≤ 此值的前景像素受保护(不 despeckle / 不 ladder 丢环);0 = 关
735
961
  majorityContrast: 40, // 局部亮度差 ≥ 此值则跳过众数滤波(保线稿);0 = 全图滤波(旧行为)
736
- bgBright: 238, // 判"近白"的亮度下限
962
+ bgBright: 238, // 判"近白"的亮度下限(removeBg:true 且未检出纯色底时的回退)
737
963
  bgTol: 10, // 判"近灰"的通道差上限
738
964
  bgColor: [255, 255, 255], // 透明/半透明像素合成底色(再进泛洪与 k-means)
965
+ // 去背景:无默认。调用方必须显式传 removeBg:true|false(或 always|never)。
966
+ // true = 抠(先识纯色底,否则近白泛洪);false = 整图保留。
967
+ bgSolidTol: 22, // 纯色判定:与众数色的每通道容差
968
+ bgSolidBorderRatio: 0.88, // 四边像素至少这么大比例同色/透明才算有纯色底
739
969
  seeds: 3, // k-means 重启次数(取 inertia 最小)
740
970
  // SVG 上限(换算成 drawio style 里的 data URI 要塞得下)。宿主的硬上限是 250000 字符,
741
971
  // 且它自己会再留 2000 的余量 —— 所以这里直接对齐到 248000:整图的精细度就由这个预算决定
@@ -783,6 +1013,117 @@ export const TRACE_PRESETS = {
783
1013
  /** 合法预设名列表(给报错 / 提示用)。 */
784
1014
  export const TRACE_PRESET_NAMES = Object.keys(TRACE_PRESETS)
785
1015
 
1016
+ /** 按用户参考图校准:保持同一棱角化色块管线,主要以采样密度区分三档。 */
1017
+ export const TRACE_FRAGMENT_LEVELS = Object.freeze({
1018
+ low: Object.freeze({ resolutionRatio: 320 / 499, tolerance: 0.1 }),
1019
+ medium: Object.freeze({ resolutionRatio: 400 / 499, tolerance: 0.08 }),
1020
+ high: Object.freeze({ resolutionRatio: 1, tolerance: 0.04 }),
1021
+ })
1022
+
1023
+ export function traceRasterForToolbar(raster, raw = {}) {
1024
+ const maxBytes = raw.maxBytes === undefined ? TRACE_DEFAULTS.maxBytes : Number(raw.maxBytes)
1025
+ if (!Number.isInteger(maxBytes) || maxBytes < 2000 || maxBytes > TRACE_DEFAULTS.maxBytes) throw new Error('无效 SVG 字符预算')
1026
+ const level = raw.level === undefined ? 'medium' : raw.level
1027
+ if (!Object.prototype.hasOwnProperty.call(TRACE_FRAGMENT_LEVELS, level)) throw new Error('未知碎片程度')
1028
+ if (typeof raw.removeBg !== 'boolean') throw new Error('removeBg 必须为 true 或 false')
1029
+ const colors = raw.colors === undefined ? TRACE_DEFAULTS.colors : Number(raw.colors)
1030
+ if (!Number.isInteger(colors) || colors < 2 || colors > 24) throw new Error('颜色数须在 2–24 之间')
1031
+ const preset = raw.preset === undefined ? 'default' : raw.preset
1032
+ const styleFactors = { default: 1, lineart: 0.8, photo: 1.2, simple: 1.5 }
1033
+ if (!Object.prototype.hasOwnProperty.call(styleFactors, preset)) throw new Error('未知复刻画风')
1034
+ const sourceLong = Math.max(raster.width, raster.height)
1035
+ const maxLongSide = raw.maxLongSide === undefined ? 887 : Number(raw.maxLongSide)
1036
+ if (!Number.isInteger(maxLongSide) || maxLongSide < 1 || maxLongSide > 16384) throw new Error('无效采样长边')
1037
+ // 参考图高档工作图为 499×887;其它宽高比按长边等比适配,不把小图虚增到该尺寸。
1038
+ const baseLong = Math.min(maxLongSide, sourceLong)
1039
+ const baseScale = baseLong / sourceLong
1040
+ const baseArea = (baseLong / 887) ** 2
1041
+ const profileMinArea = TRACE_PRESETS[preset].minArea ?? TRACE_DEFAULTS.minArea
1042
+ for (let attempt = 0; attempt < 5; attempt += 1) {
1043
+ const retryScale = 0.8 ** attempt
1044
+ const makeOptions = (ratio) => {
1045
+ const grid = Math.max(1, Math.round(raster.width * baseScale * ratio * retryScale))
1046
+ const gridH = Math.max(1, Math.ceil(raster.height * baseScale * ratio * retryScale))
1047
+ const options = { preset, removeBg: raw.removeBg, colors, maxBytes,
1048
+ adaptiveDarkColors: raw.adaptiveDarkColors,
1049
+ onTiming: raw.onTiming,
1050
+ paletteValidator: raw.paletteValidator,
1051
+ includeDrawing: raw.includeDrawing === true,
1052
+ grid, gridH, scale: 1, minRegionArea: 0,
1053
+ minArea: Math.round(profileMinArea * baseArea * retryScale ** 2),
1054
+ }
1055
+ if (raw.colorsDark !== undefined) options.colorsDark = raw.colorsDark
1056
+ if (raw.colorsFill !== undefined) options.colorsFill = raw.colorsFill
1057
+ if (Array.isArray(raw.paletteOverride) && raw.paletteOverride.length >= 2) options.paletteOverride = raw.paletteOverride
1058
+ const sized = resolveWorkSize(raster.width, raster.height, options)
1059
+ // RDP operates on pixel-grid contours: subpixel tolerances preserve stair steps.
1060
+ // Keep the calibrated angular style in work pixels, including small images and budget retries.
1061
+ options.epsilon = Math.min(2 * styleFactors[preset],
1062
+ Math.min(sized.width, sized.height) * 0.45)
1063
+ return options
1064
+ }
1065
+ const emptyResult = () => {
1066
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + raster.width + ' ' + raster.height + '"></svg>'
1067
+ return { svg, metrics: { foregroundPixels: 0, fragments: 0, paths: 0, colors: 0, svgChars: svg.length,
1068
+ budget: maxBytes, fragmentLevel: level, detailReduced: false } }
1069
+ }
1070
+ if (raw.onlySelectedLevel === true) {
1071
+ let result
1072
+ try { result = traceRasterToSvg(raster, makeOptions(TRACE_FRAGMENT_LEVELS[level].resolutionRatio)) }
1073
+ catch (error) {
1074
+ if (raw.allowEmpty === true && error.code === 'EMPTY_FOREGROUND') return emptyResult()
1075
+ throw error
1076
+ }
1077
+ if (result.svg.length > maxBytes || result.metrics.paths === 0) continue
1078
+ result.metrics.fragmentLevel = level
1079
+ result.metrics.resolutionRetries = attempt
1080
+ result.metrics.detailReduced = attempt > 0 || result.metrics.attempts > 1
1081
+ return result
1082
+ }
1083
+ let reference
1084
+ try { reference = traceRasterToSvg(raster, makeOptions(1)) }
1085
+ catch (error) {
1086
+ if (raw.allowEmpty !== true || error.code !== 'EMPTY_FOREGROUND') throw error
1087
+ // 视频的空白帧仍占时间,不能丢帧或替换为上一帧。
1088
+ return emptyResult()
1089
+ }
1090
+ const results = { high: reference }
1091
+ const referencePalette = reference.metrics.palette.slice(raw.removeBg ? 1 : 0).map((hex) => [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16)))
1092
+ for (const name of ['low', 'medium']) {
1093
+ const options = makeOptions(TRACE_FRAGMENT_LEVELS[name].resolutionRatio)
1094
+ let traced = traceRasterToSvg(raster, options)
1095
+ // 极少色图片降采样后会产生过渡色;避免重新聚类把低档变成更多细碎色圈。
1096
+ if (traced.metrics.fragments > reference.metrics.fragments) {
1097
+ traced = traceRasterToSvg(raster, Object.assign({}, options, { paletteOverride: referencePalette }))
1098
+ }
1099
+ results[name] = traced
1100
+ }
1101
+ const result = results[level]
1102
+ if (result.svg.length > maxBytes || (result.metrics.paths === 0 && !(raw.allowEmpty === true && result.metrics.foregroundPixels === 0))) continue
1103
+ const normalizedArea = reference.metrics.foregroundPixels * (887 / Math.max(reference.metrics.work.width, reference.metrics.work.height)) ** 2
1104
+ const richness = clamp(Math.sqrt(reference.metrics.fragments / Math.max(1, normalizedArea) * 32), 0, 1)
1105
+ const thresholds = {}
1106
+ for (const [name, profile] of Object.entries(TRACE_FRAGMENT_LEVELS)) {
1107
+ // 使用本图实际测得的各档数量给出参考区间,不再为了追赶预测值改变画风。
1108
+ const target = Math.max(1, results[name].metrics.fragments)
1109
+ const tolerance = profile.tolerance * (0.8 + richness * 0.2)
1110
+ thresholds[name] = { min: Math.max(1, Math.ceil(target * (1 - tolerance))),
1111
+ max: Math.max(1, Math.floor(target * (1 + tolerance))), target }
1112
+ }
1113
+ const desired = thresholds[level]
1114
+ result.metrics.fragmentLevel = level
1115
+ result.metrics.fragmentThresholds = thresholds
1116
+ result.metrics.targetFragments = desired
1117
+ result.metrics.targetReached = result.metrics.fragments >= desired.min && result.metrics.fragments <= desired.max
1118
+ result.metrics.imageAnalysis = { sourcePixels: raster.width * raster.height,
1119
+ workPixels: result.metrics.totalPixels, referenceFragments: reference.metrics.fragments, richness }
1120
+ result.metrics.resolutionRetries = attempt
1121
+ result.metrics.detailReduced = attempt > 0 || result.metrics.attempts > 1
1122
+ return result
1123
+ }
1124
+ throw new Error('此图像过于复杂,无法生成有效结果,请降低碎片程度或减少颜色数')
1125
+ }
1126
+
786
1127
  /**
787
1128
  * 合并默认值 + 预设 + 显式覆盖。未知 preset 抛错(让 AI 立刻改,而不是静默当 default)。
788
1129
  * @returns {{options:object, preset:string}}
@@ -796,6 +1137,10 @@ export function resolveTraceOptions(rawOptions) {
796
1137
  )
797
1138
  }
798
1139
  const options = Object.assign({}, TRACE_DEFAULTS, TRACE_PRESETS[presetName], raw, { preset: presetName })
1140
+ options.adaptiveDarkColors = raw.adaptiveDarkColors !== false && raw.colorsDark === undefined && !(raw.colorsFill > 0)
1141
+ if (raw.colorsDark === undefined) options.colorsDark = TRACE_PRESETS[presetName].colorsDark ?? TRACE_DEFAULTS.colorsDark
1142
+ if (raw.colorsFill === undefined) options.colorsFill = TRACE_DEFAULTS.colorsFill
1143
+ options.removeBg = normalizeRemoveBg(options.removeBg)
799
1144
  return { options: options, preset: presetName }
800
1145
  }
801
1146
 
@@ -822,23 +1167,61 @@ export function hintTraceTune(metrics) {
822
1167
  if (chars < budget * 0.35 && Number(metrics.scaleApplied) >= 2) {
823
1168
  hints.push('字符很富余且已降采样 → 可 scale:1 或减小 epsilon 换保真')
824
1169
  }
1170
+ if (
1171
+ metrics.bgRemoved === false &&
1172
+ metrics.optionsApplied &&
1173
+ metrics.optionsApplied.removeBg === 'never'
1174
+ ) {
1175
+ hints.push('当前 removeBg:false 保留了背景;白底/纯色底图标可改 removeBg:true 再描')
1176
+ }
825
1177
  if (hints.length === 0) {
826
1178
  hints.push('指标正常;线稿糊/断可试 preset:"lineart";要更省字符用 preset:"simple"')
827
1179
  }
828
1180
  return hints.join(';')
829
1181
  }
830
1182
 
1183
+ /**
1184
+ * 把普通 JSON 数据收成 DSH 工具返回值能过关的"无损 JSON":
1185
+ * · 丢掉值为 undefined 的自有属性(`{crop:undefined}` 会被 isJsonValue 判死刑,
1186
+ * 而 JSON.stringify 却会默默省略 —— 这正是 traceImage 回执曾触发
1187
+ * `tool "diagram_apply" returned invalid output: value is not lossless JSON` 的根因)
1188
+ * · 把 -0 收成 0(DSH 的 walkJsonValue 同样拒 -0)
1189
+ * · 非有限数字 → 0(宁可变钝,也不让整次工具调用被宿主打回)
1190
+ */
1191
+ export function toLosslessJson(value) {
1192
+ if (value === null) return null
1193
+ const t = typeof value
1194
+ if (t === 'boolean' || t === 'string') return value
1195
+ if (t === 'number') {
1196
+ if (!Number.isFinite(value) || Object.is(value, -0)) return 0
1197
+ return value
1198
+ }
1199
+ if (t !== 'object') return null
1200
+ if (Array.isArray(value)) {
1201
+ const out = []
1202
+ for (let i = 0; i < value.length; i += 1) out.push(toLosslessJson(value[i]))
1203
+ return out
1204
+ }
1205
+ const out = {}
1206
+ const keys = Object.keys(value)
1207
+ for (let i = 0; i < keys.length; i += 1) {
1208
+ const key = keys[i]
1209
+ const child = value[key]
1210
+ if (child === undefined) continue
1211
+ out[key] = toLosslessJson(child)
1212
+ }
1213
+ return out
1214
+ }
1215
+
831
1216
  /**
832
1217
  * 压缩给 AI 的结构化调参回执(diagram_apply.trace[])。
1218
+ *
1219
+ * 可选字段(image / nodeId / crop)**缺就不要挂键** —— 宿主对工具输出走
1220
+ * lossless JSON 校验,自有属性值为 undefined 会整次失败。
833
1221
  */
834
1222
  export function summarizeTraceForAi(metrics, extra) {
835
1223
  const base = {
836
1224
  preset: metrics.preset,
837
- image: extra && extra.image !== undefined ? extra.image : undefined,
838
- nodeId: extra && extra.nodeId !== undefined ? extra.nodeId : undefined,
839
- // 只描了原图的一块时才有:那块在原图里的位置与尺寸(像素)——
840
- // 模型据此知道"这次只覆盖了局部",以及该按原图坐标把它摆到哪。
841
- crop: metrics.crop,
842
1225
  source: metrics.source,
843
1226
  work: metrics.work,
844
1227
  scaleApplied: metrics.scaleApplied,
@@ -856,10 +1239,22 @@ export function summarizeTraceForAi(metrics, extra) {
856
1239
  budget: metrics.budget,
857
1240
  attempts: metrics.attempts,
858
1241
  exactPixelCoverage: metrics.exactPixelCoverage,
1242
+ labelPartitionComplete: metrics.labelPartitionComplete,
1243
+ coverageBasis: 'quantized-label-map',
1244
+ loopsDropped: metrics.loopsDropped,
1245
+ budgetExceeded: metrics.budgetExceeded,
1246
+ appliedEpsilon: metrics.appliedEpsilon,
1247
+ bgRemoved: metrics.bgRemoved,
1248
+ bgDetect: metrics.bgDetect,
859
1249
  options: metrics.optionsApplied,
860
1250
  hint: metrics.tuneHint,
861
1251
  }
862
- return base
1252
+ if (extra && typeof extra.image === 'string') base.image = extra.image
1253
+ if (extra && typeof extra.nodeId === 'string') base.nodeId = extra.nodeId
1254
+ // 只描了原图的一块时才有:那块在原图里的位置与尺寸(像素)——
1255
+ // 模型据此知道"这次只覆盖了局部",以及该按原图坐标把它摆到哪。
1256
+ if (metrics.crop !== undefined && metrics.crop !== null) base.crop = metrics.crop
1257
+ return toLosslessJson(base)
863
1258
  }
864
1259
 
865
1260
  /**
@@ -870,6 +1265,12 @@ export function summarizeTraceForAi(metrics, extra) {
870
1265
  * @returns {{svg:string, metrics:object}}
871
1266
  */
872
1267
  export function traceRasterToSvg(raster, rawOptions) {
1268
+ let stageAt = typeof rawOptions?.onTiming === 'function' ? performance.now() : 0
1269
+ const mark = (name) => {
1270
+ if (!stageAt) return
1271
+ const now = performance.now()
1272
+ rawOptions.onTiming(name, now - stageAt); stageAt = now
1273
+ }
873
1274
  const resolved = resolveTraceOptions(rawOptions)
874
1275
  const options = resolved.options
875
1276
  const preset = resolved.preset
@@ -930,8 +1331,30 @@ export function traceRasterToSvg(raster, rawOptions) {
930
1331
  }
931
1332
  }
932
1333
 
933
- // ② 背景 + 调色板
934
- const isBg = markBackground(work, width, height, options)
1334
+ // ② 去背景:removeBg 已由 resolveTraceOptions 规范化为 always|never(无 auto)
1335
+ const removeMode = normalizeRemoveBg(options.removeBg)
1336
+ let solid = null
1337
+ let bgDetect = { hasSolid: false, mode: removeMode, matchRatio: 0, kind: null, color: null }
1338
+ if (removeMode === 'never') {
1339
+ solid = null
1340
+ } else {
1341
+ // true:先识纯色/透明底再泛洪;识不到则近白回退(旧 always 行为)
1342
+ solid = detectSolidBackground(work, width, height, options)
1343
+ if (solid !== null) {
1344
+ bgDetect = {
1345
+ hasSolid: true,
1346
+ mode: removeMode,
1347
+ matchRatio: solid.matchRatio,
1348
+ kind: solid.kind,
1349
+ color: solid.color === null || solid.color === undefined ? null : solid.color.slice(),
1350
+ }
1351
+ } else {
1352
+ solid = { kind: 'legacy-nearwhite', matchRatio: 1, color: null }
1353
+ bgDetect = { hasSolid: false, mode: removeMode, matchRatio: 1, kind: 'legacy-nearwhite', color: null }
1354
+ }
1355
+ }
1356
+ const keepBackground = solid === null
1357
+ const isBg = markBackground(work, width, height, options, solid)
935
1358
  const bgMean = [255, 255, 255]
936
1359
  {
937
1360
  let n = 0
@@ -952,7 +1375,11 @@ export function traceRasterToSvg(raster, rawOptions) {
952
1375
 
953
1376
  const fgIndex = []
954
1377
  for (let i = 0; i < width * height; i += 1) if (isBg[i] === 0) fgIndex.push(i)
955
- if (fgIndex.length === 0) throw new Error('这张图整幅都是背景色(近白/近灰),没有可画的像素')
1378
+ if (fgIndex.length === 0) {
1379
+ const error = new Error('这张图整幅都是背景色,没有可画的像素')
1380
+ error.code = 'EMPTY_FOREGROUND'
1381
+ throw error
1382
+ }
956
1383
 
957
1384
  // 保护区:暗前景像素(线稿)—— despeckle / ladder 都不动它们
958
1385
  const protectMask = new Uint8Array(width * height)
@@ -968,8 +1395,17 @@ export function traceRasterToSvg(raster, rawOptions) {
968
1395
  }
969
1396
  }
970
1397
 
971
- const built = buildPalette(work, fgIndex, colors, colorsDark, colorsFillOpt, protectLuma, seeds)
972
- const palette = [bgMean]
1398
+ mark('pixelProcessing')
1399
+ const candidate = options.paletteOverride
1400
+ const override = Array.isArray(candidate) && typeof options.paletteValidator === 'function' &&
1401
+ !options.paletteValidator(work, fgIndex, candidate) ? null : candidate
1402
+ const overrideDark = Array.isArray(override) ? override.filter((p) => lumaRgb(p[0], p[1], p[2]) <= protectLuma).length : 0
1403
+ const built = Array.isArray(override) && override.length > 0
1404
+ ? { cents: override, colorsDark: overrideDark, colorsFill: override.length - overrideDark,
1405
+ darkSamples: protectPixels, fillSamples: fgIndex.length - protectPixels }
1406
+ : buildPalette(work, fgIndex, colors, colorsDark, colorsFillOpt, protectLuma, seeds, options.adaptiveDarkColors)
1407
+ // 保留背景时不预留"不画的 label 0":所有 k-means 色都画出来,否则白/浅色区域会被强行重映射。
1408
+ const palette = keepBackground ? [] : [bgMean]
973
1409
  for (let c = 0; c < built.cents.length; c += 1) {
974
1410
  palette.push([
975
1411
  clamp(Math.round(built.cents[c][0]), 0, 255),
@@ -977,10 +1413,11 @@ export function traceRasterToSvg(raster, rawOptions) {
977
1413
  clamp(Math.round(built.cents[c][2]), 0, 255),
978
1414
  ])
979
1415
  }
1416
+ mark('paletteQuantization')
980
1417
  const colorCount = palette.length
981
1418
  const protectLabels = new Uint8Array(colorCount)
982
1419
  if (protectLuma > 0) {
983
- for (let c = 1; c < colorCount; c += 1) {
1420
+ for (let c = keepBackground ? 0 : 1; c < colorCount; c += 1) {
984
1421
  const p = palette[c]
985
1422
  if (lumaRgb(p[0], p[1], p[2]) <= protectLuma) protectLabels[c] = 1
986
1423
  }
@@ -996,6 +1433,7 @@ export function traceRasterToSvg(raster, rawOptions) {
996
1433
  paletteLuma[c] = lumaRgb(palette[c][0], palette[c][1], palette[c][2])
997
1434
  }
998
1435
  const px = [0, 0, 0]
1436
+ let lastR = NaN, lastG = NaN, lastB = NaN, lastLabel = 0
999
1437
  for (let y = 0; y < sh; y += 1) {
1000
1438
  const fy = (y + 0.5) / ss - 0.5
1001
1439
  for (let x = 0; x < sw; x += 1) {
@@ -1016,18 +1454,23 @@ export function traceRasterToSvg(raster, rawOptions) {
1016
1454
  const v11 = work[(y1 * width + x1) * 4 + c]
1017
1455
  px[c] = (v00 * (1 - tx) + v10 * tx) * (1 - ty) + (v01 * (1 - tx) + v11 * tx) * ty
1018
1456
  }
1019
- let bi = 0
1020
- let bd = Infinity
1021
- for (let c = 0; c < colorCount; c += 1) {
1022
- const p = palette[c]
1023
- const dr = px[0] - p[0]
1024
- const dg = px[1] - p[1]
1025
- const db = px[2] - p[2]
1026
- const d = dr * dr + dg * dg + db * db
1027
- if (d < bd) {
1028
- bd = d
1029
- bi = c
1457
+ let bi = lastLabel
1458
+ // Reuse only the exact floating-point sample, without rounding/cache growth.
1459
+ if (px[0] !== lastR || px[1] !== lastG || px[2] !== lastB) {
1460
+ bi = 0
1461
+ let bd = Infinity
1462
+ for (let c = 0; c < colorCount; c += 1) {
1463
+ const p = palette[c]
1464
+ const dr = px[0] - p[0]
1465
+ const dg = px[1] - p[1]
1466
+ const db = px[2] - p[2]
1467
+ const d = dr * dr + dg * dg + db * db
1468
+ if (d < bd) {
1469
+ bd = d
1470
+ bi = c
1471
+ }
1030
1472
  }
1473
+ lastR = px[0]; lastG = px[1]; lastB = px[2]; lastLabel = bi
1031
1474
  }
1032
1475
  gridLabels[y * sw + x] = bi
1033
1476
  }
@@ -1040,37 +1483,28 @@ export function traceRasterToSvg(raster, rawOptions) {
1040
1483
  for (let y = 0; y < sh; y += 1) {
1041
1484
  for (let x = 0; x < sw; x += 1) {
1042
1485
  const self = gridLabels[y * sw + x]
1043
- // 局部对比高(线稿边缘)→ 跳过众数,避免细线被邻色吃掉
1044
- if (majorityContrast > 0) {
1045
- let minL = paletteLuma[self]
1046
- let maxL = minL
1047
- for (let dy = -1; dy <= 1; dy += 1) {
1048
- const yy = y + dy
1049
- if (yy < 0 || yy >= sh) continue
1050
- for (let dx = -1; dx <= 1; dx += 1) {
1051
- const xx = x + dx
1052
- if (xx < 0 || xx >= sw) continue
1053
- const L = paletteLuma[gridLabels[yy * sw + xx]]
1054
- if (L < minL) minL = L
1055
- if (L > maxL) maxL = L
1056
- }
1057
- }
1058
- if (maxL - minL >= majorityContrast) {
1059
- scratch[y * sw + x] = self
1060
- majoritySkipped += 1
1061
- continue
1062
- }
1063
- }
1064
1486
  hist.fill(0)
1487
+ let minL = paletteLuma[self]
1488
+ let maxL = minL
1489
+ // 局部对比高(线稿边缘)→ 跳过众数,避免细线被邻色吃掉
1065
1490
  for (let dy = -1; dy <= 1; dy += 1) {
1066
1491
  const yy = y + dy
1067
1492
  if (yy < 0 || yy >= sh) continue
1068
1493
  for (let dx = -1; dx <= 1; dx += 1) {
1069
1494
  const xx = x + dx
1070
1495
  if (xx < 0 || xx >= sw) continue
1071
- hist[gridLabels[yy * sw + xx]] += 1
1496
+ const label = gridLabels[yy * sw + xx]
1497
+ hist[label] += 1
1498
+ const L = paletteLuma[label]
1499
+ if (L < minL) minL = L
1500
+ if (L > maxL) maxL = L
1072
1501
  }
1073
1502
  }
1503
+ if (majorityContrast > 0 && maxL - minL >= majorityContrast) {
1504
+ scratch[y * sw + x] = self
1505
+ majoritySkipped += 1
1506
+ continue
1507
+ }
1074
1508
  let bi = self
1075
1509
  let bc = 0
1076
1510
  for (let c = 0; c < colorCount; c += 1) {
@@ -1105,40 +1539,44 @@ export function traceRasterToSvg(raster, rawOptions) {
1105
1539
  }
1106
1540
  }
1107
1541
 
1108
- // 背景色(label 0)只给泛洪底色用,且最终不画 path。
1542
+ // 抠底时:label 0 = 泛洪底色,最终不画 path。
1109
1543
  // 白衣白底时,角色内部的白也会被最近邻归到 palette[0] —— 若不改回前景色,
1110
1544
  // 跳过绘制 label 0 就会把裙子/头发抠成透明洞。
1545
+ // 保留背景时没有专用 bg 槽,跳过这段重映射。
1111
1546
  let fgRelabeledFromBg = 0
1112
1547
  let bgForced = 0
1113
- for (let i = 0; i < width * height; i += 1) {
1114
- if (isBg[i] === 1) {
1115
- if (labels[i] !== 0) bgForced += 1
1116
- labels[i] = 0
1117
- continue
1118
- }
1119
- if (labels[i] !== 0) continue
1120
- const o = i * 4
1121
- let bi = 1
1122
- let bd = Infinity
1123
- for (let c = 1; c < colorCount; c += 1) {
1124
- const p = palette[c]
1125
- const dr = work[o] - p[0]
1126
- const dg = work[o + 1] - p[1]
1127
- const db = work[o + 2] - p[2]
1128
- const d = dr * dr + dg * dg + db * db
1129
- if (d < bd) {
1130
- bd = d
1131
- bi = c
1548
+ if (keepBackground === false) {
1549
+ for (let i = 0; i < width * height; i += 1) {
1550
+ if (isBg[i] === 1) {
1551
+ if (labels[i] !== 0) bgForced += 1
1552
+ labels[i] = 0
1553
+ continue
1132
1554
  }
1555
+ if (labels[i] !== 0) continue
1556
+ const o = i * 4
1557
+ let bi = 1
1558
+ let bd = Infinity
1559
+ for (let c = 1; c < colorCount; c += 1) {
1560
+ const p = palette[c]
1561
+ const dr = work[o] - p[0]
1562
+ const dg = work[o + 1] - p[1]
1563
+ const db = work[o + 2] - p[2]
1564
+ const d = dr * dr + dg * dg + db * db
1565
+ if (d < bd) {
1566
+ bd = d
1567
+ bi = c
1568
+ }
1569
+ }
1570
+ labels[i] = bi
1571
+ fgRelabeledFromBg += 1
1133
1572
  }
1134
- labels[i] = bi
1135
- fgRelabeledFromBg += 1
1136
1573
  }
1137
1574
 
1138
1575
  const maskOrNull = protectPixels > 0 ? protectMask : null
1139
1576
  const labelsOrNull = protectLuma > 0 ? protectLabels : null
1140
- const speckle = despeckle(labels, width, height, palette, minArea, maskOrNull, labelsOrNull)
1141
- const regionMerge = despeckle(labels, width, height, palette, minRegionArea, maskOrNull, labelsOrNull)
1577
+ const backgroundLabel = keepBackground ? -1 : 0
1578
+ const speckle = despeckle(labels, width, height, palette, minArea, maskOrNull, labelsOrNull, backgroundLabel)
1579
+ const regionMerge = despeckle(labels, width, height, palette, minRegionArea, maskOrNull, labelsOrNull, backgroundLabel)
1142
1580
  const labelDump = options.includeLabels === true
1143
1581
  ? {
1144
1582
  width: width, height: height, colors: colorCount,
@@ -1147,6 +1585,7 @@ export function traceRasterToSvg(raster, rawOptions) {
1147
1585
  }
1148
1586
  : null
1149
1587
 
1588
+ mark('labelProcessing')
1150
1589
  // ④ 连通域 + 轮廓(记下区域是否受保护,供 ladder 保留细环)
1151
1590
  const comp = new Int32Array(width * height).fill(-1)
1152
1591
  const regionLabel = []
@@ -1200,6 +1639,7 @@ export function traceRasterToSvg(raster, rawOptions) {
1200
1639
  points: loops[i].points,
1201
1640
  area: loops[i].area,
1202
1641
  protected: protectedLoop,
1642
+ region: id,
1203
1643
  }
1204
1644
  if (loops[i].area < 0) bucket.outer.push(tagged)
1205
1645
  else bucket.hole.push(tagged)
@@ -1219,14 +1659,20 @@ export function traceRasterToSvg(raster, rawOptions) {
1219
1659
  return byColor[b].area - byColor[a].area
1220
1660
  })
1221
1661
 
1662
+ mark('contours')
1222
1663
  let attempts = 0
1223
1664
  let pathCount = 0
1224
1665
  let loopCount = 0
1225
1666
  let loopsProtectedKept = 0
1667
+ let fragments = 0
1668
+ let loopsDropped = 0
1669
+ let geometryPreserved = true
1670
+ let appliedEpsilon = options.epsilon
1226
1671
  let svg = ''
1672
+ let drawingLayers = []
1227
1673
  // 超预算就逐步收紧:先抬 RDP 容差,再丢小洞(受保护环永不因 minLoopArea 丢弃)
1228
1674
  const ladder = [
1229
- { epsilon: options.epsilon, minLoopArea: 4 },
1675
+ { epsilon: options.epsilon, minLoopArea: 0 },
1230
1676
  { epsilon: Math.max(options.epsilon, 2.5), minLoopArea: 4 },
1231
1677
  { epsilon: Math.max(options.epsilon, 3), minLoopArea: 9 },
1232
1678
  { epsilon: Math.max(options.epsilon, 3.5), minLoopArea: 25 },
@@ -1236,37 +1682,61 @@ export function traceRasterToSvg(raster, rawOptions) {
1236
1682
  attempts = step + 1
1237
1683
  const plan = ladder[step]
1238
1684
  let body = ''
1685
+ drawingLayers = []
1239
1686
  pathCount = 0
1240
1687
  loopCount = 0
1241
1688
  loopsProtectedKept = 0
1689
+ fragments = 0
1690
+ loopsDropped = 0
1691
+ geometryPreserved = true
1692
+ appliedEpsilon = plan.epsilon
1242
1693
  for (let k = 0; k < order.length; k += 1) {
1243
- // label 0 = 泛洪标出的截图底色:不画(SVG 该处透明)。
1694
+ // 抠底时 label 0 = 泛洪底色:不画(SVG 该处透明)。
1244
1695
  // 若画成 path,角色左右两块互不连通的底色会进同一条 nonzero path,
1245
1696
  // 绕向抵消时会出现"一侧透明、一侧实心白"的假背景(插画发丝顶天立地时必现)。
1246
- if (order[k] === 0) continue
1697
+ // 保留背景时没有专用 bg 槽,label 0 也是正常色,照画。
1698
+ if (keepBackground === false && order[k] === 0) continue
1247
1699
  const bucket = byColor[order[k]]
1248
1700
  let d = ''
1701
+ const drawingRegions = options.includeDrawing === true ? new Map() : null
1249
1702
  const all = bucket.outer.concat(bucket.hole)
1250
1703
  for (let i = 0; i < all.length; i += 1) {
1251
1704
  const loop = all[i]
1252
- if (!loop.protected && Math.abs(loop.area) < plan.minLoopArea) continue
1253
- // 洞反向 → nonzero 里挖空
1254
- const points = loop.area > 0 ? loop.points.slice().reverse() : loop.points
1255
- const seg = encodeLoop(simplifyClosed(points, plan.epsilon), options.precision)
1256
- if (seg === null) continue
1705
+ if (!loop.protected && Math.abs(loop.area) < plan.minLoopArea) {
1706
+ loopsDropped += 1
1707
+ geometryPreserved = false
1708
+ continue
1709
+ }
1710
+ // 格边跟随已保证外环与洞绕向相反,保留绕向才能用 nonzero 挖空。
1711
+ const points = simplifyClosed(loop.points, plan.epsilon)
1712
+ const seg = encodeLoop(points, options.precision)
1713
+ if (seg === null) {
1714
+ loopsDropped += 1
1715
+ geometryPreserved = false
1716
+ continue
1717
+ }
1718
+ if (!sameLoopGeometry(loop.points, points)) geometryPreserved = false
1257
1719
  d += seg
1720
+ if (drawingRegions !== null) {
1721
+ const region = drawingRegions.get(loop.region) || { d: '', area: regionArea[loop.region] }
1722
+ region.d += seg
1723
+ drawingRegions.set(loop.region, region)
1724
+ }
1258
1725
  loopCount += 1
1726
+ if (loop.area < 0) fragments += 1
1259
1727
  if (loop.protected) loopsProtectedKept += 1
1260
1728
  }
1261
1729
  if (d === '') continue
1262
1730
  body += "<path fill='" + hex(palette[order[k]]) + "' fill-rule='nonzero' d='" + d + "'/>"
1731
+ if (drawingRegions !== null) drawingLayers.push({ fill: hex(palette[order[k]]), regions: Array.from(drawingRegions.values()) })
1263
1732
  pathCount += 1
1264
1733
  }
1265
1734
  svg = "<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 " + width + " " + height + "'>" + body + '</svg>'
1266
1735
  if (svg.length <= options.maxBytes) break
1267
1736
  }
1268
1737
 
1269
- // 精确性度量:各色层覆盖的像素数之和 vs 图像总像素(格边轮廓下两者应当完全一致)
1738
+ mark('svgSerialization')
1739
+ // 标签分区完整性与输出轮廓无损性分开;面积累加不能证明最终 SVG 正确。
1270
1740
  let covered = 0
1271
1741
  for (let c = 0; c < colorCount; c += 1) covered += byColor[c].area
1272
1742
  const loopsTotal = { value: 0 }
@@ -1280,8 +1750,8 @@ export function traceRasterToSvg(raster, rawOptions) {
1280
1750
  const optionsApplied = {
1281
1751
  preset: preset,
1282
1752
  colors: colors,
1283
- colorsDark: colorsDark,
1284
- colorsFill: colorsFillOpt,
1753
+ colorsDark: options.adaptiveDarkColors ? built.colorsDark : colorsDark,
1754
+ colorsFill: options.adaptiveDarkColors ? built.colorsFill : colorsFillOpt,
1285
1755
  grid: sized.grid,
1286
1756
  gridH: sized.gridH,
1287
1757
  scale: scale,
@@ -1291,6 +1761,9 @@ export function traceRasterToSvg(raster, rawOptions) {
1291
1761
  minRegionArea: minRegionArea,
1292
1762
  protectLuma: protectLuma,
1293
1763
  majorityContrast: majorityContrast,
1764
+ removeBg: removeMode,
1765
+ bgSolidTol: options.bgSolidTol,
1766
+ bgSolidBorderRatio: options.bgSolidBorderRatio,
1294
1767
  }
1295
1768
 
1296
1769
  const metrics = {
@@ -1303,7 +1776,7 @@ export function traceRasterToSvg(raster, rawOptions) {
1303
1776
  gridH: sized.gridH,
1304
1777
  bgColor: bgColor.slice(),
1305
1778
  sizeNotes: sized.sizeNotes,
1306
- colors: colorCount - 1,
1779
+ colors: keepBackground ? colorCount : colorCount - 1,
1307
1780
  colorsDark: built.colorsDark,
1308
1781
  colorsFill: built.colorsFill,
1309
1782
  darkSamples: built.darkSamples,
@@ -1312,6 +1785,8 @@ export function traceRasterToSvg(raster, rawOptions) {
1312
1785
  protectPixels: protectPixels,
1313
1786
  fgRelabeledFromBg: fgRelabeledFromBg,
1314
1787
  bgForced: bgForced,
1788
+ bgRemoved: keepBackground === false,
1789
+ bgDetect: bgDetect,
1315
1790
  majorityContrast: majorityContrast,
1316
1791
  majoritySkipped: majoritySkipped,
1317
1792
  majorityApplied: majorityApplied,
@@ -1329,10 +1804,16 @@ export function traceRasterToSvg(raster, rawOptions) {
1329
1804
  loops: loopsTotal.value,
1330
1805
  paths: pathCount,
1331
1806
  loopsKept: loopCount,
1807
+ fragments: fragments,
1332
1808
  loopsProtectedKept: loopsProtectedKept,
1333
- drawnPixels: covered,
1809
+ drawnPixels: geometryPreserved ? (keepBackground ? covered : fgIndex.length) : null,
1334
1810
  totalPixels: width * height,
1335
- exactPixelCoverage: covered === width * height,
1811
+ exactPixelCoverage: covered === width * height && geometryPreserved,
1812
+ labelPartitionComplete: covered === width * height,
1813
+ coverageBasis: 'quantized-label-map',
1814
+ loopsDropped: loopsDropped,
1815
+ budgetExceeded: svg.length > options.maxBytes,
1816
+ appliedEpsilon: appliedEpsilon,
1336
1817
  svgChars: svg.length,
1337
1818
  budget: options.maxBytes,
1338
1819
  attempts: attempts,
@@ -1340,9 +1821,17 @@ export function traceRasterToSvg(raster, rawOptions) {
1340
1821
  optionsApplied: optionsApplied,
1341
1822
  }
1342
1823
  metrics.tuneHint = hintTraceTune(metrics)
1824
+ mark('metrics')
1343
1825
 
1344
1826
  return {
1345
1827
  svg: svg,
1828
+ ...(options.includeDrawing === true ? { drawing: {
1829
+ viewBox: [0, 0, width, height],
1830
+ fills: drawingLayers.map((layer) => layer.fill),
1831
+ // Keep each region's outer contour and holes together; reveal large areas before fine details.
1832
+ steps: drawingLayers.flatMap((layer, index) => layer.regions.map((region) => ({ layer: index, d: region.d, area: region.area })))
1833
+ .sort((a, b) => b.area - a.area),
1834
+ } } : {}),
1346
1835
  labels: labelDump,
1347
1836
  metrics: metrics,
1348
1837
  }