ppu-ocv 2.0.0 → 3.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/README.md +196 -59
- package/canvas-factory.d.ts +6 -0
- package/canvas-factory.js +1 -1
- package/canvas-processor.d.ts +200 -0
- package/canvas-processor.js +1 -0
- package/canvas-toolkit.base.d.ts +5 -2
- package/deskew.d.ts +68 -0
- package/deskew.js +1 -0
- package/image-processor.d.ts +0 -8
- package/image-processor.js +1 -1
- package/index.canvas-web.d.ts +6 -0
- package/index.canvas-web.js +1 -0
- package/index.canvas.d.ts +8 -0
- package/index.canvas.js +1 -0
- package/index.d.ts +3 -1
- package/index.js +1 -1
- package/index.web.d.ts +3 -1
- package/index.web.js +1 -1
- package/package.json +11 -2
package/README.md
CHANGED
|
@@ -32,6 +32,8 @@ OpenCV is powerful but can be cumbersome to use directly. This library provides:
|
|
|
32
32
|
3. **Development Speed**: Add image processing to your app in minutes, not hours
|
|
33
33
|
4. **Extensibility**: Custom operations for your specific needs without library modifications
|
|
34
34
|
5. **TypeScript Integration**: Full IntelliSense support with parameter validation
|
|
35
|
+
6. **Web Support**: Supports running directly in the browser
|
|
36
|
+
7. **Loosely Coupled**: Canvas utilities are fully decoupled from OpenCV. Usable in Browser Extensions, Service Workers, and other constrained environments where OpenCV cannot be initialised
|
|
35
37
|
|
|
36
38
|
## Installation
|
|
37
39
|
|
|
@@ -45,40 +47,43 @@ bun add ppu-ocv
|
|
|
45
47
|
|
|
46
48
|
## Usage (Node.js / Bun)
|
|
47
49
|
|
|
48
|
-
Note that
|
|
50
|
+
Note that operation order matters — you should have at least basic familiarity with OpenCV. See the operations table below.
|
|
49
51
|
|
|
50
52
|
```ts
|
|
51
|
-
import { ImageProcessor } from "ppu-ocv";
|
|
53
|
+
import { CanvasProcessor, ImageProcessor } from "ppu-ocv";
|
|
52
54
|
|
|
53
55
|
const file = Bun.file("./assets/receipt.jpg");
|
|
54
56
|
const image = await file.arrayBuffer();
|
|
55
57
|
|
|
56
|
-
|
|
57
|
-
await
|
|
58
|
+
await ImageProcessor.initRuntime(); // init opencv
|
|
59
|
+
const canvas = await CanvasProcessor.prepareCanvas(image);
|
|
58
60
|
|
|
59
61
|
const processor = new ImageProcessor(canvas);
|
|
60
|
-
processor
|
|
62
|
+
processor
|
|
63
|
+
.grayscale()
|
|
64
|
+
.blur({ size: [5, 5] })
|
|
65
|
+
.threshold();
|
|
61
66
|
|
|
62
67
|
const resultCanvas = processor.toCanvas();
|
|
63
68
|
processor.destroy();
|
|
64
69
|
```
|
|
65
70
|
|
|
66
|
-
Or
|
|
71
|
+
Or use the `execute` API directly:
|
|
67
72
|
|
|
68
73
|
```ts
|
|
69
|
-
import { CanvasToolkit, ImageProcessor, cv } from "ppu-ocv";
|
|
74
|
+
import { CanvasProcessor, CanvasToolkit, ImageProcessor, cv } from "ppu-ocv";
|
|
70
75
|
|
|
71
76
|
const file = Bun.file("./assets/receipt.jpg");
|
|
72
77
|
const image = await file.arrayBuffer();
|
|
73
78
|
|
|
74
79
|
const canvasToolkit = CanvasToolkit.getInstance();
|
|
75
|
-
const canvas = await ImageProcessor.prepareCanvas(image);
|
|
76
80
|
await ImageProcessor.initRuntime();
|
|
81
|
+
const canvas = await CanvasProcessor.prepareCanvas(image);
|
|
77
82
|
|
|
78
83
|
const processor = new ImageProcessor(canvas);
|
|
79
84
|
const grayscaleImg = processor.execute("grayscale").toCanvas();
|
|
80
85
|
|
|
81
|
-
//
|
|
86
|
+
// The pipeline continues from the grayscaled image
|
|
82
87
|
const thresholdImg = processor
|
|
83
88
|
.execute("blur")
|
|
84
89
|
.execute("threshold", {
|
|
@@ -95,25 +100,57 @@ await canvasToolkit.saveImage({
|
|
|
95
100
|
|
|
96
101
|
For more advanced usage, see: [Example usage of ppu-ocv](./examples)
|
|
97
102
|
|
|
103
|
+
## Canvas-only usage (no OpenCV)
|
|
104
|
+
|
|
105
|
+
Starting from v3.0.0, canvas utilities are fully decoupled from OpenCV. If you only need canvas I/O (e.g. loading/saving images, cropping, drawing) without any image processing, import from `ppu-ocv/canvas` (Node) or `ppu-ocv/canvas-web` (browser). OpenCV is **never imported or initialised** by these entry points, making them safe for use in Browser Extensions, Service Workers, and edge runtimes.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// Node.js — zero OpenCV dependency
|
|
109
|
+
import { CanvasProcessor, CanvasToolkit } from "ppu-ocv/canvas";
|
|
110
|
+
|
|
111
|
+
const file = Bun.file("./assets/image.jpg");
|
|
112
|
+
const canvas = await CanvasProcessor.prepareCanvas(await file.arrayBuffer());
|
|
113
|
+
|
|
114
|
+
const toolkit = CanvasToolkit.getInstance();
|
|
115
|
+
const cropped = toolkit.crop({
|
|
116
|
+
canvas,
|
|
117
|
+
bbox: { x0: 0, y0: 0, x1: 100, y1: 100 },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const buffer = await CanvasProcessor.prepareBuffer(cropped);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
// Browser Extension background script — zero OpenCV dependency
|
|
125
|
+
import { CanvasProcessor, CanvasToolkit } from "ppu-ocv/canvas-web";
|
|
126
|
+
|
|
127
|
+
const response = await fetch("/image.jpg");
|
|
128
|
+
const canvas = await CanvasProcessor.prepareCanvas(
|
|
129
|
+
await response.arrayBuffer(),
|
|
130
|
+
);
|
|
131
|
+
```
|
|
132
|
+
|
|
98
133
|
## Web / Browser Support
|
|
99
134
|
|
|
100
|
-
|
|
135
|
+
Import from `ppu-ocv/web` to use the browser-native canvas APIs (`HTMLCanvasElement` / `OffscreenCanvas`) instead of `@napi-rs/canvas`.
|
|
101
136
|
|
|
102
137
|
### With a bundler (Vite, webpack, etc.)
|
|
103
138
|
|
|
104
139
|
```ts
|
|
105
|
-
import { ImageProcessor, cv } from "ppu-ocv/web";
|
|
140
|
+
import { CanvasProcessor, ImageProcessor, cv } from "ppu-ocv/web";
|
|
106
141
|
|
|
107
142
|
await ImageProcessor.initRuntime();
|
|
108
143
|
|
|
109
|
-
// From a file input or fetch
|
|
110
144
|
const response = await fetch("/my-image.jpg");
|
|
111
145
|
const buffer = await response.arrayBuffer();
|
|
112
146
|
|
|
113
|
-
const canvas = await
|
|
147
|
+
const canvas = await CanvasProcessor.prepareCanvas(buffer);
|
|
114
148
|
const processor = new ImageProcessor(canvas);
|
|
115
149
|
|
|
116
|
-
processor
|
|
150
|
+
processor
|
|
151
|
+
.grayscale()
|
|
152
|
+
.blur({ size: [5, 5] })
|
|
153
|
+
.threshold();
|
|
117
154
|
|
|
118
155
|
const result = processor.toCanvas(); // returns HTMLCanvasElement
|
|
119
156
|
document.body.appendChild(result);
|
|
@@ -127,11 +164,22 @@ processor.destroy();
|
|
|
127
164
|
|
|
128
165
|
```html
|
|
129
166
|
<script type="module">
|
|
130
|
-
import {
|
|
167
|
+
import {
|
|
168
|
+
CanvasProcessor,
|
|
169
|
+
ImageProcessor,
|
|
170
|
+
} from "https://cdn.jsdelivr.net/npm/ppu-ocv@3/index.web.js";
|
|
131
171
|
await ImageProcessor.initRuntime();
|
|
132
172
|
|
|
173
|
+
const response = await fetch("/my-image.jpg");
|
|
174
|
+
const canvas = await CanvasProcessor.prepareCanvas(
|
|
175
|
+
await response.arrayBuffer(),
|
|
176
|
+
);
|
|
177
|
+
|
|
133
178
|
const processor = new ImageProcessor(canvas);
|
|
134
|
-
processor
|
|
179
|
+
processor
|
|
180
|
+
.grayscale()
|
|
181
|
+
.blur({ size: [5, 5] })
|
|
182
|
+
.threshold();
|
|
135
183
|
|
|
136
184
|
const result = processor.toCanvas();
|
|
137
185
|
processor.destroy();
|
|
@@ -142,28 +190,32 @@ processor.destroy();
|
|
|
142
190
|
|
|
143
191
|
See the [interactive demo](./index.html) for a full working example.
|
|
144
192
|
|
|
145
|
-
###
|
|
193
|
+
### Entry point reference
|
|
146
194
|
|
|
147
|
-
|
|
|
148
|
-
|
|
|
149
|
-
|
|
|
150
|
-
| `
|
|
151
|
-
|
|
|
152
|
-
| `
|
|
153
|
-
| `Contours` | ✅ | ✅ Same API |
|
|
154
|
-
| Image analysis | ✅ | ✅ Same API |
|
|
195
|
+
| Import path | OpenCV | Canvas backend | `CanvasToolkit` | Use case |
|
|
196
|
+
| -------------------- | ------ | ------------------------------------- | -------------------- | ------------------------------------------ |
|
|
197
|
+
| `ppu-ocv` | ✅ | `@napi-rs/canvas` | Full (with file I/O) | Full pipeline, Node.js / Bun |
|
|
198
|
+
| `ppu-ocv/web` | ✅ | `HTMLCanvasElement`/`OffscreenCanvas` | Base only | Full pipeline, browser |
|
|
199
|
+
| `ppu-ocv/canvas` | ❌ | `@napi-rs/canvas` | Full (with file I/O) | Canvas-only, Node (extensions, edge, etc.) |
|
|
200
|
+
| `ppu-ocv/canvas-web` | ❌ | `HTMLCanvasElement`/`OffscreenCanvas` | Base only | Canvas-only, browser extensions / SW |
|
|
155
201
|
|
|
156
202
|
### Platform abstraction
|
|
157
203
|
|
|
158
|
-
Under the hood, ppu-ocv uses a platform abstraction layer.
|
|
204
|
+
Under the hood, ppu-ocv uses a platform abstraction layer. Each entry point auto-registers its platform. You can also register a custom platform:
|
|
159
205
|
|
|
160
206
|
```ts
|
|
161
207
|
import { setPlatform, type CanvasPlatform } from "ppu-ocv/web";
|
|
162
208
|
|
|
163
209
|
const myPlatform: CanvasPlatform = {
|
|
164
|
-
createCanvas(width, height) {
|
|
165
|
-
|
|
166
|
-
|
|
210
|
+
createCanvas(width, height) {
|
|
211
|
+
/* ... */
|
|
212
|
+
},
|
|
213
|
+
loadImage(source) {
|
|
214
|
+
/* ... */
|
|
215
|
+
},
|
|
216
|
+
isCanvas(value) {
|
|
217
|
+
/* ... */
|
|
218
|
+
},
|
|
167
219
|
};
|
|
168
220
|
|
|
169
221
|
setPlatform(myPlatform);
|
|
@@ -191,23 +243,86 @@ To avoid bloat, we only ship essential operations for chaining. Currently shippe
|
|
|
191
243
|
|
|
192
244
|
## Extending operations
|
|
193
245
|
|
|
194
|
-
You can easily add your own by creating a prototype method or
|
|
246
|
+
You can easily add your own by creating a prototype method or extending the `ImageProcessor` class.
|
|
195
247
|
|
|
196
248
|
See: [How to extend ppu-ocv operations](./docs/how-to-extend-ppu-ocv-operations.md)
|
|
197
249
|
|
|
198
250
|
## Class documentation
|
|
199
251
|
|
|
252
|
+
#### `CanvasProcessor`
|
|
253
|
+
|
|
254
|
+
Canvas-native image processing with **no OpenCV dependency**. Available from all entry points including `ppu-ocv/canvas` and `ppu-ocv/canvas-web`. Provides a chainable instance API alongside static I/O helpers.
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
const result = new CanvasProcessor(canvas)
|
|
258
|
+
.resize({ width: 360, height: 640 })
|
|
259
|
+
.grayscale()
|
|
260
|
+
.threshold({ thresh: 127 })
|
|
261
|
+
.invert()
|
|
262
|
+
.border({ size: 10, color: "white" })
|
|
263
|
+
.toCanvas();
|
|
264
|
+
|
|
265
|
+
// Detect connected white regions on a binary image
|
|
266
|
+
const regions = new CanvasProcessor(binaryCanvas).findRegions({
|
|
267
|
+
foreground: "light",
|
|
268
|
+
minArea: 20,
|
|
269
|
+
// thresh: 0 ← use on resized binary images to match OpenCV (any non-zero pixel = foreground)
|
|
270
|
+
// padding: { vertical: 0.4, horizontal: 0.6 } ← expand bbox by fraction of height
|
|
271
|
+
// scale: 1 / resizeRatio ← map coords back to original image space
|
|
272
|
+
});
|
|
273
|
+
regions.sort((a, b) => b.area - a.area); // largest first
|
|
274
|
+
// regions[0] → { bbox: { x0, y0, x1, y1 }, area }
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Static I/O**
|
|
278
|
+
|
|
279
|
+
| Method | Args | Description |
|
|
280
|
+
| ---------------------- | ----------- | ----------------------------------------------------- |
|
|
281
|
+
| static `prepareCanvas` | ArrayBuffer | Load image bytes into a `CanvasLike` |
|
|
282
|
+
| static `prepareBuffer` | CanvasLike | Export a `CanvasLike` to an `ArrayBuffer` (PNG bytes) |
|
|
283
|
+
|
|
284
|
+
**Instance operations** (chainable, return `this`)
|
|
285
|
+
|
|
286
|
+
| Method | Options | OpenCV equivalent | Fidelity |
|
|
287
|
+
| ----------- | ---------------------------------- | ------------------------- | -------------- |
|
|
288
|
+
| `resize` | `width`, `height` | `cv.resize` INTER_LINEAR | 1:1 (↓), ≈ (↑) |
|
|
289
|
+
| `grayscale` | — | `COLOR_RGBA2GRAY` | **1:1** |
|
|
290
|
+
| `convert` | `alpha?`, `beta?` | `Mat.convertTo` (α·x + β) | **1:1** |
|
|
291
|
+
| `invert` | — | `cv.bitwise_not` | **1:1** ¹ |
|
|
292
|
+
| `threshold` | `thresh?` (127), `maxValue?` (255) | `THRESH_BINARY` | **1:1** |
|
|
293
|
+
| `border` | `size?` (10), `color?` (CSS) | `BORDER_CONSTANT` | **1:1** |
|
|
294
|
+
| `rotate` | `angle`, `cx?`, `cy?` | `warpAffine` | ≈ (±6 px) ² |
|
|
295
|
+
| `toCanvas` | — | — | — |
|
|
296
|
+
|
|
297
|
+
**Region detection** (returns data, does not mutate)
|
|
298
|
+
|
|
299
|
+
| Method | Options | Description |
|
|
300
|
+
| ------------- | ------------------------------------------------ | ------------------------------------------------------------ |
|
|
301
|
+
| `findRegions` | `foreground?` (`"light"`), `thresh?` (127), `minArea?`, `maxArea?`, `padding?`, `scale?` | 8-connected flood-fill on a binary canvas → `DetectedRegion[]` |
|
|
302
|
+
|
|
303
|
+
`DetectedRegion` shape: `{ bbox: BoundingBox, area: number }` where `bbox` is `{ x0, y0, x1, y1 }` (x1/y1 exclusive). Equivalent to OpenCV's `findContours(RETR_EXTERNAL) + boundingRect` — all matched bboxes agree within ±1 px on solid binary images. ³
|
|
304
|
+
|
|
305
|
+
**`thresh` option** — pixel value threshold for foreground detection (default `127`). For resized binary images, use `thresh: 0` so anti-aliased border pixels (values 1–127) are included as foreground, matching OpenCV's non-zero threshold. With `thresh: 0` + `padding` + `scale`, full-pipeline IoU vs OpenCV is **98.4%** (all 21/21 boxes matched).
|
|
306
|
+
|
|
307
|
+
> ¹ Canvas `invert` preserves the alpha channel; OpenCV `bitwise_not` also inverts alpha. Results are identical when the source is opaque (alpha=255).
|
|
308
|
+
>
|
|
309
|
+
> ² Canvas uses anti-aliased bilinear interpolation; OpenCV uses plain bilinear. Difference is visually imperceptible and has no impact on OCR quality.
|
|
310
|
+
>
|
|
311
|
+
> ³ `RETR_LIST` may return additional inner-hole contours for white regions that contain dark sub-regions; `findRegions` counts each connected white component once regardless of interior holes.
|
|
312
|
+
|
|
200
313
|
#### `ImageProcessor`
|
|
201
314
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
|
205
|
-
|
|
|
206
|
-
|
|
|
207
|
-
|
|
|
208
|
-
|
|
|
209
|
-
|
|
|
210
|
-
| `
|
|
315
|
+
Requires OpenCV. Available from `ppu-ocv` and `ppu-ocv/web`.
|
|
316
|
+
|
|
317
|
+
| Method | Args | Description |
|
|
318
|
+
| -------------------- | ---------------- | ------------------------------------------------------------------ |
|
|
319
|
+
| constructor | cv.Mat or Canvas | Instantiate processor with initial image |
|
|
320
|
+
| static `initRuntime` | | OpenCV runtime initialization — required once per runtime |
|
|
321
|
+
| operations | depends | Chainable operations like `blur`, `grayscale`, `resize`, and so on |
|
|
322
|
+
| `execute` | name, options | Chainable operations via the `execute` API |
|
|
323
|
+
| `toMat` | | Return the current image as a `cv.Mat` |
|
|
324
|
+
| `toCanvas` | | Return the current image as a `CanvasLike` |
|
|
325
|
+
| `destroy` | | Clean up `cv.Mat` memory |
|
|
211
326
|
|
|
212
327
|
#### `CanvasToolkit`
|
|
213
328
|
|
|
@@ -215,31 +330,41 @@ See: [How to extend ppu-ocv operations](./docs/how-to-extend-ppu-ocv-operations.
|
|
|
215
330
|
| ------------- | ---------------------- | ----------------------------------------------------------------------------------------- |
|
|
216
331
|
| `crop` | BoundingBox, Canvas | Crop a part of source canvas and return a new canvas of the cropped part |
|
|
217
332
|
| `isDirty` | Canvas, threshold | Check whether a binary canvas is dirty (full of major color either black or white) or not |
|
|
218
|
-
| `saveImage` | Canvas, filename, path | Save a canvas to an image file
|
|
219
|
-
| `clearOutput` | path | Clear the output folder
|
|
333
|
+
| `saveImage` | Canvas, filename, path | Save a canvas to an image file _(Node only)_ |
|
|
334
|
+
| `clearOutput` | path | Clear the output folder _(Node only)_ |
|
|
220
335
|
| `drawLine` | ctx, coordinate, style | Draw a non-filled rectangle outline on the canvas |
|
|
221
|
-
| `drawContour` | ctx, contour, style | Draw a contour on the canvas
|
|
336
|
+
| `drawContour` | ctx, contour, style | Draw a contour on the canvas — accepts any `ContourLike` (`{ data32S }`) |
|
|
337
|
+
|
|
338
|
+
#### `DeskewService`
|
|
339
|
+
|
|
340
|
+
Detects and corrects text skew in document images using a multi-method consensus approach (minAreaRect, baseline analysis, Hough transform). Requires OpenCV. Available from `ppu-ocv` and `ppu-ocv/web`.
|
|
341
|
+
|
|
342
|
+
| Method | Args | Description |
|
|
343
|
+
| -------------------- | ------------- | ------------------------------------ |
|
|
344
|
+
| constructor | DeskewOptions | `verbose`, `minimumAreaThreshold` |
|
|
345
|
+
| `calculateSkewAngle` | CanvasLike | Detect skew angle in degrees |
|
|
346
|
+
| `deskewImage` | CanvasLike | Return a deskewed copy of the canvas |
|
|
222
347
|
|
|
223
348
|
#### `Contours`
|
|
224
349
|
|
|
225
|
-
| Method
|
|
226
|
-
|
|
|
227
|
-
| constructor
|
|
228
|
-
| `getAll`
|
|
229
|
-
| `getFromIndex`
|
|
230
|
-
| `getRect`
|
|
231
|
-
| `iterate`
|
|
232
|
-
| `getLargestContourArea`
|
|
233
|
-
| `getCornerPoints`
|
|
234
|
-
| `
|
|
350
|
+
| Method | Args | Description |
|
|
351
|
+
| -------------------------------- | --------------- | ---------------------------------------------------------------- |
|
|
352
|
+
| constructor | cv.Mat, options | Instantiate Contours and automatically find & store contour list |
|
|
353
|
+
| `getAll` | | Return the full `cv.MatVector` of contours |
|
|
354
|
+
| `getFromIndex` | index | Get contour at a specific index |
|
|
355
|
+
| `getRect` | contour | Get the bounding rectangle of a contour |
|
|
356
|
+
| `iterate` | callback | Iterate over all contours |
|
|
357
|
+
| `getLargestContourArea` | | Return the contour with the largest area |
|
|
358
|
+
| `getCornerPoints` | options | Get four corner points for perspective transformation (warp) |
|
|
359
|
+
| `getApproximateRectangleContour` | options | Simplify a contour to an approximate rectangle |
|
|
360
|
+
| `destroy` | | Destroy and clean up contour memory |
|
|
235
361
|
|
|
236
362
|
#### `ImageAnalysis`
|
|
237
363
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
- `calculateMeanNormalizedLabLightness`: Calculates the mean normalized lightness of an image using the L channel of the Lab color space. Lightness is normalized based on the image's own maximum lightness value before averaging.
|
|
364
|
+
A collection of utility functions for analyzing image properties (requires OpenCV).
|
|
241
365
|
|
|
242
|
-
- `
|
|
366
|
+
- `calculateMeanNormalizedLabLightness`: Calculates the mean normalized lightness of an image using the L channel of the Lab color space.
|
|
367
|
+
- `calculateMeanGrayscaleValue`: Calculates the mean pixel value after converting to grayscale.
|
|
243
368
|
|
|
244
369
|
## Contributing
|
|
245
370
|
|
|
@@ -262,12 +387,10 @@ Ensure that all tests pass before submitting your pull request.
|
|
|
262
387
|
|
|
263
388
|
## Scripts
|
|
264
389
|
|
|
265
|
-
Recommended development environment is in
|
|
390
|
+
Recommended development environment is in a Linux-based environment.
|
|
266
391
|
|
|
267
392
|
Library template: https://github.com/aquapi/lib-template
|
|
268
393
|
|
|
269
|
-
All script sources and usage.
|
|
270
|
-
|
|
271
394
|
### [Build](./scripts/build.ts)
|
|
272
395
|
|
|
273
396
|
Emit `.js` and `.d.ts` files to [`lib`](./lib).
|
|
@@ -276,6 +399,20 @@ Emit `.js` and `.d.ts` files to [`lib`](./lib).
|
|
|
276
399
|
|
|
277
400
|
Move [`package.json`](./package.json), [`README.md`](./README.md) to [`lib`](./lib) and publish the package.
|
|
278
401
|
|
|
402
|
+
## Migrating from v2
|
|
403
|
+
|
|
404
|
+
See [MIGRATION.md](./MIGRATION.md) for a full guide. The short version:
|
|
405
|
+
|
|
406
|
+
```diff
|
|
407
|
+
- import { ImageProcessor } from "ppu-ocv";
|
|
408
|
+
- const canvas = await ImageProcessor.prepareCanvas(buffer);
|
|
409
|
+
- const buf = await ImageProcessor.prepareBuffer(canvas);
|
|
410
|
+
|
|
411
|
+
+ import { CanvasProcessor } from "ppu-ocv";
|
|
412
|
+
+ const canvas = await CanvasProcessor.prepareCanvas(buffer);
|
|
413
|
+
+ const buf = await CanvasProcessor.prepareBuffer(canvas);
|
|
414
|
+
```
|
|
415
|
+
|
|
279
416
|
## License
|
|
280
417
|
|
|
281
418
|
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
package/canvas-factory.d.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface Context2DLike {
|
|
|
29
29
|
strokeRect(x: number, y: number, w: number, h: number): void;
|
|
30
30
|
strokeStyle: string | CanvasGradient | CanvasPattern;
|
|
31
31
|
lineWidth: number;
|
|
32
|
+
fillStyle: string | CanvasGradient | CanvasPattern;
|
|
33
|
+
fillRect(x: number, y: number, w: number, h: number): void;
|
|
34
|
+
save(): void;
|
|
35
|
+
restore(): void;
|
|
36
|
+
translate(x: number, y: number): void;
|
|
37
|
+
rotate(angle: number): void;
|
|
32
38
|
}
|
|
33
39
|
/** Platform-specific canvas operations */
|
|
34
40
|
export interface CanvasPlatform {
|
package/canvas-factory.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
let _platform=null;export function setPlatform(platform){_platform=platform}export function getPlatform(){if(!_platform){throw new Error("No canvas platform registered. "+'Import "ppu-ocv" (Node) or "ppu-ocv/web" (browser) to auto-register.')}return _platform}
|
|
1
|
+
let _platform=null;export function setPlatform(platform){_platform=platform}export function getPlatform(){if(!_platform){throw new Error("No canvas platform registered. "+'Import "ppu-ocv" (Node), "ppu-ocv/web" (browser), '+'"ppu-ocv/canvas" (Node canvas-only), or "ppu-ocv/canvas-web" (browser canvas-only) to auto-register.')}return _platform}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { BoundingBox } from "./index.interface.js";
|
|
2
|
+
import type { CanvasLike } from "./canvas-factory.js";
|
|
3
|
+
/**
|
|
4
|
+
* A detected region returned by {@link CanvasProcessor.findRegions}.
|
|
5
|
+
*/
|
|
6
|
+
export interface DetectedRegion {
|
|
7
|
+
/** Axis-aligned bounding box of the region (x1/y1 are exclusive). */
|
|
8
|
+
bbox: BoundingBox;
|
|
9
|
+
/** Number of foreground pixels in the region. */
|
|
10
|
+
area: number;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Canvas-native image processing with no OpenCV dependency.
|
|
14
|
+
*
|
|
15
|
+
* Provides two distinct APIs:
|
|
16
|
+
* - **Static I/O** (`prepareCanvas`, `prepareBuffer`) — format conversion helpers.
|
|
17
|
+
* - **Chainable instance operations** (`resize`, `grayscale`, `convert`, `invert`,
|
|
18
|
+
* `threshold`, `border`, `rotate`) — lightweight canvas-native pipeline, usable
|
|
19
|
+
* in constrained environments where OpenCV cannot run.
|
|
20
|
+
* - **Region detection** (`findRegions`) — 8-connected flood-fill bbox detection
|
|
21
|
+
* on binary images, with optional padding and coordinate scaling.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* import { CanvasProcessor } from "ppu-ocv/canvas";
|
|
26
|
+
*
|
|
27
|
+
* const canvas = await CanvasProcessor.prepareCanvas(arrayBuffer);
|
|
28
|
+
*
|
|
29
|
+
* // Pixel pipeline
|
|
30
|
+
* const result = new CanvasProcessor(canvas)
|
|
31
|
+
* .resize({ width: 800, height: 600 })
|
|
32
|
+
* .grayscale()
|
|
33
|
+
* .threshold({ thresh: 127 })
|
|
34
|
+
* .border({ size: 10, color: "white" })
|
|
35
|
+
* .toCanvas();
|
|
36
|
+
*
|
|
37
|
+
* // Region detection (equivalent to findContours + boundingRect)
|
|
38
|
+
* const regions = new CanvasProcessor(binaryCanvas).findRegions({
|
|
39
|
+
* foreground: "light",
|
|
40
|
+
* minArea: 20,
|
|
41
|
+
* padding: { vertical: 0.4, horizontal: 0.6 },
|
|
42
|
+
* scale: originalWidth / processedWidth,
|
|
43
|
+
* });
|
|
44
|
+
* ```
|
|
45
|
+
*/
|
|
46
|
+
export declare class CanvasProcessor {
|
|
47
|
+
private _canvas;
|
|
48
|
+
constructor(source: CanvasLike);
|
|
49
|
+
get width(): number;
|
|
50
|
+
get height(): number;
|
|
51
|
+
/**
|
|
52
|
+
* Scale the canvas to new dimensions.
|
|
53
|
+
* Uses the platform's native drawImage interpolation (bilinear in most runtimes).
|
|
54
|
+
*/
|
|
55
|
+
resize(options: {
|
|
56
|
+
width: number;
|
|
57
|
+
height: number;
|
|
58
|
+
}): this;
|
|
59
|
+
/**
|
|
60
|
+
* Convert to grayscale using BT.601 luma coefficients
|
|
61
|
+
* (matches OpenCV's COLOR_RGBA2GRAY: 0.299R + 0.587G + 0.114B).
|
|
62
|
+
*
|
|
63
|
+
* The result is still RGBA — R, G, and B channels all equal the luma value.
|
|
64
|
+
* The alpha channel is preserved unchanged.
|
|
65
|
+
*/
|
|
66
|
+
grayscale(): this;
|
|
67
|
+
/**
|
|
68
|
+
* Apply a linear per-pixel transformation: `dst = clamp(alpha * src + beta)`.
|
|
69
|
+
* Applies independently to R, G, and B channels; alpha channel is unchanged.
|
|
70
|
+
*
|
|
71
|
+
* This is the canvas-native equivalent of OpenCV's `Mat.convertTo(dst, rtype, alpha, beta)`,
|
|
72
|
+
* limited to the pixel-math aspect. `rtype` is not applicable here — canvas ImageData
|
|
73
|
+
* is always 8-bit RGBA (`Uint8ClampedArray`), so type conversion has no meaning.
|
|
74
|
+
*
|
|
75
|
+
* Useful for brightness/contrast adjustment:
|
|
76
|
+
* - `alpha > 1` increases contrast
|
|
77
|
+
* - `beta > 0` increases brightness
|
|
78
|
+
* - `alpha = 0.5, beta = 0` halves contrast
|
|
79
|
+
*/
|
|
80
|
+
convert(options?: {
|
|
81
|
+
alpha?: number;
|
|
82
|
+
beta?: number;
|
|
83
|
+
}): this;
|
|
84
|
+
/**
|
|
85
|
+
* Invert all RGB channels: `dst = 255 - src`.
|
|
86
|
+
* Alpha channel is preserved unchanged.
|
|
87
|
+
* Equivalent to OpenCV's `cv.bitwise_not`.
|
|
88
|
+
*/
|
|
89
|
+
invert(): this;
|
|
90
|
+
/**
|
|
91
|
+
* Apply binary threshold: pixels with luma above `thresh` become `maxValue`,
|
|
92
|
+
* all others become 0.
|
|
93
|
+
*
|
|
94
|
+
* Equivalent to OpenCV's `cv.threshold(src, dst, thresh, maxval, THRESH_BINARY)`.
|
|
95
|
+
* Operates on the luma of each pixel (R channel is used directly when the
|
|
96
|
+
* image is already grayscale, i.e. R === G === B).
|
|
97
|
+
*
|
|
98
|
+
* Note: Otsu's automatic threshold (`THRESH_OTSU`) is not supported
|
|
99
|
+
* canvas-natively — use a fixed `thresh` value instead.
|
|
100
|
+
*/
|
|
101
|
+
threshold(options?: {
|
|
102
|
+
thresh?: number;
|
|
103
|
+
maxValue?: number;
|
|
104
|
+
}): this;
|
|
105
|
+
/**
|
|
106
|
+
* Add a uniform border around the canvas.
|
|
107
|
+
* Equivalent to OpenCV's `cv.copyMakeBorder` with `BORDER_CONSTANT`.
|
|
108
|
+
*
|
|
109
|
+
* @param options.size Border width in pixels (default 10)
|
|
110
|
+
* @param options.color CSS color string for the border fill (default "white")
|
|
111
|
+
*/
|
|
112
|
+
border(options?: {
|
|
113
|
+
size?: number;
|
|
114
|
+
color?: string;
|
|
115
|
+
}): this;
|
|
116
|
+
/**
|
|
117
|
+
* Rotate the canvas around its centre (or a custom pivot) while keeping the
|
|
118
|
+
* original canvas dimensions. Pixels that fall outside the bounds after
|
|
119
|
+
* rotation are clipped (transparent).
|
|
120
|
+
*
|
|
121
|
+
* Positive `angle` rotates counter-clockwise, matching the convention used
|
|
122
|
+
* by OpenCV's `getRotationMatrix2D`.
|
|
123
|
+
*
|
|
124
|
+
* @param options.angle Rotation angle in degrees
|
|
125
|
+
* @param options.cx Pivot X (default: canvas centre)
|
|
126
|
+
* @param options.cy Pivot Y (default: canvas centre)
|
|
127
|
+
*/
|
|
128
|
+
rotate(options: {
|
|
129
|
+
angle: number;
|
|
130
|
+
cx?: number;
|
|
131
|
+
cy?: number;
|
|
132
|
+
}): this;
|
|
133
|
+
/**
|
|
134
|
+
* Detect connected regions on a binary (black-and-white) canvas and return
|
|
135
|
+
* their bounding boxes and pixel areas.
|
|
136
|
+
*
|
|
137
|
+
* Uses an 8-connected DFS flood-fill — equivalent to OpenCV's
|
|
138
|
+
* `findContours` with `RETR_LIST + CHAIN_APPROX_SIMPLE` on a binary image,
|
|
139
|
+
* returning bounding-box level information.
|
|
140
|
+
*
|
|
141
|
+
* Best called after `.grayscale().threshold()` to ensure a clean binary input.
|
|
142
|
+
*
|
|
143
|
+
* @param options.foreground Which pixel tone is the foreground to detect:
|
|
144
|
+
* `"light"` (white regions, default) or `"dark"` (black regions).
|
|
145
|
+
* @param options.thresh Luma threshold that separates foreground from background (default 127).
|
|
146
|
+
* For `foreground: "light"`: pixel is foreground when `r > thresh`.
|
|
147
|
+
* For `foreground: "dark"`: pixel is foreground when `r <= thresh`.
|
|
148
|
+
* **Use `thresh: 0` when working on a resized binary image** — resizing
|
|
149
|
+
* introduces anti-aliased gray border pixels (values 1–127) that the
|
|
150
|
+
* default threshold would miss, matching OpenCV's behaviour of treating
|
|
151
|
+
* any non-zero pixel as contour-adjacent.
|
|
152
|
+
* @param options.minArea Ignore regions smaller than this many pixels (default 1).
|
|
153
|
+
* @param options.maxArea Ignore regions larger than this many pixels (default unlimited).
|
|
154
|
+
* @param options.padding Expand each detected bbox by a fraction of its **height**.
|
|
155
|
+
* Mirrors `extractBoxesFromContours` padding logic:
|
|
156
|
+
* `vertical` and `horizontal` are both applied as
|
|
157
|
+
* `Math.round(bboxHeight × factor)` and clamped to the canvas bounds.
|
|
158
|
+
* Default: no padding.
|
|
159
|
+
* @param options.scale Multiply all bbox coordinates by this factor after padding.
|
|
160
|
+
* Use `originalWidth / processedWidth` (i.e. `1 / resizeRatio`)
|
|
161
|
+
* to convert from a resized canvas back to original image coordinates.
|
|
162
|
+
* Default: 1 (no scaling).
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```ts
|
|
166
|
+
* // Direct equivalent of extractBoxesFromContours with default padding:
|
|
167
|
+
* const regions = new CanvasProcessor(binaryCanvas).findRegions({
|
|
168
|
+
* foreground: "light",
|
|
169
|
+
* minArea: 20,
|
|
170
|
+
* padding: { vertical: 0.4, horizontal: 0.6 },
|
|
171
|
+
* scale: originalWidth / processedWidth,
|
|
172
|
+
* });
|
|
173
|
+
* ```
|
|
174
|
+
*/
|
|
175
|
+
findRegions(options?: {
|
|
176
|
+
foreground?: "light" | "dark";
|
|
177
|
+
thresh?: number;
|
|
178
|
+
minArea?: number;
|
|
179
|
+
maxArea?: number;
|
|
180
|
+
padding?: {
|
|
181
|
+
vertical?: number;
|
|
182
|
+
horizontal?: number;
|
|
183
|
+
};
|
|
184
|
+
scale?: number;
|
|
185
|
+
}): DetectedRegion[];
|
|
186
|
+
/**
|
|
187
|
+
* Return the current canvas state.
|
|
188
|
+
*/
|
|
189
|
+
toCanvas(): CanvasLike;
|
|
190
|
+
/**
|
|
191
|
+
* Convert an ArrayBuffer (image file bytes) to a CanvasLike.
|
|
192
|
+
* If the value is already a CanvasLike it is returned as-is.
|
|
193
|
+
*/
|
|
194
|
+
static prepareCanvas(file: ArrayBuffer): Promise<CanvasLike>;
|
|
195
|
+
/**
|
|
196
|
+
* Convert a CanvasLike to an ArrayBuffer (PNG bytes).
|
|
197
|
+
* If the value is already an ArrayBuffer it is returned as-is.
|
|
198
|
+
*/
|
|
199
|
+
static prepareBuffer(canvas: CanvasLike): Promise<ArrayBuffer>;
|
|
200
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class CanvasProcessor{_canvas;constructor(source){this._canvas=source}get width(){return this._canvas.width}get height(){return this._canvas.height}resize(options){const{width,height}=options;let dst=getPlatform().createCanvas(width,height);dst.getContext("2d").drawImage(this._canvas,0,0,width,height);this._canvas=dst;return this}grayscale(){const{width,height}=this._canvas;let imageData=this._canvas.getContext("2d").getImageData(0,0,width,height);let d=imageData.data;for(let i=0;i<d.length;i+=4){let luma=Math.round(0.299*d[i]+0.587*d[i+1]+0.114*d[i+2]);d[i]=luma;d[i+1]=luma;d[i+2]=luma}let dst=getPlatform().createCanvas(width,height);dst.getContext("2d").putImageData(imageData,0,0);this._canvas=dst;return this}convert(options={}){const{alpha=1,beta=0}=options;if(alpha===1&&beta===0)return this;const{width,height}=this._canvas;let imageData=this._canvas.getContext("2d").getImageData(0,0,width,height);let d=imageData.data;for(let i=0;i<d.length;i+=4){d[i]=Math.round(d[i]*alpha+beta);d[i+1]=Math.round(d[i+1]*alpha+beta);d[i+2]=Math.round(d[i+2]*alpha+beta)}let dst=getPlatform().createCanvas(width,height);dst.getContext("2d").putImageData(imageData,0,0);this._canvas=dst;return this}invert(){const{width,height}=this._canvas;let imageData=this._canvas.getContext("2d").getImageData(0,0,width,height);let d=imageData.data;for(let i=0;i<d.length;i+=4){d[i]=255-d[i];d[i+1]=255-d[i+1];d[i+2]=255-d[i+2]}let dst=getPlatform().createCanvas(width,height);dst.getContext("2d").putImageData(imageData,0,0);this._canvas=dst;return this}threshold(options={}){const{thresh=127,maxValue=255}=options;const{width,height}=this._canvas;let imageData=this._canvas.getContext("2d").getImageData(0,0,width,height);let d=imageData.data;for(let i=0;i<d.length;i+=4){let luma=d[i]===d[i+1]&&d[i+1]===d[i+2]?d[i]:Math.round(0.299*d[i]+0.587*d[i+1]+0.114*d[i+2]);let val=luma>thresh?maxValue:0;d[i]=val;d[i+1]=val;d[i+2]=val}let dst=getPlatform().createCanvas(width,height);dst.getContext("2d").putImageData(imageData,0,0);this._canvas=dst;return this}border(options={}){const{size=10,color="white"}=options;const{width,height}=this._canvas;let dst=getPlatform().createCanvas(width+size*2,height+size*2);let ctx=dst.getContext("2d");ctx.fillStyle=color;ctx.fillRect(0,0,dst.width,dst.height);ctx.drawImage(this._canvas,size,size);this._canvas=dst;return this}rotate(options){const{angle,cx=this._canvas.width/2,cy=this._canvas.height/2}=options;if(angle===0)return this;const{width,height}=this._canvas;let dst=getPlatform().createCanvas(width,height);let ctx=dst.getContext("2d");ctx.save();ctx.translate(cx,cy);ctx.rotate(-angle*Math.PI/180);ctx.drawImage(this._canvas,-cx,-cy);ctx.restore();this._canvas=dst;return this}findRegions(options={}){const{foreground="light",thresh=127,minArea=1,maxArea=1/0,padding,scale=1}=options;const{width,height}=this._canvas;let data=this._canvas.getContext("2d").getImageData(0,0,width,height).data;let visited=new Uint8Array(width*height);let regions=[];let neighbours=[[-1,-1],[0,-1],[1,-1],[-1,0],[1,0],[-1,1],[0,1],[1,1]];let isForeground=(pixelIdx)=>{let r=data[pixelIdx];return foreground==="light"?r>thresh:r<=thresh};for(let startY=0;startY<height;startY++){for(let startX=0;startX<width;startX++){let startFlat=startY*width+startX;if(visited[startFlat])continue;visited[startFlat]=1;if(!isForeground(startFlat*4))continue;let stack=[startFlat];let minX=startX,maxX=startX;let minY=startY,maxY=startY;let area=0;while(stack.length>0){let flat=stack.pop();area++;let x=flat%width;let y=(flat-x)/width;if(x<minX)minX=x;else if(x>maxX)maxX=x;if(y<minY)minY=y;else if(y>maxY)maxY=y;for(const[dx,dy]of neighbours){let nx=x+dx;let ny=y+dy;if(nx<0||nx>=width||ny<0||ny>=height)continue;let nFlat=ny*width+nx;if(visited[nFlat])continue;visited[nFlat]=1;if(isForeground(nFlat*4))stack.push(nFlat)}}if(area>=minArea&&area<=maxArea){let x0=minX;let y0=minY;let x1=maxX+1;let y1=maxY+1;if(padding){let bboxH=y1-y0;let vPad=Math.round(bboxH*(padding.vertical??0));let hPad=Math.round(bboxH*(padding.horizontal??0));x0=Math.max(0,x0-hPad);y0=Math.max(0,y0-vPad);x1=Math.min(width,x1+hPad);y1=Math.min(height,y1+vPad)}if(scale!==1){x0=Math.max(0,Math.round(x0*scale));y0=Math.max(0,Math.round(y0*scale));x1=Math.round(x1*scale);y1=Math.round(y1*scale)}regions.push({bbox:{x0,y0,x1,y1},area})}}}return regions}toCanvas(){return this._canvas}static async prepareCanvas(file){if(getPlatform().isCanvas(file))return file;return getPlatform().loadImage(file)}static async prepareBuffer(canvas){if(canvas instanceof ArrayBuffer)return canvas;if(typeof canvas.toBuffer==="function"){let buffer=canvas.toBuffer("image/png");let arrayBuffer=new ArrayBuffer(buffer.byteLength);new Uint8Array(arrayBuffer).set(new Uint8Array(buffer));return arrayBuffer}if(typeof canvas.toDataURL==="function"){let dataURL=canvas.toDataURL("image/png");let base64Data=dataURL.replace(/^data:image\/png;base64,/,"");let binaryString=atob(base64Data);let arrayBuffer=new ArrayBuffer(binaryString.length);let bytes=new Uint8Array(arrayBuffer);for(let i=0;i<binaryString.length;i++){bytes[i]=binaryString.charCodeAt(i)}return arrayBuffer}let ctx=canvas.getContext("2d");let imageData=ctx.getImageData(0,0,canvas.width,canvas.height);let canvasBuffer=new ArrayBuffer(imageData.data.byteLength);new Uint8Array(canvasBuffer).set(new Uint8Array(imageData.data.buffer,imageData.data.byteOffset,imageData.data.byteLength));return canvasBuffer}}import{getPlatform}from"./canvas-factory.js";
|
package/canvas-toolkit.base.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { BoundingBox } from "./index.interface.js";
|
|
2
2
|
import type { CanvasLike, Context2DLike } from "./canvas-factory.js";
|
|
3
|
-
|
|
3
|
+
/** Structural interface for contour-like objects with 32-bit signed integer point data */
|
|
4
|
+
export interface ContourLike {
|
|
5
|
+
data32S: Int32Array | number[];
|
|
6
|
+
}
|
|
4
7
|
/**
|
|
5
8
|
* Cross-platform base class for canvas manipulation utilities.
|
|
6
9
|
* Contains only methods that work in both Node and browser environments.
|
|
@@ -42,7 +45,7 @@ export declare class CanvasToolkitBase {
|
|
|
42
45
|
*/
|
|
43
46
|
drawContour(options: {
|
|
44
47
|
ctx: Context2DLike;
|
|
45
|
-
contour:
|
|
48
|
+
contour: ContourLike;
|
|
46
49
|
strokeStyle?: string;
|
|
47
50
|
lineWidth?: number;
|
|
48
51
|
}): void;
|
package/deskew.d.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { CanvasLike } from "./canvas-factory.js";
|
|
2
|
+
/**
|
|
3
|
+
* Options for configuring the deskew service
|
|
4
|
+
*/
|
|
5
|
+
export interface DeskewOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Enable detailed logging of each processing step.
|
|
8
|
+
* @default false
|
|
9
|
+
*/
|
|
10
|
+
verbose?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Remove detected boxes with area below this threshold, in pixels.
|
|
13
|
+
* Used to filter out noise when detecting text regions.
|
|
14
|
+
* @default 20
|
|
15
|
+
*/
|
|
16
|
+
minimumAreaThreshold?: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Service for calculating the skew angle of an image containing text.
|
|
20
|
+
*
|
|
21
|
+
* This service analyzes text regions in an image to determine its skew angle
|
|
22
|
+
* using multiple methods (minAreaRect, baseline analysis, and Hough transform)
|
|
23
|
+
* to robustly calculate the average text orientation.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { DeskewService } from 'ppu-ocv';
|
|
28
|
+
*
|
|
29
|
+
* const service = new DeskewService({ verbose: true });
|
|
30
|
+
* const canvas = ...; // your canvas with text
|
|
31
|
+
* const angle = await service.calculateSkewAngle(canvas);
|
|
32
|
+
* console.log(`Skew angle: ${angle}°`);
|
|
33
|
+
*
|
|
34
|
+
* // Or deskew the image directly
|
|
35
|
+
* const deskewed = await service.deskewImage(canvas);
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export declare class DeskewService {
|
|
39
|
+
private readonly verbose;
|
|
40
|
+
private readonly minimumAreaThreshold;
|
|
41
|
+
constructor(options?: DeskewOptions);
|
|
42
|
+
private log;
|
|
43
|
+
/**
|
|
44
|
+
* Calculate the skew angle of text in a probability map or binary image.
|
|
45
|
+
*
|
|
46
|
+
* This method processes the input image to detect text regions and calculates
|
|
47
|
+
* the average skew angle using multiple robust methods.
|
|
48
|
+
*
|
|
49
|
+
* @param canvas - Canvas containing a probability map or binary image of text regions
|
|
50
|
+
* @returns The calculated skew angle in degrees (positive = clockwise, negative = counter-clockwise)
|
|
51
|
+
*/
|
|
52
|
+
calculateSkewAngle(canvas: CanvasLike): Promise<number>;
|
|
53
|
+
/**
|
|
54
|
+
* Deskew an image by detecting its skew angle and rotating it.
|
|
55
|
+
*
|
|
56
|
+
* This is a convenience method that combines `calculateSkewAngle()`
|
|
57
|
+
* with rotation to produce a straightened image.
|
|
58
|
+
*
|
|
59
|
+
* @param canvas - Canvas containing the image to deskew
|
|
60
|
+
* @returns A new canvas with the deskewed image
|
|
61
|
+
*/
|
|
62
|
+
deskewImage(canvas: CanvasLike): Promise<CanvasLike>;
|
|
63
|
+
private calculateMinRectAngles;
|
|
64
|
+
private calculateBaselineAngles;
|
|
65
|
+
private calculateHoughAngles;
|
|
66
|
+
private calculateLineAngle;
|
|
67
|
+
private calculateConsensusAngle;
|
|
68
|
+
}
|
package/deskew.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export class DeskewService{verbose;minimumAreaThreshold;constructor(options={}){this.verbose=options.verbose??false;this.minimumAreaThreshold=options.minimumAreaThreshold??20}log(message){if(this.verbose){console.log(`[DeskewService] ${message}`)}}async calculateSkewAngle(canvas){let processor=new ImageProcessor(canvas);let mat=processor.grayscale().threshold({lower:0,upper:255,type:cv.THRESH_BINARY+cv.THRESH_OTSU}).toMat();let contours=new Contours(mat,{mode:cv.RETR_LIST,method:cv.CHAIN_APPROX_SIMPLE});processor.destroy();let minAngle=-20;let maxAngle=20;let minArea=this.minimumAreaThreshold;let textRegions=[];contours.iterate((contour)=>{let rect=contours.getRect(contour);let area=rect.width*rect.height;if(area<minArea)return;let aspectRatio=rect.width/rect.height;if(aspectRatio>0.2&&aspectRatio<10){textRegions.push({rect,contour,area,aspectRatio})}});if(textRegions.length===0){this.log("No valid text regions found for skew calculation.");contours.destroy();return 0}let averageHeight=textRegions.reduce((sum,region)=>sum+region.rect.height,0)/textRegions.length;let filteredRegions=textRegions.filter((region)=>{return region.rect.height<=averageHeight*1.5});this.log(`Found ${filteredRegions.length} text regions for skew analysis.`);let minRectAngles=this.calculateMinRectAngles(filteredRegions,contours);let baselineAngles=this.calculateBaselineAngles(filteredRegions);let houghAngles=this.calculateHoughAngles(mat,minAngle,maxAngle);contours.destroy();let allAngles=[...minRectAngles.map((a)=>({...a,method:"minRect"})),...baselineAngles.map((a)=>({...a,method:"baseline"})),...houghAngles.map((a)=>({...a,method:"hough"}))];if(allAngles.length===0){this.log("No angles detected from any method.");return 0}let consensusAngle=this.calculateConsensusAngle(allAngles,minAngle,maxAngle);this.log(`Calculated skew angle: ${consensusAngle.toFixed(3)}° (from ${allAngles.length} measurements)`);return consensusAngle}async deskewImage(canvas){this.log("Starting image deskewing process");let angle=await this.calculateSkewAngle(canvas);this.log(`Detected skew angle: ${angle.toFixed(2)}°. Rotating image by ${-angle.toFixed(2)}°...`);let processor=new ImageProcessor(canvas);try{let rotatedCanvas=processor.rotate({angle}).toCanvas();return rotatedCanvas}finally{processor.destroy()}}calculateMinRectAngles(textRegions,contours){let angles=[];for(let region of textRegions){try{let minRect=cv.minAreaRect(region.contour);if(!minRect)continue;let angle=minRect.angle;if(angle>45){angle-=90}else if(angle<-45){angle+=90}let areaWeight=Math.log(region.area+1);let aspectWeight=Math.min(region.aspectRatio,1/region.aspectRatio)*2;let weight=areaWeight*aspectWeight;angles.push({angle,weight})}catch(error){continue}}return angles}calculateBaselineAngles(textRegions){let angles=[];for(let region of textRegions){try{let points=region.contour.data32S;if(!points||points.length<8)continue;let bottomPoints=[];for(let i=0;i<points.length;i+=2){let x=points[i];let y=points[i+1];if(x!==undefined&&y!==undefined){bottomPoints.push({x,y})}}if(bottomPoints.length<3)continue;bottomPoints.sort((a,b)=>a.x-b.x);let segments=3;let segmentSize=Math.floor(bottomPoints.length/segments);let baselinePoints=[];for(let seg=0;seg<segments;seg++){let start=seg*segmentSize;let end=seg===segments-1?bottomPoints.length:(seg+1)*segmentSize;let segmentPoints=bottomPoints.slice(start,end);if(segmentPoints.length>0){let maxYPoint=segmentPoints.reduce((max,point)=>point.y>max.y?point:max);baselinePoints.push(maxYPoint)}}if(baselinePoints.length>=2){let angle=this.calculateLineAngle(baselinePoints);let weight=region.area*Math.min(region.aspectRatio,1/region.aspectRatio);angles.push({angle,weight})}}catch(error){continue}}return angles}calculateHoughAngles(mat,minAngle,maxAngle){let angles=[];try{let kernel=cv.getStructuringElement(cv.MORPH_RECT,new cv.Size(3,1));let morphed=new cv.Mat;cv.morphologyEx(mat,morphed,cv.MORPH_CLOSE,kernel);let lines=new cv.Mat;cv.HoughLinesP(morphed,lines,1,Math.PI/180,30,50,10);for(let i=0;i<lines.rows;i++){let line=lines.data32S.subarray(i*4,(i+1)*4);const[x1,y1,x2,y2]=line;if(x1!==undefined&&y1!==undefined&&x2!==undefined&&y2!==undefined){let dx=x2-x1;let dy=y2-y1;if(Math.abs(dx)>1){let angle=Math.atan2(dy,dx)*180/Math.PI;if(angle>45)angle-=90;if(angle<-45)angle+=90;if(angle>=minAngle&&angle<=maxAngle){let lineLength=Math.sqrt(dx*dx+dy*dy);angles.push({angle,weight:lineLength})}}}}morphed.delete();lines.delete();kernel.delete()}catch(error){this.log("Hough transform failed, skipping this method.")}return angles}calculateLineAngle(points){if(points.length<2)return 0;let n=points.length;let sumX=points.reduce((sum,p)=>sum+p.x,0);let sumY=points.reduce((sum,p)=>sum+p.y,0);let sumXY=points.reduce((sum,p)=>sum+p.x*p.y,0);let sumXX=points.reduce((sum,p)=>sum+p.x*p.x,0);let denominator=n*sumXX-sumX*sumX;if(Math.abs(denominator)<0.0000000001)return 0;let slope=(n*sumXY-sumX*sumY)/denominator;let angle=Math.atan(slope)*180/Math.PI;if(angle>45)angle-=90;if(angle<-45)angle+=90;return angle}calculateConsensusAngle(angles,minAngle,maxAngle){if(angles.length===0)return 0;let sortedAngles=[...angles].sort((a,b)=>a.angle-b.angle);let q1Index=Math.floor(sortedAngles.length*0.25);let q3Index=Math.floor(sortedAngles.length*0.75);let q1=sortedAngles[q1Index]?.angle||0;let q3=sortedAngles[q3Index]?.angle||0;let iqr=q3-q1;let lowerBound=q1-1.5*iqr;let upperBound=q3+1.5*iqr;let filteredAngles=angles.filter((a)=>a.angle>=lowerBound&&a.angle<=upperBound&&a.angle>=minAngle&&a.angle<=maxAngle);if(filteredAngles.length===0){this.log("All angles filtered out as outliers, using median of original set.");let medianIndex=Math.floor(sortedAngles.length/2);return sortedAngles[medianIndex]?.angle||0}let totalWeight=filteredAngles.reduce((sum,a)=>sum+a.weight,0);if(totalWeight===0){let average=filteredAngles.reduce((sum,a)=>sum+a.angle,0)/filteredAngles.length;return average}let weightedSum=filteredAngles.reduce((sum,a)=>sum+a.angle*a.weight,0);let weightedAverage=weightedSum/totalWeight;let methodCounts=filteredAngles.reduce((counts,a)=>{counts[a.method]=(counts[a.method]||0)+1;return counts},{});this.log(`Angle methods used: ${Object.entries(methodCounts).map(([method,count])=>`${method}:${count}`).join(", ")}`);return Math.max(minAngle,Math.min(maxAngle,weightedAverage))}}import{Contours}from"./contours.js";import{cv}from"./cv-provider.js";import{ImageProcessor}from"./image-processor.js";
|
package/image-processor.d.ts
CHANGED
|
@@ -14,14 +14,6 @@ export declare class ImageProcessor {
|
|
|
14
14
|
* @param source Source image as CanvasLike or cv.Mat
|
|
15
15
|
*/
|
|
16
16
|
constructor(source: CanvasLike | cv.Mat);
|
|
17
|
-
/**
|
|
18
|
-
* Convert array buffer to canvas
|
|
19
|
-
*/
|
|
20
|
-
static prepareCanvas(file: ArrayBuffer): Promise<CanvasLike>;
|
|
21
|
-
/**
|
|
22
|
-
* Convert canvas to array buffer
|
|
23
|
-
*/
|
|
24
|
-
static prepareBuffer(canvas: CanvasLike): Promise<ArrayBuffer>;
|
|
25
17
|
/**
|
|
26
18
|
* Initialize OpenCV runtime. Must be called before any image processing.
|
|
27
19
|
*
|
package/image-processor.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export class ImageProcessor{img;width;height;constructor(source){if(getPlatform().isCanvas(source)){let ctx=source.getContext("2d");let imageData=ctx.getImageData(0,0,source.width,source.height);this.img=cv.matFromImageData(imageData);this.width=source.width;this.height=source.height}else if(source instanceof cv.Mat){this.img=source;this.width=source.cols;this.height=source.rows}else{throw new Error("Invalid source type. Must be either Canvas or cv.Mat.")}}static async
|
|
1
|
+
export class ImageProcessor{img;width;height;constructor(source){if(getPlatform().isCanvas(source)){let ctx=source.getContext("2d");let imageData=ctx.getImageData(0,0,source.width,source.height);this.img=cv.matFromImageData(imageData);this.width=source.width;this.height=source.height}else if(source instanceof cv.Mat){this.img=source;this.width=source.cols;this.height=source.rows}else{throw new Error("Invalid source type. Must be either Canvas or cv.Mat.")}}static async initRuntime(){if(globalThis.cv?.Mat){setCv(globalThis.cv);return}try{let mod=await import("@techstark/opencv-js");let _cv=mod.default||mod;setCv(_cv);if(!_cv.Mat){await new Promise((res)=>{_cv["onRuntimeInitialized"]=()=>res()})}return}catch{}if(typeof document!=="undefined"){await new Promise((resolve,reject)=>{let script=document.createElement("script");script.src="https://cdn.jsdelivr.net/npm/@techstark/opencv-js@4.10.0-release.1/dist/opencv.js";script.async=true;script.onload=()=>{let g=globalThis;if(g.cv?.Mat){setCv(g.cv);resolve()}else if(g.cv){g.cv["onRuntimeInitialized"]=()=>{setCv(g.cv);resolve()}}else{reject(new Error("OpenCV.js loaded but cv not found on globalThis"))}};script.onerror=()=>reject(new Error("Failed to load @techstark/opencv-js from CDN"));document.head.appendChild(script)});return}throw new Error("Cannot initialize OpenCV runtime. Install @techstark/opencv-js or run in a browser.")}execute(operationName,options){let result=executeOperation(operationName,this.img,options);this.img=result.img;this.width=result.width;this.height=result.height;return this}grayscale(options){return this.execute("grayscale",options)}blur(options){return this.execute("blur",options)}threshold(options){return this.execute("threshold",options)}adaptiveThreshold(options){return this.execute("adaptiveThreshold",options)}invert(options){return this.execute("invert",options)}canny(options){return this.execute("canny",options)}dilate(options){return this.execute("dilate",options)}erode(options){return this.execute("erode",options)}border(options){return this.execute("border",options)}resize(options){return this.execute("resize",options)}rotate(options){return this.execute("rotate",options)}warp(options){return this.execute("warp",options)}convert(options){return this.execute("convert",options)}morphologicalGradient(options){return this.execute("morphologicalGradient",options)}toMat(){return this.img}toCanvas(){let platform=getPlatform();let canvas=platform.createCanvas(this.width,this.height);try{cv.imshow(canvas,this.img)}catch(e){let ctx=canvas.getContext("2d");if(!ctx)throw new Error("Could not get 2d context from canvas");let imgData=ctx.createImageData(this.width,this.height);if(this.img.channels()===1){let data=imgData.data;let gray=new Uint8Array(this.img.data);for(let i=0;i<gray.length;i++){data[i*4]=gray[i];data[i*4+1]=gray[i];data[i*4+2]=gray[i];data[i*4+3]=255}}else{imgData.data.set(new Uint8ClampedArray(this.img.data))}ctx.putImageData(imgData,0,0)}return canvas}destroy(){try{this.img.delete()}catch{}}}import{getPlatform}from"./canvas-factory.js";import{cv,setCv}from"./cv-provider.js";import{executeOperation}from"./pipeline/index.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { BoundingBox, Coordinate, Points } from "./index.interface.js";
|
|
2
|
+
export { getPlatform, setPlatform } from "./canvas-factory.js";
|
|
3
|
+
export type { CanvasLike, CanvasPlatform, Context2DLike, } from "./canvas-factory.js";
|
|
4
|
+
export { webPlatform } from "./platform/web.js";
|
|
5
|
+
export { CanvasToolkitBase as CanvasToolkit, CanvasToolkitBase, type ContourLike, } from "./canvas-toolkit.base.js";
|
|
6
|
+
export { CanvasProcessor, type DetectedRegion } from "./canvas-processor.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{setPlatform}from"./canvas-factory.js";import{webPlatform}from"./platform/web.js";setPlatform(webPlatform);export{getPlatform,setPlatform}from"./canvas-factory.js";export{webPlatform}from"./platform/web.js";export{CanvasToolkitBase as CanvasToolkit,CanvasToolkitBase}from"./canvas-toolkit.base.js";export{CanvasProcessor}from"./canvas-processor.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { Canvas, createCanvas, ImageData, loadImage } from "@napi-rs/canvas";
|
|
2
|
+
export type { SKRSContext2D } from "@napi-rs/canvas";
|
|
3
|
+
export type { BoundingBox, Coordinate, Points } from "./index.interface.js";
|
|
4
|
+
export { getPlatform, setPlatform } from "./canvas-factory.js";
|
|
5
|
+
export type { CanvasLike, CanvasPlatform, Context2DLike, } from "./canvas-factory.js";
|
|
6
|
+
export { CanvasToolkitBase, type ContourLike } from "./canvas-toolkit.base.js";
|
|
7
|
+
export { CanvasToolkit } from "./canvas-toolkit.js";
|
|
8
|
+
export { CanvasProcessor, type DetectedRegion } from "./canvas-processor.js";
|
package/index.canvas.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{setPlatform}from"./canvas-factory.js";import{nodePlatform}from"./platform/node.js";setPlatform(nodePlatform);export{Canvas,createCanvas,ImageData,loadImage}from"@napi-rs/canvas";export{getPlatform,setPlatform}from"./canvas-factory.js";export{CanvasToolkitBase}from"./canvas-toolkit.base.js";export{CanvasToolkit}from"./canvas-toolkit.js";export{CanvasProcessor}from"./canvas-processor.js";
|
package/index.d.ts
CHANGED
|
@@ -6,9 +6,11 @@ export type { BoundingBox, Coordinate, Points } from "./index.interface.js";
|
|
|
6
6
|
export { executeOperation, OperationRegistry, registry, } from "./pipeline/index.js";
|
|
7
7
|
export { getPlatform, setPlatform } from "./canvas-factory.js";
|
|
8
8
|
export type { CanvasLike, CanvasPlatform, Context2DLike, } from "./canvas-factory.js";
|
|
9
|
-
export { CanvasToolkitBase } from "./canvas-toolkit.base.js";
|
|
9
|
+
export { CanvasToolkitBase, type ContourLike } from "./canvas-toolkit.base.js";
|
|
10
10
|
export { CanvasToolkit } from "./canvas-toolkit.js";
|
|
11
|
+
export { CanvasProcessor, type DetectedRegion } from "./canvas-processor.js";
|
|
11
12
|
export { Contours } from "./contours.js";
|
|
12
13
|
export { calculateMeanGrayscaleValue, calculateMeanNormalizedLabLightness, type CalculateMeanLightnessOptions, } from "./image-analysis.js";
|
|
13
14
|
export { ImageProcessor } from "./image-processor.js";
|
|
15
|
+
export { DeskewService, type DeskewOptions } from "./deskew.js";
|
|
14
16
|
export type { AdaptiveThresholdOptions, BlurOptions, BorderOptions, CannyOptions, DilateOptions, ErodeOptions, GrayscaleOptions, InvertOptions, MorphologicalGradientOptions, OperationFunction, OperationName, OperationOptions, OperationResult, PartialOptions, RegisteredOperations, RequiredOptions, ResizeOptions, RotateOptions, ThresholdOptions, WarpOptions, } from "./pipeline/index.js";
|
package/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import _cv from"@techstark/opencv-js";import{cv,setCv}from"./cv-provider.js";setCv(_cv);export{cv};import{setPlatform}from"./canvas-factory.js";import{nodePlatform}from"./platform/node.js";setPlatform(nodePlatform);export{Canvas,createCanvas,ImageData,loadImage}from"@napi-rs/canvas";export{executeOperation,OperationRegistry,registry}from"./pipeline/index.js";export{getPlatform,setPlatform}from"./canvas-factory.js";export{CanvasToolkitBase}from"./canvas-toolkit.base.js";export{CanvasToolkit}from"./canvas-toolkit.js";export{Contours}from"./contours.js";export{calculateMeanGrayscaleValue,calculateMeanNormalizedLabLightness}from"./image-analysis.js";export{ImageProcessor}from"./image-processor.js";
|
|
1
|
+
import _cv from"@techstark/opencv-js";import{cv,setCv}from"./cv-provider.js";setCv(_cv);export{cv};import{setPlatform}from"./canvas-factory.js";import{nodePlatform}from"./platform/node.js";setPlatform(nodePlatform);export{Canvas,createCanvas,ImageData,loadImage}from"@napi-rs/canvas";export{executeOperation,OperationRegistry,registry}from"./pipeline/index.js";export{getPlatform,setPlatform}from"./canvas-factory.js";export{CanvasToolkitBase}from"./canvas-toolkit.base.js";export{CanvasToolkit}from"./canvas-toolkit.js";export{CanvasProcessor}from"./canvas-processor.js";export{Contours}from"./contours.js";export{calculateMeanGrayscaleValue,calculateMeanNormalizedLabLightness}from"./image-analysis.js";export{ImageProcessor}from"./image-processor.js";export{DeskewService}from"./deskew.js";
|
package/index.web.d.ts
CHANGED
|
@@ -5,8 +5,10 @@ export type { CanvasLike, CanvasPlatform, Context2DLike, } from "./canvas-factor
|
|
|
5
5
|
export { webPlatform } from "./platform/web.js";
|
|
6
6
|
export type { BoundingBox, Coordinate, Points } from "./index.interface.js";
|
|
7
7
|
export { executeOperation, OperationRegistry, registry, } from "./pipeline/index.js";
|
|
8
|
-
export { CanvasToolkitBase as CanvasToolkit, CanvasToolkitBase, } from "./canvas-toolkit.base.js";
|
|
8
|
+
export { CanvasToolkitBase as CanvasToolkit, CanvasToolkitBase, type ContourLike, } from "./canvas-toolkit.base.js";
|
|
9
|
+
export { CanvasProcessor, type DetectedRegion } from "./canvas-processor.js";
|
|
9
10
|
export { Contours } from "./contours.js";
|
|
10
11
|
export { calculateMeanGrayscaleValue, calculateMeanNormalizedLabLightness, type CalculateMeanLightnessOptions, } from "./image-analysis.js";
|
|
11
12
|
export { ImageProcessor } from "./image-processor.js";
|
|
13
|
+
export { DeskewService, type DeskewOptions } from "./deskew.js";
|
|
12
14
|
export type { AdaptiveThresholdOptions, BlurOptions, BorderOptions, CannyOptions, DilateOptions, ErodeOptions, GrayscaleOptions, InvertOptions, MorphologicalGradientOptions, OperationFunction, OperationName, OperationOptions, OperationResult, PartialOptions, RegisteredOperations, RequiredOptions, ResizeOptions, RotateOptions, ThresholdOptions, WarpOptions, } from "./pipeline/index.js";
|
package/index.web.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{cv}from"./cv-provider.js";export{cv};import{setPlatform}from"./canvas-factory.js";import{webPlatform}from"./platform/web.js";setPlatform(webPlatform);export{getPlatform,setPlatform}from"./canvas-factory.js";export{webPlatform}from"./platform/web.js";export{executeOperation,OperationRegistry,registry}from"./pipeline/index.js";export{CanvasToolkitBase as CanvasToolkit,CanvasToolkitBase}from"./canvas-toolkit.base.js";export{Contours}from"./contours.js";export{calculateMeanGrayscaleValue,calculateMeanNormalizedLabLightness}from"./image-analysis.js";export{ImageProcessor}from"./image-processor.js";
|
|
1
|
+
import{cv}from"./cv-provider.js";export{cv};import{setPlatform}from"./canvas-factory.js";import{webPlatform}from"./platform/web.js";setPlatform(webPlatform);export{getPlatform,setPlatform}from"./canvas-factory.js";export{webPlatform}from"./platform/web.js";export{executeOperation,OperationRegistry,registry}from"./pipeline/index.js";export{CanvasToolkitBase as CanvasToolkit,CanvasToolkitBase}from"./canvas-toolkit.base.js";export{CanvasProcessor}from"./canvas-processor.js";export{Contours}from"./contours.js";export{calculateMeanGrayscaleValue,calculateMeanNormalizedLabLightness}from"./image-analysis.js";export{ImageProcessor}from"./image-processor.js";export{DeskewService}from"./deskew.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ppu-ocv",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "A type-safe, modular, chainable image processing library built on top of OpenCV.js with a fluent API leveraging pipeline processing.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"open-cv",
|
|
@@ -33,6 +33,14 @@
|
|
|
33
33
|
"./web": {
|
|
34
34
|
"types": "./index.web.d.ts",
|
|
35
35
|
"default": "./index.web.js"
|
|
36
|
+
},
|
|
37
|
+
"./canvas": {
|
|
38
|
+
"types": "./index.canvas.d.ts",
|
|
39
|
+
"default": "./index.canvas.js"
|
|
40
|
+
},
|
|
41
|
+
"./canvas-web": {
|
|
42
|
+
"types": "./index.canvas-web.d.ts",
|
|
43
|
+
"default": "./index.canvas-web.js"
|
|
36
44
|
}
|
|
37
45
|
},
|
|
38
46
|
"scripts": {
|
|
@@ -40,7 +48,8 @@
|
|
|
40
48
|
"build:test": "bun task build && bun test",
|
|
41
49
|
"build:publish": "bun task build && bun task report-size && bun task publish",
|
|
42
50
|
"lint": "prettier --check ./src",
|
|
43
|
-
"lint:fix": "prettier --write ./src"
|
|
51
|
+
"lint:fix": "prettier --write ./src",
|
|
52
|
+
"demo": "bunx -y serve -l 4567 ."
|
|
44
53
|
},
|
|
45
54
|
"devDependencies": {
|
|
46
55
|
"@stylistic/eslint-plugin": "latest",
|