dsh-drawai 0.2.4 → 0.2.6

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.
@@ -5,11 +5,12 @@
5
5
  * 这里只有**像素进、SVG 出**,不碰文件系统、不碰 ctx:宿主工具与命令行脚本共用同一份。
6
6
  *
7
7
  * 管线(每一步都能在返回的 metrics 里看到数字):
8
- * ① 四边泛洪判背景(近白 / 近灰 / 全透明)→ 不画截图底色
9
- * ② k-means 定调色板(K 个前景色 + 背景单独占第 0 号)
10
- * ③ 3× 超采样逐样本归类 → 3×3 众数滤波 → 降回原分辨率 → <minArea 的碎块并入邻色
11
- * ④ 每色按连通域取闭合轮廓:像素**格边**上的有向图 + 左转跟随(区域恒在左侧)
12
- * ⑤ RDP 简化 + 相对增量编码 → 每色一条 <path fill-rule="nonzero">
8
+ * ① 工作图尺寸 = src/scale,再套 grid(宽)/ gridH(高)上限;透明像素合成到 bgColor
9
+ * ② 四边泛洪判背景(近白 / 近灰 / 全透明)→ 不画截图底色
10
+ * ③ 分层 k-means(colorsDark 暗色 + colorsFill 填充)定调色板;暗像素进保护区
11
+ * ④ 3× 超采样归类 → 条件众数(高对比跳过)→ despeckle / minRegionArea(保护区不并)
12
+ * ⑤ 每色按连通域取闭合轮廓:像素**格边**上的有向图 + 左转跟随(区域恒在左侧)
13
+ * ⑥ RDP + 预算阶梯(受保护环不因 minLoopArea 丢弃)→ 每色一条 <path fill-rule="nonzero">
13
14
  *
14
15
  * 为什么是"格边跟随"而不是 marching squares:标签图在鞍点(对角相接)处 marching squares
15
16
  * 要人为定连通性,而格边有向图本身就无歧义 —— 每条边只属于一个区域,走完一圈恰好回到起点。
@@ -21,6 +22,11 @@ function clamp(v, lo, hi) {
21
22
  return v < lo ? lo : v > hi ? hi : v
22
23
  }
23
24
 
25
+ /** 相对亮度(0..255),用于线稿/保护区与众数对比。 */
26
+ function lumaRgb(r, g, b) {
27
+ return 0.299 * r + 0.587 * g + 0.114 * b
28
+ }
29
+
24
30
  /* ────────────────────────── ① 背景 ────────────────────────── */
25
31
 
26
32
  /**
@@ -305,12 +311,21 @@ function traceLoops(comp, width, height, regionCount) {
305
311
  * · 并进**颜色最接近**的邻色,而不是"面积最多的"—— 边缘的过渡色应该并回它看起来最像的那一侧;
306
312
  * · **不连锁**:一轮里对固定快照判一次,避免小块并进小块滚雪球。
307
313
  */
