ppu-ocv 3.1.0 → 3.1.3

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  # ppu-ocv
2
2
 
3
- A type-safe, modular, chainable image processing library built on top of OpenCV.js with a fluent API leveraging pipeline processing.
3
+ [![NPM](https://img.shields.io/npm/dw/ppu-ocv)](https://www.npmjs.com/package/ppu-ocv) [![JSR](https://jsr.io/badges/@snowfluke/ppu-ocv)](https://jsr.io/@snowfluke/ppu-ocv)
4
4
 
5
- ![ppu-ocv pipeline demo](https://raw.githubusercontent.com/PT-Perkasa-Pilar-Utama/ppu-ocv/refs/heads/main/assets/ppu-ocv-demo.jpg)
5
+ A type-safe, modular, chainable image processing library built on top of OpenCV.js with a fluent API leveraging pipeline processing. Decoupled canvas utilities run anywhere — Node, Bun, browsers, browser extensions, and service workers — with or without OpenCV.
6
6
 
7
- Image manipulation as easy as:
7
+ ![ppu-ocv pipeline demo](https://raw.githubusercontent.com/PT-Perkasa-Pilar-Utama/ppu-ocv/refs/heads/main/assets/ppu-ocv-demo.jpg)
8
8
 
9
9
  ```ts
10
10
  const processor = new ImageProcessor(canvas);
@@ -17,23 +17,33 @@ const result = processor
17
17
  .dilate({ size: [20, 20], iter: 5 })
18
18
  .toCanvas();
19
19
 
20
- // Memory cleanup
21
20
  processor.destroy();
22
21
  ```
23
22
 
24
- This work is based on https://github.com/TechStark/opencv-js.
23
+ Based on [TechStark/opencv-js](https://github.com/TechStark/opencv-js).
24
+
25
+ ## Table of Contents
25
26
 
26
- ## Why use this library?
27
+ - [Why ppu-ocv?](#why-ppu-ocv)
28
+ - [Installation](#installation)
29
+ - [Usage (Node.js / Bun)](#usage-nodejs--bun)
30
+ - [Canvas-only Usage (no OpenCV)](#canvas-only-usage-no-opencv)
31
+ - [Web / Browser Support](#web--browser-support)
32
+ - [Built-in Pipeline Operations](#built-in-pipeline-operations)
33
+ - [Extending Operations](#extending-operations)
34
+ - [Class Documentation](#class-documentation)
35
+ - [Migrating from v2](#migrating-from-v2)
36
+ - [Contributing](#contributing)
37
+ - [License](#license)
27
38
 
28
- OpenCV is powerful but can be cumbersome to use directly. This library provides:
39
+ ## Why ppu-ocv?
29
40
 
30
- 1. **Simplified API**: Transform complex OpenCV calls into simple chainable methods
31
- 2. **Reduced Boilerplate**: No need to manage memory, conversions, or dimensions manually
32
- 3. **Development Speed**: Add image processing to your app in minutes, not hours
33
- 4. **Extensibility**: Custom operations for your specific needs without library modifications
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
41
+ - **Simplified API** chainable methods that hide OpenCV's verbose Mat allocation
42
+ - **No memory management** automatic Mat lifecycle within the pipeline
43
+ - **Type-safe** full TypeScript inference for operations and options
44
+ - **Extensible** register custom operations with `registry.register(...)` without forking
45
+ - **Cross-platform** same API in Node, Bun, browsers, and constrained runtimes
46
+ - **Loosely coupled** canvas utilities work standalone; OpenCV is only loaded when actually needed
37
47
 
38
48
  ## Installation
39
49
 
@@ -125,9 +135,7 @@ const buffer = await CanvasProcessor.prepareBuffer(cropped);
125
135
  import { CanvasProcessor, CanvasToolkit } from "ppu-ocv/canvas-web";
126
136
 
127
137
  const response = await fetch("/image.jpg");
128
- const canvas = await CanvasProcessor.prepareCanvas(
129
- await response.arrayBuffer(),
130
- );
138
+ const canvas = await CanvasProcessor.prepareCanvas(await response.arrayBuffer());
131
139
  ```
132
140
 
133
141
  ## Web / Browser Support
@@ -171,9 +179,7 @@ processor.destroy();
171
179
  await ImageProcessor.initRuntime();
172
180
 
173
181
  const response = await fetch("/my-image.jpg");
174
- const canvas = await CanvasProcessor.prepareCanvas(
175
- await response.arrayBuffer(),
176
- );
182
+ const canvas = await CanvasProcessor.prepareCanvas(await response.arrayBuffer());
177
183
 
178
184
  const processor = new ImageProcessor(canvas);
179
185
  processor
@@ -296,8 +302,8 @@ regions.sort((a, b) => b.area - a.area); // largest first
296
302
 
297
303
  **Region detection** (returns data, does not mutate)
298
304
 
299
- | Method | Options | Description |
300
- | ------------- | ------------------------------------------------ | ------------------------------------------------------------ |
305
+ | Method | Options | Description |
306
+ | ------------- | ---------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
301
307
  | `findRegions` | `foreground?` (`"light"`), `thresh?` (127), `minArea?`, `maxArea?`, `padding?`, `scale?` | 8-connected flood-fill on a binary canvas → `DetectedRegion[]` |
302
308
 
303
309
  `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. ³
@@ -368,37 +374,24 @@ A collection of utility functions for analyzing image properties (requires OpenC
368
374
 
369
375
  ## Contributing
370
376
 
371
- Contributions are welcome! If you would like to contribute, please follow these steps:
377
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full guide setup, commit conventions, quality checks, and PR flow. Also:
372
378
 
373
- 1. **Fork the Repository:** Create your own fork of the project.
374
- 2. **Create a Feature Branch:** Use a descriptive branch name for your changes.
375
- 3. **Implement Changes:** Make your modifications, add tests, and ensure everything passes.
376
- 4. **Submit a Pull Request:** Open a pull request to discuss your changes and get feedback.
379
+ - [Code of Conduct](./CODE_OF_CONDUCT.md) community standards.
380
+ - [Security policy](./SECURITY.md) how to report vulnerabilities privately.
381
+ - [Issue tracker](https://github.com/PT-Perkasa-Pilar-Utama/ppu-ocv/issues) bug reports, feature requests, and docs gaps each have a template.
377
382
 
378
- ### Running Tests
379
-
380
- This project uses Bun for testing. To run the tests locally, execute:
383
+ Quick local commands:
381
384
 
382
385
  ```bash
383
- bun test
386
+ bun install
387
+ bun test # run unit tests
388
+ bun run fmt # check formatting
389
+ bun run lint # check lint
390
+ bun run type-check # tsgo --noEmit
391
+ bun task build # emit ./lib
392
+ bun task bench # micro-bench the operations registry
384
393
  ```
385
394
 
386
- Ensure that all tests pass before submitting your pull request.
387
-
388
- ## Scripts
389
-
390
- Recommended development environment is in a Linux-based environment.
391
-
392
- Library template: https://github.com/aquapi/lib-template
393
-
394
- ### [Build](./scripts/build.ts)
395
-
396
- Emit `.js` and `.d.ts` files to [`lib`](./lib).
397
-
398
- ### [Publish](./scripts/publish.ts)
399
-
400
- Move [`package.json`](./package.json), [`README.md`](./README.md) to [`lib`](./lib) and publish the package.
401
-
402
395
  ## Migrating from v2
403
396
 
404
397
  See [MIGRATION.md](./MIGRATION.md) for a full guide. The short version:
@@ -45,8 +45,11 @@ export interface DetectedRegion {
45
45
  */
46
46
  export declare class CanvasProcessor {
47
47
  private _canvas;
48
+ /** Create a processor wrapping the given canvas. */
48
49
  constructor(source: CanvasLike);
50
+ /** Current canvas width in pixels. */
49
51
  get width(): number;
52
+ /** Current canvas height in pixels. */
50
53
  get height(): number;
51
54
  /**
52
55
  * Scale the canvas to new dimensions.
@@ -1 +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";
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)+0.587*(d[i+1]??0)+0.114*(d[i+2]??0));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]??0)*alpha+beta);d[i+1]=Math.round((d[i+1]??0)*alpha+beta);d[i+2]=Math.round((d[i+2]??0)*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]??0);d[i+1]=255-(d[i+1]??0);d[i+2]=255-(d[i+2]??0)}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]??0:Math.round(0.299*(d[i]??0)+0.587*(d[i+1]??0)+0.114*(d[i+2]??0));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]??0;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();if(flat===undefined)break;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";
@@ -12,6 +12,7 @@ export declare class CanvasToolkitBase {
12
12
  protected static _baseInstance: CanvasToolkitBase | null;
13
13
  protected step: number;
14
14
  protected constructor();
15
+ /** Return the singleton instance of {@link CanvasToolkitBase}. */
15
16
  static getInstance(): CanvasToolkitBase;
16
17
  /**
17
18
  * Crop a part of source canvas and return a new canvas of the cropped part
@@ -1 +1 @@
1
- export class CanvasToolkitBase{static _baseInstance=null;step=0;constructor(){}static getInstance(){if(!CanvasToolkitBase._baseInstance){CanvasToolkitBase._baseInstance=new CanvasToolkitBase}return CanvasToolkitBase._baseInstance}crop(options){const{bbox,canvas}=options;let croppedCanvas=getPlatform().createCanvas(bbox.x1-bbox.x0,bbox.y1-bbox.y0);let croppedCtx=croppedCanvas.getContext("2d");croppedCtx.drawImage(canvas,bbox.x0,bbox.y0,bbox.x1-bbox.x0,bbox.y1-bbox.y0,0,0,croppedCanvas.width,croppedCanvas.height);return croppedCanvas}isDirty(options){const{canvas,threshold=127.5,majorColorThreshold=0.97}=options;let whiteCount=0;let blackCount=0;let borderlessCanvas=this.crop({bbox:{x0:canvas.width*0.1,y0:canvas.height*0.1,x1:canvas.width*0.9,y1:canvas.height*0.9},canvas});let ctx=borderlessCanvas.getContext("2d");let colorData=ctx.getImageData(0,0,borderlessCanvas.width,borderlessCanvas.height).data;for(let i=0;i<colorData.length;i+=4){let red=colorData[i];let green=colorData[i+1];let blue=colorData[i+2];if(red>=threshold&&green>=threshold&&blue>=threshold){whiteCount++}else{blackCount++}}let majorColorRatio=Math.max(whiteCount,blackCount)/(blackCount+whiteCount);return majorColorRatio<majorColorThreshold}drawLine(options){const{ctx,x,y,width,height,lineWidth=2,color="blue"}=options;ctx.beginPath();ctx.strokeStyle=color;ctx.lineWidth=lineWidth;ctx.strokeRect(x,y,width,height);ctx.closePath()}drawContour(options){const{ctx,contour,strokeStyle="red",lineWidth=2}=options;let pts=contour.data32S;if(pts.length<4)return;ctx.strokeStyle=strokeStyle;ctx.lineWidth=lineWidth;ctx.beginPath();ctx.moveTo(pts[0],pts[1]);for(let i=2;i<pts.length;i+=2){ctx.lineTo(pts[i],pts[i+1])}ctx.closePath();ctx.stroke()}}import{getPlatform}from"./canvas-factory.js";
1
+ export class CanvasToolkitBase{static _baseInstance=null;step=0;constructor(){}static getInstance(){if(!CanvasToolkitBase._baseInstance){CanvasToolkitBase._baseInstance=new CanvasToolkitBase}return CanvasToolkitBase._baseInstance}crop(options){const{bbox,canvas}=options;let croppedCanvas=getPlatform().createCanvas(bbox.x1-bbox.x0,bbox.y1-bbox.y0);let croppedCtx=croppedCanvas.getContext("2d");croppedCtx.drawImage(canvas,bbox.x0,bbox.y0,bbox.x1-bbox.x0,bbox.y1-bbox.y0,0,0,croppedCanvas.width,croppedCanvas.height);return croppedCanvas}isDirty(options){const{canvas,threshold=127.5,majorColorThreshold=0.97}=options;let whiteCount=0;let blackCount=0;let borderlessCanvas=this.crop({bbox:{x0:canvas.width*0.1,y0:canvas.height*0.1,x1:canvas.width*0.9,y1:canvas.height*0.9},canvas});let ctx=borderlessCanvas.getContext("2d");let colorData=ctx.getImageData(0,0,borderlessCanvas.width,borderlessCanvas.height).data;for(let i=0;i<colorData.length;i+=4){let red=colorData[i];let green=colorData[i+1];let blue=colorData[i+2];if(red>=threshold&&green>=threshold&&blue>=threshold){whiteCount++}else{blackCount++}}let majorColorRatio=Math.max(whiteCount,blackCount)/(blackCount+whiteCount);return majorColorRatio<majorColorThreshold}drawLine(options){const{ctx,x,y,width,height,lineWidth=2,color="blue"}=options;ctx.beginPath();ctx.strokeStyle=color;ctx.lineWidth=lineWidth;ctx.strokeRect(x,y,width,height);ctx.closePath()}drawContour(options){const{ctx,contour,strokeStyle="red",lineWidth=2}=options;let pts=contour.data32S;if(pts.length<4)return;ctx.strokeStyle=strokeStyle;ctx.lineWidth=lineWidth;ctx.beginPath();ctx.moveTo(pts[0]??0,pts[1]??0);for(let i=2;i<pts.length;i+=2){ctx.lineTo(pts[i]??0,pts[i+1]??0)}ctx.closePath();ctx.stroke()}}import{getPlatform}from"./canvas-factory.js";
package/canvas-toolkit.js CHANGED
@@ -1 +1 @@
1
- import{CanvasToolkitBase}from"./canvas-toolkit.base.js";import{createWriteStream,existsSync,mkdirSync,readdirSync,unlinkSync}from"fs";import{join}from"path";export class CanvasToolkit extends CanvasToolkitBase{static _nodeInstance=null;constructor(){super()}static getInstance(){if(!CanvasToolkit._nodeInstance){CanvasToolkit._nodeInstance=new CanvasToolkit}return CanvasToolkit._nodeInstance}saveImage(options){const{canvas,filename,path="out"}=options;let folderPath=join(process.cwd(),path);if(!existsSync(folderPath)){mkdirSync(folderPath,{recursive:true})}let filePath=join(folderPath,`${this.step++}. ${filename}.png`);let out=createWriteStream(filePath);let buffer=canvas.toBuffer("image/png");return new Promise((res,rej)=>{out.write(buffer,(err)=>{if(err){rej(err)}else{res()}})})}clearOutput(path="out"){let folderPath=join(process.cwd(),path);if(existsSync(folderPath)){let files=readdirSync(folderPath);for(let file of files){if(file===".gitignore")continue;let filePath=join(folderPath,file);unlinkSync(filePath)}}}}
1
+ import{CanvasToolkitBase}from"./canvas-toolkit.base.js";import{createWriteStream,existsSync,mkdirSync,readdirSync,unlinkSync}from"fs";import{join}from"path";export class CanvasToolkit extends CanvasToolkitBase{static _nodeInstance=null;constructor(){super()}static getInstance(){if(!CanvasToolkit._nodeInstance){CanvasToolkit._nodeInstance=new CanvasToolkit}return CanvasToolkit._nodeInstance}saveImage(options){const{canvas,filename,path="out"}=options;let folderPath=join(process.cwd(),path);if(!existsSync(folderPath)){mkdirSync(folderPath,{recursive:true})}let filePath=join(folderPath,`${this.step++}. ${filename}.png`);let out=createWriteStream(filePath);if(typeof canvas.toBuffer!=="function")throw new Error("toBuffer not available on this canvas");let buffer=canvas.toBuffer("image/png");return new Promise((res,rej)=>{out.write(buffer,(err)=>{if(err){rej(err)}else{res()}})})}clearOutput(path="out"){let folderPath=join(process.cwd(),path);if(existsSync(folderPath)){let files=readdirSync(folderPath);for(let file of files){if(file===".gitignore")continue;let filePath=join(folderPath,file);unlinkSync(filePath)}}}}
package/contours.d.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import type { CanvasLike } from "./canvas-factory.js";
2
2
  import { cv } from "./cv-provider.js";
3
3
  import type { BoundingBox, Points } from "./index.interface.js";
4
+ /** Options for configuring contour detection. */
4
5
  export interface ContoursOptions {
5
6
  /** The contour retrieval mode. (cv.RETR_...) */
6
7
  mode: cv.RetrievalModes;
7
8
  /** The contour approximation method. (cv.CHAIN_...) */
8
9
  method: cv.ContourApproximationModes;
9
10
  }
11
+ /**
12
+ * Wrapper around OpenCV's `findContours` that provides convenient accessors
13
+ * for iterating, filtering, and analyzing contours in a binary image.
14
+ */
10
15
  export declare class Contours {
11
16
  private contours;
12
17
  /** The constructor for the Contours class. It takes an image and options as parameters. */
package/contours.js CHANGED
@@ -1 +1 @@
1
- export class Contours{contours;constructor(img,options={}){let opts={...defaultOptions(),...options};if(img instanceof cv.Mat){let contours=new cv.MatVector;let hierarchy=new cv.Mat;try{cv.findContours(img,contours,hierarchy,opts.mode,opts.method)}catch(error){throw error}hierarchy.delete();this.contours=contours}else{throw new Error("Invalid img type. Must be cv.Mat.")}}getAll(){return this.contours}getSize(){return this.contours.size()}getFromIndex(index){if(index<this.contours.size()){return this.contours.get(index)}return new cv.Mat}getRect(contour){return cv.boundingRect(contour)}iterate(callback){for(let i=0,len=this.contours.size();i<len;i++){let contour=this.contours.get(i);callback(contour)}return this}getLargestContourArea(){let maxArea=0;let largestContour=null;this.iterate((contour)=>{let area=cv.contourArea(contour);if(area>maxArea){maxArea=area;largestContour=contour}});return largestContour}getCornerPoints(options){const{canvas,contour=this.getLargestContourArea()}=options;let bbox={x0:0,y0:0,x1:canvas.width,y1:canvas.height};if(!contour){return{points:{topLeft:{x:bbox.x0,y:bbox.y0},topRight:{x:bbox.x1,y:bbox.y0},bottomLeft:{x:bbox.x0,y:bbox.y1},bottomRight:{x:bbox.x1,y:bbox.y1}},bbox}}let rect=cv.minAreaRect(contour);let vertices=cv.RotatedRect.points(rect);let points={topLeft:{x:0,y:0},topRight:{x:0,y:0},bottomRight:{x:0,y:0},bottomLeft:{x:0,y:0}};let sums=vertices.map((pt)=>pt.x+pt.y);let diffs=vertices.map((pt)=>pt.y-pt.x);let topLeftIdx=sums.indexOf(Math.min(...sums));let topRightIdx=diffs.indexOf(Math.min(...diffs));let bottomRightIdx=sums.indexOf(Math.max(...sums));let bottomLeftIdx=diffs.indexOf(Math.max(...diffs));if(!vertices[topLeftIdx]||!vertices[topRightIdx]||!vertices[bottomRightIdx]||!vertices[bottomLeftIdx]){return{points:{topLeft:{x:bbox.x0,y:bbox.y0},topRight:{x:bbox.x1,y:bbox.y0},bottomLeft:{x:bbox.x0,y:bbox.y1},bottomRight:{x:bbox.x1,y:bbox.y1}},bbox}}points.topLeft={x:vertices[topLeftIdx].x,y:vertices[topLeftIdx].y};points.topRight={x:vertices[topRightIdx].x,y:vertices[topRightIdx].y};points.bottomRight={x:vertices[bottomRightIdx].x,y:vertices[bottomRightIdx].y};points.bottomLeft={x:vertices[bottomLeftIdx].x,y:vertices[bottomLeftIdx].y};contour.delete();let ensureInBounds=(p)=>{p.x=Math.max(0,Math.min(canvas.width,p.x));p.y=Math.max(0,Math.min(canvas.height,p.y));return p};points.topLeft=ensureInBounds(points.topLeft);points.topRight=ensureInBounds(points.topRight);points.bottomLeft=ensureInBounds(points.bottomLeft);points.bottomRight=ensureInBounds(points.bottomRight);return{points,bbox}}getApproximateRectangleContour(options){const{threshold=0.02,contour=this.getLargestContourArea()}=options??{};if(!contour)return;let epsilon=threshold*cv.arcLength(contour,true);let approxContour=new cv.Mat;cv.approxPolyDP(contour,approxContour,epsilon,true);contour.delete();return approxContour}destroy(){try{this.contours.delete()}catch(error){}}}import{cv}from"./cv-provider.js";function defaultOptions(){return{mode:cv.RETR_EXTERNAL,method:cv.CHAIN_APPROX_SIMPLE}}
1
+ export class Contours{contours;constructor(img,options={}){let opts={...defaultOptions(),...options};if(img instanceof cv.Mat){let contours=new cv.MatVector;let hierarchy=new cv.Mat;cv.findContours(img,contours,hierarchy,opts.mode,opts.method);hierarchy.delete();this.contours=contours}else{throw new Error("Invalid img type. Must be cv.Mat.")}}getAll(){return this.contours}getSize(){return this.contours.size()}getFromIndex(index){if(index<this.contours.size()){return this.contours.get(index)}return new cv.Mat}getRect(contour){return cv.boundingRect(contour)}iterate(callback){for(let i=0,len=this.contours.size();i<len;i++){let contour=this.contours.get(i);callback(contour)}return this}getLargestContourArea(){let maxArea=0;let largestContour=null;this.iterate((contour)=>{let area=cv.contourArea(contour);if(area>maxArea){maxArea=area;largestContour=contour}});return largestContour}getCornerPoints(options){const{canvas,contour=this.getLargestContourArea()}=options;let bbox={x0:0,y0:0,x1:canvas.width,y1:canvas.height};if(!contour){return{points:{topLeft:{x:bbox.x0,y:bbox.y0},topRight:{x:bbox.x1,y:bbox.y0},bottomLeft:{x:bbox.x0,y:bbox.y1},bottomRight:{x:bbox.x1,y:bbox.y1}},bbox}}let rect=cv.minAreaRect(contour);let vertices=cv.RotatedRect.points(rect);let points={topLeft:{x:0,y:0},topRight:{x:0,y:0},bottomRight:{x:0,y:0},bottomLeft:{x:0,y:0}};let sums=vertices.map((pt)=>pt.x+pt.y);let diffs=vertices.map((pt)=>pt.y-pt.x);let topLeftIdx=sums.indexOf(Math.min(...sums));let topRightIdx=diffs.indexOf(Math.min(...diffs));let bottomRightIdx=sums.indexOf(Math.max(...sums));let bottomLeftIdx=diffs.indexOf(Math.max(...diffs));if(!vertices[topLeftIdx]||!vertices[topRightIdx]||!vertices[bottomRightIdx]||!vertices[bottomLeftIdx]){return{points:{topLeft:{x:bbox.x0,y:bbox.y0},topRight:{x:bbox.x1,y:bbox.y0},bottomLeft:{x:bbox.x0,y:bbox.y1},bottomRight:{x:bbox.x1,y:bbox.y1}},bbox}}points.topLeft={x:vertices[topLeftIdx].x,y:vertices[topLeftIdx].y};points.topRight={x:vertices[topRightIdx].x,y:vertices[topRightIdx].y};points.bottomRight={x:vertices[bottomRightIdx].x,y:vertices[bottomRightIdx].y};points.bottomLeft={x:vertices[bottomLeftIdx].x,y:vertices[bottomLeftIdx].y};contour.delete();let ensureInBounds=(p)=>{p.x=Math.max(0,Math.min(canvas.width,p.x));p.y=Math.max(0,Math.min(canvas.height,p.y));return p};points.topLeft=ensureInBounds(points.topLeft);points.topRight=ensureInBounds(points.topRight);points.bottomLeft=ensureInBounds(points.bottomLeft);points.bottomRight=ensureInBounds(points.bottomRight);return{points,bbox}}getApproximateRectangleContour(options){const{threshold=0.02,contour=this.getLargestContourArea()}=options??{};if(!contour)return;let epsilon=threshold*cv.arcLength(contour,true);let approxContour=new cv.Mat;cv.approxPolyDP(contour,approxContour,epsilon,true);contour.delete();return approxContour}destroy(){try{this.contours.delete()}catch{}}}import{cv}from"./cv-provider.js";function defaultOptions(){return{mode:cv.RETR_EXTERNAL,method:cv.CHAIN_APPROX_SIMPLE}}
package/cv-provider.d.ts CHANGED
@@ -23,23 +23,47 @@ export declare function setCv(instance: CV): void;
23
23
  * get BOTH the types (e.g. `cv.Mat`) AND the runtime Proxy object.
24
24
  */
25
25
  export declare namespace cv {
26
+ /** OpenCV Mat (matrix / image buffer). */
26
27
  type Mat = _cvType.Mat;
28
+ /** A vector of Mat objects, used for contours. */
27
29
  type MatVector = _cvType.MatVector;
30
+ /** A 2D point `{ x, y }`. */
28
31
  type Point = _cvType.Point;
32
+ /** An axis-aligned rectangle `{ x, y, width, height }`. */
29
33
  type Rect = _cvType.Rect;
34
+ /** A 2D size `{ width, height }`. */
30
35
  type Size = _cvType.Size;
36
+ /** A 4-element scalar value, often used for colors `[b, g, r, a]`. */
31
37
  type Scalar = _cvType.Scalar;
38
+ /** Adaptive thresholding method constants (e.g., `cv.ADAPTIVE_THRESH_GAUSSIAN_C`). */
32
39
  type AdaptiveThresholdTypes = _cvType.AdaptiveThresholdTypes;
40
+ /** Thresholding type constants (e.g., `cv.THRESH_BINARY`). */
33
41
  type ThresholdTypes = _cvType.ThresholdTypes;
42
+ /** Line type constants (e.g., `cv.LINE_8`). */
34
43
  type LineTypes = _cvType.LineTypes;
44
+ /** Contour retrieval mode constants (e.g., `cv.RETR_EXTERNAL`). */
35
45
  type RetrievalModes = _cvType.RetrievalModes;
46
+ /** Contour approximation method constants (e.g., `cv.CHAIN_APPROX_SIMPLE`). */
36
47
  type ContourApproximationModes = _cvType.ContourApproximationModes;
48
+ /** Border type constants (e.g., `cv.BORDER_CONSTANT`). */
37
49
  type BorderTypes = _cvType.BorderTypes;
50
+ /** Interpolation flag constants (e.g., `cv.INTER_LINEAR`). */
38
51
  type InterpolationFlags = _cvType.InterpolationFlags;
52
+ /** Color conversion code constants (e.g., `cv.COLOR_RGBA2GRAY`). */
39
53
  type ColorConversionCodes = _cvType.ColorConversionCodes;
54
+ /** Morphological structuring element shape constants (e.g., `cv.MORPH_RECT`). */
40
55
  type MorphShapes = _cvType.MorphShapes;
56
+ /** Morphological operation type constants (e.g., `cv.MORPH_GRADIENT`). */
41
57
  type MorphTypes = _cvType.MorphTypes;
58
+ /** Integer alias — opencv-js represents `int` as a plain `number`. */
42
59
  type int = number;
43
60
  }
61
+ /**
62
+ * Lazy proxy for the OpenCV runtime.
63
+ * Access any OpenCV constant or constructor (e.g. `cv.Mat`, `cv.RETR_EXTERNAL`)
64
+ * through this object. The underlying instance is resolved on first access,
65
+ * so importing this module never throws at module-load time — only when a
66
+ * property is actually accessed before {@link ImageProcessor.initRuntime} has run.
67
+ */
44
68
  export declare const cv: CV;
45
69
  export {};
package/deskew.js CHANGED
@@ -1 +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";
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{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{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{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";
@@ -5,6 +5,25 @@ type NameWithRequiredOptions = {
5
5
  [N in OperationName]: OperationOptions<N> extends RequiredOptions ? N : never;
6
6
  }[OperationName];
7
7
  type NameWithOptionalOptions = Exclude<OperationName, NameWithRequiredOptions>;
8
+ /**
9
+ * OpenCV-powered image processing pipeline.
10
+ *
11
+ * Wraps a `cv.Mat` and exposes a chainable API of named operations
12
+ * (grayscale, blur, threshold, etc.). Each method mutates the internal
13
+ * state and returns `this`, so operations can be chained fluently.
14
+ *
15
+ * Call {@link ImageProcessor.initRuntime} once before creating any instances.
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * await ImageProcessor.initRuntime();
20
+ * const result = new ImageProcessor(canvas)
21
+ * .grayscale()
22
+ * .blur()
23
+ * .threshold()
24
+ * .toCanvas();
25
+ * ```
26
+ */
8
27
  export declare class ImageProcessor {
9
28
  img: cv.Mat;
10
29
  width: number;
@@ -15,11 +34,8 @@ export declare class ImageProcessor {
15
34
  */
16
35
  constructor(source: CanvasLike | cv.Mat);
17
36
  /**
18
- * Initialize OpenCV runtime. Must be called before any image processing.
19
- *
20
- * - **Node.js**: Uses `@techstark/opencv-js` from node_modules (loaded by entry point).
21
- * - **Browser with bundler**: Resolves `@techstark/opencv-js` via the bundler.
22
- * - **Browser without bundler**: Falls back to loading `@techstark/opencv-js` from npm CDN.
37
+ * Initialize OpenCV runtime. This is recommended to be called before any
38
+ * image processing.
23
39
  */
24
40
  static initRuntime(): Promise<void>;
25
41
  /**
@@ -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 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";
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(){return new Promise((res)=>{if(cv&&cv.Mat){res()}else{cv["onRuntimeInitialized"]=()=>{res()}}})}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{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}from"./cv-provider.js";import{executeOperation}from"./pipeline/index.js";
@@ -1,6 +1,6 @@
1
1
  export type { BoundingBox, Coordinate, Points } from "./index.interface.js";
2
2
  export { getPlatform, setPlatform } from "./canvas-factory.js";
3
- export type { CanvasLike, CanvasPlatform, Context2DLike, } from "./canvas-factory.js";
3
+ export type { CanvasLike, CanvasPlatform, Context2DLike } from "./canvas-factory.js";
4
4
  export { webPlatform } from "./platform/web.js";
5
5
  export { CanvasToolkitBase as CanvasToolkit, CanvasToolkitBase, type ContourLike, } from "./canvas-toolkit.base.js";
6
6
  export { CanvasProcessor, type DetectedRegion } from "./canvas-processor.js";
package/index.canvas.d.ts CHANGED
@@ -2,7 +2,7 @@ export { Canvas, createCanvas, ImageData, loadImage } from "@napi-rs/canvas";
2
2
  export type { SKRSContext2D } from "@napi-rs/canvas";
3
3
  export type { BoundingBox, Coordinate, Points } from "./index.interface.js";
4
4
  export { getPlatform, setPlatform } from "./canvas-factory.js";
5
- export type { CanvasLike, CanvasPlatform, Context2DLike, } from "./canvas-factory.js";
5
+ export type { CanvasLike, CanvasPlatform, Context2DLike } from "./canvas-factory.js";
6
6
  export { CanvasToolkitBase, type ContourLike } from "./canvas-toolkit.base.js";
7
7
  export { CanvasToolkit } from "./canvas-toolkit.js";
8
8
  export { CanvasProcessor, type DetectedRegion } from "./canvas-processor.js";
package/index.d.ts CHANGED
@@ -3,9 +3,9 @@ export { cv };
3
3
  export { Canvas, createCanvas, ImageData, loadImage } from "@napi-rs/canvas";
4
4
  export type { SKRSContext2D } from "@napi-rs/canvas";
5
5
  export type { BoundingBox, Coordinate, Points } from "./index.interface.js";
6
- export { executeOperation, OperationRegistry, registry, } from "./pipeline/index.js";
6
+ export { executeOperation, OperationRegistry, registry } from "./pipeline/index.js";
7
7
  export { getPlatform, setPlatform } from "./canvas-factory.js";
8
- export type { CanvasLike, CanvasPlatform, Context2DLike, } from "./canvas-factory.js";
8
+ export type { CanvasLike, CanvasPlatform, Context2DLike } from "./canvas-factory.js";
9
9
  export { CanvasToolkitBase, type ContourLike } from "./canvas-toolkit.base.js";
10
10
  export { CanvasToolkit } from "./canvas-toolkit.js";
11
11
  export { CanvasProcessor, type DetectedRegion } from "./canvas-processor.js";
@@ -1,16 +1,32 @@
1
+ /** A 2D point in canvas pixel coordinates. */
1
2
  export interface Coordinate {
3
+ /** X coordinate in pixels, measured from the left edge. */
2
4
  x: number;
5
+ /** Y coordinate in pixels, measured from the top edge. */
3
6
  y: number;
4
7
  }
8
+ /** The four corner points of an axis-aligned rectangle, e.g. a contour bounding box. */
5
9
  export interface Points {
10
+ /** Top-left corner. */
6
11
  topLeft: Coordinate;
12
+ /** Top-right corner. */
7
13
  topRight: Coordinate;
14
+ /** Bottom-left corner. */
8
15
  bottomLeft: Coordinate;
16
+ /** Bottom-right corner. */
9
17
  bottomRight: Coordinate;
10
18
  }
19
+ /**
20
+ * An axis-aligned bounding box with exclusive `x1` / `y1`.
21
+ * Width is `x1 - x0`; height is `y1 - y0`.
22
+ */
11
23
  export interface BoundingBox {
24
+ /** Left edge, inclusive. */
12
25
  x0: number;
26
+ /** Top edge, inclusive. */
13
27
  y0: number;
28
+ /** Right edge, exclusive. */
14
29
  x1: number;
30
+ /** Bottom edge, exclusive. */
15
31
  y1: number;
16
32
  }
package/index.web.d.ts CHANGED
@@ -1,10 +1,34 @@
1
+ /**
2
+ * Web entry point — browsers with OpenCV + DOM canvas.
3
+ *
4
+ * Use this when you need the full image-processing pipeline in a browser.
5
+ * Canvas operations target `HTMLCanvasElement` / `OffscreenCanvas` instead
6
+ * of `@napi-rs/canvas`. OpenCV is loaded via `ImageProcessor.initRuntime()`
7
+ * — either from the bundled `@techstark/opencv-js` or from jsDelivr CDN
8
+ * when no bundler is in play.
9
+ *
10
+ * Not suitable for Manifest V3 Chrome extensions or other CSP-restricted
11
+ * runtimes — OpenCV.js uses Emscripten embind which calls `new Function`.
12
+ * For those environments, import `ppu-ocv/canvas-web` (no OpenCV).
13
+ *
14
+ * @example
15
+ * ```ts
16
+ * import { ImageProcessor, CanvasProcessor } from "ppu-ocv/web";
17
+ *
18
+ * await ImageProcessor.initRuntime();
19
+ * const canvas = await CanvasProcessor.prepareCanvas(await response.arrayBuffer());
20
+ * const result = new ImageProcessor(canvas).grayscale().toCanvas();
21
+ * ```
22
+ *
23
+ * @module
24
+ */
1
25
  import { cv } from "./cv-provider.js";
2
26
  export { cv };
3
27
  export { getPlatform, setPlatform } from "./canvas-factory.js";
4
- export type { CanvasLike, CanvasPlatform, Context2DLike, } from "./canvas-factory.js";
28
+ export type { CanvasLike, CanvasPlatform, Context2DLike } from "./canvas-factory.js";
5
29
  export { webPlatform } from "./platform/web.js";
6
30
  export type { BoundingBox, Coordinate, Points } from "./index.interface.js";
7
- export { executeOperation, OperationRegistry, registry, } from "./pipeline/index.js";
31
+ export { executeOperation, OperationRegistry, registry } from "./pipeline/index.js";
8
32
  export { CanvasToolkitBase as CanvasToolkit, CanvasToolkitBase, type ContourLike, } from "./canvas-toolkit.base.js";
9
33
  export { CanvasProcessor, type DetectedRegion } from "./canvas-processor.js";
10
34
  export { Contours } from "./contours.js";
@@ -1,10 +1,6 @@
1
1
  import { cv } from "../cv-provider.js";
2
2
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- adaptiveThreshold: AdaptiveThresholdOptions;
6
- }
7
- }
3
+ /** Options for the adaptive threshold operation. */
8
4
  export interface AdaptiveThresholdOptions extends PartialOptions {
9
5
  /** Upper threshold value (0-255) */
10
6
  upper: number;
@@ -17,4 +13,5 @@ export interface AdaptiveThresholdOptions extends PartialOptions {
17
13
  /** Constant subtracted from the mean or weighted mean */
18
14
  constant: number;
19
15
  }
16
+ /** Apply adaptive thresholding to convert a grayscale image to binary. */
20
17
  export declare function adaptiveThreshold(img: cv.Mat, options: AdaptiveThresholdOptions): OperationResult;
@@ -1,14 +1,11 @@
1
1
  import { cv } from "../cv-provider.js";
2
2
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- blur: BlurOptions;
6
- }
7
- }
3
+ /** Options for the Gaussian blur operation. */
8
4
  export interface BlurOptions extends PartialOptions {
9
5
  /** Size of the blur [x, y] */
10
6
  size: [number, number];
11
7
  /** Gaussian kernel standard deviation on x axis */
12
8
  sigma: number;
13
9
  }
10
+ /** Apply Gaussian blur to reduce noise. */
14
11
  export declare function blur(img: cv.Mat, options: BlurOptions): OperationResult;
@@ -1,10 +1,6 @@
1
1
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- border: BorderOptions;
6
- }
7
- }
3
+ /** Options for adding a constant-color border around the image. */
8
4
  export interface BorderOptions extends PartialOptions {
9
5
  /** Size of the border in pixels */
10
6
  size: number;
@@ -13,4 +9,5 @@ export interface BorderOptions extends PartialOptions {
13
9
  /** Border color in [B, G, R, A] format */
14
10
  borderColor: [cv.int, cv.int, cv.int, cv.int];
15
11
  }
12
+ /** Add a uniform border around the image using `cv.copyMakeBorder`. */
16
13
  export declare function border(img: cv.Mat, options: BorderOptions): OperationResult;
@@ -1,14 +1,11 @@
1
1
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- canny: CannyOptions;
6
- }
7
- }
3
+ /** Options for the Canny edge-detection operation. */
8
4
  export interface CannyOptions extends PartialOptions {
9
5
  /** Lower threshold for the hysteresis procedure (0-255) */
10
6
  lower: number;
11
7
  /** Upper threshold for the hysteresis procedure (0-255) */
12
8
  upper: number;
13
9
  }
10
+ /** Detect edges using the Canny algorithm. */
14
11
  export declare function canny(img: cv.Mat, options: CannyOptions): OperationResult;
@@ -1,12 +1,9 @@
1
1
  import type { OperationResult, RequiredOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- convert: ConvertOptions;
6
- }
7
- }
3
+ /** Options for converting a Mat to a different depth/channel type. */
8
4
  export interface ConvertOptions extends RequiredOptions {
9
5
  /** Desired matrix type (cv.CV_...) if negative, it will be the same as input */
10
6
  rtype: number;
11
7
  }
8
+ /** Convert the image matrix to a new type via `Mat.convertTo`. */
12
9
  export declare function convert(img: cv.Mat, options: ConvertOptions): OperationResult;
@@ -1,14 +1,11 @@
1
1
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- dilate: DilateOptions;
6
- }
7
- }
3
+ /** Options for the morphological dilation operation. */
8
4
  export interface DilateOptions extends PartialOptions {
9
5
  /** Size of the block [x, y] */
10
6
  size: [number, number];
11
7
  /** Number of iterations for the dilation operation */
12
8
  iter: number;
13
9
  }
10
+ /** Dilate the image to expand foreground regions. */
14
11
  export declare function dilate(img: cv.Mat, options: DilateOptions): OperationResult;
@@ -1,14 +1,11 @@
1
1
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- erode: ErodeOptions;
6
- }
7
- }
3
+ /** Options for the morphological erosion operation. */
8
4
  export interface ErodeOptions extends PartialOptions {
9
5
  /** Size of the block [x, y] */
10
6
  size: [number, number];
11
7
  /** Number of iterations for the erosion operation */
12
8
  iter: number;
13
9
  }
10
+ /** Erode the image to shrink foreground regions and remove small noise. */
14
11
  export declare function erode(img: cv.Mat, options: ErodeOptions): OperationResult;
@@ -1,10 +1,7 @@
1
1
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- grayscale: GrayscaleOptions;
6
- }
7
- }
3
+ /** Options for the grayscale conversion operation (no configurable fields). */
8
4
  export interface GrayscaleOptions extends PartialOptions {
9
5
  }
10
- export declare function grayscale(img: cv.Mat, options: GrayscaleOptions): OperationResult;
6
+ /** Convert the image to grayscale using `COLOR_RGBA2GRAY`. */
7
+ export declare function grayscale(img: cv.Mat, _options: GrayscaleOptions): OperationResult;
@@ -1 +1 @@
1
- import{cv}from"../cv-provider.js";import{registry}from"../pipeline/registry.js";function defaultOptions(){return{}}export function grayscale(img,options){let imgGrayscale=new cv.Mat;cv.cvtColor(img,imgGrayscale,cv.COLOR_RGBA2GRAY);img.delete();return{img:imgGrayscale,width:imgGrayscale.cols,height:imgGrayscale.rows}}registry.register("grayscale",grayscale,defaultOptions);
1
+ import{cv}from"../cv-provider.js";import{registry}from"../pipeline/registry.js";function defaultOptions(){return{}}export function grayscale(img,_options){let imgGrayscale=new cv.Mat;cv.cvtColor(img,imgGrayscale,cv.COLOR_RGBA2GRAY);img.delete();return{img:imgGrayscale,width:imgGrayscale.cols,height:imgGrayscale.rows}}registry.register("grayscale",grayscale,defaultOptions);
@@ -1,10 +1,7 @@
1
1
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- invert: InvertOptions;
6
- }
7
- }
3
+ /** Options for the bitwise-NOT color inversion operation (no configurable fields). */
8
4
  export interface InvertOptions extends PartialOptions {
9
5
  }
10
- export declare function invert(img: cv.Mat, options: InvertOptions): OperationResult;
6
+ /** Invert all pixel values using `cv.bitwise_not`. */
7
+ export declare function invert(img: cv.Mat, _options: InvertOptions): OperationResult;
@@ -1 +1 @@
1
- import{cv}from"../cv-provider.js";import{registry}from"../pipeline/registry.js";function defaultOptions(){return{}}export function invert(img,options){let imgInvert=new cv.Mat;cv.bitwise_not(img,imgInvert);img.delete();return{img:imgInvert,width:imgInvert.cols,height:imgInvert.rows}}registry.register("invert",invert,defaultOptions);
1
+ import{cv}from"../cv-provider.js";import{registry}from"../pipeline/registry.js";function defaultOptions(){return{}}export function invert(img,_options){let imgInvert=new cv.Mat;cv.bitwise_not(img,imgInvert);img.delete();return{img:imgInvert,width:imgInvert.cols,height:imgInvert.rows}}registry.register("invert",invert,defaultOptions);
@@ -1,12 +1,9 @@
1
1
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- morphologicalGradient: MorphologicalGradientOptions;
6
- }
7
- }
3
+ /** Options for the morphological gradient operation. */
8
4
  export interface MorphologicalGradientOptions extends PartialOptions {
9
5
  /** Kernel size for the morphological gradient operation [x, y] */
10
6
  size: [number, number];
11
7
  }
8
+ /** Apply morphological gradient to highlight edges (dilation minus erosion). */
12
9
  export declare function morphologicalGradient(img: cv.Mat, options: MorphologicalGradientOptions): OperationResult;
@@ -1,14 +1,11 @@
1
1
  import type { OperationResult, RequiredOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- resize: ResizeOptions;
6
- }
7
- }
3
+ /** Options for resizing the image to exact pixel dimensions. */
8
4
  export interface ResizeOptions extends RequiredOptions {
9
5
  /** Width of the resized image */
10
6
  width: number;
11
7
  /** Height of the resized image */
12
8
  height: number;
13
9
  }
10
+ /** Resize the image to the given width and height. */
14
11
  export declare function resize(img: cv.Mat, options: ResizeOptions): OperationResult;
@@ -1,14 +1,11 @@
1
1
  import type { OperationResult, RequiredOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- rotate: RotateOptions;
6
- }
7
- }
3
+ /** Options for rotating the image around a pivot point. */
8
4
  export interface RotateOptions extends RequiredOptions {
9
5
  /** Angle of rotation in degrees (positive for counter-clockwise) */
10
6
  angle: number;
11
7
  /** Optional center of rotation. Defaults to the image center. */
12
8
  center?: cv.Point;
13
9
  }
10
+ /** Rotate the image by the given angle around its centre (or a custom pivot). */
14
11
  export declare function rotate(img: cv.Mat, options: RotateOptions): OperationResult;
@@ -1,10 +1,6 @@
1
1
  import type { OperationResult, PartialOptions } from "../pipeline/types.js";
2
2
  import { cv } from "../cv-provider.js";
3
- declare module "../pipeline/types" {
4
- interface RegisteredOperations {
5
- threshold: ThresholdOptions;
6
- }
7
- }
3
+ /** Options for the global threshold operation. */
8
4
  export interface ThresholdOptions extends PartialOptions {
9
5
  /** Lower threshold value (0-255) */
10
6
  lower: number;
@@ -13,4 +9,5 @@ export interface ThresholdOptions extends PartialOptions {
13
9
  /** Type of thresholding (cv.THRESH_...) */
14
10
  type: cv.ThresholdTypes;
15
11
  }
12
+ /** Apply a global threshold to convert a grayscale image to binary. */
16
13
  export declare function threshold(img: cv.Mat, options: ThresholdOptions): OperationResult;
@@ -1,11 +1,7 @@
1
1
  import { cv } from "../cv-provider.js";
2
2
  import type { BoundingBox, Points } from "../index.interface.js";
3
3
  import type { OperationResult, RequiredOptions } from "../pipeline/types.js";
4
- declare module "../pipeline/types" {
5
- interface RegisteredOperations {
6
- warp: WarpOptions;
7
- }
8
- }
4
+ /** Options for the perspective warp (four-point transform) operation. */
9
5
  export interface WarpOptions extends RequiredOptions {
10
6
  /** Four points of the source image containing x and y point in
11
7
  * topLeft, topRight, bottomLeft and BottomRight.
@@ -15,4 +11,5 @@ export interface WarpOptions extends RequiredOptions {
15
11
  /** A destination canvas bounding box for cropping the original canvas */
16
12
  bbox: BoundingBox;
17
13
  }
14
+ /** Apply a perspective warp using four source/destination corner points. */
18
15
  export declare function warp(img: cv.Mat, options: WarpOptions): OperationResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ppu-ocv",
3
- "version": "3.1.0",
3
+ "version": "3.1.3",
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",
@@ -45,21 +45,30 @@
45
45
  },
46
46
  "scripts": {
47
47
  "task": "bun scripts/task.ts",
48
+ "build": "bun task build",
48
49
  "build:test": "bun task build && bun test",
49
50
  "build:publish": "bun task build && bun task report-size && bun task publish",
50
- "lint": "prettier --check ./src",
51
- "lint:fix": "prettier --write ./src",
52
- "demo": "bunx -y serve -l 4567 ."
51
+ "type-check": "bunx tsgo --noEmit",
52
+ "test": "bun test --parallel=$(ls tests/*.test.ts | wc -l | tr -d ' ')",
53
+ "lint": "oxlint",
54
+ "lint:fix": "oxlint --fix",
55
+ "fmt": "oxfmt --check",
56
+ "fmt:fix": "oxfmt .",
57
+ "demo": "bunx -y serve -l 4567 .",
58
+ "prepare": "husky"
53
59
  },
54
60
  "devDependencies": {
55
- "@stylistic/eslint-plugin": "latest",
56
61
  "@types/bun": "latest",
57
62
  "@types/uglify-js": "latest",
63
+ "@typescript/native-preview": "^7.0.0-dev.20260513.1",
64
+ "canvas": "^3.2.3",
65
+ "husky": "^9.1.7",
66
+ "lint-staged": "^16.0.0",
58
67
  "mitata": "latest",
59
- "prettier": "^3.8.1",
68
+ "oxfmt": "^0.48.0",
69
+ "oxlint": "^1.63.0",
60
70
  "tsx": "latest",
61
71
  "typescript": "latest",
62
- "typescript-eslint": "latest",
63
72
  "uglify-js": ">=2.4.24"
64
73
  },
65
74
  "repository": {
@@ -1,13 +1,39 @@
1
- import { cv } from "../cv-provider.js";
1
+ import type { cv } from "../cv-provider.js";
2
2
  import type { OperationFunction, OperationName, OperationOptions, OperationResult } from "./index.js";
3
+ /**
4
+ * Registry that maps operation names to their implementation functions and
5
+ * default-option factories. Operation files call {@link OperationRegistry.register}
6
+ * at module-load time to make themselves available to {@link executeOperation}.
7
+ */
3
8
  export declare class OperationRegistry {
4
9
  private operations;
5
10
  private defaultOptions;
11
+ /**
12
+ * Register a named operation.
13
+ * @param name Unique operation name (must match a key in {@link RegisteredOperations}).
14
+ * @param operation The function that performs the operation.
15
+ * @param defaultOptions Optional factory returning default option values.
16
+ */
6
17
  register<Name extends OperationName>(name: Name, operation: OperationFunction<OperationOptions<Name>>, defaultOptions?: () => Partial<OperationOptions<Name>>): void;
18
+ /** Look up the implementation for an operation by name. */
7
19
  getOperation(name: string): OperationFunction<any> | undefined;
20
+ /** Return the default-options factory for an operation, or an empty object if none was registered. */
8
21
  getDefaultOptionsGenerator(name: string): any;
22
+ /** Return `true` if an operation with the given name has been registered. */
9
23
  hasOperation(name: string): boolean;
24
+ /** Return the names of all registered operations. */
10
25
  getOperationNames(): OperationName[];
11
26
  }
27
+ /** Singleton registry populated by each `src/operations/*.ts` module at load time. */
12
28
  export declare const registry: OperationRegistry;
29
+ /**
30
+ * Look up and execute a registered operation, merging caller-supplied options
31
+ * with the operation's defaults.
32
+ *
33
+ * @param operationName Name of the registered operation.
34
+ * @param img Input OpenCV Mat. The Mat is consumed (deleted) by the operation.
35
+ * @param options Partial options to merge with the operation's defaults.
36
+ * @returns The operation result containing the transformed Mat and its dimensions.
37
+ * @throws {Error} If no operation with `operationName` is registered.
38
+ */
13
39
  export declare function executeOperation<Name extends OperationName>(operationName: Name, img: cv.Mat, options?: Partial<OperationOptions<Name>>): OperationResult;
@@ -1,25 +1,76 @@
1
- import { cv } from "../cv-provider.js";
1
+ import type { cv } from "../cv-provider.js";
2
+ import type { AdaptiveThresholdOptions } from "../operations/adaptive-threshold.js";
3
+ import type { BlurOptions } from "../operations/blur.js";
4
+ import type { BorderOptions } from "../operations/border.js";
5
+ import type { CannyOptions } from "../operations/canny.js";
6
+ import type { ConvertOptions } from "../operations/convert.js";
7
+ import type { DilateOptions } from "../operations/dilate.js";
8
+ import type { ErodeOptions } from "../operations/erode.js";
9
+ import type { GrayscaleOptions } from "../operations/grayscale.js";
10
+ import type { InvertOptions } from "../operations/invert.js";
11
+ import type { MorphologicalGradientOptions } from "../operations/morphological-gradient.js";
12
+ import type { ResizeOptions } from "../operations/resize.js";
13
+ import type { RotateOptions } from "../operations/rotate.js";
14
+ import type { ThresholdOptions } from "../operations/threshold.js";
15
+ import type { WarpOptions } from "../operations/warp.js";
16
+ /** The output produced by every pipeline operation: the transformed Mat plus its dimensions. */
2
17
  export interface OperationResult {
18
+ /** Resulting OpenCV Mat after the operation. The caller is responsible for deleting it. */
3
19
  img: cv.Mat;
20
+ /** Width of the resulting image in pixels. */
4
21
  width: number;
22
+ /** Height of the resulting image in pixels. */
5
23
  height: number;
6
24
  }
7
25
  declare const RequiredBrand: unique symbol;
26
+ /**
27
+ * Marker interface for operation options that have no usable defaults and
28
+ * must be supplied by the caller. Operation option types that extend this
29
+ * cannot be omitted when calling {@link ImageProcessor.execute}.
30
+ */
8
31
  export interface RequiredOptions {
9
32
  [RequiredBrand]?: never;
10
33
  }
11
34
  declare const PartialBrand: unique symbol;
35
+ /**
36
+ * Marker interface for operation options that have sensible defaults.
37
+ * Operation option types that extend this may be omitted or partially supplied.
38
+ */
12
39
  export interface PartialOptions {
13
40
  [PartialBrand]?: never;
14
41
  }
42
+ /** Signature every registered operation function must conform to. */
15
43
  export type OperationFunction<T> = (img: cv.Mat, options: T) => OperationResult;
16
44
  /**
17
- * @description
18
- * Central registry mapping operation names to their specific option types.
19
- * Operation modules MUST augment this interface.
45
+ * Central registry mapping operation names to their option types. Each entry
46
+ * is the options type exported by the corresponding `src/operations/*.ts`
47
+ * file. Adding a new operation requires three changes: create the file,
48
+ * export the Options type, and add the entry below.
49
+ *
50
+ * Previously this used `declare module` augmentation so each operation file
51
+ * could register itself. JSR rejects that pattern because it modifies global
52
+ * types, so the registry is now explicit. Consumers can still extend this
53
+ * interface from their own code via `declare module "ppu-ocv"` — that's why
54
+ * it stays an interface rather than a type alias.
20
55
  */
21
56
  export interface RegisteredOperations {
57
+ adaptiveThreshold: AdaptiveThresholdOptions;
58
+ blur: BlurOptions;
59
+ border: BorderOptions;
60
+ canny: CannyOptions;
61
+ convert: ConvertOptions;
62
+ dilate: DilateOptions;
63
+ erode: ErodeOptions;
64
+ grayscale: GrayscaleOptions;
65
+ invert: InvertOptions;
66
+ morphologicalGradient: MorphologicalGradientOptions;
67
+ resize: ResizeOptions;
68
+ rotate: RotateOptions;
69
+ threshold: ThresholdOptions;
70
+ warp: WarpOptions;
22
71
  }
72
+ /** Union of all registered operation names. Extend {@link RegisteredOperations} to add new ones. */
23
73
  export type OperationName = keyof RegisteredOperations;
74
+ /** Resolve the options type for a given operation name. */
24
75
  export type OperationOptions<N extends OperationName> = RegisteredOperations[N];
25
76
  export {};