dsh-drawai 0.2.7 → 0.3.1
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.
- package/README.md +51 -9
- package/lib/client.js +3990 -470
- package/lib/drawing-results.js +36 -0
- package/lib/drawing-runner.js +213 -0
- package/lib/drawing-tasks.js +230 -0
- package/lib/index.js +1480 -46
- package/lib/media.js +664 -0
- package/lib/mxfile.js +91 -13
- package/lib/performance-log.js +66 -0
- package/lib/performance-metrics.js +47 -0
- package/lib/raster.js +21 -4
- package/lib/style-kernel.js +51 -11
- package/lib/task-coordinator.js +54 -0
- package/lib/task-patches.js +59 -0
- package/lib/trace-image.js +595 -106
- package/lib/video-export-worker.js +58 -0
- package/lib/video-export.js +304 -0
- package/lib/video-trace-pool.js +129 -0
- package/lib/video-trace-reuse.js +49 -0
- package/lib/video-trace-worker.js +61 -0
- package/package.json +6 -2
package/lib/trace-image.js
CHANGED
|
@@ -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
|
-
*
|
|
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 <=
|
|
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 (!
|
|
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
|
-
|
|
258
|
-
|
|
427
|
+
// 回到起点即闭合。不能再用 list.indexOf(first):first 已从起点弹出,
|
|
428
|
+
// 引用永远不在 list 里,外环与洞在共用角点处会被串成一条错环。
|
|
429
|
+
if (key === startKey && points.length > 1) {
|
|
259
430
|
edge = null
|
|
260
431
|
break
|
|
261
432
|
}
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
//
|
|
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 ===
|
|
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(
|
|
701
|
-
const nextH = Math.max(
|
|
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, //
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
-
|
|
972
|
-
const
|
|
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 =
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
if (
|
|
1116
|
-
|
|
1117
|
-
|
|
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
|
|
1141
|
-
const
|
|
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:
|
|
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 =
|
|
1694
|
+
// 抠底时 label 0 = 泛洪底色:不画(SVG 该处透明)。
|
|
1244
1695
|
// 若画成 path,角色左右两块互不连通的底色会进同一条 nonzero path,
|
|
1245
1696
|
// 绕向抵消时会出现"一侧透明、一侧实心白"的假背景(插画发丝顶天立地时必现)。
|
|
1246
|
-
|
|
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)
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
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
|
-
|
|
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
|
}
|