ppu-ocv 3.2.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -1
- package/canvas-factory.d.ts +7 -0
- package/canvas-io.d.ts +16 -0
- package/canvas-io.js +1 -0
- package/canvas-processor.d.ts +3 -21
- package/canvas-processor.js +1 -1
- package/canvas-regions.d.ts +41 -0
- package/canvas-regions.js +1 -0
- package/deskew-angles.d.ts +33 -0
- package/deskew-angles.js +1 -0
- package/deskew.d.ts +0 -5
- package/deskew.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -168,14 +168,24 @@ processor.destroy();
|
|
|
168
168
|
|
|
169
169
|
### Vanilla HTML (no bundler)
|
|
170
170
|
|
|
171
|
-
|
|
171
|
+
The web build does not bundle OpenCV — load `opencv.js` yourself so it is on
|
|
172
|
+
`globalThis.cv`, then `initRuntime()` waits for the WASM to finish initializing:
|
|
172
173
|
|
|
173
174
|
```html
|
|
175
|
+
<!-- Load OpenCV; sets globalThis.cv (WASM initializes asynchronously). -->
|
|
176
|
+
<script async src="https://docs.opencv.org/4.10.0/opencv.js"></script>
|
|
174
177
|
<script type="module">
|
|
175
178
|
import {
|
|
176
179
|
CanvasProcessor,
|
|
177
180
|
ImageProcessor,
|
|
178
181
|
} from "https://cdn.jsdelivr.net/npm/ppu-ocv@3/index.web.js";
|
|
182
|
+
|
|
183
|
+
// Wait until the opencv.js script tag is present, then for the runtime.
|
|
184
|
+
await new Promise((resolve) => {
|
|
185
|
+
const ready = () => globalThis.cv && globalThis.cv.Mat;
|
|
186
|
+
const tick = () => (ready() ? resolve() : setTimeout(tick, 30));
|
|
187
|
+
tick();
|
|
188
|
+
});
|
|
179
189
|
await ImageProcessor.initRuntime();
|
|
180
190
|
|
|
181
191
|
const response = await fetch("/my-image.jpg");
|
|
@@ -192,6 +202,9 @@ processor.destroy();
|
|
|
192
202
|
</script>
|
|
193
203
|
```
|
|
194
204
|
|
|
205
|
+
> With a bundler, importing `@techstark/opencv-js` once (it self-registers on
|
|
206
|
+
> `globalThis.cv`) is enough — no script tag needed.
|
|
207
|
+
|
|
195
208
|
> **Note:** ES modules require HTTP/HTTPS — use a local server (`npx serve .`) for dev, or deploy to GitHub Pages.
|
|
196
209
|
|
|
197
210
|
See the [interactive demo](./index.html) for a full working example.
|
package/canvas-factory.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ export type CanvasLike = {
|
|
|
14
14
|
toBuffer?: (...args: any[]) => Buffer;
|
|
15
15
|
/** Serialize the canvas to a data-URL string (browser canvases). Absent on Node-side `@napi-rs/canvas`. */
|
|
16
16
|
toDataURL?: (...args: any[]) => string;
|
|
17
|
+
/** Asynchronously serialize to a `Blob` via callback (browser `HTMLCanvasElement`). */
|
|
18
|
+
toBlob?: (callback: (blob: Blob | null) => void, type?: string, quality?: number) => void;
|
|
19
|
+
/** Asynchronously serialize to a `Blob` (`OffscreenCanvas`, used in workers and extensions). */
|
|
20
|
+
convertToBlob?: (options?: {
|
|
21
|
+
type?: string;
|
|
22
|
+
quality?: number;
|
|
23
|
+
}) => Promise<Blob>;
|
|
17
24
|
};
|
|
18
25
|
/** Structural type for 2D rendering context, matching the cross-runtime subset used by ppu-ocv. */
|
|
19
26
|
export type Context2DLike = {
|
package/canvas-io.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas <-> ArrayBuffer conversion helpers, shared by `CanvasProcessor`'s
|
|
3
|
+
* static `prepareCanvas` / `prepareBuffer`. Kept separate so the processor
|
|
4
|
+
* class stays focused on the pixel pipeline.
|
|
5
|
+
*/
|
|
6
|
+
import type { CanvasLike } from "./canvas-factory.js";
|
|
7
|
+
/**
|
|
8
|
+
* Convert an ArrayBuffer (image file bytes) to a CanvasLike. If the value is
|
|
9
|
+
* already a CanvasLike it is returned as-is.
|
|
10
|
+
*/
|
|
11
|
+
export declare function bufferToCanvas(file: ArrayBuffer): Promise<CanvasLike>;
|
|
12
|
+
/**
|
|
13
|
+
* Convert a CanvasLike to an ArrayBuffer (PNG bytes). If the value is already
|
|
14
|
+
* an ArrayBuffer it is returned as-is.
|
|
15
|
+
*/
|
|
16
|
+
export declare function canvasToBuffer(canvas: CanvasLike): Promise<ArrayBuffer>;
|
package/canvas-io.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{getPlatform,isCanvasLike}from"./canvas-factory.js";export async function bufferToCanvas(file){if(isCanvasLike(file))return file;return getPlatform().loadImage(file)}export async function canvasToBuffer(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}let toBlob=canvas.toBlob;if(typeof toBlob==="function"){let blob=await new Promise((resolve,reject)=>{toBlob.call(canvas,(b)=>b?resolve(b):reject(new Error("toBlob returned null")),"image/png")});return blob.arrayBuffer()}if(typeof canvas.convertToBlob==="function"){let blob=await canvas.convertToBlob({type:"image/png"});return blob.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}
|
package/canvas-processor.d.ts
CHANGED
|
@@ -1,14 +1,6 @@
|
|
|
1
|
-
import type { BoundingBox } from "./index.interface.js";
|
|
2
1
|
import type { CanvasLike } from "./canvas-factory.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
export type 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
|
-
};
|
|
2
|
+
import type { DetectedRegion, FindRegionsOptions } from "./canvas-regions.js";
|
|
3
|
+
export type { DetectedRegion } from "./canvas-regions.js";
|
|
12
4
|
/**
|
|
13
5
|
* Canvas-native image processing with no OpenCV dependency.
|
|
14
6
|
*
|
|
@@ -175,17 +167,7 @@ export declare class CanvasProcessor {
|
|
|
175
167
|
* });
|
|
176
168
|
* ```
|
|
177
169
|
*/
|
|
178
|
-
findRegions(options?:
|
|
179
|
-
foreground?: "light" | "dark";
|
|
180
|
-
thresh?: number;
|
|
181
|
-
minArea?: number;
|
|
182
|
-
maxArea?: number;
|
|
183
|
-
padding?: {
|
|
184
|
-
vertical?: number;
|
|
185
|
-
horizontal?: number;
|
|
186
|
-
};
|
|
187
|
-
scale?: number;
|
|
188
|
-
}): DetectedRegion[];
|
|
170
|
+
findRegions(options?: FindRegionsOptions): DetectedRegion[];
|
|
189
171
|
/**
|
|
190
172
|
* Return the current canvas state.
|
|
191
173
|
*/
|
package/canvas-processor.js
CHANGED
|
@@ -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)+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{
|
|
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{width,height}=this._canvas;let data=this._canvas.getContext("2d").getImageData(0,0,width,height).data;return detectRegions(data,width,height,options)}toCanvas(){return this._canvas}static async prepareCanvas(file){return bufferToCanvas(file)}static async prepareBuffer(canvas){return canvasToBuffer(canvas)}}import{getPlatform}from"./canvas-factory.js";import{bufferToCanvas,canvasToBuffer}from"./canvas-io.js";import{detectRegions}from"./canvas-regions.js";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canvas-native region detection: 8-connected flood-fill bounding-box
|
|
3
|
+
* extraction on a binary image's raw RGBA pixels. Extracted from
|
|
4
|
+
* `CanvasProcessor.findRegions` so the processor stays a thin wrapper over this
|
|
5
|
+
* pure algorithm.
|
|
6
|
+
*/
|
|
7
|
+
import type { BoundingBox } from "./index.interface.js";
|
|
8
|
+
/** A detected region: its bounding box and foreground-pixel count. */
|
|
9
|
+
export type DetectedRegion = {
|
|
10
|
+
/** Axis-aligned bounding box of the region (x1/y1 are exclusive). */
|
|
11
|
+
bbox: BoundingBox;
|
|
12
|
+
/** Number of foreground pixels in the region. */
|
|
13
|
+
area: number;
|
|
14
|
+
};
|
|
15
|
+
/** Options for {@link detectRegions} / `CanvasProcessor.findRegions`. */
|
|
16
|
+
export type FindRegionsOptions = {
|
|
17
|
+
/** Which pixels count as foreground relative to `thresh`. @default "light" */
|
|
18
|
+
foreground?: "light" | "dark";
|
|
19
|
+
/** Grayscale threshold separating foreground from background. @default 127 */
|
|
20
|
+
thresh?: number;
|
|
21
|
+
/** Discard regions with fewer than this many pixels. @default 1 */
|
|
22
|
+
minArea?: number;
|
|
23
|
+
/** Discard regions with more than this many pixels. @default Infinity */
|
|
24
|
+
maxArea?: number;
|
|
25
|
+
/** Padding per box as a fraction of its height (vertical/horizontal). */
|
|
26
|
+
padding?: {
|
|
27
|
+
vertical?: number;
|
|
28
|
+
horizontal?: number;
|
|
29
|
+
};
|
|
30
|
+
/** Multiply all bbox coordinates by this factor after padding. @default 1 */
|
|
31
|
+
scale?: number;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Detect foreground regions in raw RGBA pixel data via 8-connected flood fill.
|
|
35
|
+
*
|
|
36
|
+
* @param data - RGBA pixel data (`width * height * 4` bytes).
|
|
37
|
+
* @param width - Image width in pixels.
|
|
38
|
+
* @param height - Image height in pixels.
|
|
39
|
+
* @param options - See {@link FindRegionsOptions}.
|
|
40
|
+
*/
|
|
41
|
+
export declare function detectRegions(data: Uint8ClampedArray, width: number, height: number, options?: FindRegionsOptions): DetectedRegion[];
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export function detectRegions(data,width,height,options={}){const{foreground="light",thresh=127,minArea=1,maxArea=1/0,padding,scale=1}=options;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}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skew-angle estimators used by {@link DeskewService}. Pure helpers extracted
|
|
3
|
+
* from `deskew.ts`: each takes detected text regions (or a binary Mat) and
|
|
4
|
+
* returns candidate angles with weights, plus a consensus reducer. Methods that
|
|
5
|
+
* log accept a `log` callback so they stay free of service state.
|
|
6
|
+
*/
|
|
7
|
+
import { cv } from "./cv-provider.js";
|
|
8
|
+
/** A candidate skew angle with its confidence weight. */
|
|
9
|
+
export type WeightedAngle = {
|
|
10
|
+
angle: number;
|
|
11
|
+
weight: number;
|
|
12
|
+
};
|
|
13
|
+
/** A detected text region: its contour and basic shape metrics. */
|
|
14
|
+
export type TextRegion = {
|
|
15
|
+
contour: cv.Mat;
|
|
16
|
+
area: number;
|
|
17
|
+
aspectRatio: number;
|
|
18
|
+
};
|
|
19
|
+
/** Least-squares slope of a point set, as an angle in degrees clamped to ±45°. */
|
|
20
|
+
export declare function calculateLineAngle(points: Array<{
|
|
21
|
+
x: number;
|
|
22
|
+
y: number;
|
|
23
|
+
}>): number;
|
|
24
|
+
/** Angles from each region's minimum-area rectangle, weighted by area and aspect. */
|
|
25
|
+
export declare function calculateMinRectAngles(textRegions: TextRegion[]): WeightedAngle[];
|
|
26
|
+
/** Angles from each region's text baseline (line fit through bottom points). */
|
|
27
|
+
export declare function calculateBaselineAngles(textRegions: TextRegion[]): WeightedAngle[];
|
|
28
|
+
/** Angles from a probabilistic Hough line transform of the morphed binary Mat. */
|
|
29
|
+
export declare function calculateHoughAngles(mat: cv.Mat, minAngle: number, maxAngle: number, log: (message: string) => void): WeightedAngle[];
|
|
30
|
+
/** Robust consensus over all candidate angles: IQR outlier rejection + weighted mean. */
|
|
31
|
+
export declare function calculateConsensusAngle(angles: Array<WeightedAngle & {
|
|
32
|
+
method: string;
|
|
33
|
+
}>, minAngle: number, maxAngle: number, log: (message: string) => void): number;
|
package/deskew-angles.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{cv}from"./cv-provider.js";export function 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}export function calculateMinRectAngles(textRegions){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}export function 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=calculateLineAngle(baselinePoints);let weight=region.area*Math.min(region.aspectRatio,1/region.aspectRatio);angles.push({angle,weight})}}catch{continue}}return angles}export function calculateHoughAngles(mat,minAngle,maxAngle,log){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{log("Hough transform failed, skipping this method.")}return angles}export function calculateConsensusAngle(angles,minAngle,maxAngle,log){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){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},{});log(`Angle methods used: ${Object.entries(methodCounts).map(([method,count])=>`${method}:${count}`).join(", ")}`);return Math.max(minAngle,Math.min(maxAngle,weightedAverage))}
|
package/deskew.d.ts
CHANGED
|
@@ -64,9 +64,4 @@ export declare class DeskewService {
|
|
|
64
64
|
* @returns A new canvas with the deskewed image
|
|
65
65
|
*/
|
|
66
66
|
deskewImage(canvas: CanvasLike): Promise<CanvasLike>;
|
|
67
|
-
private calculateMinRectAngles;
|
|
68
|
-
private calculateBaselineAngles;
|
|
69
|
-
private calculateHoughAngles;
|
|
70
|
-
private calculateLineAngle;
|
|
71
|
-
private calculateConsensusAngle;
|
|
72
67
|
}
|
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=
|
|
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=calculateMinRectAngles(filteredRegions);let baselineAngles=calculateBaselineAngles(filteredRegions);let houghAngles=calculateHoughAngles(mat,minAngle,maxAngle,(m)=>this.log(m));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=calculateConsensusAngle(allAngles,minAngle,maxAngle,(m)=>this.log(m));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()}}}import{Contours}from"./contours.js";import{cv}from"./cv-provider.js";import{calculateBaselineAngles,calculateConsensusAngle,calculateHoughAngles,calculateMinRectAngles}from"./deskew-angles.js";import{ImageProcessor}from"./image-processor.js";
|
package/package.json
CHANGED