dsh-drawai 0.2.4 → 0.2.5
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/lib/index.js +249 -40
- package/lib/raster.js +55 -7
- package/lib/trace-image.js +539 -75
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -19,7 +19,13 @@ import { readdir } from 'node:fs/promises'
|
|
|
19
19
|
import { isAbsolute, resolve as resolvePath, join as joinPath } from 'node:path'
|
|
20
20
|
import { applyDocToMxfile, buildMxfile, contentHash, parseMxfile } from './mxfile.js'
|
|
21
21
|
import { decodeRaster } from './raster.js'
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
TRACE_DEFAULTS,
|
|
24
|
+
TRACE_PRESET_NAMES,
|
|
25
|
+
resolveTraceOptions,
|
|
26
|
+
summarizeTraceForAi,
|
|
27
|
+
traceRasterToSvg,
|
|
28
|
+
} from './trace-image.js'
|
|
23
29
|
import {
|
|
24
30
|
ARROW_KINDS,
|
|
25
31
|
DASH_KINDS,
|
|
@@ -408,8 +414,17 @@ function fontColorFieldOf(style) {
|
|
|
408
414
|
const DEFAULT_SVG_W = 80
|
|
409
415
|
const DEFAULT_SVG_H = 80
|
|
410
416
|
|
|
411
|
-
/**
|
|
412
|
-
|
|
417
|
+
/**
|
|
418
|
+
* 一段内嵌 SVG 标记的长度上限。超了直接拒:一个单元格几十万字符会把文件和上下文一起撑爆。
|
|
419
|
+
*
|
|
420
|
+
* 为什么是 250000:**这是整图矢量化精细度的直接瓶颈**。实测一张 1024×1536 的立绘,
|
|
421
|
+
* grid 800 的原始产出就要 249630 字符 —— 卡在 60000 时管线只能压掉 5.4 倍(eps 抬到 3.2、
|
|
422
|
+
* 小环大量丢弃,环保留从 10235 掉到 2049),于是"脸和细节糊"就成了必然。
|
|
423
|
+
* 放到 250000:同一张图 grid 1000 只压 2 次、环保留 5455(翻 2.7 倍)。
|
|
424
|
+
* 再往上(不压缩约 327000 字符)文件会到 440KB/节点、读回时上下文开销约 8~9 万 token,
|
|
425
|
+
* 收益递减,所以停在 250000。要更细请用 cropX/cropY/cropW/cropH 做局部(那不受这个上限的边际影响)。
|
|
426
|
+
*/
|
|
427
|
+
const SVG_MARKUP_LIMIT = 250000
|
|
413
428
|
/** traceImage 能读的图片字节上限(够任何截图,又不至于让一次调用把宿主内存吃干)。 */
|
|
414
429
|
const TRACE_IMAGE_MAX_BYTES = 32 * 1024 * 1024
|
|
415
430
|
/** diagram_read 里回显 svg 的长度上限(超了截断并注明总长)。 */
|
|
@@ -625,6 +640,54 @@ function styleNote(style) {
|
|
|
625
640
|
return bits.length === 0 ? '' : ' [' + bits.join(', ') + ']'
|
|
626
641
|
}
|
|
627
642
|
|
|
643
|
+
/**
|
|
644
|
+
* 解析 traceImage 的裁剪矩形(cropX / cropY / cropW / cropH,都相对原图像素坐标)。
|
|
645
|
+
*
|
|
646
|
+
* 为什么要裁剪:矢量化的工作分辨率上限由 grid(默认 499px 宽)决定 —— 整张图画进去时,
|
|
647
|
+
* 一张 1024 宽的立绘里"脸"只有约 38px 宽、眼睛 3~4px,量化后五官必然消失。
|
|
648
|
+
* 裁出局部再描,同一块区域就占满工作图宽度,脸能有 190px —— 这是唯一能真正改善
|
|
649
|
+
* "面部特征缺失"的手段(调 colors / protectLuma 都无效:已经实测扫过参数)。
|
|
650
|
+
*
|
|
651
|
+
* 只写 cropW/cropH 时默认从左上角裁;一个字段都不给 = 整图(返回 null)。
|
|
652
|
+
* 裁剪后的 SVG viewBox 会平移回原图像素系,所以节点位置仍与原图对齐。
|
|
653
|
+
*/
|
|
654
|
+
function traceCropRect(op, where) {
|
|
655
|
+
const given = ['cropX', 'cropY', 'cropW', 'cropH'].filter((key) => has(op, key))
|
|
656
|
+
if (given.length === 0) return null
|
|
657
|
+
const box = {
|
|
658
|
+
x: Math.round(numberOr(op.cropX, 0)),
|
|
659
|
+
y: Math.round(numberOr(op.cropY, 0)),
|
|
660
|
+
w: Math.round(numberOr(op.cropW, 0)),
|
|
661
|
+
h: Math.round(numberOr(op.cropH, 0)),
|
|
662
|
+
}
|
|
663
|
+
if (box.w <= 0 || box.h <= 0) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
where + ': 裁剪区域要给出正数 cropW / cropH(当前 ' + box.w + '×' + box.h +
|
|
666
|
+
');cropX / cropY 缺省为 0(左上角)。只给 cropX/cropY 不是裁剪,给全 cropX/cropY/cropW/cropH。',
|
|
667
|
+
)
|
|
668
|
+
}
|
|
669
|
+
if (box.x < 0 || box.y < 0) {
|
|
670
|
+
throw new Error(where + ': cropX / cropY 不能是负数(当前 ' + box.x + ',' + box.y + ')')
|
|
671
|
+
}
|
|
672
|
+
return box
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** 裁出 raster 的 [x, x+w) × [y, y+h) 一块(RGBA8,逐行拷)。越界会报错,不静默补白。 */
|
|
676
|
+
function cropRaster(raster, box, where) {
|
|
677
|
+
if (box.x + box.w > raster.width || box.y + box.h > raster.height) {
|
|
678
|
+
throw new Error(
|
|
679
|
+
where + ': 裁剪区域超出图片范围(图片 ' + raster.width + '×' + raster.height +
|
|
680
|
+
',要裁 x=' + box.x + ' y=' + box.y + ' ' + box.w + '×' + box.h + ')',
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
const out = new Uint8Array(box.w * box.h * 4)
|
|
684
|
+
for (let row = 0; row < box.h; row += 1) {
|
|
685
|
+
const from = ((box.y + row) * raster.width + box.x) * 4
|
|
686
|
+
out.set(raster.data.subarray(from, from + box.w * 4), row * box.w * 4)
|
|
687
|
+
}
|
|
688
|
+
return { width: box.w, height: box.h, data: out }
|
|
689
|
+
}
|
|
690
|
+
|
|
628
691
|
/**
|
|
629
692
|
* 施加 ops。语义校验在这里:任何指向不存在节点的边、任何未知 id,都直接抛错并列出已知节点,
|
|
630
693
|
* 于是失败发生在写盘之前 —— 不会画出半张烂图。
|
|
@@ -762,6 +825,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
762
825
|
//
|
|
763
826
|
// 这一步要读像素(I/O),所以整条 applyOps 是 async 的 —— 别把它当成同步 op 来加。
|
|
764
827
|
const where = 'ops[' + i + '] traceImage'
|
|
828
|
+
const traceCrop = traceCropRect(op, where)
|
|
765
829
|
if (opctx === undefined || opctx === null) throw new Error(where + ': 这个上下文不支持读图片(内部错误)')
|
|
766
830
|
const rawImage = typeof op.image === 'string' && op.image.length > 0 ? op.image : undefined
|
|
767
831
|
if (rawImage === undefined) {
|
|
@@ -774,26 +838,65 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
774
838
|
} catch (error) {
|
|
775
839
|
throw new Error(where + ': ' + messageOf(error))
|
|
776
840
|
}
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
841
|
+
// 裁剪:只把这一块交给矢量化,于是这块区域能占满整个工作分辨率(见 traceCropRect 的说明)。
|
|
842
|
+
let tracedFrom = raster
|
|
843
|
+
if (traceCrop !== null) {
|
|
844
|
+
try {
|
|
845
|
+
tracedFrom = cropRaster(raster, traceCrop, where)
|
|
846
|
+
} catch (error) {
|
|
847
|
+
throw new Error(where + ': ' + messageOf(error))
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
// 显式字段覆盖预设:没写的键用 undefined,让 resolveTraceOptions / 默认值接手
|
|
851
|
+
// 没给 maxBytes 时按上限走:细则由这个预算决定(预算越小,管线越要抬 epsilon、丢小环)
|
|
852
|
+
const rawTraceBudget = has(op, 'maxBytes')
|
|
853
|
+
? numberOr(op.maxBytes, TRACE_DEFAULTS.maxBytes)
|
|
854
|
+
: SVG_MARKUP_LIMIT - 2000
|
|
855
|
+
const rawTrace = {
|
|
856
|
+
preset: typeof op.preset === 'string' ? op.preset : TRACE_DEFAULTS.preset,
|
|
857
|
+
colors: has(op, 'colors') ? op.colors : undefined,
|
|
858
|
+
colorsDark: has(op, 'colorsDark') ? op.colorsDark : undefined,
|
|
859
|
+
colorsFill: has(op, 'colorsFill') ? op.colorsFill : undefined,
|
|
860
|
+
grid: has(op, 'grid') ? op.grid : undefined,
|
|
861
|
+
gridH: has(op, 'gridH') ? op.gridH : undefined,
|
|
862
|
+
scale: has(op, 'scale') ? op.scale : undefined,
|
|
863
|
+
epsilon: has(op, 'epsilon') ? op.epsilon : undefined,
|
|
864
|
+
precision: has(op, 'precision') ? op.precision : undefined,
|
|
865
|
+
minArea: has(op, 'minArea') ? op.minArea : undefined,
|
|
866
|
+
minRegionArea: has(op, 'minRegionArea') ? op.minRegionArea : undefined,
|
|
867
|
+
protectLuma: has(op, 'protectLuma') ? op.protectLuma : undefined,
|
|
868
|
+
majorityContrast: has(op, 'majorityContrast') ? op.majorityContrast : undefined,
|
|
869
|
+
bgBright: has(op, 'bgBright') ? op.bgBright : undefined,
|
|
870
|
+
bgTol: has(op, 'bgTol') ? op.bgTol : undefined,
|
|
871
|
+
bgColor: has(op, 'bgColor') ? op.bgColor : undefined,
|
|
872
|
+
seeds: has(op, 'seeds') ? op.seeds : undefined,
|
|
873
|
+
maxBytes: Math.min(rawTraceBudget, SVG_MARKUP_LIMIT - 2000),
|
|
874
|
+
}
|
|
875
|
+
// 清掉 undefined,避免 Object.assign 把预设盖成 undefined
|
|
876
|
+
for (const key of Object.keys(rawTrace)) {
|
|
877
|
+
if (rawTrace[key] === undefined) delete rawTrace[key]
|
|
878
|
+
}
|
|
879
|
+
let resolved
|
|
880
|
+
try {
|
|
881
|
+
resolved = resolveTraceOptions(rawTrace)
|
|
882
|
+
} catch (error) {
|
|
883
|
+
throw new Error(where + ': ' + messageOf(error))
|
|
884
|
+
}
|
|
885
|
+
const traced = traceRasterToSvg(tracedFrom, resolved.options)
|
|
789
886
|
const metrics = traced.metrics
|
|
790
887
|
if (traced.svg.length > SVG_MARKUP_LIMIT) {
|
|
791
888
|
throw new Error(
|
|
792
889
|
where + ': 描出来的 SVG 太长(' + traced.svg.length + ' 字符,上限 ' + SVG_MARKUP_LIMIT +
|
|
793
|
-
')。调小 grid(当前 ' + metrics.work.width + ')、调大 epsilon
|
|
890
|
+
')。调小 grid(当前 ' + metrics.work.width + ')、调大 epsilon、减少 colors,或 preset:"simple"。' +
|
|
891
|
+
(metrics.tuneHint ? ' 建议:' + metrics.tuneHint : ''),
|
|
794
892
|
)
|
|
795
893
|
}
|
|
796
|
-
// 尺寸:默认 1:1(画布坐标 = 原图像素坐标),w/h
|
|
894
|
+
// 尺寸:默认 1:1(画布坐标 = 原图像素坐标),w/h 显式给了就用给的。
|
|
895
|
+
//
|
|
896
|
+
// 裁剪块的 SVG 里路径坐标是**裁剪局部**的(viewBox = 0 0 cropW cropH)—— 别去平移 viewBox:
|
|
897
|
+
// 路径不会跟着走,内容会整体跑出可视区(实测渲染出空白)。要让它落在原图对应的位置上,
|
|
898
|
+
// 靠的是节点框的**比例**:节点 w/h ≈ 原图尺寸 × (裁剪块尺寸 / 该块缩放),
|
|
899
|
+
// 配合 imageAspect=0 拉伸铺满,局部坐标系就正好铺到原图那一块。
|
|
797
900
|
const w = numberOr(op.w, raster.width)
|
|
798
901
|
const h = numberOr(op.h, raster.height)
|
|
799
902
|
const synthetic = {
|
|
@@ -801,7 +904,7 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
801
904
|
svg: traced.svg,
|
|
802
905
|
w: w,
|
|
803
906
|
h: h,
|
|
804
|
-
keys: { imageAspect: 0 }, //
|
|
907
|
+
keys: { imageAspect: 0 }, // 拉伸铺满:局部 viewBox 按节点框等比铺开
|
|
805
908
|
}
|
|
806
909
|
if (typeof op.id === 'string') synthetic.id = op.id
|
|
807
910
|
if (typeof op.as === 'string') synthetic.as = op.as
|
|
@@ -809,15 +912,33 @@ async function applyOps(doc, ops, out, opctx) {
|
|
|
809
912
|
if (typeof op.label === 'string') synthetic.label = op.label
|
|
810
913
|
if (has(op, 'x')) synthetic.x = op.x
|
|
811
914
|
if (has(op, 'y')) synthetic.y = op.y
|
|
915
|
+
// 裁剪块没给坐标时,落在原图里它该在的位置(否则会堆到原点压住整图)
|
|
916
|
+
if (traceCrop !== null && has(op, 'x') === false) synthetic.x = traceCrop.x
|
|
917
|
+
if (traceCrop !== null && has(op, 'y') === false) synthetic.y = traceCrop.y
|
|
812
918
|
// 就地插一条 addNode 紧随其后(读像素是 async 的,所以这条 op 不能在解析参数时先展开)。
|
|
813
919
|
// 注意:这里改的必须是**我们自己那份** ops —— 调用方(execute)已经 slice 过,
|
|
814
920
|
// 因为宿主给的 arguments 是深冻结的,直接 splice 会抛 "object is not extensible"。
|
|
815
921
|
ops.splice(i + 1, 0, synthetic)
|
|
922
|
+
// 节点 id:显式 id / as 别名会在下一条 addNode 里落地;这里先记 as 或 id 方便回执
|
|
923
|
+
const nodeRef = typeof op.id === 'string' ? op.id : typeof op.as === 'string' ? op.as : undefined
|
|
924
|
+
if (traceCrop !== null) metrics.crop = { x: traceCrop.x, y: traceCrop.y, w: traceCrop.w, h: traceCrop.h }
|
|
925
|
+
if (out !== undefined && out !== null) {
|
|
926
|
+
if (!Array.isArray(out.trace)) out.trace = []
|
|
927
|
+
out.trace.push(summarizeTraceForAi(metrics, { image: rawImage, nodeId: nodeRef }))
|
|
928
|
+
}
|
|
816
929
|
notes.push(
|
|
817
|
-
'🖼 ' + rawImage +
|
|
930
|
+
'🖼 ' + rawImage +
|
|
931
|
+
(traceCrop === null ? '' : ' 裁剪 ' + traceCrop.w + '×' + traceCrop.h + '@' + traceCrop.x + ',' + traceCrop.y) +
|
|
932
|
+
' → 逐像素矢量化 preset=' + metrics.preset + ':' +
|
|
933
|
+
metrics.palette.length + ' 色 / ' + metrics.paths + ' 条 path / ' +
|
|
818
934
|
metrics.loopsKept + ' 个轮廓环 / ' + metrics.svgChars + ' 字符' +
|
|
819
|
-
'(工作图 ' + metrics.work.width + '×' + metrics.work.height +
|
|
820
|
-
|
|
935
|
+
'(工作图 ' + metrics.work.width + '×' + metrics.work.height +
|
|
936
|
+
', scale ' + metrics.scaleApplied +
|
|
937
|
+
(metrics.sizeNotes ? '; ' + metrics.sizeNotes : '') +
|
|
938
|
+
',像素覆盖' +
|
|
939
|
+
(metrics.exactPixelCoverage === true ? '精确' : '不精确 ' + metrics.drawnPixels + '/' + metrics.totalPixels) +
|
|
940
|
+
')' +
|
|
941
|
+
(metrics.tuneHint ? '\n 调参:' + metrics.tuneHint : ''),
|
|
821
942
|
)
|
|
822
943
|
continue
|
|
823
944
|
}
|
|
@@ -1848,7 +1969,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
|
|
|
1848
1969
|
{op:"setLayer", id|ids, layer}
|
|
1849
1970
|
{op:"setLayerProps", layer, name?, visible?, locked?}
|
|
1850
1971
|
{op:"export", format?:"svg"|"png", name?}
|
|
1851
|
-
{op:"traceImage", image, w?, h?, x?, y?, colors?, grid?, scale?, epsilon?, minArea?, as?, layer?}
|
|
1972
|
+
{op:"traceImage", image, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, cropX?, cropY?, cropW?, cropH?, as?, layer?}
|
|
1852
1973
|
{op:"move", id|ids, dx?, dy?, x?, y?}
|
|
1853
1974
|
{op:"remove", id|ids}
|
|
1854
1975
|
{op:"highlight", ids:["n1","n2"]}
|
|
@@ -1874,17 +1995,25 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
|
|
|
1874
1995
|
pending → done(带文件路径)/ downloaded / failed。看到 pending 说明画布还没刷新 ——
|
|
1875
1996
|
别反复请求,等下一次 read 再看(或提醒用户打开那张画布)。
|
|
1876
1997
|
- **把一张位图"描"成矢量图用 traceImage**:{op:"traceImage", image:"ref.png", x:40, y:40}。
|
|
1877
|
-
|
|
1878
|
-
3× 超采样归类 + 众数滤波 → 按颜色分连通域、沿像素格边取闭合轮廓 → RDP 简化 →
|
|
1879
|
-
每色一条 nonzero path,按面积降序铺),建成一个 svg 内容节点。
|
|
1998
|
+
它**真的读那张图的像素**,建成一个 svg 内容节点。
|
|
1880
1999
|
**不要用"手写一段近似的 svg"冒充复刻** —— 形状词汇表表达不了的照片/插画就直接用这个 op。
|
|
1881
|
-
· 默认 **w/h = 原图像素尺寸**、viewBox =
|
|
1882
|
-
·
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
·
|
|
2000
|
+
· 默认 **w/h = 原图像素尺寸**、viewBox = 原图像素坐标系(1:1 铺满)。
|
|
2001
|
+
· **先选 preset,再按需覆盖单字段**(合并:默认 ← preset ← 显式字段):
|
|
2002
|
+
- preset:"lineart" 插画/线稿(多暗色配额、严保细线)
|
|
2003
|
+
- preset:"photo" 照片/渐变
|
|
2004
|
+
- preset:"simple" 图标/简单图(少色+降采样,省字符)
|
|
2005
|
+
- 不写 = "default"
|
|
2006
|
+
· 调参闭环:看返回值里的 **trace[]**(结构化指标 + hint),不满意就 **remove 旧节点再 traceImage**(改 preset/字段)。
|
|
2007
|
+
常见:字符紧 → scale:2 / 加大 epsilon / preset:"simple";线糊断 → preset:"lineart" 或提高 protectLuma/colorsDark。
|
|
2008
|
+
· 单次上限:SVG 标记 250000 字符(整图的精细度主要由它决定:预算紧时管线会抬 epsilon、丢小环,
|
|
2009
|
+
把细节压掉——想要更细也可以给 maxBytes 顶到上限,或干脆用下面的裁剪只描局部);
|
|
2010
|
+
输入 PNG(零依赖)与 JPEG/GIF/WebP(需 sharp)。
|
|
2011
|
+
· **五官/小物件糊成一团时,别只调参 —— 用 cropX/cropY/cropW/cropH 裁出那一块单独描**:
|
|
2012
|
+
工作分辨率上限是 grid(默认 499px 宽),整张立绘塞进去时"脸"只占三四十像素,眼睛只有 3~4px,
|
|
2013
|
+
量化后五官必然消失(实测把 colors 拉满 24、protectLuma 提到 235 也救不回来)。
|
|
2014
|
+
裁出头部再描,同一块就占满工作图宽度 —— 脸能有一两百像素,五官才留得住。
|
|
2015
|
+
裁剪块的坐标在**裁剪局部系**里,所以要按比例给它 w/h(= 整张图的 w/h × 裁剪块宽高 / 整块原尺寸),
|
|
2016
|
+
x/y 不写时落在原图对应位置,可与整图那块叠着放(顺序在后 = 画在上面)。
|
|
1888
2017
|
|
|
1889
2018
|
- **改一组用 ids**:{op:"setStyle", ids:["n1","n2","n5"], style:"green"} —— setStyle / setLabel /
|
|
1890
2019
|
move / remove 都收 ids,不要为了"把这几个改成绿色"发 12 个 op。批量 move 只收 dx/dy
|
|
@@ -1945,7 +2074,7 @@ diagram_read —— 返回里的 path 就是它(这两条是一致的,同一
|
|
|
1945
2074
|
(那就把 w/h 设成图形的比例,否则四周会留白)。
|
|
1946
2075
|
· svg 节点**可以没有 label**(画图不带字);要图下面带文字就照常给 label,它画在图上。
|
|
1947
2076
|
· 颜色写在 SVG 自己身上(fill/stroke)—— 配色参数对它是无效的(图片不受 fillColor/strokeColor 影响)。
|
|
1948
|
-
· 内容上限
|
|
2077
|
+
· 内容上限 250000 字符;**别放位图/base64**(那是图片不是图形,本画布画不出来)。
|
|
1949
2078
|
· 改内容只能整段替换(没有"改某一根线"的 op):先 read 拿回当前 svg,改完整段写回。
|
|
1950
2079
|
- style:调色板名 plain|blue|green|orange|yellow|red|purple|grey,或直接给一段 style 串
|
|
1951
2080
|
- dash:solid|dashed|dotted;arrow:end(单向)|both(双向)|none(无)|start(反向)
|
|
@@ -2768,7 +2897,7 @@ export function apply(ctx) {
|
|
|
2768
2897
|
'{op:"setEdge", id, from?, to?, fromPoint?, toPoint?} / {op:"order", id|ids, to:"front"|"back"|"up"|"down"} / ' +
|
|
2769
2898
|
'{op:"duplicate", id|ids, dx?, dy?, withEdges?, layer?, as?} / {op:"addLayer", name?, as?} / {op:"setLayer", id|ids, layer} / {op:"setLayerProps", layer, name?, visible?, locked?} / ' +
|
|
2770
2899
|
'{op:"export", format?:"svg"|"png", name?} / ' +
|
|
2771
|
-
'{op:"traceImage", image, w?, h?, x?, y?, colors?, grid?, scale?, epsilon?, minArea?, as?, layer?} / ' +
|
|
2900
|
+
'{op:"traceImage", image, preset?, w?, h?, x?, y?, colors?, colorsDark?, colorsFill?, grid?, gridH?, scale?, epsilon?, minArea?, minRegionArea?, protectLuma?, majorityContrast?, bgColor?, as?, layer?} / ' +
|
|
2772
2901
|
'{op:"move", id|ids, dx?, dy?, x?, y?} / {op:"remove", id|ids} / {op:"highlight", ids:[...]};节点 id 省略时自动分配。' +
|
|
2773
2902
|
'**改接一条边**用 setEdge(换 from/to 或某一端改成自由点)—— id、标签、标签位置、折点、端点约束全都留着;' +
|
|
2774
2903
|
'不要 remove + addEdge 重画(那会把这些丢掉)。' +
|
|
@@ -2779,12 +2908,14 @@ export function apply(ctx) {
|
|
|
2779
2908
|
'**图层**用 addLayer/setLayer/setLayerProps(加层、把单元移到别的层、改名/显示/隐藏/锁定;隐藏≠删除)。' +
|
|
2780
2909
|
'**导出**用 export:format:"svg" 会让画布渲染一张 SVG 落在 .drawio 旁边、format:"png" 是浏览器下载;' +
|
|
2781
2910
|
'这一步由画布执行,返回值里的 export.status 是 pending/done/downloaded/failed(pending = 画布还没刷新)。' +
|
|
2782
|
-
'**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", x, y}
|
|
2783
|
-
'
|
|
2784
|
-
'
|
|
2785
|
-
'
|
|
2786
|
-
'
|
|
2787
|
-
'
|
|
2911
|
+
'**照着一张位图描矢量图**用 traceImage:{op:"traceImage", image:"ref.png", x, y} 会真的读像素建成 svg 节点,' +
|
|
2912
|
+
'**不是凭印象重画**。默认 w/h = 原图像素尺寸(1:1)。' +
|
|
2913
|
+
'**调参**:先选 preset(' + TRACE_PRESET_NAMES.join('|') + ';lineart=插画线稿、photo=照片、simple=省字符、default=默认),' +
|
|
2914
|
+
'再按需覆盖 colors/colorsDark/scale/epsilon/grid/gridH/minArea/minRegionArea/protectLuma/majorityContrast/bgColor 等。' +
|
|
2915
|
+
'合并顺序:默认 ← preset ← 显式字段。' +
|
|
2916
|
+
'返回值里的 **trace[]** 是结构化指标(svgChars/protectPixels/options/hint 等);' +
|
|
2917
|
+
'质量不够就根据 hint remove 旧节点再 trace(改 preset 或字段)。' +
|
|
2918
|
+
'字符紧→scale:2/加大epsilon/preset:simple;线糊断→preset:lineart 或提高 protectLuma/colorsDark。' +
|
|
2788
2919
|
'**一次改一组**就写 ids:[...](setStyle/setLabel/move/remove 都收,move 的批量只收 dx/dy 相对位移)—— 用户说"把这几个换成绿色"时不要逐个发 op。' +
|
|
2789
2920
|
'**引用刚建的那个**用 as:"名字":{op:"addNode", label:"开始", as:"start"} 之后同一个 ops 数组里 from/to/id 可以直接写 "start",不必猜自动分配的 n7。' +
|
|
2790
2921
|
'**画图形(图标/logo/示意图)用 svg**:addNode {shape:"svg", svg:"<svg viewBox=…>…</svg>", w, h} 让节点的内容就是那段 SVG 标记' +
|
|
@@ -2876,6 +3007,64 @@ export function apply(ctx) {
|
|
|
2876
3007
|
error: { type: 'string' },
|
|
2877
3008
|
},
|
|
2878
3009
|
},
|
|
3010
|
+
// 本次调用里每次 traceImage 一条:给模型读指标再调参(additionalProperties:false 必须声明)
|
|
3011
|
+
trace: {
|
|
3012
|
+
type: 'array',
|
|
3013
|
+
items: {
|
|
3014
|
+
type: 'object',
|
|
3015
|
+
additionalProperties: false,
|
|
3016
|
+
properties: {
|
|
3017
|
+
preset: { type: 'string' },
|
|
3018
|
+
image: { type: 'string' },
|
|
3019
|
+
nodeId: { type: 'string' },
|
|
3020
|
+
// 只描了原图的一块时才有:那块在原图里的位置与尺寸(像素)
|
|
3021
|
+
crop: {
|
|
3022
|
+
type: 'object',
|
|
3023
|
+
additionalProperties: false,
|
|
3024
|
+
properties: {
|
|
3025
|
+
x: { type: 'number' },
|
|
3026
|
+
y: { type: 'number' },
|
|
3027
|
+
w: { type: 'number' },
|
|
3028
|
+
h: { type: 'number' },
|
|
3029
|
+
},
|
|
3030
|
+
},
|
|
3031
|
+
source: {
|
|
3032
|
+
type: 'object',
|
|
3033
|
+
additionalProperties: false,
|
|
3034
|
+
properties: {
|
|
3035
|
+
width: { type: 'number' },
|
|
3036
|
+
height: { type: 'number' },
|
|
3037
|
+
},
|
|
3038
|
+
},
|
|
3039
|
+
work: {
|
|
3040
|
+
type: 'object',
|
|
3041
|
+
additionalProperties: false,
|
|
3042
|
+
properties: {
|
|
3043
|
+
width: { type: 'number' },
|
|
3044
|
+
height: { type: 'number' },
|
|
3045
|
+
scale: { type: 'number' },
|
|
3046
|
+
},
|
|
3047
|
+
},
|
|
3048
|
+
scaleApplied: { type: 'number' },
|
|
3049
|
+
sizeNotes: { type: 'string' },
|
|
3050
|
+
colors: { type: 'number' },
|
|
3051
|
+
colorsDark: { type: 'number' },
|
|
3052
|
+
colorsFill: { type: 'number' },
|
|
3053
|
+
protectPixels: { type: 'number' },
|
|
3054
|
+
majoritySkipped: { type: 'number' },
|
|
3055
|
+
paths: { type: 'number' },
|
|
3056
|
+
loopsKept: { type: 'number' },
|
|
3057
|
+
loopsProtectedKept: { type: 'number' },
|
|
3058
|
+
regionsProtected: { type: 'number' },
|
|
3059
|
+
svgChars: { type: 'number' },
|
|
3060
|
+
budget: { type: 'number' },
|
|
3061
|
+
attempts: { type: 'number' },
|
|
3062
|
+
exactPixelCoverage: { type: 'boolean' },
|
|
3063
|
+
options: { type: 'json' },
|
|
3064
|
+
hint: { type: 'string' },
|
|
3065
|
+
},
|
|
3066
|
+
},
|
|
3067
|
+
},
|
|
2879
3068
|
},
|
|
2880
3069
|
},
|
|
2881
3070
|
render: function (args, value) {
|
|
@@ -2886,6 +3075,24 @@ export function apply(ctx) {
|
|
|
2886
3075
|
}
|
|
2887
3076
|
if (Array.isArray(value.changed) && value.changed.length > 0) bits.push('改动 ' + value.changed.join(', '))
|
|
2888
3077
|
if (Array.isArray(value.removed) && value.removed.length > 0) bits.push('删除 ' + value.removed.join(', '))
|
|
3078
|
+
if (Array.isArray(value.trace) && value.trace.length > 0) {
|
|
3079
|
+
bits.push(
|
|
3080
|
+
'矢量化 ' +
|
|
3081
|
+
value.trace
|
|
3082
|
+
.map(function (t) {
|
|
3083
|
+
return (
|
|
3084
|
+
(t.image || '?') +
|
|
3085
|
+
' preset=' +
|
|
3086
|
+
(t.preset || 'default') +
|
|
3087
|
+
' ' +
|
|
3088
|
+
t.svgChars +
|
|
3089
|
+
'字' +
|
|
3090
|
+
(t.hint ? ' | ' + t.hint : '')
|
|
3091
|
+
)
|
|
3092
|
+
})
|
|
3093
|
+
.join(';'),
|
|
3094
|
+
)
|
|
3095
|
+
}
|
|
2889
3096
|
return [
|
|
2890
3097
|
{
|
|
2891
3098
|
type: 'text',
|
|
@@ -3001,6 +3208,7 @@ export function apply(ctx) {
|
|
|
3001
3208
|
created: [],
|
|
3002
3209
|
changed: [],
|
|
3003
3210
|
removed: [],
|
|
3211
|
+
...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
|
|
3004
3212
|
...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
|
|
3005
3213
|
summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
|
|
3006
3214
|
}
|
|
@@ -3052,6 +3260,7 @@ export function apply(ctx) {
|
|
|
3052
3260
|
created: Array.isArray(out.created) ? out.created : [],
|
|
3053
3261
|
changed: Array.isArray(out.changed) ? out.changed : [],
|
|
3054
3262
|
removed: Array.isArray(out.removed) ? out.removed : [],
|
|
3263
|
+
...(Array.isArray(out.trace) && out.trace.length > 0 ? { trace: out.trace } : {}),
|
|
3055
3264
|
...(exportStateFor(sessionId, loaded.absolute) !== undefined ? { export: exportStateFor(sessionId, loaded.absolute) } : {}),
|
|
3056
3265
|
summary: notes.length === 0 ? '(no change)' : notes.join('\n'),
|
|
3057
3266
|
}
|
package/lib/raster.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { inflateSync } from 'node:zlib'
|
|
16
|
+
import { createRequire } from 'node:module'
|
|
17
|
+
import { pathToFileURL } from 'node:url'
|
|
16
18
|
|
|
17
19
|
/** PNG 的 8 字节签名。 */
|
|
18
20
|
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
@@ -267,24 +269,70 @@ export function looksLikeRaster(bytes) {
|
|
|
267
269
|
return false
|
|
268
270
|
}
|
|
269
271
|
|
|
272
|
+
/**
|
|
273
|
+
* sharp 可能装在哪:包名 + DSH profile 里那份(本机是这么装的)。
|
|
274
|
+
*
|
|
275
|
+
* 为什么本机路径写死:sharp 是可选依赖,没装它的部署不该因为我们 import 不到就崩;
|
|
276
|
+
* 而宿主进程的 cwd 是**用户的工作区**、不在插件的 node_modules 链上,所以裸包名
|
|
277
|
+
* 在这种部署里注定解析不到(package.json 里 sharp 也不是依赖)。
|
|
278
|
+
*/
|
|
279
|
+
const SHARP_CANDIDATES = [
|
|
280
|
+
'sharp',
|
|
281
|
+
'C:/Users/86476/AppData/Roaming/npm/node_modules/@deepseek-ai/dsh/node_modules/sharp',
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 把候选解成**可 import 的入口文件 URL**。
|
|
286
|
+
*
|
|
287
|
+
* 这里踩过两个坑,都只能在真机上才发现:
|
|
288
|
+
* · 动态 import 的说明符必须是 URL 或裸包名,**给文件系统路径会抛
|
|
289
|
+
* ERR_UNSUPPORTED_ESM_URL_SCHEME**(`C:/…/sharp` 和 `C:/…/sharp/dist/index.cjs` 都是);
|
|
290
|
+
* · 给**目录**还会抛 ERR_UNSUPPORTED_DIR_IMPORT —— ESM 不认目录,哪怕那个目录里有 package.json
|
|
291
|
+
* (sharp 0.35 是 CJS,main 指向 ./dist/index.cjs)。
|
|
292
|
+
* 所以:先用 createRequire 的 require.resolve 解出**入口文件**,再用 pathToFileURL 转成
|
|
293
|
+
* file:// URL。require.resolve 只用来"定位"、不用来加载任何东西;也正因为它认 package.json 的
|
|
294
|
+
* exports/main,这条不依赖 sharp 内部目录布局(比写死 /dist/index.cjs 耐升级)。
|
|
295
|
+
*/
|
|
296
|
+
function resolveSharpImport() {
|
|
297
|
+
const urls = []
|
|
298
|
+
const req = createRequire(import.meta.url)
|
|
299
|
+
const add = (candidate) => {
|
|
300
|
+
try {
|
|
301
|
+
const entry = req.resolve(candidate)
|
|
302
|
+
const url = pathToFileURL(entry).href
|
|
303
|
+
if (urls.indexOf(url) < 0) urls.push(url)
|
|
304
|
+
} catch (error) {
|
|
305
|
+
/* 这个候选不在(没装 / 解析不到),换下一个 */
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
for (const candidate of SHARP_CANDIDATES) add(candidate)
|
|
309
|
+
// 兜底:require.resolve 那条也可能失败(例如导出图把它藏了),此时按目录试常见入口。
|
|
310
|
+
// pathToFileURL 会把目录路径照常编成 file:// URL,import 再按 package.json 的 main 解析。
|
|
311
|
+
for (const candidate of SHARP_CANDIDATES) {
|
|
312
|
+
if (candidate === 'sharp') continue
|
|
313
|
+
add(candidate)
|
|
314
|
+
add(candidate + '/dist/index.cjs')
|
|
315
|
+
}
|
|
316
|
+
// 去重:兜底可能补出与前一条相同的 URL(entry 与 /dist/index.cjs 同址时)
|
|
317
|
+
const unique = urls.filter((value, index) => urls.indexOf(value) === index)
|
|
318
|
+
return unique
|
|
319
|
+
}
|
|
320
|
+
|
|
270
321
|
/**
|
|
271
322
|
* 非 PNG 的解码回退:**可选** sharp(宿主没装就返回 null,由调用方给一句人话报错)。
|
|
272
323
|
*
|
|
273
324
|
* 用动态 import 而不是顶层 import:没装 sharp 的部署不该连模块都加载不了。
|
|
274
|
-
* 路径依次试:包名、DSH profile 里的那份(本机是这么装的)。
|
|
275
325
|
*/
|
|
276
326
|
async function decodeWithSharp(bytes) {
|
|
277
|
-
const
|
|
278
|
-
'sharp',
|
|
279
|
-
'C:/Users/86476/AppData/Roaming/npm/node_modules/@deepseek-ai/dsh/node_modules/sharp',
|
|
280
|
-
]
|
|
281
|
-
for (const spec of candidates) {
|
|
327
|
+
for (const spec of resolveSharpImport()) {
|
|
282
328
|
let sharp
|
|
283
329
|
try {
|
|
284
|
-
|
|
330
|
+
const mod = await import(spec)
|
|
331
|
+
sharp = mod.default !== undefined && mod.default !== null ? mod.default : mod
|
|
285
332
|
} catch (error) {
|
|
286
333
|
continue
|
|
287
334
|
}
|
|
335
|
+
if (typeof sharp !== 'function') continue
|
|
288
336
|
try {
|
|
289
337
|
const got = await sharp(Buffer.from(bytes)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
|
|
290
338
|
return { width: got.info.width, height: got.info.height, data: got.data }
|
package/lib/trace-image.js
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* 这里只有**像素进、SVG 出**,不碰文件系统、不碰 ctx:宿主工具与命令行脚本共用同一份。
|
|
6
6
|
*
|
|
7
7
|
* 管线(每一步都能在返回的 metrics 里看到数字):
|
|
8
|
-
* ①
|
|
9
|
-
* ②
|
|
10
|
-
* ③
|
|
11
|
-
* ④
|
|
12
|
-
* ⑤
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
554
|
-
const
|
|
555
|
-
const minArea = Math.max(
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
// 降采样(面积平均)+
|
|
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
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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 <
|
|
641
|
-
palette.push([
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
789
|
-
|
|
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
|
|