@pooder/kit 5.2.0 → 5.3.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/CanvasService.js +249 -249
- package/.test-dist/src/ViewportSystem.js +75 -75
- package/.test-dist/src/background.js +203 -203
- package/.test-dist/src/bridgeSelection.js +20 -20
- package/.test-dist/src/constraints.js +237 -237
- package/.test-dist/src/dieline.js +818 -818
- package/.test-dist/src/edgeScale.js +12 -12
- 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/feature.js +826 -826
- package/.test-dist/src/featureComplete.js +32 -32
- package/.test-dist/src/film.js +167 -167
- package/.test-dist/src/geometry.js +506 -506
- package/.test-dist/src/image.js +1250 -1250
- package/.test-dist/src/index.js +2 -19
- package/.test-dist/src/maskOps.js +270 -270
- package/.test-dist/src/mirror.js +104 -104
- package/.test-dist/src/renderSpec.js +2 -2
- package/.test-dist/src/ruler.js +343 -343
- package/.test-dist/src/sceneLayout.js +99 -99
- package/.test-dist/src/sceneLayoutModel.js +196 -196
- package/.test-dist/src/sceneView.js +40 -40
- package/.test-dist/src/sceneVisibility.js +42 -42
- 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/.test-dist/src/size.js +332 -332
- package/.test-dist/src/tracer.js +544 -544
- package/.test-dist/src/white-ink.js +829 -829
- package/.test-dist/src/wrappedOffsets.js +33 -33
- package/CHANGELOG.md +6 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +411 -375
- package/dist/index.mjs +411 -375
- package/package.json +1 -1
- package/src/coordinate.ts +106 -106
- package/src/extensions/background.ts +230 -230
- package/src/extensions/bridgeSelection.ts +17 -17
- package/src/extensions/constraints.ts +322 -322
- package/src/extensions/dieline.ts +20 -17
- package/src/extensions/edgeScale.ts +19 -19
- package/src/extensions/feature.ts +1021 -1021
- package/src/extensions/featureComplete.ts +46 -46
- package/src/extensions/film.ts +194 -194
- package/src/extensions/geometry.ts +719 -719
- package/src/extensions/image.ts +1924 -1924
- package/src/extensions/index.ts +11 -11
- package/src/extensions/maskOps.ts +365 -299
- package/src/extensions/mirror.ts +128 -128
- package/src/extensions/ruler.ts +451 -451
- package/src/extensions/sceneLayout.ts +140 -140
- package/src/extensions/sceneLayoutModel.ts +342 -342
- package/src/extensions/sceneVisibility.ts +71 -71
- package/src/extensions/size.ts +389 -389
- package/src/extensions/tracer.ts +302 -370
- package/src/extensions/white-ink.ts +1489 -1366
- package/src/extensions/wrappedOffsets.ts +33 -33
- package/src/index.ts +2 -2
- package/src/services/CanvasService.ts +300 -300
- package/src/services/ViewportSystem.ts +95 -95
- package/src/services/index.ts +3 -3
- package/src/services/renderSpec.ts +18 -18
- package/src/units.ts +27 -27
- package/tests/run.ts +118 -118
- package/tsconfig.test.json +15 -15
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
|
|
|
@@ -309,16 +367,15 @@ export class ImageTracer {
|
|
|
309
367
|
};
|
|
310
368
|
}
|
|
311
369
|
|
|
370
|
+
// Keep expanded coordinates in the unpadded space without clamping to
|
|
371
|
+
// original image bounds. If the shape touches an edge, clamping would
|
|
372
|
+
// drop one-sided expand distance (e.g. bottom/right expansion).
|
|
312
373
|
const expandedUnpaddedContours = expandedContours
|
|
313
374
|
.map((contour) =>
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
})),
|
|
319
|
-
width,
|
|
320
|
-
height,
|
|
321
|
-
),
|
|
375
|
+
contour.map((p) => ({
|
|
376
|
+
x: p.x - padding,
|
|
377
|
+
y: p.y - padding,
|
|
378
|
+
})),
|
|
322
379
|
)
|
|
323
380
|
.filter((contour) => contour.length > 2);
|
|
324
381
|
if (!expandedUnpaddedContours.length) {
|
|
@@ -339,7 +396,7 @@ export class ImageTracer {
|
|
|
339
396
|
this.flattenContours(expandedUnpaddedContours),
|
|
340
397
|
);
|
|
341
398
|
|
|
342
|
-
//
|
|
399
|
+
// Post-processing (Scale)
|
|
343
400
|
let finalContours = expandedUnpaddedContours;
|
|
344
401
|
if (options.scaleToWidth && options.scaleToHeight) {
|
|
345
402
|
finalContours = this.scaleContours(
|
|
@@ -356,43 +413,39 @@ export class ImageTracer {
|
|
|
356
413
|
options.scaleToHeight,
|
|
357
414
|
baseBounds,
|
|
358
415
|
);
|
|
359
|
-
baseBounds = this.boundsFromPoints(
|
|
416
|
+
baseBounds = this.boundsFromPoints(
|
|
417
|
+
this.flattenContours(baseScaledContours),
|
|
418
|
+
);
|
|
360
419
|
}
|
|
361
420
|
|
|
362
|
-
//
|
|
363
|
-
const useSmoothing = options.smoothing !== false; // Default true
|
|
421
|
+
// Simplify and Generate SVG
|
|
364
422
|
debugLog("traceWithBounds:contours", {
|
|
365
423
|
baseContourCount: baseContoursRaw.length,
|
|
366
424
|
baseSelectedCount: baseContours.length,
|
|
367
425
|
expandedContourCount: expandedContoursRaw.length,
|
|
368
426
|
expandedSelectedCount: expandedContours.length,
|
|
369
|
-
connectRadiusMax,
|
|
370
|
-
appliedConnectRadius: rConnect,
|
|
371
427
|
baseBounds,
|
|
372
428
|
expandedBounds: globalBounds,
|
|
373
429
|
expandedDeltaX: globalBounds.width - baseBounds.width,
|
|
374
430
|
expandedDeltaY: globalBounds.height - baseBounds.height,
|
|
431
|
+
expandedMayOverflowImageBounds: expand > 0,
|
|
375
432
|
useSmoothing,
|
|
376
433
|
componentMode,
|
|
377
434
|
});
|
|
378
435
|
|
|
379
436
|
if (useSmoothing) {
|
|
380
437
|
return {
|
|
381
|
-
pathData: this.contoursToSVGPaper(
|
|
382
|
-
finalContours,
|
|
383
|
-
options.simplifyTolerance ?? 2.5,
|
|
384
|
-
),
|
|
438
|
+
pathData: this.contoursToSVGPaper(finalContours, simplifyTolerance),
|
|
385
439
|
baseBounds,
|
|
386
440
|
bounds: globalBounds,
|
|
387
441
|
};
|
|
388
442
|
} else {
|
|
389
443
|
const simplifiedContours = finalContours
|
|
390
|
-
.map((points) =>
|
|
391
|
-
this.douglasPeucker(points, options.simplifyTolerance ?? 2.0),
|
|
392
|
-
)
|
|
444
|
+
.map((points) => this.douglasPeucker(points, simplifyTolerance))
|
|
393
445
|
.filter((points) => points.length > 2);
|
|
394
446
|
const pathData =
|
|
395
|
-
this.contoursToSVG(simplifiedContours) ||
|
|
447
|
+
this.contoursToSVG(simplifiedContours) ||
|
|
448
|
+
this.contoursToSVG(finalContours);
|
|
396
449
|
return {
|
|
397
450
|
pathData,
|
|
398
451
|
baseBounds,
|
|
@@ -438,7 +491,7 @@ export class ImageTracer {
|
|
|
438
491
|
const yj = polygon[j].y;
|
|
439
492
|
const intersects =
|
|
440
493
|
yi > y !== yj > y &&
|
|
441
|
-
x < ((xj - xi) * (y - yi)) / (
|
|
494
|
+
x < ((xj - xi) * (y - yi)) / (yj - yi || Number.EPSILON) + xi;
|
|
442
495
|
if (intersects) inside = !inside;
|
|
443
496
|
}
|
|
444
497
|
return inside;
|
|
@@ -463,55 +516,120 @@ export class ImageTracer {
|
|
|
463
516
|
return selected;
|
|
464
517
|
}
|
|
465
518
|
|
|
466
|
-
private static
|
|
519
|
+
private static summarizeAllContours(
|
|
467
520
|
mask: Uint8Array,
|
|
468
521
|
width: number,
|
|
469
522
|
height: number,
|
|
470
523
|
minComponentArea: number,
|
|
471
|
-
): number {
|
|
472
|
-
const
|
|
473
|
-
|
|
524
|
+
): { rawCount: number; selectedCount: number } {
|
|
525
|
+
const raw = this.traceAllContours(mask, width, height);
|
|
526
|
+
const selected = this.selectContours(raw, "all", minComponentArea);
|
|
527
|
+
return {
|
|
528
|
+
rawCount: raw.length,
|
|
529
|
+
selectedCount: selected.length,
|
|
530
|
+
};
|
|
474
531
|
}
|
|
475
532
|
|
|
476
|
-
private static
|
|
477
|
-
|
|
533
|
+
private static findForceConnectResult(
|
|
534
|
+
sourceMask: Uint8Array,
|
|
478
535
|
width: number,
|
|
479
536
|
height: number,
|
|
480
|
-
maxRadius: number,
|
|
481
537
|
minComponentArea: number,
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
538
|
+
startDilateRadius: number,
|
|
539
|
+
maxDilateRadius: number,
|
|
540
|
+
erodeRatio: number,
|
|
541
|
+
): ForceConnectResult {
|
|
542
|
+
const initial = this.summarizeAllContours(
|
|
543
|
+
sourceMask,
|
|
544
|
+
width,
|
|
545
|
+
height,
|
|
546
|
+
minComponentArea,
|
|
547
|
+
);
|
|
548
|
+
if (initial.selectedCount <= 1) {
|
|
549
|
+
return {
|
|
550
|
+
mask: sourceMask,
|
|
551
|
+
appliedDilateRadius: 0,
|
|
552
|
+
appliedErodeRadius: 0,
|
|
553
|
+
reachedSingleComponent: true,
|
|
554
|
+
rawContourCount: initial.rawCount,
|
|
555
|
+
selectedContourCount: initial.selectedCount,
|
|
556
|
+
};
|
|
486
557
|
}
|
|
487
558
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
559
|
+
const normalizedStart = Math.max(1, Math.floor(startDilateRadius));
|
|
560
|
+
const normalizedMax = Math.max(
|
|
561
|
+
normalizedStart,
|
|
562
|
+
Math.floor(maxDilateRadius),
|
|
563
|
+
);
|
|
564
|
+
const normalizedErodeRatio = Math.max(0, erodeRatio);
|
|
565
|
+
const evaluate = (dilateRadius: number) => {
|
|
566
|
+
const erodeRadius = Math.max(
|
|
567
|
+
1,
|
|
568
|
+
Math.floor(dilateRadius * normalizedErodeRatio),
|
|
569
|
+
);
|
|
570
|
+
let mask = sourceMask;
|
|
571
|
+
mask = circularMorphology(mask, width, height, dilateRadius, "dilate");
|
|
572
|
+
mask = fillHoles(mask, width, height);
|
|
573
|
+
mask = circularMorphology(mask, width, height, erodeRadius, "erode");
|
|
574
|
+
mask = fillHoles(mask, width, height);
|
|
575
|
+
const summary = this.summarizeAllContours(
|
|
576
|
+
mask,
|
|
577
|
+
width,
|
|
578
|
+
height,
|
|
579
|
+
minComponentArea,
|
|
580
|
+
);
|
|
581
|
+
return {
|
|
582
|
+
dilateRadius,
|
|
583
|
+
erodeRadius,
|
|
584
|
+
mask,
|
|
585
|
+
rawCount: summary.rawCount,
|
|
586
|
+
selectedCount: summary.selectedCount,
|
|
587
|
+
};
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
let low = normalizedStart - 1;
|
|
591
|
+
let high = normalizedStart;
|
|
592
|
+
let highResult = evaluate(high);
|
|
593
|
+
while (high < normalizedMax && highResult.selectedCount > 1) {
|
|
594
|
+
low = high;
|
|
595
|
+
high = Math.min(
|
|
596
|
+
normalizedMax,
|
|
597
|
+
Math.max(high + 1, Math.floor(high * 1.6)),
|
|
598
|
+
);
|
|
599
|
+
highResult = evaluate(high);
|
|
496
600
|
}
|
|
497
|
-
if (high > maxRadius) high = maxRadius;
|
|
498
601
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
602
|
+
if (highResult.selectedCount > 1) {
|
|
603
|
+
return {
|
|
604
|
+
mask: highResult.mask,
|
|
605
|
+
appliedDilateRadius: highResult.dilateRadius,
|
|
606
|
+
appliedErodeRadius: highResult.erodeRadius,
|
|
607
|
+
reachedSingleComponent: false,
|
|
608
|
+
rawContourCount: highResult.rawCount,
|
|
609
|
+
selectedContourCount: highResult.selectedCount,
|
|
610
|
+
};
|
|
502
611
|
}
|
|
503
612
|
|
|
613
|
+
let best = highResult;
|
|
504
614
|
while (low + 1 < high) {
|
|
505
615
|
const mid = Math.floor((low + high) / 2);
|
|
506
|
-
const
|
|
507
|
-
if (
|
|
616
|
+
const midResult = evaluate(mid);
|
|
617
|
+
if (midResult.selectedCount <= 1) {
|
|
618
|
+
best = midResult;
|
|
508
619
|
high = mid;
|
|
509
620
|
} else {
|
|
510
621
|
low = mid;
|
|
511
622
|
}
|
|
512
623
|
}
|
|
513
624
|
|
|
514
|
-
return
|
|
625
|
+
return {
|
|
626
|
+
mask: best.mask,
|
|
627
|
+
appliedDilateRadius: best.dilateRadius,
|
|
628
|
+
appliedErodeRadius: best.erodeRadius,
|
|
629
|
+
reachedSingleComponent: true,
|
|
630
|
+
rawContourCount: best.rawCount,
|
|
631
|
+
selectedContourCount: best.selectedCount,
|
|
632
|
+
};
|
|
515
633
|
}
|
|
516
634
|
|
|
517
635
|
private static selectContours(
|
|
@@ -566,192 +684,6 @@ export class ImageTracer {
|
|
|
566
684
|
};
|
|
567
685
|
}
|
|
568
686
|
|
|
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
687
|
/**
|
|
756
688
|
* Traces all contours in the mask with optimized start-point detection
|
|
757
689
|
*/
|
|
@@ -1001,21 +933,21 @@ export class ImageTracer {
|
|
|
1001
933
|
|
|
1002
934
|
private static pointsToSVGPaper(points: Point[], tolerance: number): string {
|
|
1003
935
|
if (points.length < 3) return this.pointsToSVG(points);
|
|
1004
|
-
|
|
936
|
+
|
|
1005
937
|
this.ensurePaper();
|
|
1006
|
-
|
|
938
|
+
|
|
1007
939
|
// Create Path
|
|
1008
940
|
const path = new paper.Path({
|
|
1009
|
-
segments: points.map(p => [p.x, p.y]),
|
|
1010
|
-
closed: true
|
|
941
|
+
segments: points.map((p) => [p.x, p.y]),
|
|
942
|
+
closed: true,
|
|
1011
943
|
});
|
|
1012
|
-
|
|
944
|
+
|
|
1013
945
|
// Simplify
|
|
1014
946
|
path.simplify(tolerance);
|
|
1015
|
-
|
|
947
|
+
|
|
1016
948
|
const data = path.pathData;
|
|
1017
949
|
path.remove();
|
|
1018
|
-
|
|
950
|
+
|
|
1019
951
|
return data;
|
|
1020
952
|
}
|
|
1021
953
|
|