308
- function despeckle(labels, width, height, palette, minArea) {
314
+ /**
315
+ * 按颜色分块合并碎块。
316
+ * @param {Uint8Array|null} protectMask 非空时:块内受保护像素过半,或该 label 本身是保护色 → 不合并
317
+ * @param {Uint8Array|null} protectLabels 长度为 palette.length;1 = 该色永不被合并
318
+ */
319
+ function despeckle(labels, width, height, palette, minArea, protectMask, protectLabels) {
320
+ if (!(minArea > 0)) {
321
+ return { mergedPixels: 0, blocksMerged: 0, blocks: 0, protectedSkipped: 0 }
322
+ }
309
323
  const colorCount = palette.length
310
324
  const comp = new Int32Array(width * height).fill(-1)
311
325
  const stack = []
312
326
  let mergedPixels = 0
313
327
  let blocksMerged = 0
328
+ let protectedSkipped = 0
314
329
 
315
330
  // 先把每块 4 连通区域找出来(一次,不随合并改变)
316
331
  const blocks = []
@@ -353,6 +368,18 @@ function despeckle(labels, width, height, palette, minArea) {
353
368
  if (block.pixels.length >= minArea) continue
354
369
  // 背景(label 0)不参与:它是"没画东西的地方",并它等于把图啃出洞
355
370
  if (palette[block.label] === undefined) continue
371
+ if (protectLabels !== null && protectLabels !== undefined && protectLabels[block.label] === 1) {
372
+ protectedSkipped += 1
373
+ continue
374
+ }
375
+ if (protectMask !== null && protectMask !== undefined) {
376
+ let prot = 0
377
+ for (let k = 0; k < block.pixels.length; k += 1) if (protectMask[block.pixels[k]] === 1) prot += 1
378
+ if (prot * 2 >= block.pixels.length) {
379
+ protectedSkipped += 1
380
+ continue
381
+ }
382
+ }
356
383
  hist.fill(0)
357
384
  for (let k = 0; k < block.pixels.length; k += 1) {
358
385
  const p = block.pixels[k]
@@ -392,7 +419,109 @@ function despeckle(labels, width, height, palette, minArea) {
392
419
  mergedPixels += pixels.length
393
420
  blocksMerged += 1
394
421
  }
395
- return { mergedPixels: mergedPixels, blocksMerged: blocksMerged, blocks: blocks.length }
422
+ return {
423
+ mergedPixels: mergedPixels,
424
+ blocksMerged: blocksMerged,
425
+ blocks: blocks.length,
426
+ protectedSkipped: protectedSkipped,
427
+ }
428
+ }
429
+
430
+ /**
431
+ * 分层调色板:暗像素单独 k-means(colorsDark),其余走 colorsFill。
432
+ * colorsDark=0 时退回单次 k-means(与旧行为一致)。
433
+ */
434
+ function buildPalette(work, fgIndex, colors, colorsDark, colorsFill, protectLuma, seeds) {
435
+ const darkK = Math.max(0, Math.min(colors - 1, colorsDark))
436
+ const fillK = Math.max(1, colors - darkK)
437
+ // 显式 colorsFill 时以它为准(仍保证总和 = colors)
438
+ const useFill = colorsFill > 0 ? Math.max(1, Math.min(colors, colorsFill)) : fillK
439
+ const useDark = colorsFill > 0 ? Math.max(0, colors - useFill) : darkK
440
+
441
+ if (useDark === 0 || !(protectLuma > 0)) {
442
+ const samples = new Float64Array(fgIndex.length * 3)
443
+ for (let i = 0; i < fgIndex.length; i += 1) {
444
+ const o = fgIndex[i] * 4
445
+ samples[i * 3] = work[o]
446
+ samples[i * 3 + 1] = work[o + 1]
447
+ samples[i * 3 + 2] = work[o + 2]
448
+ }
449
+ let best = null
450
+ for (let s = 1; s <= seeds; s += 1) {
451
+ const got = kmeans(samples, colors, 24, s * 2654435761)
452
+ if (best === null || got.inertia < best.inertia) best = got
453
+ }
454
+ return {
455
+ cents: best.cent,
456
+ colorsDark: 0,
457
+ colorsFill: colors,
458
+ darkSamples: 0,
459
+ fillSamples: fgIndex.length,
460
+ }
461
+ }
462
+
463
+ const darkIdx = []
464
+ const fillIdx = []
465
+ for (let i = 0; i < fgIndex.length; i += 1) {
466
+ const p = fgIndex[i]
467
+ const o = p * 4
468
+ if (lumaRgb(work[o], work[o + 1], work[o + 2]) <= protectLuma) darkIdx.push(p)
469
+ else fillIdx.push(p)
470
+ }
471
+
472
+ // 某一侧为空时,把配额并给另一侧,避免空簇
473
+ let kDark = useDark
474
+ let kFill = useFill
475
+ if (darkIdx.length === 0) {
476
+ kFill = colors
477
+ kDark = 0
478
+ } else if (fillIdx.length === 0) {
479
+ kDark = colors
480
+ kFill = 0
481
+ }
482
+
483
+ const cents = []
484
+ function runK(indexes, k, seedBase) {
485
+ if (k <= 0 || indexes.length === 0) return
486
+ const samples = new Float64Array(indexes.length * 3)
487
+ for (let i = 0; i < indexes.length; i += 1) {
488
+ const o = indexes[i] * 4
489
+ samples[i * 3] = work[o]
490
+ samples[i * 3 + 1] = work[o + 1]
491
+ samples[i * 3 + 2] = work[o + 2]
492
+ }
493
+ const kk = Math.min(k, indexes.length)
494
+ let best = null
495
+ for (let s = 1; s <= seeds; s += 1) {
496
+ const got = kmeans(samples, kk, 24, seedBase + s * 2654435761)
497
+ if (best === null || got.inertia < best.inertia) best = got
498
+ }
499
+ for (let c = 0; c < best.cent.length; c += 1) cents.push(best.cent[c])
500
+ }
501
+ runK(darkIdx, kDark, 1)
502
+ runK(fillIdx, kFill, 100)
503
+ // 极端情况下簇数不足:用全体像素补满
504
+ if (cents.length < colors) {
505
+ const samples = new Float64Array(fgIndex.length * 3)
506
+ for (let i = 0; i < fgIndex.length; i += 1) {
507
+ const o = fgIndex[i] * 4
508
+ samples[i * 3] = work[o]
509
+ samples[i * 3 + 1] = work[o + 1]
510
+ samples[i * 3 + 2] = work[o + 2]
511
+ }
512
+ const need = colors - cents.length
513
+ const got = kmeans(samples, Math.min(colors, fgIndex.length), 24, 999 * 2654435761)
514
+ for (let c = 0; c < got.cent.length && cents.length < colors; c += 1) cents.push(got.cent[c])
515
+ void need
516
+ }
517
+
518
+ return {
519
+ cents: cents.slice(0, colors),
520
+ colorsDark: kDark,
521
+ colorsFill: kFill,
522
+ darkSamples: darkIdx.length,
523
+ fillSamples: fillIdx.length,
524
+ }
396
525
  }
397
526
 
398
527
  /* ────────────────────────── ⑤ 简化与编码 ────────────────────────── */
@@ -525,50 +654,249 @@ function encodeLoop(points, precision) {
525
654
 
526
655
  /* ────────────────────────── 门面 ────────────────────────── */
527
656
 
657
+ /** 把 bgColor 规范成 [r,g,b](数组或 #rrggbb;认不出就白)。 */
658
+ function parseBgColor(raw) {
659
+ if (Array.isArray(raw) && raw.length >= 3) {
660
+ return [
661
+ clamp(Math.round(Number(raw[0])), 0, 255),
662
+ clamp(Math.round(Number(raw[1])), 0, 255),
663
+ clamp(Math.round(Number(raw[2])), 0, 255),
664
+ ]
665
+ }
666
+ if (typeof raw === 'string') {
667
+ const m = /^#?([0-9a-fA-F]{6})$/.exec(raw.trim())
668
+ if (m) {
669
+ const n = parseInt(m[1], 16)
670
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
671
+ }
672
+ }
673
+ return [255, 255, 255]
674
+ }
675
+
676
+ /**
677
+ * 工作图尺寸:先按 scale 降,再用 grid(宽)/ gridH(高,0=不限)做上限;超限等比压。
678
+ * @returns {{width:number,height:number,scaleRequested:number,scaleApplied:number,grid:number,gridH:number,sizeNotes:string}}
679
+ */
680
+ function resolveWorkSize(srcWidth, srcHeight, options) {
681
+ const scaleRequested = Number(options.scale)
682
+ const scaleRounded = Math.round(scaleRequested)
683
+ const scaleApplied = scaleRounded >= 1 ? scaleRounded : 1
684
+ const grid = Math.max(16, Math.round(Number(options.grid)))
685
+ const gridHRaw = Number(options.gridH)
686
+ const gridH = gridHRaw > 0 ? Math.max(16, Math.round(gridHRaw)) : 0
687
+
688
+ const notes = []
689
+ if (!(scaleRequested >= 1) || scaleRounded !== scaleApplied) {
690
+ notes.push('scale clamped to ' + scaleApplied + ' (requested ' + scaleRequested + ')')
691
+ }
692
+
693
+ let width = Math.max(1, Math.round(srcWidth / scaleApplied))
694
+ let height = Math.max(1, Math.round(srcHeight / scaleApplied))
695
+ if (scaleApplied > 1) notes.push('scaleApplied ' + scaleApplied)
696
+
697
+ const capH = gridH > 0 ? gridH : Infinity
698
+ if (width > grid || height > capH) {
699
+ 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))
702
+ const parts = []
703
+ if (width > grid) parts.push('width')
704
+ if (height > capH) parts.push('height')
705
+ notes.push('grid capped ' + parts.join('+') + ' (' + width + '×' + height + ' → ' + nextW + '×' + nextH + ')')
706
+ width = nextW
707
+ height = nextH
708
+ }
709
+
710
+ return {
711
+ width: width,
712
+ height: height,
713
+ scaleRequested: scaleRequested,
714
+ scaleApplied: scaleApplied,
715
+ grid: grid,
716
+ gridH: gridH,
717
+ sizeNotes: notes.join('; '),
718
+ }
719
+ }
720
+
528
721
  /** 矢量化的默认参数(宿主工具与命令行共用)。 */
529
722
  export const TRACE_DEFAULTS = {
530
- colors: 12, // 前景调色板色数(背景另占 1 色)
531
- grid: 499, // 按这个宽度做;高度按原图比例算,够大就原尺寸
532
- scale: 1, // 先按 scale×scale 面积平均降采样,再做管线(=1 表示不降)
723
+ preset: 'default', // 命名预设,见 TRACE_PRESETS;显式字段覆盖预设
724
+ colors: 12, // 前景调色板色数(背景另占 1 色)= colorsDark + colorsFill(auto)
725
+ colorsDark: 3, // 暗色配额(线稿);0 = 不分层,全体一次 k-means
726
+ colorsFill: 0, // 填充色配额;0 = 自动 colors - colorsDark
727
+ grid: 499, // 工作图宽度上限(px);与 gridH 一起做等比封顶
728
+ gridH: 0, // 工作图高度上限(px);0 = 不限高(只卡宽,兼容旧行为)
729
+ scale: 1, // 真降采样因子:work ≈ src/scale,再套 grid/gridH(=1 表示不按 scale 降)
533
730
  epsilon: 2.0, // RDP 简化容差(像素)
534
731
  precision: 1, // 坐标小数位
535
- minArea: 10, // 小于这个面积(像素²)的碎块并进邻色
732
+ minArea: 10, // despeckle:小于这个面积(像素²)的碎块并进邻色;0 = 关
733
+ minRegionArea: 0, // despeckle 之后再按面积并区域;0 = 关(与 minArea 分开调)
734
+ protectLuma: 110, // 亮度 ≤ 此值的前景像素受保护(不 despeckle / 不 ladder 丢环);0 = 关
735
+ majorityContrast: 40, // 局部亮度差 ≥ 此值则跳过众数滤波(保线稿);0 = 全图滤波(旧行为)
536
736
  bgBright: 238, // 判"近白"的亮度下限
537
737
  bgTol: 10, // 判"近灰"的通道差上限
738
+ bgColor: [255, 255, 255], // 透明/半透明像素合成底色(再进泛洪与 k-means)
538
739
  seeds: 3, // k-means 重启次数(取 inertia 最小)
539
- maxBytes: 50000, // SVG 上限(drawio style 里的 data URI 要塞得下;60000 是硬上限,留余量)
740
+ // SVG 上限(换算成 drawio style 里的 data URI 要塞得下)。宿主的硬上限是 250000 字符,
741
+ // 且它自己会再留 2000 的余量 —— 所以这里直接对齐到 248000:整图的精细度就由这个预算决定
742
+ // (预算越小,管线越要抬 epsilon、丢小环)。命令行想更省字符可显式传 --budget。
743
+ maxBytes: 248000,
540
744
  includeLabels: false, // 额外返回"去碎块后"的标签图(调试/比对用;会额外占 width*height 字节的 base64)
541
745
  }
542
746
 
747
+ /**
748
+ * 命名预设:AI 先选场景,再按需覆盖单个字段。
749
+ * 合并顺序:TRACE_DEFAULTS ← preset ← 显式字段。
750
+ */
751
+ export const TRACE_PRESETS = {
752
+ default: {},
753
+ /** 插画 / 线稿:多留暗色、严保细线、轮廓更细 */
754
+ lineart: {
755
+ colorsDark: 4,
756
+ protectLuma: 130,
757
+ majorityContrast: 32,
758
+ minArea: 4,
759
+ epsilon: 1.5,
760
+ precision: 2,
761
+ },
762
+ /** 照片 / 渐变:少暗色配额、碎块并多一点、轮廓可粗 */
763
+ photo: {
764
+ colors: 14,
765
+ colorsDark: 1,
766
+ protectLuma: 80,
767
+ majorityContrast: 55,
768
+ minArea: 16,
769
+ epsilon: 2.5,
770
+ },
771
+ /** 图标 / 简单截图:少色 + 降采样,压字符数 */
772
+ simple: {
773
+ colors: 6,
774
+ colorsDark: 2,
775
+ scale: 2,
776
+ grid: 320,
777
+ epsilon: 3,
778
+ minArea: 20,
779
+ protectLuma: 100,
780
+ },
781
+ }
782
+
783
+ /** 合法预设名列表(给报错 / 提示用)。 */
784
+ export const TRACE_PRESET_NAMES = Object.keys(TRACE_PRESETS)
785
+
786
+ /**
787
+ * 合并默认值 + 预设 + 显式覆盖。未知 preset 抛错(让 AI 立刻改,而不是静默当 default)。
788
+ * @returns {{options:object, preset:string}}
789
+ */
790
+ export function resolveTraceOptions(rawOptions) {
791
+ const raw = rawOptions === undefined || rawOptions === null ? {} : rawOptions
792
+ const presetName = typeof raw.preset === 'string' && raw.preset.length > 0 ? raw.preset : 'default'
793
+ if (TRACE_PRESETS[presetName] === undefined) {
794
+ throw new Error(
795
+ '未知 trace preset "' + presetName + '";可用:' + TRACE_PRESET_NAMES.join(', '),
796
+ )
797
+ }
798
+ const options = Object.assign({}, TRACE_DEFAULTS, TRACE_PRESETS[presetName], raw, { preset: presetName })
799
+ return { options: options, preset: presetName }
800
+ }
801
+
802
+ /**
803
+ * 根据 metrics 生成一句调参建议(给 AI 读,不是给用户的长文)。
804
+ */
805
+ export function hintTraceTune(metrics) {
806
+ if (metrics === undefined || metrics === null) return ''
807
+ const hints = []
808
+ const budget = Number(metrics.budget) || TRACE_DEFAULTS.maxBytes
809
+ const chars = Number(metrics.svgChars) || 0
810
+ if (chars > budget * 0.9 || Number(metrics.attempts) > 1) {
811
+ hints.push('字符紧/触发预算阶梯 → 试 scale:2、加大 epsilon、减小 colors/grid,或 preset:"simple"')
812
+ }
813
+ if (Number(metrics.protectPixels) === 0 && metrics.preset !== 'photo') {
814
+ hints.push('无暗像素保护区 → 插画线稿可试 preset:"lineart" 或提高 protectLuma')
815
+ }
816
+ if (Number(metrics.colorsDark) === 0 && Number(metrics.darkSamples) === 0 && metrics.preset === 'lineart') {
817
+ hints.push('lineart 但没分到暗样本 → 提高 protectLuma 或检查原图对比度')
818
+ }
819
+ if (Number(metrics.majoritySkipped) === 0 && Number(metrics.majorityContrast) > 0) {
820
+ hints.push('众数未跳过任何像素 → 图对比偏低,可减小 majorityContrast 或改 preset')
821
+ }
822
+ if (chars < budget * 0.35 && Number(metrics.scaleApplied) >= 2) {
823
+ hints.push('字符很富余且已降采样 → 可 scale:1 或减小 epsilon 换保真')
824
+ }
825
+ if (hints.length === 0) {
826
+ hints.push('指标正常;线稿糊/断可试 preset:"lineart";要更省字符用 preset:"simple"')
827
+ }
828
+ return hints.join(';')
829
+ }
830
+
831
+ /**
832
+ * 压缩给 AI 的结构化调参回执(diagram_apply.trace[])。
833
+ */
834
+ export function summarizeTraceForAi(metrics, extra) {
835
+ const base = {
836
+ 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
+ source: metrics.source,
843
+ work: metrics.work,
844
+ scaleApplied: metrics.scaleApplied,
845
+ sizeNotes: metrics.sizeNotes,
846
+ colors: metrics.colors,
847
+ colorsDark: metrics.colorsDark,
848
+ colorsFill: metrics.colorsFill,
849
+ protectPixels: metrics.protectPixels,
850
+ majoritySkipped: metrics.majoritySkipped,
851
+ paths: metrics.paths,
852
+ loopsKept: metrics.loopsKept,
853
+ loopsProtectedKept: metrics.loopsProtectedKept,
854
+ regionsProtected: metrics.regionsProtected,
855
+ svgChars: metrics.svgChars,
856
+ budget: metrics.budget,
857
+ attempts: metrics.attempts,
858
+ exactPixelCoverage: metrics.exactPixelCoverage,
859
+ options: metrics.optionsApplied,
860
+ hint: metrics.tuneHint,
861
+ }
862
+ return base
863
+ }
864
+
543
865
  /**
544
866
  * 位图 → SVG 标记。
545
867
  *
546
868
  * @param {{width:number,height:number,data:Buffer|Uint8Array}} raster RGBA8
547
- * @param {object} [rawOptions] 见 TRACE_DEFAULTS
869
+ * @param {object} [rawOptions] 见 TRACE_DEFAULTS / TRACE_PRESETS(可用 preset)
548
870
  * @returns {{svg:string, metrics:object}}
549
871
  */
550
872
  export function traceRasterToSvg(raster, rawOptions) {
551
- const options = Object.assign({}, TRACE_DEFAULTS, rawOptions === undefined || rawOptions === null ? {} : rawOptions)
873
+ const resolved = resolveTraceOptions(rawOptions)
874
+ const options = resolved.options
875
+ const preset = resolved.preset
552
876
  const colors = Math.max(2, Math.min(24, Math.round(options.colors)))
553
- const grid = Math.max(16, Math.round(options.grid))
554
- const scale = Math.max(1, Math.round(options.scale))
555
- const minArea = Math.max(1, Math.round(options.minArea))
877
+ const colorsDark = Math.max(0, Math.min(colors, Math.round(options.colorsDark)))
878
+ const colorsFillOpt = Math.max(0, Math.min(colors, Math.round(options.colorsFill)))
879
+ const minArea = Math.max(0, Math.round(options.minArea))
880
+ const minRegionArea = Math.max(0, Math.round(options.minRegionArea))
881
+ const protectLuma = Math.max(0, Number(options.protectLuma))
882
+ const majorityContrast = Math.max(0, Number(options.majorityContrast))
556
883
  const seeds = Math.max(1, Math.round(options.seeds))
884
+ const bgColor = parseBgColor(options.bgColor)
557
885
 
558
886
  const srcWidth = raster.width
559
887
  const srcHeight = raster.height
560
888
  const rgba = raster.data
561
889
 
562
- // 目标尺寸:宽度向 grid 靠(够大就原尺寸),高度按比例(至少 16px)
563
- let width = srcWidth
564
- let height = srcHeight
565
- if (grid < srcWidth) {
566
- width = grid
567
- height = Math.max(16, Math.round((srcHeight * grid) / srcWidth))
568
- }
890
+ const sized = resolveWorkSize(srcWidth, srcHeight, options)
891
+ const width = sized.width
892
+ const height = sized.height
893
+ const scale = sized.scaleApplied
569
894
 
570
- // 降采样(面积平均)+ 缩放:一次写完,后面只认 width×height 的工作图
895
+ // 降采样(面积平均)+ 把每个源像素先合成到 bgColor:一次写完,后面只认不透明的 width×height 工作图
571
896
  const work = new Uint8Array(width * height * 4)
897
+ const bgR = bgColor[0]
898
+ const bgG = bgColor[1]
899
+ const bgB = bgColor[2]
572
900
  for (let y = 0; y < height; y += 1) {
573
901
  const y0 = Math.floor((y * srcHeight) / height)
574
902
  const y1 = Math.max(y0 + 1, Math.floor(((y + 1) * srcHeight) / height))
@@ -583,10 +911,14 @@ export function traceRasterToSvg(raster, rawOptions) {
583
911
  for (let yy = y0; yy < y1; yy += 1) {
584
912
  for (let xx = x0; xx < x1; xx += 1) {
585
913
  const o = (yy * srcWidth + xx) * 4
586
- r += rgba[o]
587
- g += rgba[o + 1]
588
- b += rgba[o + 2]
589
- a += rgba[o + 3]
914
+ const srcA = rgba[o + 3]
915
+ const af = srcA / 255
916
+ const inv = 1 - af
917
+ // RGB 先合成到 bgColor,避免半透明边缘带着脏 RGB;alpha 原样平均留给 nearWhite(a<16)
918
+ r += rgba[o] * af + bgR * inv
919
+ g += rgba[o + 1] * af + bgG * inv
920
+ b += rgba[o + 2] * af + bgB * inv
921
+ a += srcA
590
922
  n += 1
591
923
  }
592
924
  }
@@ -622,31 +954,47 @@ export function traceRasterToSvg(raster, rawOptions) {
622
954
  for (let i = 0; i < width * height; i += 1) if (isBg[i] === 0) fgIndex.push(i)
623
955
  if (fgIndex.length === 0) throw new Error('这张图整幅都是背景色(近白/近灰),没有可画的像素')
624
956
 
625
- let best = null
626
- {
627
- const samples = new Float64Array(fgIndex.length * 3)
957
+ // 保护区:暗前景像素(线稿)—— despeckle / ladder 都不动它们
958
+ const protectMask = new Uint8Array(width * height)
959
+ let protectPixels = 0
960
+ if (protectLuma > 0) {
628
961
  for (let i = 0; i < fgIndex.length; i += 1) {
629
- const o = fgIndex[i] * 4
630
- samples[i * 3] = work[o]
631
- samples[i * 3 + 1] = work[o + 1]
632
- samples[i * 3 + 2] = work[o + 2]
633
- }
634
- for (let s = 1; s <= seeds; s += 1) {
635
- const got = kmeans(samples, colors, 24, s * 2654435761)
636
- if (best === null || got.inertia < best.inertia) best = got
962
+ const p = fgIndex[i]
963
+ const o = p * 4
964
+ if (lumaRgb(work[o], work[o + 1], work[o + 2]) <= protectLuma) {
965
+ protectMask[p] = 1
966
+ protectPixels += 1
967
+ }
637
968
  }
638
969
  }
970
+
971
+ const built = buildPalette(work, fgIndex, colors, colorsDark, colorsFillOpt, protectLuma, seeds)
639
972
  const palette = [bgMean]
640
- for (let c = 0; c < best.cent.length; c += 1) {
641
- palette.push([clamp(Math.round(best.cent[c][0]), 0, 255), clamp(Math.round(best.cent[c][1]), 0, 255), clamp(Math.round(best.cent[c][2]), 0, 255)])
973
+ for (let c = 0; c < built.cents.length; c += 1) {
974
+ palette.push([
975
+ clamp(Math.round(built.cents[c][0]), 0, 255),
976
+ clamp(Math.round(built.cents[c][1]), 0, 255),
977
+ clamp(Math.round(built.cents[c][2]), 0, 255),
978
+ ])
642
979
  }
643
980
  const colorCount = palette.length
981
+ const protectLabels = new Uint8Array(colorCount)
982
+ if (protectLuma > 0) {
983
+ for (let c = 1; c < colorCount; c += 1) {
984
+ const p = palette[c]
985
+ if (lumaRgb(p[0], p[1], p[2]) <= protectLuma) protectLabels[c] = 1
986
+ }
987
+ }
644
988
 
645
- // ③ 超采样归类(双线性)→ 众数滤波 → 降回原分辨率
989
+ // ③ 超采样归类(双线性)→ 条件众数滤波 → 降回原分辨率
646
990
  const ss = 3
647
991
  const sw = width * ss
648
992
  const sh = height * ss
649
993
  const gridLabels = new Uint8Array(sw * sh)
994
+ const paletteLuma = new Float64Array(colorCount)
995
+ for (let c = 0; c < colorCount; c += 1) {
996
+ paletteLuma[c] = lumaRgb(palette[c][0], palette[c][1], palette[c][2])
997
+ }
650
998
  const px = [0, 0, 0]
651
999
  for (let y = 0; y < sh; y += 1) {
652
1000
  const fy = (y + 0.5) / ss - 0.5
@@ -687,8 +1035,32 @@ export function traceRasterToSvg(raster, rawOptions) {
687
1035
 
688
1036
  const scratch = new Uint8Array(gridLabels.length)
689
1037
  const hist = new Int32Array(colorCount + 1)
1038
+ let majoritySkipped = 0
1039
+ let majorityApplied = 0
690
1040
  for (let y = 0; y < sh; y += 1) {
691
1041
  for (let x = 0; x < sw; x += 1) {
1042
+ 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
+ }
692
1064
  hist.fill(0)
693
1065
  for (let dy = -1; dy <= 1; dy += 1) {
694
1066
  const yy = y + dy
@@ -699,7 +1071,7 @@ export function traceRasterToSvg(raster, rawOptions) {
699
1071
  hist[gridLabels[yy * sw + xx]] += 1
700
1072
  }
701
1073
  }
702
- let bi = gridLabels[y * sw + x]
1074
+ let bi = self
703
1075
  let bc = 0
704
1076
  for (let c = 0; c < colorCount; c += 1) {
705
1077
  if (hist[c] > bc) {
@@ -708,6 +1080,7 @@ export function traceRasterToSvg(raster, rawOptions) {
708
1080
  }
709
1081
  }
710
1082
  scratch[y * sw + x] = bi
1083
+ majorityApplied += 1
711
1084
  }
712
1085
  }
713
1086
  gridLabels.set(scratch)
@@ -732,7 +1105,40 @@ export function traceRasterToSvg(raster, rawOptions) {
732
1105
  }
733
1106
  }
734
1107
 
735
- const speckle = despeckle(labels, width, height, palette, minArea)
1108
+ // 背景色(label 0)只给泛洪底色用,且最终不画 path。
1109
+ // 白衣白底时,角色内部的白也会被最近邻归到 palette[0] —— 若不改回前景色,
1110
+ // 跳过绘制 label 0 就会把裙子/头发抠成透明洞。
1111
+ let fgRelabeledFromBg = 0
1112
+ 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
1132
+ }
1133
+ }
1134
+ labels[i] = bi
1135
+ fgRelabeledFromBg += 1
1136
+ }
1137
+
1138
+ const maskOrNull = protectPixels > 0 ? protectMask : null
1139
+ 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)
736
1142
  const labelDump = options.includeLabels === true
737
1143
  ? {
738
1144
  width: width, height: height, colors: colorCount,
@@ -741,21 +1147,24 @@ export function traceRasterToSvg(raster, rawOptions) {
741
1147
  }
742
1148
  : null
743
1149
 
744
- // ④ 连通域 + 轮廓
1150
+ // ④ 连通域 + 轮廓(记下区域是否受保护,供 ladder 保留细环)
745
1151
  const comp = new Int32Array(width * height).fill(-1)
746
1152
  const regionLabel = []
747
1153
  const regionArea = []
1154
+ const regionProtected = []
748
1155
  const stack = []
749
1156
  for (let i = 0; i < width * height; i += 1) {
750
1157
  if (comp[i] !== -1) continue
751
1158
  const label = labels[i]
752
1159
  const id = regionLabel.length
753
1160
  let area = 0
1161
+ let prot = protectLabels[label] === 1 ? 1 : 0
754
1162
  comp[i] = id
755
1163
  stack.push(i)
756
1164
  while (stack.length > 0) {
757
1165
  const p = stack.pop()
758
1166
  area += 1
1167
+ if (protectMask[p] === 1) prot = 1
759
1168
  const x = p % width
760
1169
  const y = (p - x) / width
761
1170
  for (let dy = -1; dy <= 1; dy += 1) {
@@ -774,19 +1183,26 @@ export function traceRasterToSvg(raster, rawOptions) {
774
1183
  }
775
1184
  regionLabel.push(label)
776
1185
  regionArea.push(area)
1186
+ regionProtected.push(prot)
777
1187
  }
778
1188
 
779
1189
  const loopsByRegion = traceLoops(comp, width, height, regionLabel.length)
780
1190
 
781
- // ⑤ 每色一条 path:外环(面积<0,顺时针)+ 洞(>0,反向)
1191
+ // ⑤ 每色一条 path:外环(面积<0,顺时针)+ 洞(>0,反向);环带 protected 标记
782
1192
  const byColor = []
783
1193
  for (let c = 0; c < colorCount; c += 1) byColor.push({ outer: [], hole: [], area: 0 })
784
1194
  loopsByRegion.forEach(function (loops, id) {
785
1195
  const label = regionLabel[id]
786
1196
  const bucket = byColor[label]
1197
+ const protectedLoop = regionProtected[id] === 1
787
1198
  for (let i = 0; i < loops.length; i += 1) {
788
- if (loops[i].area < 0) bucket.outer.push(loops[i])
789
- else bucket.hole.push(loops[i])
1199
+ const tagged = {
1200
+ points: loops[i].points,
1201
+ area: loops[i].area,
1202
+ protected: protectedLoop,
1203
+ }
1204
+ if (loops[i].area < 0) bucket.outer.push(tagged)
1205
+ else bucket.hole.push(tagged)
790
1206
  }
791
1207
  bucket.area += regionArea[id]
792
1208
  })
@@ -806,8 +1222,9 @@ export function traceRasterToSvg(raster, rawOptions) {
806
1222
  let attempts = 0
807
1223
  let pathCount = 0
808
1224
  let loopCount = 0
1225
+ let loopsProtectedKept = 0
809
1226
  let svg = ''
810
- // 超预算就逐步收紧:先抬 RDP 容差,再丢小洞
1227
+ // 超预算就逐步收紧:先抬 RDP 容差,再丢小洞(受保护环永不因 minLoopArea 丢弃)
811
1228
  const ladder = [
812
1229
  { epsilon: options.epsilon, minLoopArea: 4 },
813
1230
  { epsilon: Math.max(options.epsilon, 2.5), minLoopArea: 4 },
@@ -821,19 +1238,25 @@ export function traceRasterToSvg(raster, rawOptions) {
821
1238
  let body = ''
822
1239
  pathCount = 0
823
1240
  loopCount = 0
1241
+ loopsProtectedKept = 0
824
1242
  for (let k = 0; k < order.length; k += 1) {
1243
+ // label 0 = 泛洪标出的截图底色:不画(SVG 该处透明)。
1244
+ // 若画成 path,角色左右两块互不连通的底色会进同一条 nonzero path,
1245
+ // 绕向抵消时会出现"一侧透明、一侧实心白"的假背景(插画发丝顶天立地时必现)。
1246
+ if (order[k] === 0) continue
825
1247
  const bucket = byColor[order[k]]
826
1248
  let d = ''
827
1249
  const all = bucket.outer.concat(bucket.hole)
828
1250
  for (let i = 0; i < all.length; i += 1) {
829
1251
  const loop = all[i]
830
- if (Math.abs(loop.area) < plan.minLoopArea) continue
1252
+ if (!loop.protected && Math.abs(loop.area) < plan.minLoopArea) continue
831
1253
  // 洞反向 → nonzero 里挖空
832
1254
  const points = loop.area > 0 ? loop.points.slice().reverse() : loop.points
833
1255
  const seg = encodeLoop(simplifyClosed(points, plan.epsilon), options.precision)
834
1256
  if (seg === null) continue
835
1257
  d += seg
836
1258
  loopCount += 1
1259
+ if (loop.protected) loopsProtectedKept += 1
837
1260
  }
838
1261
  if (d === '') continue
839
1262
  body += "<path fill='" + hex(palette[order[k]]) + "' fill-rule='nonzero' d='" + d + "'/>"
@@ -854,33 +1277,74 @@ export function traceRasterToSvg(raster, rawOptions) {
854
1277
  let paletteArea = 0
855
1278
  for (let c = 0; c < colorCount; c += 1) paletteArea += byColor[c].area
856
1279
 
1280
+ const optionsApplied = {
1281
+ preset: preset,
1282
+ colors: colors,
1283
+ colorsDark: colorsDark,
1284
+ colorsFill: colorsFillOpt,
1285
+ grid: sized.grid,
1286
+ gridH: sized.gridH,
1287
+ scale: scale,
1288
+ epsilon: options.epsilon,
1289
+ precision: options.precision,
1290
+ minArea: minArea,
1291
+ minRegionArea: minRegionArea,
1292
+ protectLuma: protectLuma,
1293
+ majorityContrast: majorityContrast,
1294
+ }
1295
+
1296
+ const metrics = {
1297
+ preset: preset,
1298
+ source: { width: srcWidth, height: srcHeight },
1299
+ work: { width: width, height: height, scale: scale },
1300
+ scaleRequested: sized.scaleRequested,
1301
+ scaleApplied: sized.scaleApplied,
1302
+ grid: sized.grid,
1303
+ gridH: sized.gridH,
1304
+ bgColor: bgColor.slice(),
1305
+ sizeNotes: sized.sizeNotes,
1306
+ colors: colorCount - 1,
1307
+ colorsDark: built.colorsDark,
1308
+ colorsFill: built.colorsFill,
1309
+ darkSamples: built.darkSamples,
1310
+ fillSamples: built.fillSamples,
1311
+ protectLuma: protectLuma,
1312
+ protectPixels: protectPixels,
1313
+ fgRelabeledFromBg: fgRelabeledFromBg,
1314
+ bgForced: bgForced,
1315
+ majorityContrast: majorityContrast,
1316
+ majoritySkipped: majoritySkipped,
1317
+ majorityApplied: majorityApplied,
1318
+ palette: palette.map(hex),
1319
+ backgroundPixels: (function () {
1320
+ let n = 0
1321
+ for (let i = 0; i < width * height; i += 1) if (isBg[i] === 1) n += 1
1322
+ return n
1323
+ })(),
1324
+ foregroundPixels: fgIndex.length,
1325
+ despeckle: speckle,
1326
+ regionMerge: regionMerge,
1327
+ regions: regionLabel.length,
1328
+ regionsProtected: regionProtected.reduce(function (n, v) { return n + v }, 0),
1329
+ loops: loopsTotal.value,
1330
+ paths: pathCount,
1331
+ loopsKept: loopCount,
1332
+ loopsProtectedKept: loopsProtectedKept,
1333
+ drawnPixels: covered,
1334
+ totalPixels: width * height,
1335
+ exactPixelCoverage: covered === width * height,
1336
+ svgChars: svg.length,
1337
+ budget: options.maxBytes,
1338
+ attempts: attempts,
1339
+ paletteAreaSum: paletteArea,
1340
+ optionsApplied: optionsApplied,
1341
+ }
1342
+ metrics.tuneHint = hintTraceTune(metrics)
1343
+
857
1344
  return {
858
1345
  svg: svg,
859
1346
  labels: labelDump,
860
- metrics: {
861
- source: { width: srcWidth, height: srcHeight },
862
- work: { width: width, height: height, scale: scale },
863
- colors: colorCount - 1,
864
- palette: palette.map(hex),
865
- backgroundPixels: (function () {
866
- let n = 0
867
- for (let i = 0; i < width * height; i += 1) if (isBg[i] === 1) n += 1
868
- return n
869
- })(),
870
- foregroundPixels: fgIndex.length,
871
- despeckle: speckle,
872
- regions: regionLabel.length,
873
- loops: loopsTotal.value,
874
- paths: pathCount,
875
- loopsKept: loopCount,
876
- drawnPixels: covered,
877
- totalPixels: width * height,
878
- exactPixelCoverage: covered === width * height,
879
- svgChars: svg.length,
880
- budget: options.maxBytes,
881
- attempts: attempts,
882
- paletteAreaSum: paletteArea,
883
- },
1347
+ metrics: metrics,
884
1348
  }
885
1349
  }
886
1350