@pooder/kit 6.0.1 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.test-dist/src/extensions/background/BackgroundTool.js +524 -0
- package/.test-dist/src/extensions/background/index.js +17 -0
- package/.test-dist/src/extensions/dieline/DielineTool.js +748 -0
- package/.test-dist/src/extensions/dieline/commands.js +127 -0
- package/.test-dist/src/extensions/dieline/config.js +107 -0
- package/.test-dist/src/extensions/dieline/index.js +21 -0
- package/.test-dist/src/extensions/dieline/model.js +2 -0
- package/.test-dist/src/extensions/dieline/renderer.js +2 -0
- package/.test-dist/src/extensions/feature/FeatureTool.js +914 -0
- package/.test-dist/src/extensions/feature/index.js +17 -0
- package/.test-dist/src/extensions/film/FilmTool.js +207 -0
- package/.test-dist/src/extensions/film/index.js +17 -0
- package/.test-dist/src/extensions/image/ImageTool.js +1499 -0
- package/.test-dist/src/extensions/image/commands.js +162 -0
- package/.test-dist/src/extensions/image/config.js +129 -0
- package/.test-dist/src/extensions/image/index.js +21 -0
- package/.test-dist/src/extensions/image/model.js +2 -0
- package/.test-dist/src/extensions/image/renderer.js +5 -0
- package/.test-dist/src/extensions/mirror/MirrorTool.js +104 -0
- package/.test-dist/src/extensions/mirror/index.js +17 -0
- package/.test-dist/src/extensions/ruler/RulerTool.js +442 -0
- package/.test-dist/src/extensions/ruler/index.js +17 -0
- package/.test-dist/src/extensions/sceneLayout.js +2 -93
- package/.test-dist/src/extensions/sceneLayoutModel.js +15 -200
- package/.test-dist/src/extensions/size/SizeTool.js +332 -0
- package/.test-dist/src/extensions/size/index.js +17 -0
- package/.test-dist/src/extensions/white-ink/WhiteInkTool.js +1003 -0
- package/.test-dist/src/extensions/white-ink/commands.js +148 -0
- package/.test-dist/src/extensions/white-ink/config.js +31 -0
- package/.test-dist/src/extensions/white-ink/index.js +21 -0
- package/.test-dist/src/extensions/white-ink/model.js +2 -0
- package/.test-dist/src/extensions/white-ink/renderer.js +5 -0
- package/.test-dist/src/services/SceneLayoutService.js +96 -0
- package/.test-dist/src/services/index.js +1 -0
- package/.test-dist/src/shared/constants/layers.js +25 -0
- package/.test-dist/src/shared/imaging/sourceSizeCache.js +82 -0
- package/.test-dist/src/shared/index.js +22 -0
- package/.test-dist/src/shared/runtime/sessionState.js +74 -0
- package/.test-dist/src/shared/runtime/subscriptions.js +30 -0
- package/.test-dist/src/shared/scene/frame.js +34 -0
- package/.test-dist/src/shared/scene/sceneLayoutModel.js +202 -0
- package/.test-dist/tests/run.js +116 -0
- package/CHANGELOG.md +6 -0
- package/dist/index.d.mts +390 -367
- package/dist/index.d.ts +390 -367
- package/dist/index.js +5138 -4927
- package/dist/index.mjs +1149 -1977
- package/dist/tracer-PO7CRBYY.mjs +1016 -0
- package/package.json +1 -1
- package/src/extensions/{background.ts → background/BackgroundTool.ts} +33 -50
- package/src/extensions/background/index.ts +1 -0
- package/src/extensions/{dieline.ts → dieline/DielineTool.ts} +14 -218
- package/src/extensions/dieline/commands.ts +109 -0
- package/src/extensions/dieline/config.ts +106 -0
- package/src/extensions/dieline/index.ts +5 -0
- package/src/extensions/dieline/model.ts +1 -0
- package/src/extensions/dieline/renderer.ts +1 -0
- package/src/extensions/{feature.ts → feature/FeatureTool.ts} +27 -21
- package/src/extensions/feature/index.ts +1 -0
- package/src/extensions/{film.ts → film/FilmTool.ts} +36 -48
- package/src/extensions/film/index.ts +1 -0
- package/src/extensions/{image.ts → image/ImageTool.ts} +123 -402
- package/src/extensions/image/commands.ts +176 -0
- package/src/extensions/image/config.ts +128 -0
- package/src/extensions/image/index.ts +5 -0
- package/src/extensions/image/model.ts +1 -0
- package/src/extensions/image/renderer.ts +1 -0
- package/src/extensions/{mirror.ts → mirror/MirrorTool.ts} +1 -1
- package/src/extensions/mirror/index.ts +1 -0
- package/src/extensions/{ruler.ts → ruler/RulerTool.ts} +4 -5
- package/src/extensions/ruler/index.ts +1 -0
- package/src/extensions/sceneLayout.ts +1 -140
- package/src/extensions/sceneLayoutModel.ts +1 -364
- package/src/extensions/{size.ts → size/SizeTool.ts} +7 -6
- package/src/extensions/size/index.ts +1 -0
- package/src/extensions/{white-ink.ts → white-ink/WhiteInkTool.ts} +130 -317
- package/src/extensions/white-ink/commands.ts +157 -0
- package/src/extensions/white-ink/config.ts +30 -0
- package/src/extensions/white-ink/index.ts +5 -0
- package/src/extensions/white-ink/model.ts +1 -0
- package/src/extensions/white-ink/renderer.ts +1 -0
- package/src/services/SceneLayoutService.ts +139 -0
- package/src/services/index.ts +1 -0
- package/src/shared/constants/layers.ts +23 -0
- package/src/shared/imaging/sourceSizeCache.ts +103 -0
- package/src/shared/index.ts +6 -0
- package/src/shared/runtime/sessionState.ts +105 -0
- package/src/shared/runtime/subscriptions.ts +45 -0
- package/src/shared/scene/frame.ts +46 -0
- package/src/shared/scene/sceneLayoutModel.ts +367 -0
- package/tests/run.ts +146 -0
|
@@ -0,0 +1,1016 @@
|
|
|
1
|
+
// src/extensions/tracer.ts
|
|
2
|
+
import paper from "paper";
|
|
3
|
+
|
|
4
|
+
// src/extensions/maskOps.ts
|
|
5
|
+
function createMask(imageData, options) {
|
|
6
|
+
const { width, height, data } = imageData;
|
|
7
|
+
const {
|
|
8
|
+
threshold,
|
|
9
|
+
padding,
|
|
10
|
+
paddedWidth,
|
|
11
|
+
paddedHeight,
|
|
12
|
+
maskMode = "auto",
|
|
13
|
+
whiteThreshold = 240,
|
|
14
|
+
alphaOpaqueCutoff = 250
|
|
15
|
+
} = options;
|
|
16
|
+
const resolvedMode = maskMode === "auto" ? inferMaskMode(imageData, alphaOpaqueCutoff) : maskMode;
|
|
17
|
+
const mask = new Uint8Array(paddedWidth * paddedHeight);
|
|
18
|
+
for (let y = 0; y < height; y++) {
|
|
19
|
+
for (let x = 0; x < width; x++) {
|
|
20
|
+
const srcIdx = (y * width + x) * 4;
|
|
21
|
+
const r = data[srcIdx];
|
|
22
|
+
const g = data[srcIdx + 1];
|
|
23
|
+
const b = data[srcIdx + 2];
|
|
24
|
+
const a = data[srcIdx + 3];
|
|
25
|
+
const destIdx = (y + padding) * paddedWidth + (x + padding);
|
|
26
|
+
if (resolvedMode === "alpha") {
|
|
27
|
+
if (a > threshold) mask[destIdx] = 1;
|
|
28
|
+
} else {
|
|
29
|
+
if (a > threshold && !(r > whiteThreshold && g > whiteThreshold && b > whiteThreshold)) {
|
|
30
|
+
mask[destIdx] = 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return mask;
|
|
36
|
+
}
|
|
37
|
+
function inferMaskMode(imageData, alphaOpaqueCutoff) {
|
|
38
|
+
const analysis = analyzeAlpha(imageData, alphaOpaqueCutoff);
|
|
39
|
+
if (analysis.minAlpha === 255) return "whitebg";
|
|
40
|
+
if (analysis.veryTransparentRatio >= 5e-4) return "alpha";
|
|
41
|
+
if (analysis.belowOpaqueRatio >= 0.01) return "alpha";
|
|
42
|
+
return "whitebg";
|
|
43
|
+
}
|
|
44
|
+
function analyzeAlpha(imageData, alphaOpaqueCutoff) {
|
|
45
|
+
const { data } = imageData;
|
|
46
|
+
const total = data.length / 4;
|
|
47
|
+
let belowOpaque = 0;
|
|
48
|
+
let veryTransparent = 0;
|
|
49
|
+
let minAlpha = 255;
|
|
50
|
+
for (let i = 3; i < data.length; i += 4) {
|
|
51
|
+
const a = data[i];
|
|
52
|
+
if (a < minAlpha) minAlpha = a;
|
|
53
|
+
if (a < alphaOpaqueCutoff) belowOpaque++;
|
|
54
|
+
if (a < 32) veryTransparent++;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
total,
|
|
58
|
+
minAlpha,
|
|
59
|
+
belowOpaqueRatio: belowOpaque / total,
|
|
60
|
+
veryTransparentRatio: veryTransparent / total
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
function circularMorphology(mask, width, height, radius, op) {
|
|
64
|
+
const r = Math.max(0, Math.floor(radius));
|
|
65
|
+
if (r <= 0) {
|
|
66
|
+
return mask.slice();
|
|
67
|
+
}
|
|
68
|
+
const dilateDisk = (m, radiusPx) => {
|
|
69
|
+
const horizontalDist = new Int32Array(width * height);
|
|
70
|
+
for (let y = 0; y < height; y++) {
|
|
71
|
+
let lastSolid = -radiusPx * 2;
|
|
72
|
+
for (let x = 0; x < width; x++) {
|
|
73
|
+
if (m[y * width + x]) lastSolid = x;
|
|
74
|
+
horizontalDist[y * width + x] = x - lastSolid;
|
|
75
|
+
}
|
|
76
|
+
lastSolid = width + radiusPx * 2;
|
|
77
|
+
for (let x = width - 1; x >= 0; x--) {
|
|
78
|
+
if (m[y * width + x]) lastSolid = x;
|
|
79
|
+
horizontalDist[y * width + x] = Math.min(
|
|
80
|
+
horizontalDist[y * width + x],
|
|
81
|
+
lastSolid - x
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const result = new Uint8Array(width * height);
|
|
86
|
+
const r2 = radiusPx * radiusPx;
|
|
87
|
+
for (let x = 0; x < width; x++) {
|
|
88
|
+
for (let y = 0; y < height; y++) {
|
|
89
|
+
let found = false;
|
|
90
|
+
const minY = Math.max(0, y - radiusPx);
|
|
91
|
+
const maxY = Math.min(height - 1, y + radiusPx);
|
|
92
|
+
for (let dy = minY; dy <= maxY; dy++) {
|
|
93
|
+
const dY = dy - y;
|
|
94
|
+
const hDist = horizontalDist[dy * width + x];
|
|
95
|
+
if (hDist * hDist + dY * dY <= r2) {
|
|
96
|
+
found = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (found) result[y * width + x] = 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
const erodeDiamond = (m, radiusPx) => {
|
|
106
|
+
if (radiusPx <= 0) return m.slice();
|
|
107
|
+
let current = m;
|
|
108
|
+
for (let step = 0; step < radiusPx; step++) {
|
|
109
|
+
const next = new Uint8Array(width * height);
|
|
110
|
+
for (let y = 1; y < height - 1; y++) {
|
|
111
|
+
const row = y * width;
|
|
112
|
+
for (let x = 1; x < width - 1; x++) {
|
|
113
|
+
const idx = row + x;
|
|
114
|
+
if (current[idx] && current[idx - 1] && current[idx + 1] && current[idx - width] && current[idx + width]) {
|
|
115
|
+
next[idx] = 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
current = next;
|
|
120
|
+
}
|
|
121
|
+
return current;
|
|
122
|
+
};
|
|
123
|
+
const restoreBridgePixels = (source, eroded) => {
|
|
124
|
+
const restored = eroded.slice();
|
|
125
|
+
for (let y = 1; y < height - 1; y++) {
|
|
126
|
+
const row = y * width;
|
|
127
|
+
for (let x = 1; x < width - 1; x++) {
|
|
128
|
+
const idx = row + x;
|
|
129
|
+
if (!source[idx] || restored[idx]) continue;
|
|
130
|
+
const up = source[idx - width] === 1;
|
|
131
|
+
const down = source[idx + width] === 1;
|
|
132
|
+
const left = source[idx - 1] === 1;
|
|
133
|
+
const right = source[idx + 1] === 1;
|
|
134
|
+
const upLeft = source[idx - width - 1] === 1;
|
|
135
|
+
const upRight = source[idx - width + 1] === 1;
|
|
136
|
+
const downLeft = source[idx + width - 1] === 1;
|
|
137
|
+
const downRight = source[idx + width + 1] === 1;
|
|
138
|
+
const keepsBridge = left && right || up && down || upLeft && downRight || upRight && downLeft;
|
|
139
|
+
if (keepsBridge) {
|
|
140
|
+
restored[idx] = 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return restored;
|
|
145
|
+
};
|
|
146
|
+
const erodePreservingBridges = (m, radiusPx) => {
|
|
147
|
+
const eroded = erodeDiamond(m, radiusPx);
|
|
148
|
+
return restoreBridgePixels(m, eroded);
|
|
149
|
+
};
|
|
150
|
+
switch (op) {
|
|
151
|
+
case "dilate":
|
|
152
|
+
return dilateDisk(mask, r);
|
|
153
|
+
case "erode":
|
|
154
|
+
return erodePreservingBridges(mask, r);
|
|
155
|
+
case "closing": {
|
|
156
|
+
const erodeRadius = Math.max(1, Math.floor(r * 0.65));
|
|
157
|
+
return erodePreservingBridges(dilateDisk(mask, r), erodeRadius);
|
|
158
|
+
}
|
|
159
|
+
case "opening":
|
|
160
|
+
return dilateDisk(erodePreservingBridges(mask, r), r);
|
|
161
|
+
default:
|
|
162
|
+
return mask;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function fillHoles(mask, width, height) {
|
|
166
|
+
const background = new Uint8Array(width * height);
|
|
167
|
+
const queue = [];
|
|
168
|
+
for (let x = 0; x < width; x++) {
|
|
169
|
+
if (mask[x] === 0) {
|
|
170
|
+
background[x] = 1;
|
|
171
|
+
queue.push(x);
|
|
172
|
+
}
|
|
173
|
+
const lastRowIdx = (height - 1) * width + x;
|
|
174
|
+
if (mask[lastRowIdx] === 0) {
|
|
175
|
+
background[lastRowIdx] = 1;
|
|
176
|
+
queue.push(lastRowIdx);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (let y = 1; y < height - 1; y++) {
|
|
180
|
+
const leftIdx = y * width;
|
|
181
|
+
const rightIdx = y * width + (width - 1);
|
|
182
|
+
if (mask[leftIdx] === 0) {
|
|
183
|
+
background[leftIdx] = 1;
|
|
184
|
+
queue.push(leftIdx);
|
|
185
|
+
}
|
|
186
|
+
if (mask[rightIdx] === 0) {
|
|
187
|
+
background[rightIdx] = 1;
|
|
188
|
+
queue.push(rightIdx);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
let head = 0;
|
|
192
|
+
while (head < queue.length) {
|
|
193
|
+
const idx = queue[head++];
|
|
194
|
+
const x = idx % width;
|
|
195
|
+
const y = (idx - x) / width;
|
|
196
|
+
const up = y > 0 ? idx - width : -1;
|
|
197
|
+
const down = y < height - 1 ? idx + width : -1;
|
|
198
|
+
const left = x > 0 ? idx - 1 : -1;
|
|
199
|
+
const right = x < width - 1 ? idx + 1 : -1;
|
|
200
|
+
if (up >= 0 && mask[up] === 0 && background[up] === 0) {
|
|
201
|
+
background[up] = 1;
|
|
202
|
+
queue.push(up);
|
|
203
|
+
}
|
|
204
|
+
if (down >= 0 && mask[down] === 0 && background[down] === 0) {
|
|
205
|
+
background[down] = 1;
|
|
206
|
+
queue.push(down);
|
|
207
|
+
}
|
|
208
|
+
if (left >= 0 && mask[left] === 0 && background[left] === 0) {
|
|
209
|
+
background[left] = 1;
|
|
210
|
+
queue.push(left);
|
|
211
|
+
}
|
|
212
|
+
if (right >= 0 && mask[right] === 0 && background[right] === 0) {
|
|
213
|
+
background[right] = 1;
|
|
214
|
+
queue.push(right);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
const filledMask = new Uint8Array(width * height);
|
|
218
|
+
for (let i = 0; i < width * height; i++) {
|
|
219
|
+
filledMask[i] = background[i] === 0 ? 1 : 0;
|
|
220
|
+
}
|
|
221
|
+
return filledMask;
|
|
222
|
+
}
|
|
223
|
+
function polygonSignedArea(points) {
|
|
224
|
+
if (points.length < 3) return 0;
|
|
225
|
+
let sum = 0;
|
|
226
|
+
for (let i = 0; i < points.length; i++) {
|
|
227
|
+
const a = points[i];
|
|
228
|
+
const b = points[(i + 1) % points.length];
|
|
229
|
+
sum += a.x * b.y - b.x * a.y;
|
|
230
|
+
}
|
|
231
|
+
return sum / 2;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/extensions/tracer.ts
|
|
235
|
+
var ImageTracer = class {
|
|
236
|
+
/**
|
|
237
|
+
* Main entry point: Traces an image URL to an SVG path string.
|
|
238
|
+
* @param imageUrl The URL or Base64 string of the image.
|
|
239
|
+
* @param options Configuration options.
|
|
240
|
+
*/
|
|
241
|
+
static async trace(imageUrl, options = {}) {
|
|
242
|
+
const { pathData } = await this.traceWithBounds(imageUrl, options);
|
|
243
|
+
return pathData;
|
|
244
|
+
}
|
|
245
|
+
static async traceWithBounds(imageUrl, options = {}) {
|
|
246
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _i;
|
|
247
|
+
const img = await this.loadImage(imageUrl);
|
|
248
|
+
const width = img.width;
|
|
249
|
+
const height = img.height;
|
|
250
|
+
if (width <= 0 || height <= 0) {
|
|
251
|
+
const w = (_a = options.scaleToWidth) != null ? _a : 0;
|
|
252
|
+
const h = (_b = options.scaleToHeight) != null ? _b : 0;
|
|
253
|
+
return {
|
|
254
|
+
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
255
|
+
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
256
|
+
bounds: { x: 0, y: 0, width: w, height: h }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const debug = options.debug === true;
|
|
260
|
+
const debugLog = (message, payload) => {
|
|
261
|
+
if (!debug) return;
|
|
262
|
+
if (payload) {
|
|
263
|
+
console.info(`[ImageTracer] ${message}`, payload);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
console.info(`[ImageTracer] ${message}`);
|
|
267
|
+
};
|
|
268
|
+
const canvas = document.createElement("canvas");
|
|
269
|
+
canvas.width = width;
|
|
270
|
+
canvas.height = height;
|
|
271
|
+
const ctx = canvas.getContext("2d");
|
|
272
|
+
if (!ctx) throw new Error("Could not get 2D context");
|
|
273
|
+
ctx.drawImage(img, 0, 0);
|
|
274
|
+
const imageData = ctx.getImageData(0, 0, width, height);
|
|
275
|
+
const threshold = (_c = options.threshold) != null ? _c : 10;
|
|
276
|
+
const expand = Math.max(0, Math.floor((_d = options.expand) != null ? _d : 0));
|
|
277
|
+
const simplifyTolerance = (_e = options.simplifyTolerance) != null ? _e : 2.5;
|
|
278
|
+
const useSmoothing = options.smoothing !== false;
|
|
279
|
+
const componentMode = "all";
|
|
280
|
+
const minComponentArea = 0;
|
|
281
|
+
const maxDim = Math.max(width, height);
|
|
282
|
+
const maskMode = "auto";
|
|
283
|
+
const whiteThreshold = 240;
|
|
284
|
+
const alphaOpaqueCutoff = 250;
|
|
285
|
+
const preprocessDilateRadius = Math.max(
|
|
286
|
+
2,
|
|
287
|
+
Math.floor(Math.max(maxDim * 0.012, expand * 0.35))
|
|
288
|
+
);
|
|
289
|
+
const preprocessErodeRadius = Math.max(
|
|
290
|
+
1,
|
|
291
|
+
Math.floor(preprocessDilateRadius * 0.65)
|
|
292
|
+
);
|
|
293
|
+
const smoothDilateRadius = Math.max(
|
|
294
|
+
1,
|
|
295
|
+
Math.floor(preprocessDilateRadius * 0.25)
|
|
296
|
+
);
|
|
297
|
+
const smoothErodeRadius = Math.max(1, Math.floor(smoothDilateRadius * 0.8));
|
|
298
|
+
const connectStartDilateRadius = Math.max(
|
|
299
|
+
1,
|
|
300
|
+
Math.floor(Math.max(maxDim * 6e-3, expand * 0.2))
|
|
301
|
+
);
|
|
302
|
+
const connectMaxDilateRadius = Math.max(
|
|
303
|
+
connectStartDilateRadius,
|
|
304
|
+
Math.floor(Math.max(maxDim * 0.2, expand * 2.5))
|
|
305
|
+
);
|
|
306
|
+
const connectErodeRatio = 0.65;
|
|
307
|
+
debugLog("traceWithBounds:start", {
|
|
308
|
+
width,
|
|
309
|
+
height,
|
|
310
|
+
threshold,
|
|
311
|
+
expand,
|
|
312
|
+
simplifyTolerance,
|
|
313
|
+
smoothing: useSmoothing,
|
|
314
|
+
strategy: {
|
|
315
|
+
maskMode,
|
|
316
|
+
whiteThreshold,
|
|
317
|
+
alphaOpaqueCutoff,
|
|
318
|
+
fillHoles: true,
|
|
319
|
+
preprocessDilateRadius,
|
|
320
|
+
preprocessErodeRadius,
|
|
321
|
+
smoothDilateRadius,
|
|
322
|
+
smoothErodeRadius,
|
|
323
|
+
connectEnabled: true,
|
|
324
|
+
connectStartDilateRadius,
|
|
325
|
+
connectMaxDilateRadius,
|
|
326
|
+
connectErodeRatio
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
const padding = Math.max(
|
|
330
|
+
preprocessDilateRadius,
|
|
331
|
+
smoothDilateRadius,
|
|
332
|
+
connectMaxDilateRadius,
|
|
333
|
+
expand
|
|
334
|
+
) + 2;
|
|
335
|
+
const paddedWidth = width + padding * 2;
|
|
336
|
+
const paddedHeight = height + padding * 2;
|
|
337
|
+
const summarizeMaskContours = (m) => {
|
|
338
|
+
const summary = this.summarizeAllContours(
|
|
339
|
+
m,
|
|
340
|
+
paddedWidth,
|
|
341
|
+
paddedHeight,
|
|
342
|
+
minComponentArea
|
|
343
|
+
);
|
|
344
|
+
return {
|
|
345
|
+
rawContourCount: summary.rawCount,
|
|
346
|
+
selectedContourCount: summary.selectedCount
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
let mask = createMask(imageData, {
|
|
350
|
+
threshold,
|
|
351
|
+
padding,
|
|
352
|
+
paddedWidth,
|
|
353
|
+
paddedHeight,
|
|
354
|
+
maskMode,
|
|
355
|
+
whiteThreshold,
|
|
356
|
+
alphaOpaqueCutoff
|
|
357
|
+
});
|
|
358
|
+
if (debug) {
|
|
359
|
+
debugLog(
|
|
360
|
+
"traceWithBounds:mask:after-create",
|
|
361
|
+
summarizeMaskContours(mask)
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
mask = circularMorphology(
|
|
365
|
+
mask,
|
|
366
|
+
paddedWidth,
|
|
367
|
+
paddedHeight,
|
|
368
|
+
preprocessDilateRadius,
|
|
369
|
+
"dilate"
|
|
370
|
+
);
|
|
371
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
372
|
+
mask = circularMorphology(
|
|
373
|
+
mask,
|
|
374
|
+
paddedWidth,
|
|
375
|
+
paddedHeight,
|
|
376
|
+
preprocessErodeRadius,
|
|
377
|
+
"erode"
|
|
378
|
+
);
|
|
379
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
380
|
+
if (debug) {
|
|
381
|
+
debugLog("traceWithBounds:mask:after-preprocess", {
|
|
382
|
+
dilateRadius: preprocessDilateRadius,
|
|
383
|
+
erodeRadius: preprocessErodeRadius,
|
|
384
|
+
...summarizeMaskContours(mask)
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
mask = circularMorphology(
|
|
388
|
+
mask,
|
|
389
|
+
paddedWidth,
|
|
390
|
+
paddedHeight,
|
|
391
|
+
smoothDilateRadius,
|
|
392
|
+
"dilate"
|
|
393
|
+
);
|
|
394
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
395
|
+
mask = circularMorphology(
|
|
396
|
+
mask,
|
|
397
|
+
paddedWidth,
|
|
398
|
+
paddedHeight,
|
|
399
|
+
smoothErodeRadius,
|
|
400
|
+
"erode"
|
|
401
|
+
);
|
|
402
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
403
|
+
if (debug) {
|
|
404
|
+
debugLog("traceWithBounds:mask:after-smooth", {
|
|
405
|
+
dilateRadius: smoothDilateRadius,
|
|
406
|
+
erodeRadius: smoothErodeRadius,
|
|
407
|
+
...summarizeMaskContours(mask)
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
const beforeConnectSummary = summarizeMaskContours(mask);
|
|
411
|
+
if (beforeConnectSummary.selectedContourCount <= 1) {
|
|
412
|
+
debugLog("traceWithBounds:mask:connect-skipped", {
|
|
413
|
+
reason: "already-single-component",
|
|
414
|
+
before: beforeConnectSummary
|
|
415
|
+
});
|
|
416
|
+
} else {
|
|
417
|
+
const connectResult = this.findForceConnectResult(
|
|
418
|
+
mask,
|
|
419
|
+
paddedWidth,
|
|
420
|
+
paddedHeight,
|
|
421
|
+
minComponentArea,
|
|
422
|
+
connectStartDilateRadius,
|
|
423
|
+
connectMaxDilateRadius,
|
|
424
|
+
connectErodeRatio
|
|
425
|
+
);
|
|
426
|
+
if (debug) {
|
|
427
|
+
debugLog("traceWithBounds:mask:after-connect", {
|
|
428
|
+
before: beforeConnectSummary,
|
|
429
|
+
appliedDilateRadius: connectResult.appliedDilateRadius,
|
|
430
|
+
appliedErodeRadius: connectResult.appliedErodeRadius,
|
|
431
|
+
reachedSingleComponent: connectResult.reachedSingleComponent,
|
|
432
|
+
after: {
|
|
433
|
+
rawContourCount: connectResult.rawContourCount,
|
|
434
|
+
selectedContourCount: connectResult.selectedContourCount
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
mask = connectResult.mask;
|
|
439
|
+
}
|
|
440
|
+
if (debug) {
|
|
441
|
+
const afterConnectSummary = summarizeMaskContours(mask);
|
|
442
|
+
if (afterConnectSummary.selectedContourCount > 1) {
|
|
443
|
+
debugLog("traceWithBounds:mask:connect-warning", {
|
|
444
|
+
reason: "still-multi-component-after-connect-search",
|
|
445
|
+
summary: afterConnectSummary
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const baseMask = mask;
|
|
450
|
+
const baseContoursRaw = this.traceAllContours(
|
|
451
|
+
baseMask,
|
|
452
|
+
paddedWidth,
|
|
453
|
+
paddedHeight
|
|
454
|
+
);
|
|
455
|
+
const baseContours = this.selectContours(
|
|
456
|
+
baseContoursRaw,
|
|
457
|
+
componentMode,
|
|
458
|
+
minComponentArea
|
|
459
|
+
);
|
|
460
|
+
if (!baseContours.length) {
|
|
461
|
+
const w = (_f = options.scaleToWidth) != null ? _f : width;
|
|
462
|
+
const h = (_g = options.scaleToHeight) != null ? _g : height;
|
|
463
|
+
debugLog("fallback:no-base-contour", { width: w, height: h });
|
|
464
|
+
return {
|
|
465
|
+
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
466
|
+
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
467
|
+
bounds: { x: 0, y: 0, width: w, height: h }
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const baseUnpaddedContours = baseContours.map(
|
|
471
|
+
(contour) => contour.map((p) => ({
|
|
472
|
+
x: p.x - padding,
|
|
473
|
+
y: p.y - padding
|
|
474
|
+
}))
|
|
475
|
+
).filter((contour) => contour.length > 2);
|
|
476
|
+
if (!baseUnpaddedContours.length) {
|
|
477
|
+
const w = (_h = options.scaleToWidth) != null ? _h : width;
|
|
478
|
+
const h = (_i = options.scaleToHeight) != null ? _i : height;
|
|
479
|
+
debugLog("fallback:empty-base-contours", { width: w, height: h });
|
|
480
|
+
return {
|
|
481
|
+
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
482
|
+
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
483
|
+
bounds: { x: 0, y: 0, width: w, height: h }
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
let baseBounds = this.boundsFromPoints(
|
|
487
|
+
this.flattenContours(baseUnpaddedContours)
|
|
488
|
+
);
|
|
489
|
+
let maskExpanded = baseMask;
|
|
490
|
+
if (expand > 0) {
|
|
491
|
+
maskExpanded = circularMorphology(
|
|
492
|
+
baseMask,
|
|
493
|
+
paddedWidth,
|
|
494
|
+
paddedHeight,
|
|
495
|
+
expand,
|
|
496
|
+
"dilate"
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
const expandedContoursRaw = this.traceAllContours(
|
|
500
|
+
maskExpanded,
|
|
501
|
+
paddedWidth,
|
|
502
|
+
paddedHeight
|
|
503
|
+
);
|
|
504
|
+
const expandedContours = this.selectContours(
|
|
505
|
+
expandedContoursRaw,
|
|
506
|
+
componentMode,
|
|
507
|
+
minComponentArea
|
|
508
|
+
);
|
|
509
|
+
if (!expandedContours.length) {
|
|
510
|
+
debugLog("fallback:no-expanded-contour", {
|
|
511
|
+
baseBounds,
|
|
512
|
+
width,
|
|
513
|
+
height,
|
|
514
|
+
expand
|
|
515
|
+
});
|
|
516
|
+
return {
|
|
517
|
+
pathData: `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`,
|
|
518
|
+
baseBounds,
|
|
519
|
+
bounds: baseBounds
|
|
520
|
+
};
|
|
521
|
+
}
|
|
522
|
+
const expandedUnpaddedContours = expandedContours.map(
|
|
523
|
+
(contour) => contour.map((p) => ({
|
|
524
|
+
x: p.x - padding,
|
|
525
|
+
y: p.y - padding
|
|
526
|
+
}))
|
|
527
|
+
).filter((contour) => contour.length > 2);
|
|
528
|
+
if (!expandedUnpaddedContours.length) {
|
|
529
|
+
debugLog("fallback:empty-expanded-contours", {
|
|
530
|
+
baseBounds,
|
|
531
|
+
width,
|
|
532
|
+
height,
|
|
533
|
+
expand
|
|
534
|
+
});
|
|
535
|
+
return {
|
|
536
|
+
pathData: `M 0 0 L ${width} 0 L ${width} ${height} L 0 ${height} Z`,
|
|
537
|
+
baseBounds,
|
|
538
|
+
bounds: baseBounds
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
let globalBounds = this.boundsFromPoints(
|
|
542
|
+
this.flattenContours(expandedUnpaddedContours)
|
|
543
|
+
);
|
|
544
|
+
let finalContours = expandedUnpaddedContours;
|
|
545
|
+
if (options.scaleToWidth && options.scaleToHeight) {
|
|
546
|
+
finalContours = this.scaleContours(
|
|
547
|
+
expandedUnpaddedContours,
|
|
548
|
+
options.scaleToWidth,
|
|
549
|
+
options.scaleToHeight,
|
|
550
|
+
globalBounds
|
|
551
|
+
);
|
|
552
|
+
globalBounds = this.boundsFromPoints(this.flattenContours(finalContours));
|
|
553
|
+
const baseScaledContours = this.scaleContours(
|
|
554
|
+
baseUnpaddedContours,
|
|
555
|
+
options.scaleToWidth,
|
|
556
|
+
options.scaleToHeight,
|
|
557
|
+
baseBounds
|
|
558
|
+
);
|
|
559
|
+
baseBounds = this.boundsFromPoints(
|
|
560
|
+
this.flattenContours(baseScaledContours)
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
if (expand > 0) {
|
|
564
|
+
const expectedExpandedBounds = {
|
|
565
|
+
x: baseBounds.x - expand,
|
|
566
|
+
y: baseBounds.y - expand,
|
|
567
|
+
width: baseBounds.width + expand * 2,
|
|
568
|
+
height: baseBounds.height + expand * 2
|
|
569
|
+
};
|
|
570
|
+
if (expectedExpandedBounds.width > 0 && expectedExpandedBounds.height > 0 && globalBounds.width > 0 && globalBounds.height > 0) {
|
|
571
|
+
const shouldNormalizeExpandBounds = Math.abs(globalBounds.x - expectedExpandedBounds.x) > 1 || Math.abs(globalBounds.y - expectedExpandedBounds.y) > 1 || Math.abs(globalBounds.width - expectedExpandedBounds.width) > 1 || Math.abs(globalBounds.height - expectedExpandedBounds.height) > 1;
|
|
572
|
+
if (shouldNormalizeExpandBounds) {
|
|
573
|
+
const beforeNormalize = globalBounds;
|
|
574
|
+
finalContours = this.translateContours(
|
|
575
|
+
this.scaleContours(
|
|
576
|
+
finalContours,
|
|
577
|
+
expectedExpandedBounds.width,
|
|
578
|
+
expectedExpandedBounds.height,
|
|
579
|
+
globalBounds
|
|
580
|
+
),
|
|
581
|
+
expectedExpandedBounds.x,
|
|
582
|
+
expectedExpandedBounds.y
|
|
583
|
+
);
|
|
584
|
+
globalBounds = this.boundsFromPoints(
|
|
585
|
+
this.flattenContours(finalContours)
|
|
586
|
+
);
|
|
587
|
+
debugLog("traceWithBounds:expand-normalized", {
|
|
588
|
+
expand,
|
|
589
|
+
expectedExpandedBounds,
|
|
590
|
+
beforeNormalize,
|
|
591
|
+
afterNormalize: globalBounds
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
debugLog("traceWithBounds:contours", {
|
|
597
|
+
baseContourCount: baseContoursRaw.length,
|
|
598
|
+
baseSelectedCount: baseContours.length,
|
|
599
|
+
expandedContourCount: expandedContoursRaw.length,
|
|
600
|
+
expandedSelectedCount: expandedContours.length,
|
|
601
|
+
baseBounds,
|
|
602
|
+
expandedBounds: globalBounds,
|
|
603
|
+
expandedDeltaX: globalBounds.width - baseBounds.width,
|
|
604
|
+
expandedDeltaY: globalBounds.height - baseBounds.height,
|
|
605
|
+
expandedMayOverflowImageBounds: expand > 0,
|
|
606
|
+
useSmoothing,
|
|
607
|
+
componentMode
|
|
608
|
+
});
|
|
609
|
+
if (useSmoothing) {
|
|
610
|
+
return {
|
|
611
|
+
pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
|
|
612
|
+
baseBounds,
|
|
613
|
+
bounds: globalBounds
|
|
614
|
+
};
|
|
615
|
+
} else {
|
|
616
|
+
const simplifiedContours = finalContours.map((points) => this.douglasPeucker(points, simplifyTolerance)).filter((points) => points.length > 2);
|
|
617
|
+
const pathData = this.contoursToSVG(simplifiedContours) || this.contoursToSVG(finalContours);
|
|
618
|
+
return {
|
|
619
|
+
pathData,
|
|
620
|
+
baseBounds,
|
|
621
|
+
bounds: globalBounds
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
static pickPrimaryContour(contours) {
|
|
626
|
+
if (contours.length === 0) return null;
|
|
627
|
+
return contours.reduce((best, cur) => {
|
|
628
|
+
if (!best) return cur;
|
|
629
|
+
const bestArea = Math.abs(polygonSignedArea(best));
|
|
630
|
+
const curArea = Math.abs(polygonSignedArea(cur));
|
|
631
|
+
if (curArea !== bestArea) return curArea > bestArea ? cur : best;
|
|
632
|
+
return cur.length > best.length ? cur : best;
|
|
633
|
+
}, contours[0]);
|
|
634
|
+
}
|
|
635
|
+
static flattenContours(contours) {
|
|
636
|
+
return contours.flatMap((contour) => contour);
|
|
637
|
+
}
|
|
638
|
+
static contourCentroid(points) {
|
|
639
|
+
if (!points.length) return { x: 0, y: 0 };
|
|
640
|
+
const sum = points.reduce(
|
|
641
|
+
(acc, p) => ({ x: acc.x + p.x, y: acc.y + p.y }),
|
|
642
|
+
{ x: 0, y: 0 }
|
|
643
|
+
);
|
|
644
|
+
return {
|
|
645
|
+
x: sum.x / points.length,
|
|
646
|
+
y: sum.y / points.length
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
static pointInPolygon(point, polygon) {
|
|
650
|
+
let inside = false;
|
|
651
|
+
const { x, y } = point;
|
|
652
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
653
|
+
const xi = polygon[i].x;
|
|
654
|
+
const yi = polygon[i].y;
|
|
655
|
+
const xj = polygon[j].x;
|
|
656
|
+
const yj = polygon[j].y;
|
|
657
|
+
const intersects = yi > y !== yj > y && x < (xj - xi) * (y - yi) / (yj - yi || Number.EPSILON) + xi;
|
|
658
|
+
if (intersects) inside = !inside;
|
|
659
|
+
}
|
|
660
|
+
return inside;
|
|
661
|
+
}
|
|
662
|
+
static keepOutermostContours(contours) {
|
|
663
|
+
if (contours.length <= 1) return contours;
|
|
664
|
+
const sorted = [...contours].sort(
|
|
665
|
+
(a, b) => Math.abs(polygonSignedArea(b)) - Math.abs(polygonSignedArea(a))
|
|
666
|
+
);
|
|
667
|
+
const selected = [];
|
|
668
|
+
for (const contour of sorted) {
|
|
669
|
+
const centroid = this.contourCentroid(contour);
|
|
670
|
+
const isNested = selected.some(
|
|
671
|
+
(outer) => this.pointInPolygon(centroid, outer)
|
|
672
|
+
);
|
|
673
|
+
if (!isNested) {
|
|
674
|
+
selected.push(contour);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
return selected;
|
|
678
|
+
}
|
|
679
|
+
static summarizeAllContours(mask, width, height, minComponentArea) {
|
|
680
|
+
const raw = this.traceAllContours(mask, width, height);
|
|
681
|
+
const selected = this.selectContours(raw, "all", minComponentArea);
|
|
682
|
+
return {
|
|
683
|
+
rawCount: raw.length,
|
|
684
|
+
selectedCount: selected.length
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
static findForceConnectResult(sourceMask, width, height, minComponentArea, startDilateRadius, maxDilateRadius, erodeRatio) {
|
|
688
|
+
const initial = this.summarizeAllContours(
|
|
689
|
+
sourceMask,
|
|
690
|
+
width,
|
|
691
|
+
height,
|
|
692
|
+
minComponentArea
|
|
693
|
+
);
|
|
694
|
+
if (initial.selectedCount <= 1) {
|
|
695
|
+
return {
|
|
696
|
+
mask: sourceMask,
|
|
697
|
+
appliedDilateRadius: 0,
|
|
698
|
+
appliedErodeRadius: 0,
|
|
699
|
+
reachedSingleComponent: true,
|
|
700
|
+
rawContourCount: initial.rawCount,
|
|
701
|
+
selectedContourCount: initial.selectedCount
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
const normalizedStart = Math.max(1, Math.floor(startDilateRadius));
|
|
705
|
+
const normalizedMax = Math.max(
|
|
706
|
+
normalizedStart,
|
|
707
|
+
Math.floor(maxDilateRadius)
|
|
708
|
+
);
|
|
709
|
+
const normalizedErodeRatio = Math.max(0, erodeRatio);
|
|
710
|
+
const evaluate = (dilateRadius) => {
|
|
711
|
+
const erodeRadius = Math.max(
|
|
712
|
+
1,
|
|
713
|
+
Math.floor(dilateRadius * normalizedErodeRatio)
|
|
714
|
+
);
|
|
715
|
+
let mask = sourceMask;
|
|
716
|
+
mask = circularMorphology(mask, width, height, dilateRadius, "dilate");
|
|
717
|
+
mask = fillHoles(mask, width, height);
|
|
718
|
+
mask = circularMorphology(mask, width, height, erodeRadius, "erode");
|
|
719
|
+
mask = fillHoles(mask, width, height);
|
|
720
|
+
const summary = this.summarizeAllContours(
|
|
721
|
+
mask,
|
|
722
|
+
width,
|
|
723
|
+
height,
|
|
724
|
+
minComponentArea
|
|
725
|
+
);
|
|
726
|
+
return {
|
|
727
|
+
dilateRadius,
|
|
728
|
+
erodeRadius,
|
|
729
|
+
mask,
|
|
730
|
+
rawCount: summary.rawCount,
|
|
731
|
+
selectedCount: summary.selectedCount
|
|
732
|
+
};
|
|
733
|
+
};
|
|
734
|
+
let low = normalizedStart - 1;
|
|
735
|
+
let high = normalizedStart;
|
|
736
|
+
let highResult = evaluate(high);
|
|
737
|
+
while (high < normalizedMax && highResult.selectedCount > 1) {
|
|
738
|
+
low = high;
|
|
739
|
+
high = Math.min(
|
|
740
|
+
normalizedMax,
|
|
741
|
+
Math.max(high + 1, Math.floor(high * 1.6))
|
|
742
|
+
);
|
|
743
|
+
highResult = evaluate(high);
|
|
744
|
+
}
|
|
745
|
+
if (highResult.selectedCount > 1) {
|
|
746
|
+
return {
|
|
747
|
+
mask: highResult.mask,
|
|
748
|
+
appliedDilateRadius: highResult.dilateRadius,
|
|
749
|
+
appliedErodeRadius: highResult.erodeRadius,
|
|
750
|
+
reachedSingleComponent: false,
|
|
751
|
+
rawContourCount: highResult.rawCount,
|
|
752
|
+
selectedContourCount: highResult.selectedCount
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
let best = highResult;
|
|
756
|
+
while (low + 1 < high) {
|
|
757
|
+
const mid = Math.floor((low + high) / 2);
|
|
758
|
+
const midResult = evaluate(mid);
|
|
759
|
+
if (midResult.selectedCount <= 1) {
|
|
760
|
+
best = midResult;
|
|
761
|
+
high = mid;
|
|
762
|
+
} else {
|
|
763
|
+
low = mid;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return {
|
|
767
|
+
mask: best.mask,
|
|
768
|
+
appliedDilateRadius: best.dilateRadius,
|
|
769
|
+
appliedErodeRadius: best.erodeRadius,
|
|
770
|
+
reachedSingleComponent: true,
|
|
771
|
+
rawContourCount: best.rawCount,
|
|
772
|
+
selectedContourCount: best.selectedCount
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
static selectContours(contours, mode, minComponentArea) {
|
|
776
|
+
if (!contours.length) return [];
|
|
777
|
+
if (mode === "largest") {
|
|
778
|
+
const primary2 = this.pickPrimaryContour(contours);
|
|
779
|
+
return primary2 ? [primary2] : [];
|
|
780
|
+
}
|
|
781
|
+
const threshold = Math.max(0, minComponentArea);
|
|
782
|
+
if (threshold <= 0) {
|
|
783
|
+
return this.keepOutermostContours(contours);
|
|
784
|
+
}
|
|
785
|
+
const filtered = contours.filter(
|
|
786
|
+
(contour) => Math.abs(polygonSignedArea(contour)) >= threshold
|
|
787
|
+
);
|
|
788
|
+
if (filtered.length > 0) {
|
|
789
|
+
return this.keepOutermostContours(filtered);
|
|
790
|
+
}
|
|
791
|
+
const primary = this.pickPrimaryContour(contours);
|
|
792
|
+
return primary ? [primary] : [];
|
|
793
|
+
}
|
|
794
|
+
static boundsFromPoints(points) {
|
|
795
|
+
let minX = Infinity;
|
|
796
|
+
let minY = Infinity;
|
|
797
|
+
let maxX = -Infinity;
|
|
798
|
+
let maxY = -Infinity;
|
|
799
|
+
for (const p of points) {
|
|
800
|
+
if (p.x < minX) minX = p.x;
|
|
801
|
+
if (p.y < minY) minY = p.y;
|
|
802
|
+
if (p.x > maxX) maxX = p.x;
|
|
803
|
+
if (p.y > maxY) maxY = p.y;
|
|
804
|
+
}
|
|
805
|
+
if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
|
|
806
|
+
return { x: 0, y: 0, width: 0, height: 0 };
|
|
807
|
+
}
|
|
808
|
+
return {
|
|
809
|
+
x: minX,
|
|
810
|
+
y: minY,
|
|
811
|
+
width: maxX - minX,
|
|
812
|
+
height: maxY - minY
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Traces all contours in the mask with optimized start-point detection
|
|
817
|
+
*/
|
|
818
|
+
static traceAllContours(mask, width, height) {
|
|
819
|
+
const visited = new Uint8Array(width * height);
|
|
820
|
+
const allContours = [];
|
|
821
|
+
for (let y = 0; y < height; y++) {
|
|
822
|
+
for (let x = 0; x < width; x++) {
|
|
823
|
+
const idx = y * width + x;
|
|
824
|
+
if (mask[idx] && !visited[idx]) {
|
|
825
|
+
const isLeftEdge = x === 0 || mask[idx - 1] === 0;
|
|
826
|
+
if (isLeftEdge) {
|
|
827
|
+
const contour = this.marchingSquares(
|
|
828
|
+
mask,
|
|
829
|
+
visited,
|
|
830
|
+
x,
|
|
831
|
+
y,
|
|
832
|
+
width,
|
|
833
|
+
height
|
|
834
|
+
);
|
|
835
|
+
if (contour.length > 2) {
|
|
836
|
+
allContours.push(contour);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
return allContours;
|
|
843
|
+
}
|
|
844
|
+
static loadImage(url) {
|
|
845
|
+
return new Promise((resolve, reject) => {
|
|
846
|
+
const img = new Image();
|
|
847
|
+
img.crossOrigin = "Anonymous";
|
|
848
|
+
img.onload = () => resolve(img);
|
|
849
|
+
img.onerror = (e) => reject(e);
|
|
850
|
+
img.src = url;
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Moore-Neighbor Tracing Algorithm
|
|
855
|
+
* More robust for irregular shapes than simple Marching Squares walker.
|
|
856
|
+
*/
|
|
857
|
+
static marchingSquares(mask, visited, startX, startY, width, height) {
|
|
858
|
+
const isSolid = (x, y) => {
|
|
859
|
+
if (x < 0 || x >= width || y < 0 || y >= height) return false;
|
|
860
|
+
return mask[y * width + x] === 1;
|
|
861
|
+
};
|
|
862
|
+
const points = [];
|
|
863
|
+
let cx = startX;
|
|
864
|
+
let cy = startY;
|
|
865
|
+
const neighbors = [
|
|
866
|
+
{ x: 0, y: -1 },
|
|
867
|
+
{ x: 1, y: -1 },
|
|
868
|
+
{ x: 1, y: 0 },
|
|
869
|
+
{ x: 1, y: 1 },
|
|
870
|
+
{ x: 0, y: 1 },
|
|
871
|
+
{ x: -1, y: 1 },
|
|
872
|
+
{ x: -1, y: 0 },
|
|
873
|
+
{ x: -1, y: -1 }
|
|
874
|
+
];
|
|
875
|
+
let backtrack = 6;
|
|
876
|
+
const maxSteps = width * height * 3;
|
|
877
|
+
let steps = 0;
|
|
878
|
+
do {
|
|
879
|
+
points.push({ x: cx, y: cy });
|
|
880
|
+
visited[cy * width + cx] = 1;
|
|
881
|
+
let found = false;
|
|
882
|
+
for (let i = 0; i < 8; i++) {
|
|
883
|
+
const idx = (backtrack + 1 + i) % 8;
|
|
884
|
+
const nx = cx + neighbors[idx].x;
|
|
885
|
+
const ny = cy + neighbors[idx].y;
|
|
886
|
+
if (isSolid(nx, ny)) {
|
|
887
|
+
cx = nx;
|
|
888
|
+
cy = ny;
|
|
889
|
+
backtrack = (idx + 4 + 1) % 8;
|
|
890
|
+
found = true;
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
if (!found) break;
|
|
895
|
+
steps++;
|
|
896
|
+
} while ((cx !== startX || cy !== startY) && steps < maxSteps);
|
|
897
|
+
return points;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Douglas-Peucker Line Simplification
|
|
901
|
+
*/
|
|
902
|
+
static douglasPeucker(points, tolerance) {
|
|
903
|
+
if (points.length <= 2) return points;
|
|
904
|
+
const sqTolerance = tolerance * tolerance;
|
|
905
|
+
let maxSqDist = 0;
|
|
906
|
+
let index = 0;
|
|
907
|
+
const first = points[0];
|
|
908
|
+
const last = points[points.length - 1];
|
|
909
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
910
|
+
const sqDist = this.getSqSegDist(points[i], first, last);
|
|
911
|
+
if (sqDist > maxSqDist) {
|
|
912
|
+
index = i;
|
|
913
|
+
maxSqDist = sqDist;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (maxSqDist > sqTolerance) {
|
|
917
|
+
const left = this.douglasPeucker(points.slice(0, index + 1), tolerance);
|
|
918
|
+
const right = this.douglasPeucker(points.slice(index), tolerance);
|
|
919
|
+
return left.slice(0, left.length - 1).concat(right);
|
|
920
|
+
} else {
|
|
921
|
+
return [first, last];
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
static getSqSegDist(p, p1, p2) {
|
|
925
|
+
let x = p1.x;
|
|
926
|
+
let y = p1.y;
|
|
927
|
+
let dx = p2.x - x;
|
|
928
|
+
let dy = p2.y - y;
|
|
929
|
+
if (dx !== 0 || dy !== 0) {
|
|
930
|
+
const t = ((p.x - x) * dx + (p.y - y) * dy) / (dx * dx + dy * dy);
|
|
931
|
+
if (t > 1) {
|
|
932
|
+
x = p2.x;
|
|
933
|
+
y = p2.y;
|
|
934
|
+
} else if (t > 0) {
|
|
935
|
+
x += dx * t;
|
|
936
|
+
y += dy * t;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
dx = p.x - x;
|
|
940
|
+
dy = p.y - y;
|
|
941
|
+
return dx * dx + dy * dy;
|
|
942
|
+
}
|
|
943
|
+
static scalePoints(points, targetWidth, targetHeight, bounds) {
|
|
944
|
+
if (points.length === 0) return points;
|
|
945
|
+
if (bounds.width === 0 || bounds.height === 0) return points;
|
|
946
|
+
const scaleX = targetWidth / bounds.width;
|
|
947
|
+
const scaleY = targetHeight / bounds.height;
|
|
948
|
+
return points.map((p) => ({
|
|
949
|
+
x: (p.x - bounds.x) * scaleX,
|
|
950
|
+
y: (p.y - bounds.y) * scaleY
|
|
951
|
+
}));
|
|
952
|
+
}
|
|
953
|
+
static scaleContours(contours, targetWidth, targetHeight, bounds) {
|
|
954
|
+
return contours.map(
|
|
955
|
+
(points) => this.scalePoints(points, targetWidth, targetHeight, bounds)
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
static translateContours(contours, offsetX, offsetY) {
|
|
959
|
+
return contours.map(
|
|
960
|
+
(points) => points.map((p) => ({
|
|
961
|
+
x: p.x + offsetX,
|
|
962
|
+
y: p.y + offsetY
|
|
963
|
+
}))
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
static pointsToSVG(points) {
|
|
967
|
+
if (points.length === 0) return "";
|
|
968
|
+
const head = points[0];
|
|
969
|
+
const tail = points.slice(1);
|
|
970
|
+
return `M ${head.x} ${head.y} ` + tail.map((p) => `L ${p.x} ${p.y}`).join(" ") + " Z";
|
|
971
|
+
}
|
|
972
|
+
static contoursToSVG(contours) {
|
|
973
|
+
return contours.filter((points) => points.length > 2).map((points) => this.pointsToSVG(points)).join(" ").trim();
|
|
974
|
+
}
|
|
975
|
+
static ensurePaper() {
|
|
976
|
+
if (!paper.project) {
|
|
977
|
+
paper.setup(new paper.Size(100, 100));
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
static pointsToSVGPaper(points, tolerance) {
|
|
981
|
+
if (points.length < 3) return this.pointsToSVG(points);
|
|
982
|
+
this.ensurePaper();
|
|
983
|
+
const path = new paper.Path({
|
|
984
|
+
segments: points.map((p) => [p.x, p.y]),
|
|
985
|
+
closed: true
|
|
986
|
+
});
|
|
987
|
+
path.simplify(tolerance);
|
|
988
|
+
const data = path.pathData;
|
|
989
|
+
path.remove();
|
|
990
|
+
return data;
|
|
991
|
+
}
|
|
992
|
+
static contoursToSVGPaper(contours, tolerance) {
|
|
993
|
+
const normalizedContours = contours.filter((points) => points.length > 2);
|
|
994
|
+
if (!normalizedContours.length) return "";
|
|
995
|
+
if (normalizedContours.length === 1) {
|
|
996
|
+
return this.pointsToSVGPaper(normalizedContours[0], tolerance);
|
|
997
|
+
}
|
|
998
|
+
this.ensurePaper();
|
|
999
|
+
const compound = new paper.CompoundPath({ insert: false });
|
|
1000
|
+
for (const points of normalizedContours) {
|
|
1001
|
+
const child = new paper.Path({
|
|
1002
|
+
segments: points.map((p) => [p.x, p.y]),
|
|
1003
|
+
closed: true,
|
|
1004
|
+
insert: false
|
|
1005
|
+
});
|
|
1006
|
+
child.simplify(tolerance);
|
|
1007
|
+
compound.addChild(child);
|
|
1008
|
+
}
|
|
1009
|
+
const data = compound.pathData || this.contoursToSVG(normalizedContours);
|
|
1010
|
+
compound.remove();
|
|
1011
|
+
return data;
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
export {
|
|
1015
|
+
ImageTracer
|
|
1016
|
+
};
|