@pooder/kit 3.3.0 → 3.4.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/src/tracer.ts CHANGED
@@ -17,11 +17,12 @@ export class ImageTracer {
17
17
  public static async trace(
18
18
  imageUrl: string,
19
19
  options: {
20
- threshold?: number; // 0-255, default 128
21
- simplifyTolerance?: number; // default 1.0
22
- scale?: number; // Scale factor for the processing canvas, default 1.0 (or smaller for speed)
20
+ threshold?: number; // 0-255, default 10
21
+ simplifyTolerance?: number; // default 2.0 (Balanced)
22
+ scale?: number; // Scale factor for the processing canvas, default 1.0
23
23
  scaleToWidth?: number;
24
24
  scaleToHeight?: number;
25
+ morphologyRadius?: number; // Default 10.
25
26
  } = {},
26
27
  ): Promise<string> {
27
28
  const img = await this.loadImage(imageUrl);
@@ -38,29 +39,278 @@ export class ImageTracer {
38
39
  ctx.drawImage(img, 0, 0);
39
40
  const imageData = ctx.getImageData(0, 0, width, height);
40
41
 
41
- // 2. Trace contours using Marching Squares
42
- const points = this.marchingSquares(imageData, options.threshold ?? 10);
42
+ // 2. Morphology processing
43
+ const threshold = options.threshold ?? 10;
44
+ // Adaptive radius: 3% of the image's largest dimension, at least 5px
45
+ const adaptiveRadius = Math.max(
46
+ 5,
47
+ Math.floor(Math.max(width, height) * 0.02),
48
+ );
49
+ const radius = options.morphologyRadius ?? adaptiveRadius;
50
+
51
+ let mask = this.createMask(imageData, threshold);
52
+ if (radius > 0) {
53
+ // Closing operation: Dilation followed by Erosion to merge parts and smooth
54
+ mask = this.dilate(mask, width, height, radius);
55
+ mask = this.erode(mask, width, height, radius);
56
+ // Fill internal holes to ensure we only get the overall outer contour
57
+ mask = this.fillHoles(mask, width, height);
58
+ }
43
59
 
44
- // 2.1 Scale points if target size is provided
45
- let finalPoints = points;
46
- if (options.scaleToWidth && options.scaleToHeight && points.length > 0) {
60
+ // 3. Trace contours from the unified mask
61
+ const allContourPoints = this.traceAllContours(mask, width, height);
62
+
63
+ if (allContourPoints.length === 0) {
64
+ // Fallback: Return a rectangular outline matching dimensions
65
+ const w = options.scaleToWidth ?? width;
66
+ const h = options.scaleToHeight ?? height;
67
+ return `M 0 0 L ${w} 0 L ${w} ${h} L 0 ${h} Z`;
68
+ }
69
+
70
+ // 4. Select the largest contour to ensure a single, consistent overall shape
71
+ const primaryContour = allContourPoints.sort(
72
+ (a, b) => b.length - a.length,
73
+ )[0];
74
+
75
+ // 5. Find bounds for the selected contour
76
+ let minX = Infinity,
77
+ minY = Infinity,
78
+ maxX = -Infinity,
79
+ maxY = -Infinity;
80
+
81
+ for (const p of primaryContour) {
82
+ if (p.x < minX) minX = p.x;
83
+ if (p.y < minY) minY = p.y;
84
+ if (p.x > maxX) maxX = p.x;
85
+ if (p.y > maxY) maxY = p.y;
86
+ }
87
+
88
+ const globalBounds = {
89
+ minX,
90
+ minY,
91
+ width: maxX - minX,
92
+ height: maxY - minY,
93
+ };
94
+
95
+ // 6. Post-processing
96
+ let finalPoints = primaryContour;
97
+ if (options.scaleToWidth && options.scaleToHeight) {
47
98
  finalPoints = this.scalePoints(
48
- points,
99
+ primaryContour,
49
100
  options.scaleToWidth,
50
101
  options.scaleToHeight,
102
+ globalBounds,
51
103
  );
52
104
  }
53
105
 
54
- // 3. Simplify path
55
106
  const simplifiedPoints = this.douglasPeucker(
56
107
  finalPoints,
57
- options.simplifyTolerance ?? 0.5,
108
+ options.simplifyTolerance ?? 2.0,
58
109
  );
59
110
 
60
- // 4. Convert to SVG Path
61
111
  return this.pointsToSVG(simplifiedPoints);
62
112
  }
63
113
 
114
+ private static createMask(
115
+ imageData: ImageData,
116
+ threshold: number,
117
+ ): Uint8Array {
118
+ const { width, height, data } = imageData;
119
+ const mask = new Uint8Array(width * height);
120
+
121
+ for (let i = 0; i < width * height; i++) {
122
+ const idx = i * 4;
123
+ const r = data[idx];
124
+ const g = data[idx + 1];
125
+ const b = data[idx + 2];
126
+ const a = data[idx + 3];
127
+
128
+ // Alpha threshold + White background heuristic
129
+ if (a > threshold && !(r > 240 && g > 240 && b > 240)) {
130
+ mask[i] = 1;
131
+ } else {
132
+ mask[i] = 0;
133
+ }
134
+ }
135
+ return mask;
136
+ }
137
+
138
+ /**
139
+ * Fast 1D-separable Dilation
140
+ */
141
+ private static dilate(
142
+ mask: Uint8Array,
143
+ width: number,
144
+ height: number,
145
+ radius: number,
146
+ ): Uint8Array {
147
+ const horizontal = new Uint8Array(width * height);
148
+ for (let y = 0; y < height; y++) {
149
+ let count = 0;
150
+ for (let x = -radius; x < width; x++) {
151
+ if (x + radius < width && mask[y * width + x + radius]) count++;
152
+ if (x - radius - 1 >= 0 && mask[y * width + x - radius - 1]) count--;
153
+ if (x >= 0) horizontal[y * width + x] = count > 0 ? 1 : 0;
154
+ }
155
+ }
156
+
157
+ const vertical = new Uint8Array(width * height);
158
+ for (let x = 0; x < width; x++) {
159
+ let count = 0;
160
+ for (let y = -radius; y < height; y++) {
161
+ if (y + radius < height && horizontal[(y + radius) * width + x])
162
+ count++;
163
+ if (y - radius - 1 >= 0 && horizontal[(y - radius - 1) * width + x])
164
+ count--;
165
+ if (y >= 0) vertical[y * width + x] = count > 0 ? 1 : 0;
166
+ }
167
+ }
168
+ return vertical;
169
+ }
170
+
171
+ /**
172
+ * Fast 1D-separable Erosion
173
+ */
174
+ private static erode(
175
+ mask: Uint8Array,
176
+ width: number,
177
+ height: number,
178
+ radius: number,
179
+ ): Uint8Array {
180
+ const horizontal = new Uint8Array(width * height);
181
+ for (let y = 0; y < height; y++) {
182
+ let count = 0;
183
+ for (let x = -radius; x < width; x++) {
184
+ if (x + radius < width && mask[y * width + x + radius]) count++;
185
+ if (x - radius - 1 >= 0 && mask[y * width + x - radius - 1]) count--;
186
+ if (x >= 0) {
187
+ const winWidth =
188
+ Math.min(x + radius, width - 1) - Math.max(x - radius, 0) + 1;
189
+ horizontal[y * width + x] = count === winWidth ? 1 : 0;
190
+ }
191
+ }
192
+ }
193
+
194
+ const vertical = new Uint8Array(width * height);
195
+ for (let x = 0; x < width; x++) {
196
+ let count = 0;
197
+ for (let y = -radius; y < height; y++) {
198
+ if (y + radius < height && horizontal[(y + radius) * width + x])
199
+ count++;
200
+ if (y - radius - 1 >= 0 && horizontal[(y - radius - 1) * width + x])
201
+ count--;
202
+ if (y >= 0) {
203
+ const winHeight =
204
+ Math.min(y + radius, height - 1) - Math.max(y - radius, 0) + 1;
205
+ vertical[y * width + x] = count === winHeight ? 1 : 0;
206
+ }
207
+ }
208
+ }
209
+ return vertical;
210
+ }
211
+
212
+ /**
213
+ * Fills internal holes in the binary mask using flood fill from edges.
214
+ */
215
+ private static fillHoles(
216
+ mask: Uint8Array,
217
+ width: number,
218
+ height: number,
219
+ ): Uint8Array {
220
+ const background = new Uint8Array(width * height);
221
+ const queue: [number, number][] = [];
222
+
223
+ // Add all edge pixels that are 0 to the queue
224
+ for (let x = 0; x < width; x++) {
225
+ if (mask[x] === 0) {
226
+ background[x] = 1;
227
+ queue.push([x, 0]);
228
+ }
229
+ const lastRow = (height - 1) * width + x;
230
+ if (mask[lastRow] === 0) {
231
+ background[lastRow] = 1;
232
+ queue.push([x, height - 1]);
233
+ }
234
+ }
235
+ for (let y = 1; y < height - 1; y++) {
236
+ if (mask[y * width] === 0) {
237
+ background[y * width] = 1;
238
+ queue.push([0, y]);
239
+ }
240
+ if (mask[y * width + width - 1] === 0) {
241
+ background[y * width + width - 1] = 1;
242
+ queue.push([width - 1, y]);
243
+ }
244
+ }
245
+
246
+ // Flood fill from the edges to find all background pixels
247
+ const dirs = [
248
+ [0, 1],
249
+ [0, -1],
250
+ [1, 0],
251
+ [-1, 0],
252
+ ];
253
+ let head = 0;
254
+ while (head < queue.length) {
255
+ const [cx, cy] = queue[head++];
256
+ for (const [dx, dy] of dirs) {
257
+ const nx = cx + dx;
258
+ const ny = cy + dy;
259
+ if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
260
+ const nidx = ny * width + nx;
261
+ if (mask[nidx] === 0 && background[nidx] === 0) {
262
+ background[nidx] = 1;
263
+ queue.push([nx, ny]);
264
+ }
265
+ }
266
+ }
267
+ }
268
+
269
+ // Any pixel that is NOT reachable from the background is part of the "filled" mask
270
+ const filledMask = new Uint8Array(width * height);
271
+ for (let i = 0; i < width * height; i++) {
272
+ filledMask[i] = background[i] === 0 ? 1 : 0;
273
+ }
274
+
275
+ return filledMask;
276
+ }
277
+
278
+ /**
279
+ * Traces all contours in the mask with optimized start-point detection
280
+ */
281
+ private static traceAllContours(
282
+ mask: Uint8Array,
283
+ width: number,
284
+ height: number,
285
+ ): Point[][] {
286
+ const visited = new Uint8Array(width * height);
287
+ const allContours: Point[][] = [];
288
+
289
+ for (let y = 0; y < height; y++) {
290
+ for (let x = 0; x < width; x++) {
291
+ const idx = y * width + x;
292
+ if (mask[idx] && !visited[idx]) {
293
+ // Only start a new trace if it's a potential outer boundary (left edge)
294
+ const isLeftEdge = x === 0 || mask[idx - 1] === 0;
295
+ if (isLeftEdge) {
296
+ const contour = this.marchingSquares(
297
+ mask,
298
+ visited,
299
+ x,
300
+ y,
301
+ width,
302
+ height,
303
+ );
304
+ if (contour.length > 2) {
305
+ allContours.push(contour);
306
+ }
307
+ }
308
+ }
309
+ }
310
+ }
311
+ return allContours;
312
+ }
313
+
64
314
  private static loadImage(url: string): Promise<HTMLImageElement> {
65
315
  return new Promise((resolve, reject) => {
66
316
  const img = new Image();
@@ -76,73 +326,18 @@ export class ImageTracer {
76
326
  * More robust for irregular shapes than simple Marching Squares walker.
77
327
  */
78
328
  private static marchingSquares(
79
- imageData: ImageData,
80
- alphaThreshold: number,
329
+ mask: Uint8Array,
330
+ visited: Uint8Array,
331
+ startX: number,
332
+ startY: number,
333
+ width: number,
334
+ height: number,
81
335
  ): Point[] {
82
- const width = imageData.width;
83
- const height = imageData.height;
84
- const data = imageData.data;
85
-
86
- // Use Luminance for solid check if Alpha is fully opaque
87
- // Or check Alpha first, then Luminance?
88
- // Let's assume:
89
- // If pixel is transparent (Alpha <= threshold), it's empty.
90
- // If pixel is opaque (Alpha > threshold):
91
- // If it's white (Luminance > some_high_value), it's empty (background).
92
- // Else it's solid.
93
- // This supports black shapes on white background (JPG).
94
-
95
- // Luminance = 0.299*R + 0.587*G + 0.114*B
96
- // We treat "Dark" as solid? Or "Light" as solid?
97
- // Usually "Content" is non-white on white background.
98
- // Let's add a `luminanceThreshold` option?
99
- // For now, let's hardcode a heuristic:
100
- // If R,G,B are all > 240, treat as background (white).
101
-
102
336
  const isSolid = (x: number, y: number): boolean => {
103
337
  if (x < 0 || x >= width || y < 0 || y >= height) return false;
104
- const index = (y * width + x) * 4;
105
- const r = data[index];
106
- const g = data[index + 1];
107
- const b = data[index + 2];
108
- const a = data[index + 3];
109
-
110
- if (a <= alphaThreshold) return false;
111
-
112
- // Check for White Background (approx)
113
- // If average > 240, treat as empty
114
- if (r > 240 && g > 240 && b > 240) return false;
115
-
116
- return true;
338
+ return mask[y * width + x] === 1;
117
339
  };
118
340
 
119
- // 1. Find Starting Pixel (Scanline)
120
- // We want the *largest* contour ideally, or the first one.
121
- // For now, let's just find the first one.
122
- // To support holes, we would need to keep scanning.
123
- // But Moore Tracing follows a single contour.
124
-
125
- let startX = -1;
126
- let startY = -1;
127
-
128
- // Gaussian Blur Simulation (Box Blur) to reduce noise?
129
- // Or just simple neighbor check?
130
- // Let's implement a simple noise filter in isSolid? No, isSolid is per pixel.
131
- // Let's preprocess data? Too slow in JS?
132
- // Let's just rely on threshold.
133
-
134
- searchLoop: for (let y = 0; y < height; y++) {
135
- for (let x = 0; x < width; x++) {
136
- if (isSolid(x, y)) {
137
- startX = x;
138
- startY = y;
139
- break searchLoop;
140
- }
141
- }
142
- }
143
-
144
- if (startX === -1) return [];
145
-
146
341
  const points: Point[] = [];
147
342
 
148
343
  // Moore-Neighbor Tracing
@@ -175,83 +370,26 @@ export class ImageTracer {
175
370
 
176
371
  do {
177
372
  points.push({ x: cx, y: cy });
373
+ visited[cy * width + cx] = 1; // Mark as visited to avoid re-starting here
178
374
 
179
375
  // Search for next solid neighbor in clockwise order, starting from backtrack
180
376
  let found = false;
181
377
 
182
- // We check 8 neighbors.
183
- // Moore algorithm says: start from backtrack, go clockwise until you find a black pixel.
184
- // The backtrack for the NEXT step will be the neighbor BEFORE the one we found.
185
-
186
378
  for (let i = 0; i < 8; i++) {
187
- // Index in neighbors array. Start from backtrack direction.
188
- // Actually Moore algorithm typically starts from (backtrack + 1) % 8 ?
189
- // Let's standard: Start searching clockwise from the pixel entered from.
190
-
191
379
  const idx = (backtrack + 1 + i) % 8;
192
380
  const nx = cx + neighbors[idx].x;
193
381
  const ny = cy + neighbors[idx].y;
194
382
 
195
383
  if (isSolid(nx, ny)) {
196
- // Found next pixel P
197
384
  cx = nx;
198
385
  cy = ny;
199
- // New backtrack is the neighbor pointing back to current P from previous P?
200
- // No, backtrack is the empty neighbor immediately counter-clockwise from the new P.
201
- // In our loop, it's the previous index (idx - 1).
202
- backtrack = (idx + 4) % 8; // Actually, backtrack direction relative to New P is opposite?
203
- // Let's strictly follow Moore:
204
- // Entering P from direction D. Start scan from D-1 (or D+something).
205
- // Let's use the property:
206
- // We entered P from `idx`. The previous check `idx-1` was empty.
207
- // So for the next step, we can start checking from `idx-3` (approx 90 deg back) or `idx-2`.
208
- // Standard Moore: Backtrack = neighbor index that was empty previously.
209
- // Here, `idx` is the direction FROM old P TO new P.
210
- // The direction FROM new P TO old P is `(idx + 4) % 8`.
211
- // We want to start scanning around new P.
212
- // We start scanning from the neighbor that is "Left" of the incoming edge.
213
-
214
- // Let's simplify: Start scanning from (EntryDirection + 5) % 8 ?
215
- // EntryDirection is (idx). Backwards is (idx+4).
216
- // We want to start from the white pixel we just passed.
217
- // That was `(idx - 1)`.
218
- // Direction FROM old P to (idx-1) is neighbors[idx-1].
219
- // We want direction FROM new P to that same white pixel? No.
220
-
221
- // Working Heuristic:
222
- // Next search starts from (current_incoming_direction + 4 + 1) ?
223
- // Let's set backtrack to point to the neighbor we entered from, then rotate CCW?
224
- // Let's use: start search from `(idx + 5) % 8`.
225
- // Why? idx is direction 0..7. Back is idx+4. +1 is clockwise.
226
- // We want counter-clockwise.
227
-
228
- backtrack = (idx + 4 + 1) % 8; // Start searching from neighbor "after" the one we came from (CCW)?
229
- // Wait, loop above is Clockwise.
230
- // To trace outer boundary counter-clockwise, we scan neighbors counter-clockwise?
231
- // Or trace outer boundary clockwise, scan neighbors clockwise.
232
-
233
- // Let's trace Clockwise.
234
- // Scan neighbors Clockwise.
235
- // Start scan from (IncomingDirection + 5) % 8. (Backwards + 1 CW).
236
- backtrack = (idx + 4 + 1) % 8; // Backwards + 1 step CW.
237
-
238
- // But wait, if we are tracing an 1-pixel line, we turn around (backtrack).
239
- // Let's try `(idx + 5) % 8` if neighbors are ordered CW.
240
- // neighbors[0] is Up. [2] is Right.
241
- // If we move Right (idx=2). Back is Left (6).
242
- // We want to check UpLeft (7), Up (0), UpRight (1)...
243
- // So start from 7? That is 6+1.
244
- // So `(idx + 4 + 1) % 8` seems correct.
245
-
386
+ backtrack = (idx + 4 + 1) % 8;
246
387
  found = true;
247
388
  break;
248
389
  }
249
390
  }
250
391
 
251
- if (!found) {
252
- // Isolated pixel
253
- break;
254
- }
392
+ if (!found) break;
255
393
 
256
394
  steps++;
257
395
  } while ((cx !== startX || cy !== startY) && steps < maxSteps);
@@ -319,42 +457,18 @@ export class ImageTracer {
319
457
  points: Point[],
320
458
  targetWidth: number,
321
459
  targetHeight: number,
460
+ bounds: { minX: number; minY: number; width: number; height: number },
322
461
  ): Point[] {
323
462
  if (points.length === 0) return points;
324
463
 
325
- // Find bounds
326
- let minX = Infinity,
327
- minY = Infinity,
328
- maxX = -Infinity,
329
- maxY = -Infinity;
330
- for (const p of points) {
331
- if (p.x < minX) minX = p.x;
332
- if (p.y < minY) minY = p.y;
333
- if (p.x > maxX) maxX = p.x;
334
- if (p.y > maxY) maxY = p.y;
335
- }
336
-
337
- const srcW = maxX - minX;
338
- const srcH = maxY - minY;
339
-
340
- if (srcW === 0 || srcH === 0) return points;
341
-
342
- const scaleX = targetWidth / srcW;
343
- const scaleY = targetHeight / srcH;
464
+ if (bounds.width === 0 || bounds.height === 0) return points;
344
465
 
345
- // Scale and center? Or just scale?
346
- // User usually wants to fit the shape into the box.
347
- // Let's just scale and align top-left to 0,0 for now, or center it?
348
- // Dieline usually expects centered shape?
349
- // geometry.ts createBaseShape aligns path.position = center.
350
- // So the path data coordinates should probably be relative to 0,0 or centered.
351
- // Paper.js Path(pathData) creates path in original coordinates.
352
- // If we return points in 0..targetWidth, 0..targetHeight, paper will create it there.
353
- // geometry.ts will then center it.
466
+ const scaleX = targetWidth / bounds.width;
467
+ const scaleY = targetHeight / bounds.height;
354
468
 
355
469
  return points.map((p) => ({
356
- x: (p.x - minX) * scaleX,
357
- y: (p.y - minY) * scaleY,
470
+ x: (p.x - bounds.minX) * scaleX,
471
+ y: (p.y - bounds.minY) * scaleY,
358
472
  }));
359
473
  }
360
474