@pooder/kit 5.2.0 → 5.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.test-dist/src/extensions/background.js +203 -0
- package/.test-dist/src/extensions/bridgeSelection.js +20 -0
- package/.test-dist/src/extensions/constraints.js +237 -0
- package/.test-dist/src/extensions/dieline.js +828 -0
- package/.test-dist/src/extensions/edgeScale.js +12 -0
- package/.test-dist/src/extensions/feature.js +825 -0
- package/.test-dist/src/extensions/featureComplete.js +32 -0
- package/.test-dist/src/extensions/film.js +167 -0
- package/.test-dist/src/extensions/geometry.js +545 -0
- package/.test-dist/src/extensions/image.js +1529 -0
- package/.test-dist/src/extensions/index.js +30 -0
- package/.test-dist/src/extensions/maskOps.js +279 -0
- package/.test-dist/src/extensions/mirror.js +104 -0
- package/.test-dist/src/extensions/ruler.js +345 -0
- package/.test-dist/src/extensions/sceneLayout.js +96 -0
- package/.test-dist/src/extensions/sceneLayoutModel.js +196 -0
- package/.test-dist/src/extensions/sceneVisibility.js +62 -0
- package/.test-dist/src/extensions/size.js +331 -0
- package/.test-dist/src/extensions/tracer.js +538 -0
- package/.test-dist/src/extensions/white-ink.js +1190 -0
- package/.test-dist/src/extensions/wrappedOffsets.js +33 -0
- package/.test-dist/src/index.js +2 -19
- package/.test-dist/src/services/CanvasService.js +249 -0
- package/.test-dist/src/services/ViewportSystem.js +76 -0
- package/.test-dist/src/services/index.js +24 -0
- package/.test-dist/src/services/renderSpec.js +2 -0
- package/CHANGELOG.md +12 -0
- package/dist/index.d.mts +11 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +519 -395
- package/dist/index.mjs +519 -395
- package/package.json +1 -1
- package/src/extensions/dieline.ts +66 -17
- package/src/extensions/geometry.ts +36 -3
- package/src/extensions/image.ts +2 -0
- package/src/extensions/maskOps.ts +84 -18
- package/src/extensions/sceneLayoutModel.ts +10 -0
- package/src/extensions/tracer.ts +360 -389
- package/src/extensions/white-ink.ts +125 -2
package/src/extensions/tracer.ts
CHANGED
|
@@ -5,12 +5,9 @@
|
|
|
5
5
|
|
|
6
6
|
import paper from "paper";
|
|
7
7
|
import {
|
|
8
|
-
analyzeAlpha,
|
|
9
8
|
circularMorphology,
|
|
10
9
|
createMask,
|
|
11
10
|
fillHoles,
|
|
12
|
-
findMinimalConnectRadius,
|
|
13
|
-
inferMaskMode,
|
|
14
11
|
polygonSignedArea,
|
|
15
12
|
type MaskMode,
|
|
16
13
|
} from "./maskOps";
|
|
@@ -29,6 +26,25 @@ interface Bounds {
|
|
|
29
26
|
|
|
30
27
|
type ComponentMode = "largest" | "all";
|
|
31
28
|
|
|
29
|
+
interface ForceConnectResult {
|
|
30
|
+
mask: Uint8Array;
|
|
31
|
+
appliedDilateRadius: number;
|
|
32
|
+
appliedErodeRadius: number;
|
|
33
|
+
reachedSingleComponent: boolean;
|
|
34
|
+
rawContourCount: number;
|
|
35
|
+
selectedContourCount: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ImageTraceOptions {
|
|
39
|
+
threshold?: number;
|
|
40
|
+
simplifyTolerance?: number;
|
|
41
|
+
expand?: number;
|
|
42
|
+
smoothing?: boolean;
|
|
43
|
+
scaleToWidth?: number;
|
|
44
|
+
scaleToHeight?: number;
|
|
45
|
+
debug?: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
export class ImageTracer {
|
|
33
49
|
/**
|
|
34
50
|
* Main entry point: Traces an image URL to an SVG path string.
|
|
@@ -37,25 +53,7 @@ export class ImageTracer {
|
|
|
37
53
|
*/
|
|
38
54
|
public static async trace(
|
|
39
55
|
imageUrl: string,
|
|
40
|
-
options: {
|
|
41
|
-
threshold?: number; // 0-255, default 10
|
|
42
|
-
simplifyTolerance?: number; // default 2.5
|
|
43
|
-
scale?: number; // Scale factor for the processing canvas, default 1.0
|
|
44
|
-
scaleToWidth?: number;
|
|
45
|
-
scaleToHeight?: number;
|
|
46
|
-
morphologyRadius?: number; // Default 10.
|
|
47
|
-
connectRadiusMax?: number;
|
|
48
|
-
maskMode?: MaskMode;
|
|
49
|
-
whiteThreshold?: number;
|
|
50
|
-
alphaOpaqueCutoff?: number;
|
|
51
|
-
expand?: number; // Expansion radius in pixels. Default 0.
|
|
52
|
-
noChannels?: boolean;
|
|
53
|
-
smoothing?: boolean; // Use Paper.js smoothing (curve fitting). Default true.
|
|
54
|
-
componentMode?: ComponentMode;
|
|
55
|
-
minComponentArea?: number;
|
|
56
|
-
forceConnected?: boolean;
|
|
57
|
-
debug?: boolean;
|
|
58
|
-
} = {},
|
|
56
|
+
options: ImageTraceOptions = {},
|
|
59
57
|
): Promise<string> {
|
|
60
58
|
const { pathData } = await this.traceWithBounds(imageUrl, options);
|
|
61
59
|
return pathData;
|
|
@@ -63,29 +61,20 @@ export class ImageTracer {
|
|
|
63
61
|
|
|
64
62
|
public static async traceWithBounds(
|
|
65
63
|
imageUrl: string,
|
|
66
|
-
options: {
|
|
67
|
-
threshold?: number;
|
|
68
|
-
simplifyTolerance?: number;
|
|
69
|
-
scale?: number;
|
|
70
|
-
scaleToWidth?: number;
|
|
71
|
-
scaleToHeight?: number;
|
|
72
|
-
morphologyRadius?: number;
|
|
73
|
-
connectRadiusMax?: number;
|
|
74
|
-
maskMode?: MaskMode;
|
|
75
|
-
whiteThreshold?: number;
|
|
76
|
-
alphaOpaqueCutoff?: number;
|
|
77
|
-
expand?: number;
|
|
78
|
-
noChannels?: boolean;
|
|
79
|
-
smoothing?: boolean;
|
|
80
|
-
componentMode?: ComponentMode;
|
|
81
|
-
minComponentArea?: number;
|
|
82
|
-
forceConnected?: boolean;
|
|
83
|
-
debug?: boolean;
|
|
84
|
-
} = {},
|
|
64
|
+
options: ImageTraceOptions = {},
|
|
85
65
|
): Promise<{ pathData: string; baseBounds: Bounds; bounds: Bounds }> {
|
|
86
66
|
const img = await this.loadImage(imageUrl);
|
|
87
67
|
const width = img.width;
|
|
88
68
|
const height = img.height;
|
|
69
|
+
if (width <= 0 || height <= 0) {
|
|
70
|
+
const w = options.scaleToWidth ?? 0;
|
|
71
|
+
const h = options.scaleToHeight ?? 0;
|
|
72
|
+
return {
|
|
73
|
+
pathData: `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`,
|
|
74
|
+
baseBounds: { x: 0, y: 0, width: w, height: h },
|
|
75
|
+
bounds: { x: 0, y: 0, width: w, height: h },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
89
78
|
const debug = options.debug === true;
|
|
90
79
|
const debugLog = (message: string, payload?: Record<string, unknown>) => {
|
|
91
80
|
if (!debug) return;
|
|
@@ -96,7 +85,7 @@ export class ImageTracer {
|
|
|
96
85
|
console.info(`[ImageTracer] ${message}`);
|
|
97
86
|
};
|
|
98
87
|
|
|
99
|
-
//
|
|
88
|
+
// Draw to canvas and get pixel data
|
|
100
89
|
const canvas = document.createElement("canvas");
|
|
101
90
|
canvas.width = width;
|
|
102
91
|
canvas.height = height;
|
|
@@ -106,119 +95,188 @@ export class ImageTracer {
|
|
|
106
95
|
ctx.drawImage(img, 0, 0);
|
|
107
96
|
const imageData = ctx.getImageData(0, 0, width, height);
|
|
108
97
|
|
|
109
|
-
//
|
|
98
|
+
// Strategy: fixed internal morphology + single-component target.
|
|
110
99
|
const threshold = options.threshold ?? 10;
|
|
111
|
-
const
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
100
|
+
const expand = Math.max(0, Math.floor(options.expand ?? 0));
|
|
101
|
+
const simplifyTolerance = options.simplifyTolerance ?? 2.5;
|
|
102
|
+
const useSmoothing = options.smoothing !== false;
|
|
103
|
+
const componentMode: ComponentMode = "all";
|
|
104
|
+
const minComponentArea = 0;
|
|
105
|
+
const maxDim = Math.max(width, height);
|
|
106
|
+
const maskMode: MaskMode = "auto";
|
|
107
|
+
const whiteThreshold = 240;
|
|
108
|
+
const alphaOpaqueCutoff = 250;
|
|
109
|
+
const preprocessDilateRadius = Math.max(
|
|
110
|
+
2,
|
|
111
|
+
Math.floor(Math.max(maxDim * 0.012, expand * 0.35)),
|
|
118
112
|
);
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
113
|
+
const preprocessErodeRadius = Math.max(
|
|
114
|
+
1,
|
|
115
|
+
Math.floor(preprocessDilateRadius * 0.65),
|
|
116
|
+
);
|
|
117
|
+
const smoothDilateRadius = Math.max(
|
|
118
|
+
1,
|
|
119
|
+
Math.floor(preprocessDilateRadius * 0.25),
|
|
120
|
+
);
|
|
121
|
+
const smoothErodeRadius = Math.max(1, Math.floor(smoothDilateRadius * 0.8));
|
|
122
|
+
const connectStartDilateRadius = Math.max(
|
|
123
|
+
1,
|
|
124
|
+
Math.floor(Math.max(maxDim * 0.006, expand * 0.2)),
|
|
125
|
+
);
|
|
126
|
+
const connectMaxDilateRadius = Math.max(
|
|
127
|
+
connectStartDilateRadius,
|
|
128
|
+
Math.floor(Math.max(maxDim * 0.2, expand * 2.5)),
|
|
129
|
+
);
|
|
130
|
+
const connectErodeRatio = 0.65;
|
|
131
|
+
|
|
128
132
|
debugLog("traceWithBounds:start", {
|
|
129
133
|
width,
|
|
130
134
|
height,
|
|
131
135
|
threshold,
|
|
132
|
-
radius,
|
|
133
136
|
expand,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
simplifyTolerance,
|
|
138
|
+
smoothing: useSmoothing,
|
|
139
|
+
strategy: {
|
|
140
|
+
maskMode,
|
|
141
|
+
whiteThreshold,
|
|
142
|
+
alphaOpaqueCutoff,
|
|
143
|
+
fillHoles: true,
|
|
144
|
+
preprocessDilateRadius,
|
|
145
|
+
preprocessErodeRadius,
|
|
146
|
+
smoothDilateRadius,
|
|
147
|
+
smoothErodeRadius,
|
|
148
|
+
connectEnabled: true,
|
|
149
|
+
connectStartDilateRadius,
|
|
150
|
+
connectMaxDilateRadius,
|
|
151
|
+
connectErodeRatio,
|
|
144
152
|
},
|
|
145
|
-
componentMode,
|
|
146
|
-
minComponentArea,
|
|
147
|
-
forceConnected,
|
|
148
|
-
simplifyTolerance: options.simplifyTolerance ?? 2.5,
|
|
149
|
-
smoothing: options.smoothing !== false,
|
|
150
153
|
});
|
|
151
154
|
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
+
// Padding must cover morphology and expansion margins.
|
|
156
|
+
const padding =
|
|
157
|
+
Math.max(
|
|
158
|
+
preprocessDilateRadius,
|
|
159
|
+
smoothDilateRadius,
|
|
160
|
+
connectMaxDilateRadius,
|
|
161
|
+
expand,
|
|
162
|
+
) + 2;
|
|
155
163
|
const paddedWidth = width + padding * 2;
|
|
156
164
|
const paddedHeight = height + padding * 2;
|
|
165
|
+
const summarizeMaskContours = (m: Uint8Array) => {
|
|
166
|
+
const summary = this.summarizeAllContours(
|
|
167
|
+
m,
|
|
168
|
+
paddedWidth,
|
|
169
|
+
paddedHeight,
|
|
170
|
+
minComponentArea,
|
|
171
|
+
);
|
|
172
|
+
return {
|
|
173
|
+
rawContourCount: summary.rawCount,
|
|
174
|
+
selectedContourCount: summary.selectedCount,
|
|
175
|
+
};
|
|
176
|
+
};
|
|
157
177
|
|
|
158
178
|
let mask = createMask(imageData, {
|
|
159
179
|
threshold,
|
|
160
180
|
padding,
|
|
161
181
|
paddedWidth,
|
|
162
182
|
paddedHeight,
|
|
163
|
-
maskMode
|
|
164
|
-
whiteThreshold
|
|
183
|
+
maskMode,
|
|
184
|
+
whiteThreshold,
|
|
165
185
|
alphaOpaqueCutoff,
|
|
166
186
|
});
|
|
187
|
+
if (debug) {
|
|
188
|
+
debugLog(
|
|
189
|
+
"traceWithBounds:mask:after-create",
|
|
190
|
+
summarizeMaskContours(mask),
|
|
191
|
+
);
|
|
192
|
+
}
|
|
167
193
|
|
|
168
|
-
|
|
169
|
-
mask
|
|
194
|
+
mask = circularMorphology(
|
|
195
|
+
mask,
|
|
196
|
+
paddedWidth,
|
|
197
|
+
paddedHeight,
|
|
198
|
+
preprocessDilateRadius,
|
|
199
|
+
"dilate",
|
|
200
|
+
);
|
|
201
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
202
|
+
mask = circularMorphology(
|
|
203
|
+
mask,
|
|
204
|
+
paddedWidth,
|
|
205
|
+
paddedHeight,
|
|
206
|
+
preprocessErodeRadius,
|
|
207
|
+
"erode",
|
|
208
|
+
);
|
|
209
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
210
|
+
if (debug) {
|
|
211
|
+
debugLog("traceWithBounds:mask:after-preprocess", {
|
|
212
|
+
dilateRadius: preprocessDilateRadius,
|
|
213
|
+
erodeRadius: preprocessErodeRadius,
|
|
214
|
+
...summarizeMaskContours(mask),
|
|
215
|
+
});
|
|
170
216
|
}
|
|
171
217
|
|
|
172
|
-
|
|
173
|
-
mask
|
|
218
|
+
mask = circularMorphology(
|
|
219
|
+
mask,
|
|
220
|
+
paddedWidth,
|
|
221
|
+
paddedHeight,
|
|
222
|
+
smoothDilateRadius,
|
|
223
|
+
"dilate",
|
|
224
|
+
);
|
|
225
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
226
|
+
mask = circularMorphology(
|
|
227
|
+
mask,
|
|
228
|
+
paddedWidth,
|
|
229
|
+
paddedHeight,
|
|
230
|
+
smoothErodeRadius,
|
|
231
|
+
"erode",
|
|
232
|
+
);
|
|
233
|
+
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
234
|
+
if (debug) {
|
|
235
|
+
debugLog("traceWithBounds:mask:after-smooth", {
|
|
236
|
+
dilateRadius: smoothDilateRadius,
|
|
237
|
+
erodeRadius: smoothErodeRadius,
|
|
238
|
+
...summarizeMaskContours(mask),
|
|
239
|
+
});
|
|
174
240
|
}
|
|
175
241
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
242
|
+
const beforeConnectSummary = summarizeMaskContours(mask);
|
|
243
|
+
if (beforeConnectSummary.selectedContourCount <= 1) {
|
|
244
|
+
debugLog("traceWithBounds:mask:connect-skipped", {
|
|
245
|
+
reason: "already-single-component",
|
|
246
|
+
before: beforeConnectSummary,
|
|
247
|
+
});
|
|
248
|
+
} else {
|
|
249
|
+
const connectResult = this.findForceConnectResult(
|
|
250
|
+
mask,
|
|
251
|
+
paddedWidth,
|
|
252
|
+
paddedHeight,
|
|
253
|
+
minComponentArea,
|
|
254
|
+
connectStartDilateRadius,
|
|
255
|
+
connectMaxDilateRadius,
|
|
256
|
+
connectErodeRatio,
|
|
257
|
+
);
|
|
258
|
+
if (debug) {
|
|
259
|
+
debugLog("traceWithBounds:mask:after-connect", {
|
|
260
|
+
before: beforeConnectSummary,
|
|
261
|
+
appliedDilateRadius: connectResult.appliedDilateRadius,
|
|
262
|
+
appliedErodeRadius: connectResult.appliedErodeRadius,
|
|
263
|
+
reachedSingleComponent: connectResult.reachedSingleComponent,
|
|
264
|
+
after: {
|
|
265
|
+
rawContourCount: connectResult.rawContourCount,
|
|
266
|
+
selectedContourCount: connectResult.selectedContourCount,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
mask = connectResult.mask;
|
|
179
271
|
}
|
|
180
272
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
? autoConnectRadiusMax
|
|
189
|
-
: requestedConnectRadiusMax > 0
|
|
190
|
-
? requestedConnectRadiusMax
|
|
191
|
-
: forceConnected
|
|
192
|
-
? autoConnectRadiusMax
|
|
193
|
-
: 0;
|
|
194
|
-
|
|
195
|
-
let rConnect = 0;
|
|
196
|
-
if (connectRadiusMax > 0) {
|
|
197
|
-
rConnect = forceConnected
|
|
198
|
-
? this.findMinimalMergeRadiusByContourCount(
|
|
199
|
-
mask,
|
|
200
|
-
paddedWidth,
|
|
201
|
-
paddedHeight,
|
|
202
|
-
connectRadiusMax,
|
|
203
|
-
minComponentArea,
|
|
204
|
-
)
|
|
205
|
-
: findMinimalConnectRadius(
|
|
206
|
-
mask,
|
|
207
|
-
paddedWidth,
|
|
208
|
-
paddedHeight,
|
|
209
|
-
connectRadiusMax,
|
|
210
|
-
);
|
|
211
|
-
if (rConnect > 0) {
|
|
212
|
-
mask = circularMorphology(
|
|
213
|
-
mask,
|
|
214
|
-
paddedWidth,
|
|
215
|
-
paddedHeight,
|
|
216
|
-
rConnect,
|
|
217
|
-
"closing",
|
|
218
|
-
);
|
|
219
|
-
if (noChannels) {
|
|
220
|
-
mask = fillHoles(mask, paddedWidth, paddedHeight);
|
|
221
|
-
}
|
|
273
|
+
if (debug) {
|
|
274
|
+
const afterConnectSummary = summarizeMaskContours(mask);
|
|
275
|
+
if (afterConnectSummary.selectedContourCount > 1) {
|
|
276
|
+
debugLog("traceWithBounds:mask:connect-warning", {
|
|
277
|
+
reason: "still-multi-component-after-connect-search",
|
|
278
|
+
summary: afterConnectSummary,
|
|
279
|
+
});
|
|
222
280
|
}
|
|
223
281
|
}
|
|
224
282
|
|
|
@@ -248,14 +306,10 @@ export class ImageTracer {
|
|
|
248
306
|
|
|
249
307
|
const baseUnpaddedContours = baseContours
|
|
250
308
|
.map((contour) =>
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
})),
|
|
256
|
-
width,
|
|
257
|
-
height,
|
|
258
|
-
),
|
|
309
|
+
contour.map((p) => ({
|
|
310
|
+
x: p.x - padding,
|
|
311
|
+
y: p.y - padding,
|
|
312
|
+
})),
|
|
259
313
|
)
|
|
260
314
|
.filter((contour) => contour.length > 2);
|
|
261
315
|
|
|
@@ -309,16 +363,15 @@ export class ImageTracer {
|
|
|
309
363
|
};
|
|
310
364
|
}
|
|
311
365
|
|
|
366
|
+
// Keep expanded coordinates in the unpadded space without clamping to
|
|
367
|
+
// original image bounds. If the shape touches an edge, clamping would
|
|
368
|
+
// drop one-sided expand distance (e.g. bottom/right expansion).
|
|
312
369
|
const expandedUnpaddedContours = expandedContours
|
|
313
370
|
.map((contour) =>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
})),
|
|
319
|
-
width,
|
|
320
|
-
height,
|
|
321
|
-
),
|
|
371
|
+
contour.map((p) => ({
|
|
372
|
+
x: p.x - padding,
|
|
373
|
+
y: p.y - padding,
|
|
374
|
+
})),
|
|
322
375
|
)
|
|
323
376
|
.filter((contour) => contour.length > 2);
|
|
324
377
|
if (!expandedUnpaddedContours.length) {
|
|
@@ -339,7 +392,7 @@ export class ImageTracer {
|
|
|
339
392
|
this.flattenContours(expandedUnpaddedContours),
|
|
340
393
|
);
|
|
341
394
|
|
|
342
|
-
//
|
|
395
|
+
// Post-processing (Scale)
|
|
343
396
|
let finalContours = expandedUnpaddedContours;
|
|
344
397
|
if (options.scaleToWidth && options.scaleToHeight) {
|
|
345
398
|
finalContours = this.scaleContours(
|
|
@@ -356,43 +409,82 @@ export class ImageTracer {
|
|
|
356
409
|
options.scaleToHeight,
|
|
357
410
|
baseBounds,
|
|
358
411
|
);
|
|
359
|
-
baseBounds = this.boundsFromPoints(
|
|
412
|
+
baseBounds = this.boundsFromPoints(
|
|
413
|
+
this.flattenContours(baseScaledContours),
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (expand > 0) {
|
|
418
|
+
const expectedExpandedBounds = {
|
|
419
|
+
x: baseBounds.x - expand,
|
|
420
|
+
y: baseBounds.y - expand,
|
|
421
|
+
width: baseBounds.width + expand * 2,
|
|
422
|
+
height: baseBounds.height + expand * 2,
|
|
423
|
+
};
|
|
424
|
+
if (
|
|
425
|
+
expectedExpandedBounds.width > 0 &&
|
|
426
|
+
expectedExpandedBounds.height > 0 &&
|
|
427
|
+
globalBounds.width > 0 &&
|
|
428
|
+
globalBounds.height > 0
|
|
429
|
+
) {
|
|
430
|
+
const shouldNormalizeExpandBounds =
|
|
431
|
+
Math.abs(globalBounds.x - expectedExpandedBounds.x) > 1 ||
|
|
432
|
+
Math.abs(globalBounds.y - expectedExpandedBounds.y) > 1 ||
|
|
433
|
+
Math.abs(globalBounds.width - expectedExpandedBounds.width) > 1 ||
|
|
434
|
+
Math.abs(globalBounds.height - expectedExpandedBounds.height) > 1;
|
|
435
|
+
if (shouldNormalizeExpandBounds) {
|
|
436
|
+
const beforeNormalize = globalBounds;
|
|
437
|
+
finalContours = this.translateContours(
|
|
438
|
+
this.scaleContours(
|
|
439
|
+
finalContours,
|
|
440
|
+
expectedExpandedBounds.width,
|
|
441
|
+
expectedExpandedBounds.height,
|
|
442
|
+
globalBounds,
|
|
443
|
+
),
|
|
444
|
+
expectedExpandedBounds.x,
|
|
445
|
+
expectedExpandedBounds.y,
|
|
446
|
+
);
|
|
447
|
+
globalBounds = this.boundsFromPoints(
|
|
448
|
+
this.flattenContours(finalContours),
|
|
449
|
+
);
|
|
450
|
+
debugLog("traceWithBounds:expand-normalized", {
|
|
451
|
+
expand,
|
|
452
|
+
expectedExpandedBounds,
|
|
453
|
+
beforeNormalize,
|
|
454
|
+
afterNormalize: globalBounds,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
360
458
|
}
|
|
361
459
|
|
|
362
|
-
//
|
|
363
|
-
const useSmoothing = options.smoothing !== false; // Default true
|
|
460
|
+
// Simplify and Generate SVG
|
|
364
461
|
debugLog("traceWithBounds:contours", {
|
|
365
462
|
baseContourCount: baseContoursRaw.length,
|
|
366
463
|
baseSelectedCount: baseContours.length,
|
|
367
464
|
expandedContourCount: expandedContoursRaw.length,
|
|
368
465
|
expandedSelectedCount: expandedContours.length,
|
|
369
|
-
connectRadiusMax,
|
|
370
|
-
appliedConnectRadius: rConnect,
|
|
371
466
|
baseBounds,
|
|
372
467
|
expandedBounds: globalBounds,
|
|
373
468
|
expandedDeltaX: globalBounds.width - baseBounds.width,
|
|
374
469
|
expandedDeltaY: globalBounds.height - baseBounds.height,
|
|
470
|
+
expandedMayOverflowImageBounds: expand > 0,
|
|
375
471
|
useSmoothing,
|
|
376
472
|
componentMode,
|
|
377
473
|
});
|
|
378
474
|
|
|
379
475
|
if (useSmoothing) {
|
|
380
476
|
return {
|
|
381
|
-
pathData: this.contoursToSVGPaper(
|
|
382
|
-
finalContours,
|
|
383
|
-
options.simplifyTolerance ?? 2.5,
|
|
384
|
-
),
|
|
477
|
+
pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
|
|
385
478
|
baseBounds,
|
|
386
479
|
bounds: globalBounds,
|
|
387
480
|
};
|
|
388
481
|
} else {
|
|
389
482
|
const simplifiedContours = finalContours
|
|
390
|
-
.map((points) =>
|
|
391
|
-
this.douglasPeucker(points, options.simplifyTolerance ?? 2.0),
|
|
392
|
-
)
|
|
483
|
+
.map((points) => this.douglasPeucker(points, simplifyTolerance))
|
|
393
484
|
.filter((points) => points.length > 2);
|
|
394
485
|
const pathData =
|
|
395
|
-
this.contoursToSVG(simplifiedContours) ||
|
|
486
|
+
this.contoursToSVG(simplifiedContours) ||
|
|
487
|
+
this.contoursToSVG(finalContours);
|
|
396
488
|
return {
|
|
397
489
|
pathData,
|
|
398
490
|
baseBounds,
|
|
@@ -438,7 +530,7 @@ export class ImageTracer {
|
|
|
438
530
|
const yj = polygon[j].y;
|
|
439
531
|
const intersects =
|
|
440
532
|
yi > y !== yj > y &&
|
|
441
|
-
x < ((xj - xi) * (y - yi)) / (
|
|
533
|
+
x < ((xj - xi) * (y - yi)) / (yj - yi || Number.EPSILON) + xi;
|
|
442
534
|
if (intersects) inside = !inside;
|
|
443
535
|
}
|
|
444
536
|
return inside;
|
|
@@ -463,55 +555,120 @@ export class ImageTracer {
|
|
|
463
555
|
return selected;
|
|
464
556
|
}
|
|
465
557
|
|
|
466
|
-
private static
|
|
558
|
+
private static summarizeAllContours(
|
|
467
559
|
mask: Uint8Array,
|
|
468
560
|
width: number,
|
|
469
561
|
height: number,
|
|
470
562
|
minComponentArea: number,
|
|
471
|
-
): number {
|
|
472
|
-
const
|
|
473
|
-
|
|
563
|
+
): { rawCount: number; selectedCount: number } {
|
|
564
|
+
const raw = this.traceAllContours(mask, width, height);
|
|
565
|
+
const selected = this.selectContours(raw, "all", minComponentArea);
|
|
566
|
+
return {
|
|
567
|
+
rawCount: raw.length,
|
|
568
|
+
selectedCount: selected.length,
|
|
569
|
+
};
|
|
474
570
|
}
|
|
475
571
|
|
|
476
|
-
private static
|
|
477
|
-
|
|
572
|
+
private static findForceConnectResult(
|
|
573
|
+
sourceMask: Uint8Array,
|
|
478
574
|
width: number,
|
|
479
575
|
height: number,
|
|
480
|
-
maxRadius: number,
|
|
481
576
|
minComponentArea: number,
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
577
|
+
startDilateRadius: number,
|
|
578
|
+
maxDilateRadius: number,
|
|
579
|
+
erodeRatio: number,
|
|
580
|
+
): ForceConnectResult {
|
|
581
|
+
const initial = this.summarizeAllContours(
|
|
582
|
+
sourceMask,
|
|
583
|
+
width,
|
|
584
|
+
height,
|
|
585
|
+
minComponentArea,
|
|
586
|
+
);
|
|
587
|
+
if (initial.selectedCount <= 1) {
|
|
588
|
+
return {
|
|
589
|
+
mask: sourceMask,
|
|
590
|
+
appliedDilateRadius: 0,
|
|
591
|
+
appliedErodeRadius: 0,
|
|
592
|
+
reachedSingleComponent: true,
|
|
593
|
+
rawContourCount: initial.rawCount,
|
|
594
|
+
selectedContourCount: initial.selectedCount,
|
|
595
|
+
};
|
|
486
596
|
}
|
|
487
597
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
598
|
+
const normalizedStart = Math.max(1, Math.floor(startDilateRadius));
|
|
599
|
+
const normalizedMax = Math.max(
|
|
600
|
+
normalizedStart,
|
|
601
|
+
Math.floor(maxDilateRadius),
|
|
602
|
+
);
|
|
603
|
+
const normalizedErodeRatio = Math.max(0, erodeRatio);
|
|
604
|
+
const evaluate = (dilateRadius: number) => {
|
|
605
|
+
const erodeRadius = Math.max(
|
|
606
|
+
1,
|
|
607
|
+
Math.floor(dilateRadius * normalizedErodeRatio),
|
|
608
|
+
);
|
|
609
|
+
let mask = sourceMask;
|
|
610
|
+
mask = circularMorphology(mask, width, height, dilateRadius, "dilate");
|
|
611
|
+
mask = fillHoles(mask, width, height);
|
|
612
|
+
mask = circularMorphology(mask, width, height, erodeRadius, "erode");
|
|
613
|
+
mask = fillHoles(mask, width, height);
|
|
614
|
+
const summary = this.summarizeAllContours(
|
|
615
|
+
mask,
|
|
616
|
+
width,
|
|
617
|
+
height,
|
|
618
|
+
minComponentArea,
|
|
619
|
+
);
|
|
620
|
+
return {
|
|
621
|
+
dilateRadius,
|
|
622
|
+
erodeRadius,
|
|
623
|
+
mask,
|
|
624
|
+
rawCount: summary.rawCount,
|
|
625
|
+
selectedCount: summary.selectedCount,
|
|
626
|
+
};
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
let low = normalizedStart - 1;
|
|
630
|
+
let high = normalizedStart;
|
|
631
|
+
let highResult = evaluate(high);
|
|
632
|
+
while (high < normalizedMax && highResult.selectedCount > 1) {
|
|
633
|
+
low = high;
|
|
634
|
+
high = Math.min(
|
|
635
|
+
normalizedMax,
|
|
636
|
+
Math.max(high + 1, Math.floor(high * 1.6)),
|
|
637
|
+
);
|
|
638
|
+
highResult = evaluate(high);
|
|
496
639
|
}
|
|
497
|
-
if (high > maxRadius) high = maxRadius;
|
|
498
640
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
641
|
+
if (highResult.selectedCount > 1) {
|
|
642
|
+
return {
|
|
643
|
+
mask: highResult.mask,
|
|
644
|
+
appliedDilateRadius: highResult.dilateRadius,
|
|
645
|
+
appliedErodeRadius: highResult.erodeRadius,
|
|
646
|
+
reachedSingleComponent: false,
|
|
647
|
+
rawContourCount: highResult.rawCount,
|
|
648
|
+
selectedContourCount: highResult.selectedCount,
|
|
649
|
+
};
|
|
502
650
|
}
|
|
503
651
|
|
|
652
|
+
let best = highResult;
|
|
504
653
|
while (low + 1 < high) {
|
|
505
654
|
const mid = Math.floor((low + high) / 2);
|
|
506
|
-
const
|
|
507
|
-
if (
|
|
655
|
+
const midResult = evaluate(mid);
|
|
656
|
+
if (midResult.selectedCount <= 1) {
|
|
657
|
+
best = midResult;
|
|
508
658
|
high = mid;
|
|
509
659
|
} else {
|
|
510
660
|
low = mid;
|
|
511
661
|
}
|
|
512
662
|
}
|
|
513
663
|
|
|
514
|
-
return
|
|
664
|
+
return {
|
|
665
|
+
mask: best.mask,
|
|
666
|
+
appliedDilateRadius: best.dilateRadius,
|
|
667
|
+
appliedErodeRadius: best.erodeRadius,
|
|
668
|
+
reachedSingleComponent: true,
|
|
669
|
+
rawContourCount: best.rawCount,
|
|
670
|
+
selectedContourCount: best.selectedCount,
|
|
671
|
+
};
|
|
515
672
|
}
|
|
516
673
|
|
|
517
674
|
private static selectContours(
|
|
@@ -566,192 +723,6 @@ export class ImageTracer {
|
|
|
566
723
|
};
|
|
567
724
|
}
|
|
568
725
|
|
|
569
|
-
private static createMask(
|
|
570
|
-
imageData: ImageData,
|
|
571
|
-
threshold: number,
|
|
572
|
-
padding: number,
|
|
573
|
-
paddedWidth: number,
|
|
574
|
-
paddedHeight: number,
|
|
575
|
-
): Uint8Array {
|
|
576
|
-
const { width, height, data } = imageData;
|
|
577
|
-
const mask = new Uint8Array(paddedWidth * paddedHeight);
|
|
578
|
-
|
|
579
|
-
// 1. Detect if the image has transparency (any pixel with alpha < 255)
|
|
580
|
-
let hasTransparency = false;
|
|
581
|
-
for (let i = 3; i < data.length; i += 4) {
|
|
582
|
-
if (data[i] < 255) {
|
|
583
|
-
hasTransparency = true;
|
|
584
|
-
break;
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
// 2. Binarize based on alpha or luminance
|
|
589
|
-
for (let y = 0; y < height; y++) {
|
|
590
|
-
for (let x = 0; x < width; x++) {
|
|
591
|
-
const srcIdx = (y * width + x) * 4;
|
|
592
|
-
const r = data[srcIdx];
|
|
593
|
-
const g = data[srcIdx + 1];
|
|
594
|
-
const b = data[srcIdx + 2];
|
|
595
|
-
const a = data[srcIdx + 3];
|
|
596
|
-
|
|
597
|
-
const destIdx = (y + padding) * paddedWidth + (x + padding);
|
|
598
|
-
|
|
599
|
-
if (hasTransparency) {
|
|
600
|
-
if (a > threshold) {
|
|
601
|
-
mask[destIdx] = 1;
|
|
602
|
-
}
|
|
603
|
-
} else {
|
|
604
|
-
if (!(r > 240 && g > 240 && b > 240)) {
|
|
605
|
-
mask[destIdx] = 1;
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
return mask;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* Fast circular morphology using a distance-transform inspired separable approach.
|
|
615
|
-
* O(N * R) complexity, where R is the radius.
|
|
616
|
-
*/
|
|
617
|
-
private static circularMorphology(
|
|
618
|
-
mask: Uint8Array,
|
|
619
|
-
width: number,
|
|
620
|
-
height: number,
|
|
621
|
-
radius: number,
|
|
622
|
-
op: "dilate" | "erode" | "closing" | "opening",
|
|
623
|
-
): Uint8Array {
|
|
624
|
-
const dilate = (m: Uint8Array, r: number) => {
|
|
625
|
-
const horizontalDist = new Int32Array(width * height);
|
|
626
|
-
// Horizontal pass: dist to nearest solid pixel in row
|
|
627
|
-
for (let y = 0; y < height; y++) {
|
|
628
|
-
let lastSolid = -r * 2;
|
|
629
|
-
for (let x = 0; x < width; x++) {
|
|
630
|
-
if (m[y * width + x]) lastSolid = x;
|
|
631
|
-
horizontalDist[y * width + x] = x - lastSolid;
|
|
632
|
-
}
|
|
633
|
-
lastSolid = width + r * 2;
|
|
634
|
-
for (let x = width - 1; x >= 0; x--) {
|
|
635
|
-
if (m[y * width + x]) lastSolid = x;
|
|
636
|
-
horizontalDist[y * width + x] = Math.min(
|
|
637
|
-
horizontalDist[y * width + x],
|
|
638
|
-
lastSolid - x,
|
|
639
|
-
);
|
|
640
|
-
}
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const result = new Uint8Array(width * height);
|
|
644
|
-
const r2 = r * r;
|
|
645
|
-
// Vertical pass: check Euclidean distance using precomputed horizontal distances
|
|
646
|
-
for (let x = 0; x < width; x++) {
|
|
647
|
-
for (let y = 0; y < height; y++) {
|
|
648
|
-
let found = false;
|
|
649
|
-
const minY = Math.max(0, y - r);
|
|
650
|
-
const maxY = Math.min(height - 1, y + r);
|
|
651
|
-
for (let dy = minY; dy <= maxY; dy++) {
|
|
652
|
-
const dY = dy - y;
|
|
653
|
-
const hDist = horizontalDist[dy * width + x];
|
|
654
|
-
if (hDist * hDist + dY * dY <= r2) {
|
|
655
|
-
found = true;
|
|
656
|
-
break;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
if (found) result[y * width + x] = 1;
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
return result;
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
const erode = (m: Uint8Array, r: number) => {
|
|
666
|
-
// Erosion is dilation of the inverted mask
|
|
667
|
-
const inverted = new Uint8Array(m.length);
|
|
668
|
-
for (let i = 0; i < m.length; i++) inverted[i] = m[i] ? 0 : 1;
|
|
669
|
-
const dilatedInverted = dilate(inverted, r);
|
|
670
|
-
const result = new Uint8Array(m.length);
|
|
671
|
-
for (let i = 0; i < m.length; i++) result[i] = dilatedInverted[i] ? 0 : 1;
|
|
672
|
-
return result;
|
|
673
|
-
};
|
|
674
|
-
|
|
675
|
-
switch (op) {
|
|
676
|
-
case "dilate":
|
|
677
|
-
return dilate(mask, radius);
|
|
678
|
-
case "erode":
|
|
679
|
-
return erode(mask, radius);
|
|
680
|
-
case "closing":
|
|
681
|
-
return erode(dilate(mask, radius), radius);
|
|
682
|
-
case "opening":
|
|
683
|
-
return dilate(erode(mask, radius), radius);
|
|
684
|
-
default:
|
|
685
|
-
return mask;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Fills internal holes in the binary mask using flood fill from edges.
|
|
691
|
-
*/
|
|
692
|
-
private static fillHoles(
|
|
693
|
-
mask: Uint8Array,
|
|
694
|
-
width: number,
|
|
695
|
-
height: number,
|
|
696
|
-
): Uint8Array {
|
|
697
|
-
const background = new Uint8Array(width * height);
|
|
698
|
-
const queue: [number, number][] = [];
|
|
699
|
-
|
|
700
|
-
// Add all edge pixels that are 0 to the queue
|
|
701
|
-
for (let x = 0; x < width; x++) {
|
|
702
|
-
if (mask[x] === 0) {
|
|
703
|
-
background[x] = 1;
|
|
704
|
-
queue.push([x, 0]);
|
|
705
|
-
}
|
|
706
|
-
const lastRow = (height - 1) * width + x;
|
|
707
|
-
if (mask[lastRow] === 0) {
|
|
708
|
-
background[lastRow] = 1;
|
|
709
|
-
queue.push([x, height - 1]);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
for (let y = 1; y < height - 1; y++) {
|
|
713
|
-
if (mask[y * width] === 0) {
|
|
714
|
-
background[y * width] = 1;
|
|
715
|
-
queue.push([0, y]);
|
|
716
|
-
}
|
|
717
|
-
if (mask[y * width + width - 1] === 0) {
|
|
718
|
-
background[y * width + width - 1] = 1;
|
|
719
|
-
queue.push([width - 1, y]);
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// Flood fill from the edges to find all background pixels
|
|
724
|
-
const dirs = [
|
|
725
|
-
[0, 1],
|
|
726
|
-
[0, -1],
|
|
727
|
-
[1, 0],
|
|
728
|
-
[-1, 0],
|
|
729
|
-
];
|
|
730
|
-
let head = 0;
|
|
731
|
-
while (head < queue.length) {
|
|
732
|
-
const [cx, cy] = queue[head++];
|
|
733
|
-
for (const [dx, dy] of dirs) {
|
|
734
|
-
const nx = cx + dx;
|
|
735
|
-
const ny = cy + dy;
|
|
736
|
-
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
|
|
737
|
-
const nidx = ny * width + nx;
|
|
738
|
-
if (mask[nidx] === 0 && background[nidx] === 0) {
|
|
739
|
-
background[nidx] = 1;
|
|
740
|
-
queue.push([nx, ny]);
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// Any pixel that is NOT reachable from the background is part of the "filled" mask
|
|
747
|
-
const filledMask = new Uint8Array(width * height);
|
|
748
|
-
for (let i = 0; i < width * height; i++) {
|
|
749
|
-
filledMask[i] = background[i] === 0 ? 1 : 0;
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
return filledMask;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
726
|
/**
|
|
756
727
|
* Traces all contours in the mask with optimized start-point detection
|
|
757
728
|
*/
|
|
@@ -960,17 +931,17 @@ export class ImageTracer {
|
|
|
960
931
|
);
|
|
961
932
|
}
|
|
962
933
|
|
|
963
|
-
private static
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
): Point[] {
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
934
|
+
private static translateContours(
|
|
935
|
+
contours: Point[][],
|
|
936
|
+
offsetX: number,
|
|
937
|
+
offsetY: number,
|
|
938
|
+
): Point[][] {
|
|
939
|
+
return contours.map((points) =>
|
|
940
|
+
points.map((p) => ({
|
|
941
|
+
x: p.x + offsetX,
|
|
942
|
+
y: p.y + offsetY,
|
|
943
|
+
})),
|
|
944
|
+
);
|
|
974
945
|
}
|
|
975
946
|
|
|
976
947
|
private static pointsToSVG(points: Point[]): string {
|
|
@@ -1001,21 +972,21 @@ export class ImageTracer {
|
|
|
1001
972
|
|
|
1002
973
|
private static pointsToSVGPaper(points: Point[], tolerance: number): string {
|
|
1003
974
|
if (points.length < 3) return this.pointsToSVG(points);
|
|
1004
|
-
|
|
975
|
+
|
|
1005
976
|
this.ensurePaper();
|
|
1006
|
-
|
|
977
|
+
|
|
1007
978
|
// Create Path
|
|
1008
979
|
const path = new paper.Path({
|
|
1009
|
-
segments: points.map(p => [p.x, p.y]),
|
|
1010
|
-
closed: true
|
|
980
|
+
segments: points.map((p) => [p.x, p.y]),
|
|
981
|
+
closed: true,
|
|
1011
982
|
});
|
|
1012
|
-
|
|
983
|
+
|
|
1013
984
|
// Simplify
|
|
1014
985
|
path.simplify(tolerance);
|
|
1015
|
-
|
|
986
|
+
|
|
1016
987
|
const data = path.pathData;
|
|
1017
988
|
path.remove();
|
|
1018
|
-
|
|
989
|
+
|
|
1019
990
|
return data;
|
|
1020
991
|
}
|
|
1021
992
|
|