skew-score 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +101 -0
- package/dist/index.cjs +144 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +94 -0
- package/dist/index.d.ts +94 -0
- package/dist/index.js +136 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vijay Misal (vjymisal0)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# skew-score
|
|
2
|
+
|
|
3
|
+
> Quantifies document and image tilt/skew angle (-45° to +45°) using Radon projection profile variance analysis via Sharp. Optimized for pre-OCR document scanning, KYC ID verification, and receipt processing.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/skew-score)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
## Why skew-score?
|
|
9
|
+
|
|
10
|
+
When users photograph identification cards (PAN, Aadhaar, Driver Licenses), checks, or receipts with mobile cameras, angular tilt and perspective skew degrade OCR accuracy by up to 60%.
|
|
11
|
+
|
|
12
|
+
`skew-score` runs an ultra-fast angular sweep across directional edge projections to accurately compute:
|
|
13
|
+
- **Exact tilt angle** (-45.0° to +45.0°)
|
|
14
|
+
- **Corrective rotation** required to straighten the image
|
|
15
|
+
- **Skew severity score** (0.0 = aligned, 1.0 = heavily tilted)
|
|
16
|
+
|
|
17
|
+
Companion to [`blur-score`](https://www.npmjs.com/package/blur-score), [`exposure-score`](https://www.npmjs.com/package/exposure-score), [`glare-score`](https://www.npmjs.com/package/glare-score), and [`contrast-score`](https://www.npmjs.com/package/contrast-score).
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install skew-score sharp
|
|
23
|
+
# or
|
|
24
|
+
pnpm add skew-score sharp
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
### 1. Analyze Skew
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { analyzeSkew } from 'skew-score';
|
|
33
|
+
|
|
34
|
+
const result = await analyzeSkew('path/to/document.jpg');
|
|
35
|
+
console.log(result);
|
|
36
|
+
/*
|
|
37
|
+
{
|
|
38
|
+
angle: -8.5,
|
|
39
|
+
score: 0.1889,
|
|
40
|
+
isSkewed: true,
|
|
41
|
+
confidence: 0.84,
|
|
42
|
+
correctiveRotation: 8.5,
|
|
43
|
+
quality: 'moderate-skew',
|
|
44
|
+
details: {
|
|
45
|
+
angle: -8.5,
|
|
46
|
+
confidence: 0.84,
|
|
47
|
+
width: 400,
|
|
48
|
+
height: 260
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
*/
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 2. Quick Helpers
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
import { getSkewAngle, isSkewed } from 'skew-score';
|
|
58
|
+
|
|
59
|
+
// Get just the rotation angle
|
|
60
|
+
const angle = await getSkewAngle(buffer); // e.g. -4.2
|
|
61
|
+
|
|
62
|
+
// Check if tilted beyond threshold (default 3 degrees)
|
|
63
|
+
if (await isSkewed(buffer)) {
|
|
64
|
+
console.log('Please hold the camera level with your document.');
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 3. Straightening the Document
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
import sharp from 'sharp';
|
|
72
|
+
import { analyzeSkew } from 'skew-score';
|
|
73
|
+
|
|
74
|
+
const result = await analyzeSkew(imageBuffer);
|
|
75
|
+
if (result.isSkewed && result.confidence > 0.5) {
|
|
76
|
+
const deskewedBuffer = await sharp(imageBuffer)
|
|
77
|
+
.rotate(result.correctiveRotation, { background: '#ffffff' })
|
|
78
|
+
.toBuffer();
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Options
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
interface SkewOptions {
|
|
86
|
+
/** Maximum allowed tilt before flagging as skewed (default: 3.0 degrees) */
|
|
87
|
+
angleThreshold?: number;
|
|
88
|
+
/** Minimum confidence required to accept angle (default: 0.20) */
|
|
89
|
+
minConfidence?: number;
|
|
90
|
+
/** Maximum angle search range (default: 45) */
|
|
91
|
+
maxAngle?: number;
|
|
92
|
+
/** Angular search step resolution (default: 0.5 degrees) */
|
|
93
|
+
angleStep?: number;
|
|
94
|
+
/** Downsample width for fast processing (default: 400px) */
|
|
95
|
+
downsampleWidth?: number;
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT © [Vijay Misal](https://github.com/vjymisal0)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var sharp = require('sharp');
|
|
4
|
+
|
|
5
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
6
|
+
|
|
7
|
+
var sharp__default = /*#__PURE__*/_interopDefault(sharp);
|
|
8
|
+
|
|
9
|
+
// src/detector.ts
|
|
10
|
+
function normalizeOptions(options) {
|
|
11
|
+
return {
|
|
12
|
+
angleThreshold: options?.angleThreshold ?? 3,
|
|
13
|
+
minConfidence: options?.minConfidence ?? 0.2,
|
|
14
|
+
maxAngle: options?.maxAngle ?? 45,
|
|
15
|
+
angleStep: options?.angleStep ?? 0.5,
|
|
16
|
+
downsampleWidth: options?.downsampleWidth ?? 400
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function evaluateProjectionVariance(edgeMap, width, height, angleDegrees) {
|
|
20
|
+
const rad = angleDegrees * Math.PI / 180;
|
|
21
|
+
const sin = Math.sin(rad);
|
|
22
|
+
const cos = Math.cos(rad);
|
|
23
|
+
const cx = width / 2;
|
|
24
|
+
const cy = height / 2;
|
|
25
|
+
const projectedRows = new Float64Array(height);
|
|
26
|
+
const countPerBin = new Int32Array(height);
|
|
27
|
+
for (let y = 0; y < height; y++) {
|
|
28
|
+
const dy = y - cy;
|
|
29
|
+
const rowOffset = y * width;
|
|
30
|
+
for (let x = 0; x < width; x++) {
|
|
31
|
+
const val = edgeMap[rowOffset + x];
|
|
32
|
+
if (val === 0) continue;
|
|
33
|
+
const dx = x - cx;
|
|
34
|
+
const rotY = Math.round(-dx * sin + dy * cos + cy);
|
|
35
|
+
if (rotY >= 0 && rotY < height) {
|
|
36
|
+
projectedRows[rotY] += val;
|
|
37
|
+
countPerBin[rotY] += 1;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
let sum = 0;
|
|
42
|
+
let sumSq = 0;
|
|
43
|
+
let count = 0;
|
|
44
|
+
for (let i = 0; i < height; i++) {
|
|
45
|
+
if (countPerBin[i] > 0) {
|
|
46
|
+
const val = projectedRows[i];
|
|
47
|
+
sum += val;
|
|
48
|
+
sumSq += val * val;
|
|
49
|
+
count++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (count <= 1) return 0;
|
|
53
|
+
const mean = sum / count;
|
|
54
|
+
return sumSq / count - mean * mean;
|
|
55
|
+
}
|
|
56
|
+
async function analyzeSkew(input, options) {
|
|
57
|
+
const opts = normalizeOptions(options);
|
|
58
|
+
const { data: rawGray, info } = await sharp__default.default(input).resize({ width: opts.downsampleWidth, withoutEnlargement: true }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
59
|
+
const width = info.width;
|
|
60
|
+
const height = info.height;
|
|
61
|
+
const totalPixels = width * height;
|
|
62
|
+
if (totalPixels === 0) {
|
|
63
|
+
throw new Error("Image contains no pixel data.");
|
|
64
|
+
}
|
|
65
|
+
const edges = new Uint8Array(totalPixels);
|
|
66
|
+
for (let y = 1; y < height - 1; y++) {
|
|
67
|
+
const rPrev = (y - 1) * width;
|
|
68
|
+
const rCurr = y * width;
|
|
69
|
+
const rNext = (y + 1) * width;
|
|
70
|
+
for (let x = 1; x < width - 1; x++) {
|
|
71
|
+
const gy = -(rawGray[rPrev + x - 1] + 2 * rawGray[rPrev + x] + rawGray[rPrev + x + 1]) + (rawGray[rNext + x - 1] + 2 * rawGray[rNext + x] + rawGray[rNext + x + 1]);
|
|
72
|
+
const mag = Math.abs(gy);
|
|
73
|
+
edges[rCurr + x] = mag > 45 ? 255 : 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
let bestAngle = 0;
|
|
77
|
+
let maxVariance = -1;
|
|
78
|
+
let minVariance = Infinity;
|
|
79
|
+
const startAngle = -opts.maxAngle;
|
|
80
|
+
const endAngle = opts.maxAngle;
|
|
81
|
+
const step = opts.angleStep;
|
|
82
|
+
for (let angle = startAngle; angle <= endAngle; angle += step) {
|
|
83
|
+
const roundedAngle = Math.round(angle * 100) / 100;
|
|
84
|
+
const variance = evaluateProjectionVariance(edges, width, height, roundedAngle);
|
|
85
|
+
if (variance > maxVariance) {
|
|
86
|
+
maxVariance = variance;
|
|
87
|
+
bestAngle = roundedAngle;
|
|
88
|
+
}
|
|
89
|
+
if (variance < minVariance) {
|
|
90
|
+
minVariance = variance;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const baseline = minVariance === Infinity || minVariance < 0 ? 0 : minVariance;
|
|
94
|
+
const peakContrast = maxVariance > baseline && baseline > 0 ? (maxVariance - baseline) / maxVariance : 0;
|
|
95
|
+
const confidence = Number(Math.min(1, Math.max(0, peakContrast)).toFixed(3));
|
|
96
|
+
const finalAngle = confidence >= opts.minConfidence ? bestAngle : 0;
|
|
97
|
+
const absAngle = Math.abs(finalAngle);
|
|
98
|
+
const score = Number(Math.min(1, absAngle / 45).toFixed(4));
|
|
99
|
+
const isSkewed2 = absAngle >= opts.angleThreshold;
|
|
100
|
+
const correctiveRotation = finalAngle === 0 ? 0 : Number((-finalAngle).toFixed(2));
|
|
101
|
+
let quality;
|
|
102
|
+
if (absAngle < 1) {
|
|
103
|
+
quality = "straight";
|
|
104
|
+
} else if (absAngle <= opts.angleThreshold) {
|
|
105
|
+
quality = "slight-tilt";
|
|
106
|
+
} else if (absAngle <= 15) {
|
|
107
|
+
quality = "moderate-skew";
|
|
108
|
+
} else {
|
|
109
|
+
quality = "severe-skew";
|
|
110
|
+
}
|
|
111
|
+
const details = {
|
|
112
|
+
angle: finalAngle,
|
|
113
|
+
confidence,
|
|
114
|
+
width,
|
|
115
|
+
height
|
|
116
|
+
};
|
|
117
|
+
return {
|
|
118
|
+
angle: finalAngle,
|
|
119
|
+
score,
|
|
120
|
+
isSkewed: isSkewed2,
|
|
121
|
+
confidence,
|
|
122
|
+
correctiveRotation,
|
|
123
|
+
quality,
|
|
124
|
+
details
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
async function getSkewAngle(input, options) {
|
|
128
|
+
const res = await analyzeSkew(input, options);
|
|
129
|
+
return res.angle;
|
|
130
|
+
}
|
|
131
|
+
async function isSkewed(input, threshold) {
|
|
132
|
+
const options = {};
|
|
133
|
+
if (threshold !== void 0) {
|
|
134
|
+
options.angleThreshold = threshold;
|
|
135
|
+
}
|
|
136
|
+
const res = await analyzeSkew(input, options);
|
|
137
|
+
return res.isSkewed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
exports.analyzeSkew = analyzeSkew;
|
|
141
|
+
exports.getSkewAngle = getSkewAngle;
|
|
142
|
+
exports.isSkewed = isSkewed;
|
|
143
|
+
//# sourceMappingURL=index.cjs.map
|
|
144
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/detector.ts"],"names":["sharp","isSkewed"],"mappings":"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","file":"index.cjs","sourcesContent":["import sharp from 'sharp';\r\nimport type { SkewOptions, SkewResult, SkewDetails } from './types.js';\r\n\r\nexport function normalizeOptions(options?: SkewOptions): Required<SkewOptions> {\r\n return {\r\n angleThreshold: options?.angleThreshold ?? 3.0,\r\n minConfidence: options?.minConfidence ?? 0.20,\r\n maxAngle: options?.maxAngle ?? 45,\r\n angleStep: options?.angleStep ?? 0.5,\r\n downsampleWidth: options?.downsampleWidth ?? 400\r\n };\r\n}\r\n\r\n/**\r\n * Computes horizontal projection profile variance at a specific rotation angle.\r\n * When text lines or document edges are aligned horizontally, the variance of row sums is maximized.\r\n */\r\nfunction evaluateProjectionVariance(\r\n edgeMap: Uint8Array,\r\n width: number,\r\n height: number,\r\n angleDegrees: number\r\n): number {\r\n const rad = (angleDegrees * Math.PI) / 180;\r\n const sin = Math.sin(rad);\r\n const cos = Math.cos(rad);\r\n\r\n const cx = width / 2;\r\n const cy = height / 2;\r\n\r\n // Project onto vertical axis (row projection)\r\n // Bounded buffer for projected row sums\r\n const projectedRows = new Float64Array(height);\r\n const countPerBin = new Int32Array(height);\r\n\r\n for (let y = 0; y < height; y++) {\r\n const dy = y - cy;\r\n const rowOffset = y * width;\r\n for (let x = 0; x < width; x++) {\r\n const val = edgeMap[rowOffset + x]!;\r\n if (val === 0) continue;\r\n\r\n const dx = x - cx;\r\n // Rotated Y coordinate: -dx * sin + dy * cos + cy\r\n const rotY = Math.round(-dx * sin + dy * cos + cy);\r\n\r\n if (rotY >= 0 && rotY < height) {\r\n projectedRows[rotY] += val;\r\n countPerBin[rotY] += 1;\r\n }\r\n }\r\n }\r\n\r\n // Calculate variance of non-empty bins\r\n let sum = 0;\r\n let sumSq = 0;\r\n let count = 0;\r\n\r\n for (let i = 0; i < height; i++) {\r\n if (countPerBin[i]! > 0) {\r\n const val = projectedRows[i]!;\r\n sum += val;\r\n sumSq += val * val;\r\n count++;\r\n }\r\n }\r\n\r\n if (count <= 1) return 0;\r\n const mean = sum / count;\r\n return (sumSq / count) - (mean * mean);\r\n}\r\n\r\n/**\r\n * Analyzes document and text skew using Radon / Horizontal Projection Profile variance.\r\n *\r\n * @param input - File path, Buffer, or Uint8Array representing an image.\r\n * @param options - Configurable parameters.\r\n * @returns Promise resolving to a SkewResult.\r\n */\r\nexport async function analyzeSkew(\r\n input: string | Buffer | Uint8Array,\r\n options?: SkewOptions\r\n): Promise<SkewResult> {\r\n const opts = normalizeOptions(options);\r\n\r\n // Load image, grayscale, downsample\r\n const { data: rawGray, info } = await sharp(input)\r\n .resize({ width: opts.downsampleWidth, withoutEnlargement: true })\r\n .grayscale()\r\n .raw()\r\n .toBuffer({ resolveWithObject: true });\r\n\r\n const width = info.width;\r\n const height = info.height;\r\n const totalPixels = width * height;\r\n\r\n if (totalPixels === 0) {\r\n throw new Error('Image contains no pixel data.');\r\n }\r\n\r\n // Fast Sobel horizontal edge filtering (horizontal edges are most sensitive to tilt)\r\n const edges = new Uint8Array(totalPixels);\r\n for (let y = 1; y < height - 1; y++) {\r\n const rPrev = (y - 1) * width;\r\n const rCurr = y * width;\r\n const rNext = (y + 1) * width;\r\n\r\n for (let x = 1; x < width - 1; x++) {\r\n // Sobel Y (horizontal gradients)\r\n const gy =\r\n -(rawGray[rPrev + x - 1]! + 2 * rawGray[rPrev + x]! + rawGray[rPrev + x + 1]!) +\r\n (rawGray[rNext + x - 1]! + 2 * rawGray[rNext + x]! + rawGray[rNext + x + 1]!);\r\n\r\n const mag = Math.abs(gy);\r\n // Threshold edges to binary structure\r\n edges[rCurr + x] = mag > 45 ? 255 : 0;\r\n }\r\n }\r\n\r\n // Angular sweep to find peak variance\r\n let bestAngle = 0;\r\n let maxVariance = -1;\r\n let minVariance = Infinity;\r\n let zeroAngleVariance = 0;\r\n\r\n const startAngle = -opts.maxAngle;\r\n const endAngle = opts.maxAngle;\r\n const step = opts.angleStep;\r\n\r\n const variances: { angle: number; variance: number }[] = [];\r\n\r\n for (let angle = startAngle; angle <= endAngle; angle += step) {\r\n const roundedAngle = Math.round(angle * 100) / 100;\r\n const variance = evaluateProjectionVariance(edges, width, height, roundedAngle);\r\n\r\n variances.push({ angle: roundedAngle, variance });\r\n\r\n if (roundedAngle === 0) {\r\n zeroAngleVariance = variance;\r\n }\r\n\r\n if (variance > maxVariance) {\r\n maxVariance = variance;\r\n bestAngle = roundedAngle;\r\n }\r\n if (variance < minVariance) {\r\n minVariance = variance;\r\n }\r\n }\r\n\r\n // Compute confidence based on contrast between peak variance and baseline variance\r\n const baseline = (minVariance === Infinity || minVariance < 0) ? 0 : minVariance;\r\n const peakContrast = maxVariance > baseline && baseline > 0\r\n ? (maxVariance - baseline) / maxVariance\r\n : 0;\r\n\r\n const confidence = Number(Math.min(1.0, Math.max(0.0, peakContrast)).toFixed(3));\r\n\r\n // If confidence is too low (e.g. blank page or uniform texture), assume 0 skew\r\n const finalAngle = confidence >= opts.minConfidence ? bestAngle : 0;\r\n const absAngle = Math.abs(finalAngle);\r\n\r\n // Normalized score (0.0 = 0 deg, 1.0 = 45+ deg)\r\n const score = Number(Math.min(1.0, absAngle / 45).toFixed(4));\r\n const isSkewed = absAngle >= opts.angleThreshold;\r\n\r\n // Corrective rotation is opposite of detected angle\r\n const correctiveRotation = finalAngle === 0 ? 0 : Number((-finalAngle).toFixed(2));\r\n\r\n let quality: 'straight' | 'slight-tilt' | 'moderate-skew' | 'severe-skew';\r\n if (absAngle < 1.0) {\r\n quality = 'straight';\r\n } else if (absAngle <= opts.angleThreshold) {\r\n quality = 'slight-tilt';\r\n } else if (absAngle <= 15.0) {\r\n quality = 'moderate-skew';\r\n } else {\r\n quality = 'severe-skew';\r\n }\r\n\r\n const details: SkewDetails = {\r\n angle: finalAngle,\r\n confidence,\r\n width,\r\n height\r\n };\r\n\r\n return {\r\n angle: finalAngle,\r\n score,\r\n isSkewed,\r\n confidence,\r\n correctiveRotation,\r\n quality,\r\n details\r\n };\r\n}\r\n\r\n/**\r\n * Returns only the detected skew angle in degrees.\r\n */\r\nexport async function getSkewAngle(\r\n input: string | Buffer | Uint8Array,\r\n options?: SkewOptions\r\n): Promise<number> {\r\n const res = await analyzeSkew(input, options);\r\n return res.angle;\r\n}\r\n\r\n/**\r\n * Returns true if the image exceeds the skew threshold.\r\n */\r\nexport async function isSkewed(\r\n input: string | Buffer | Uint8Array,\r\n threshold?: number\r\n): Promise<boolean> {\r\n const options: SkewOptions = {};\r\n if (threshold !== undefined) {\r\n options.angleThreshold = threshold;\r\n }\r\n const res = await analyzeSkew(input, options);\r\n return res.isSkewed;\r\n}\r\n"]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
interface SkewDetails {
|
|
2
|
+
/**
|
|
3
|
+
* Detected skew angle in degrees (negative = counter-clockwise, positive = clockwise).
|
|
4
|
+
*/
|
|
5
|
+
angle: number;
|
|
6
|
+
/**
|
|
7
|
+
* Confidence of the detected angle from 0.0 to 1.0.
|
|
8
|
+
*/
|
|
9
|
+
confidence: number;
|
|
10
|
+
/**
|
|
11
|
+
* Width of the analyzed image after optional downsampling.
|
|
12
|
+
*/
|
|
13
|
+
width: number;
|
|
14
|
+
/**
|
|
15
|
+
* Height of the analyzed image after optional downsampling.
|
|
16
|
+
*/
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
interface SkewOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Maximum allowed absolute tilt angle in degrees before flagging as skewed.
|
|
22
|
+
* @default 3.0
|
|
23
|
+
*/
|
|
24
|
+
angleThreshold?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Minimum confidence score (0.0 to 1.0) required to consider skew detection valid.
|
|
27
|
+
* @default 0.25
|
|
28
|
+
*/
|
|
29
|
+
minConfidence?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Search range of angles to inspect in degrees [-range, +range].
|
|
32
|
+
* @default 45
|
|
33
|
+
*/
|
|
34
|
+
maxAngle?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Step size in degrees during angular projection search.
|
|
37
|
+
* @default 0.5
|
|
38
|
+
*/
|
|
39
|
+
angleStep?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Target width to resize the image for blazing fast analysis.
|
|
42
|
+
* @default 400
|
|
43
|
+
*/
|
|
44
|
+
downsampleWidth?: number;
|
|
45
|
+
}
|
|
46
|
+
interface SkewResult {
|
|
47
|
+
/**
|
|
48
|
+
* Detected skew angle in degrees (-45° to +45°).
|
|
49
|
+
*/
|
|
50
|
+
angle: number;
|
|
51
|
+
/**
|
|
52
|
+
* Normalized skew severity score from 0.0 (perfectly straight) to 1.0 (heavily rotated).
|
|
53
|
+
*/
|
|
54
|
+
score: number;
|
|
55
|
+
/**
|
|
56
|
+
* True if absolute angle exceeds angleThreshold.
|
|
57
|
+
*/
|
|
58
|
+
isSkewed: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Confidence level of detection (0.0 to 1.0).
|
|
61
|
+
*/
|
|
62
|
+
confidence: number;
|
|
63
|
+
/**
|
|
64
|
+
* Suggested rotation angle (in degrees) needed to de-skew / straighten the image.
|
|
65
|
+
*/
|
|
66
|
+
correctiveRotation: number;
|
|
67
|
+
/**
|
|
68
|
+
* Qualitative alignment rating.
|
|
69
|
+
*/
|
|
70
|
+
quality: 'straight' | 'slight-tilt' | 'moderate-skew' | 'severe-skew';
|
|
71
|
+
/**
|
|
72
|
+
* Optional granular diagnostics.
|
|
73
|
+
*/
|
|
74
|
+
details?: SkewDetails;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Analyzes document and text skew using Radon / Horizontal Projection Profile variance.
|
|
79
|
+
*
|
|
80
|
+
* @param input - File path, Buffer, or Uint8Array representing an image.
|
|
81
|
+
* @param options - Configurable parameters.
|
|
82
|
+
* @returns Promise resolving to a SkewResult.
|
|
83
|
+
*/
|
|
84
|
+
declare function analyzeSkew(input: string | Buffer | Uint8Array, options?: SkewOptions): Promise<SkewResult>;
|
|
85
|
+
/**
|
|
86
|
+
* Returns only the detected skew angle in degrees.
|
|
87
|
+
*/
|
|
88
|
+
declare function getSkewAngle(input: string | Buffer | Uint8Array, options?: SkewOptions): Promise<number>;
|
|
89
|
+
/**
|
|
90
|
+
* Returns true if the image exceeds the skew threshold.
|
|
91
|
+
*/
|
|
92
|
+
declare function isSkewed(input: string | Buffer | Uint8Array, threshold?: number): Promise<boolean>;
|
|
93
|
+
|
|
94
|
+
export { type SkewDetails, type SkewOptions, type SkewResult, analyzeSkew, getSkewAngle, isSkewed };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
interface SkewDetails {
|
|
2
|
+
/**
|
|
3
|
+
* Detected skew angle in degrees (negative = counter-clockwise, positive = clockwise).
|
|
4
|
+
*/
|
|
5
|
+
angle: number;
|
|
6
|
+
/**
|
|
7
|
+
* Confidence of the detected angle from 0.0 to 1.0.
|
|
8
|
+
*/
|
|
9
|
+
confidence: number;
|
|
10
|
+
/**
|
|
11
|
+
* Width of the analyzed image after optional downsampling.
|
|
12
|
+
*/
|
|
13
|
+
width: number;
|
|
14
|
+
/**
|
|
15
|
+
* Height of the analyzed image after optional downsampling.
|
|
16
|
+
*/
|
|
17
|
+
height: number;
|
|
18
|
+
}
|
|
19
|
+
interface SkewOptions {
|
|
20
|
+
/**
|
|
21
|
+
* Maximum allowed absolute tilt angle in degrees before flagging as skewed.
|
|
22
|
+
* @default 3.0
|
|
23
|
+
*/
|
|
24
|
+
angleThreshold?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Minimum confidence score (0.0 to 1.0) required to consider skew detection valid.
|
|
27
|
+
* @default 0.25
|
|
28
|
+
*/
|
|
29
|
+
minConfidence?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Search range of angles to inspect in degrees [-range, +range].
|
|
32
|
+
* @default 45
|
|
33
|
+
*/
|
|
34
|
+
maxAngle?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Step size in degrees during angular projection search.
|
|
37
|
+
* @default 0.5
|
|
38
|
+
*/
|
|
39
|
+
angleStep?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Target width to resize the image for blazing fast analysis.
|
|
42
|
+
* @default 400
|
|
43
|
+
*/
|
|
44
|
+
downsampleWidth?: number;
|
|
45
|
+
}
|
|
46
|
+
interface SkewResult {
|
|
47
|
+
/**
|
|
48
|
+
* Detected skew angle in degrees (-45° to +45°).
|
|
49
|
+
*/
|
|
50
|
+
angle: number;
|
|
51
|
+
/**
|
|
52
|
+
* Normalized skew severity score from 0.0 (perfectly straight) to 1.0 (heavily rotated).
|
|
53
|
+
*/
|
|
54
|
+
score: number;
|
|
55
|
+
/**
|
|
56
|
+
* True if absolute angle exceeds angleThreshold.
|
|
57
|
+
*/
|
|
58
|
+
isSkewed: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Confidence level of detection (0.0 to 1.0).
|
|
61
|
+
*/
|
|
62
|
+
confidence: number;
|
|
63
|
+
/**
|
|
64
|
+
* Suggested rotation angle (in degrees) needed to de-skew / straighten the image.
|
|
65
|
+
*/
|
|
66
|
+
correctiveRotation: number;
|
|
67
|
+
/**
|
|
68
|
+
* Qualitative alignment rating.
|
|
69
|
+
*/
|
|
70
|
+
quality: 'straight' | 'slight-tilt' | 'moderate-skew' | 'severe-skew';
|
|
71
|
+
/**
|
|
72
|
+
* Optional granular diagnostics.
|
|
73
|
+
*/
|
|
74
|
+
details?: SkewDetails;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Analyzes document and text skew using Radon / Horizontal Projection Profile variance.
|
|
79
|
+
*
|
|
80
|
+
* @param input - File path, Buffer, or Uint8Array representing an image.
|
|
81
|
+
* @param options - Configurable parameters.
|
|
82
|
+
* @returns Promise resolving to a SkewResult.
|
|
83
|
+
*/
|
|
84
|
+
declare function analyzeSkew(input: string | Buffer | Uint8Array, options?: SkewOptions): Promise<SkewResult>;
|
|
85
|
+
/**
|
|
86
|
+
* Returns only the detected skew angle in degrees.
|
|
87
|
+
*/
|
|
88
|
+
declare function getSkewAngle(input: string | Buffer | Uint8Array, options?: SkewOptions): Promise<number>;
|
|
89
|
+
/**
|
|
90
|
+
* Returns true if the image exceeds the skew threshold.
|
|
91
|
+
*/
|
|
92
|
+
declare function isSkewed(input: string | Buffer | Uint8Array, threshold?: number): Promise<boolean>;
|
|
93
|
+
|
|
94
|
+
export { type SkewDetails, type SkewOptions, type SkewResult, analyzeSkew, getSkewAngle, isSkewed };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
// src/detector.ts
|
|
4
|
+
function normalizeOptions(options) {
|
|
5
|
+
return {
|
|
6
|
+
angleThreshold: options?.angleThreshold ?? 3,
|
|
7
|
+
minConfidence: options?.minConfidence ?? 0.2,
|
|
8
|
+
maxAngle: options?.maxAngle ?? 45,
|
|
9
|
+
angleStep: options?.angleStep ?? 0.5,
|
|
10
|
+
downsampleWidth: options?.downsampleWidth ?? 400
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
function evaluateProjectionVariance(edgeMap, width, height, angleDegrees) {
|
|
14
|
+
const rad = angleDegrees * Math.PI / 180;
|
|
15
|
+
const sin = Math.sin(rad);
|
|
16
|
+
const cos = Math.cos(rad);
|
|
17
|
+
const cx = width / 2;
|
|
18
|
+
const cy = height / 2;
|
|
19
|
+
const projectedRows = new Float64Array(height);
|
|
20
|
+
const countPerBin = new Int32Array(height);
|
|
21
|
+
for (let y = 0; y < height; y++) {
|
|
22
|
+
const dy = y - cy;
|
|
23
|
+
const rowOffset = y * width;
|
|
24
|
+
for (let x = 0; x < width; x++) {
|
|
25
|
+
const val = edgeMap[rowOffset + x];
|
|
26
|
+
if (val === 0) continue;
|
|
27
|
+
const dx = x - cx;
|
|
28
|
+
const rotY = Math.round(-dx * sin + dy * cos + cy);
|
|
29
|
+
if (rotY >= 0 && rotY < height) {
|
|
30
|
+
projectedRows[rotY] += val;
|
|
31
|
+
countPerBin[rotY] += 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
let sum = 0;
|
|
36
|
+
let sumSq = 0;
|
|
37
|
+
let count = 0;
|
|
38
|
+
for (let i = 0; i < height; i++) {
|
|
39
|
+
if (countPerBin[i] > 0) {
|
|
40
|
+
const val = projectedRows[i];
|
|
41
|
+
sum += val;
|
|
42
|
+
sumSq += val * val;
|
|
43
|
+
count++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (count <= 1) return 0;
|
|
47
|
+
const mean = sum / count;
|
|
48
|
+
return sumSq / count - mean * mean;
|
|
49
|
+
}
|
|
50
|
+
async function analyzeSkew(input, options) {
|
|
51
|
+
const opts = normalizeOptions(options);
|
|
52
|
+
const { data: rawGray, info } = await sharp(input).resize({ width: opts.downsampleWidth, withoutEnlargement: true }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
53
|
+
const width = info.width;
|
|
54
|
+
const height = info.height;
|
|
55
|
+
const totalPixels = width * height;
|
|
56
|
+
if (totalPixels === 0) {
|
|
57
|
+
throw new Error("Image contains no pixel data.");
|
|
58
|
+
}
|
|
59
|
+
const edges = new Uint8Array(totalPixels);
|
|
60
|
+
for (let y = 1; y < height - 1; y++) {
|
|
61
|
+
const rPrev = (y - 1) * width;
|
|
62
|
+
const rCurr = y * width;
|
|
63
|
+
const rNext = (y + 1) * width;
|
|
64
|
+
for (let x = 1; x < width - 1; x++) {
|
|
65
|
+
const gy = -(rawGray[rPrev + x - 1] + 2 * rawGray[rPrev + x] + rawGray[rPrev + x + 1]) + (rawGray[rNext + x - 1] + 2 * rawGray[rNext + x] + rawGray[rNext + x + 1]);
|
|
66
|
+
const mag = Math.abs(gy);
|
|
67
|
+
edges[rCurr + x] = mag > 45 ? 255 : 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let bestAngle = 0;
|
|
71
|
+
let maxVariance = -1;
|
|
72
|
+
let minVariance = Infinity;
|
|
73
|
+
const startAngle = -opts.maxAngle;
|
|
74
|
+
const endAngle = opts.maxAngle;
|
|
75
|
+
const step = opts.angleStep;
|
|
76
|
+
for (let angle = startAngle; angle <= endAngle; angle += step) {
|
|
77
|
+
const roundedAngle = Math.round(angle * 100) / 100;
|
|
78
|
+
const variance = evaluateProjectionVariance(edges, width, height, roundedAngle);
|
|
79
|
+
if (variance > maxVariance) {
|
|
80
|
+
maxVariance = variance;
|
|
81
|
+
bestAngle = roundedAngle;
|
|
82
|
+
}
|
|
83
|
+
if (variance < minVariance) {
|
|
84
|
+
minVariance = variance;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const baseline = minVariance === Infinity || minVariance < 0 ? 0 : minVariance;
|
|
88
|
+
const peakContrast = maxVariance > baseline && baseline > 0 ? (maxVariance - baseline) / maxVariance : 0;
|
|
89
|
+
const confidence = Number(Math.min(1, Math.max(0, peakContrast)).toFixed(3));
|
|
90
|
+
const finalAngle = confidence >= opts.minConfidence ? bestAngle : 0;
|
|
91
|
+
const absAngle = Math.abs(finalAngle);
|
|
92
|
+
const score = Number(Math.min(1, absAngle / 45).toFixed(4));
|
|
93
|
+
const isSkewed2 = absAngle >= opts.angleThreshold;
|
|
94
|
+
const correctiveRotation = finalAngle === 0 ? 0 : Number((-finalAngle).toFixed(2));
|
|
95
|
+
let quality;
|
|
96
|
+
if (absAngle < 1) {
|
|
97
|
+
quality = "straight";
|
|
98
|
+
} else if (absAngle <= opts.angleThreshold) {
|
|
99
|
+
quality = "slight-tilt";
|
|
100
|
+
} else if (absAngle <= 15) {
|
|
101
|
+
quality = "moderate-skew";
|
|
102
|
+
} else {
|
|
103
|
+
quality = "severe-skew";
|
|
104
|
+
}
|
|
105
|
+
const details = {
|
|
106
|
+
angle: finalAngle,
|
|
107
|
+
confidence,
|
|
108
|
+
width,
|
|
109
|
+
height
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
angle: finalAngle,
|
|
113
|
+
score,
|
|
114
|
+
isSkewed: isSkewed2,
|
|
115
|
+
confidence,
|
|
116
|
+
correctiveRotation,
|
|
117
|
+
quality,
|
|
118
|
+
details
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
async function getSkewAngle(input, options) {
|
|
122
|
+
const res = await analyzeSkew(input, options);
|
|
123
|
+
return res.angle;
|
|
124
|
+
}
|
|
125
|
+
async function isSkewed(input, threshold) {
|
|
126
|
+
const options = {};
|
|
127
|
+
if (threshold !== void 0) {
|
|
128
|
+
options.angleThreshold = threshold;
|
|
129
|
+
}
|
|
130
|
+
const res = await analyzeSkew(input, options);
|
|
131
|
+
return res.isSkewed;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { analyzeSkew, getSkewAngle, isSkewed };
|
|
135
|
+
//# sourceMappingURL=index.js.map
|
|
136
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/detector.ts"],"names":["isSkewed"],"mappings":"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","file":"index.js","sourcesContent":["import sharp from 'sharp';\r\nimport type { SkewOptions, SkewResult, SkewDetails } from './types.js';\r\n\r\nexport function normalizeOptions(options?: SkewOptions): Required<SkewOptions> {\r\n return {\r\n angleThreshold: options?.angleThreshold ?? 3.0,\r\n minConfidence: options?.minConfidence ?? 0.20,\r\n maxAngle: options?.maxAngle ?? 45,\r\n angleStep: options?.angleStep ?? 0.5,\r\n downsampleWidth: options?.downsampleWidth ?? 400\r\n };\r\n}\r\n\r\n/**\r\n * Computes horizontal projection profile variance at a specific rotation angle.\r\n * When text lines or document edges are aligned horizontally, the variance of row sums is maximized.\r\n */\r\nfunction evaluateProjectionVariance(\r\n edgeMap: Uint8Array,\r\n width: number,\r\n height: number,\r\n angleDegrees: number\r\n): number {\r\n const rad = (angleDegrees * Math.PI) / 180;\r\n const sin = Math.sin(rad);\r\n const cos = Math.cos(rad);\r\n\r\n const cx = width / 2;\r\n const cy = height / 2;\r\n\r\n // Project onto vertical axis (row projection)\r\n // Bounded buffer for projected row sums\r\n const projectedRows = new Float64Array(height);\r\n const countPerBin = new Int32Array(height);\r\n\r\n for (let y = 0; y < height; y++) {\r\n const dy = y - cy;\r\n const rowOffset = y * width;\r\n for (let x = 0; x < width; x++) {\r\n const val = edgeMap[rowOffset + x]!;\r\n if (val === 0) continue;\r\n\r\n const dx = x - cx;\r\n // Rotated Y coordinate: -dx * sin + dy * cos + cy\r\n const rotY = Math.round(-dx * sin + dy * cos + cy);\r\n\r\n if (rotY >= 0 && rotY < height) {\r\n projectedRows[rotY] += val;\r\n countPerBin[rotY] += 1;\r\n }\r\n }\r\n }\r\n\r\n // Calculate variance of non-empty bins\r\n let sum = 0;\r\n let sumSq = 0;\r\n let count = 0;\r\n\r\n for (let i = 0; i < height; i++) {\r\n if (countPerBin[i]! > 0) {\r\n const val = projectedRows[i]!;\r\n sum += val;\r\n sumSq += val * val;\r\n count++;\r\n }\r\n }\r\n\r\n if (count <= 1) return 0;\r\n const mean = sum / count;\r\n return (sumSq / count) - (mean * mean);\r\n}\r\n\r\n/**\r\n * Analyzes document and text skew using Radon / Horizontal Projection Profile variance.\r\n *\r\n * @param input - File path, Buffer, or Uint8Array representing an image.\r\n * @param options - Configurable parameters.\r\n * @returns Promise resolving to a SkewResult.\r\n */\r\nexport async function analyzeSkew(\r\n input: string | Buffer | Uint8Array,\r\n options?: SkewOptions\r\n): Promise<SkewResult> {\r\n const opts = normalizeOptions(options);\r\n\r\n // Load image, grayscale, downsample\r\n const { data: rawGray, info } = await sharp(input)\r\n .resize({ width: opts.downsampleWidth, withoutEnlargement: true })\r\n .grayscale()\r\n .raw()\r\n .toBuffer({ resolveWithObject: true });\r\n\r\n const width = info.width;\r\n const height = info.height;\r\n const totalPixels = width * height;\r\n\r\n if (totalPixels === 0) {\r\n throw new Error('Image contains no pixel data.');\r\n }\r\n\r\n // Fast Sobel horizontal edge filtering (horizontal edges are most sensitive to tilt)\r\n const edges = new Uint8Array(totalPixels);\r\n for (let y = 1; y < height - 1; y++) {\r\n const rPrev = (y - 1) * width;\r\n const rCurr = y * width;\r\n const rNext = (y + 1) * width;\r\n\r\n for (let x = 1; x < width - 1; x++) {\r\n // Sobel Y (horizontal gradients)\r\n const gy =\r\n -(rawGray[rPrev + x - 1]! + 2 * rawGray[rPrev + x]! + rawGray[rPrev + x + 1]!) +\r\n (rawGray[rNext + x - 1]! + 2 * rawGray[rNext + x]! + rawGray[rNext + x + 1]!);\r\n\r\n const mag = Math.abs(gy);\r\n // Threshold edges to binary structure\r\n edges[rCurr + x] = mag > 45 ? 255 : 0;\r\n }\r\n }\r\n\r\n // Angular sweep to find peak variance\r\n let bestAngle = 0;\r\n let maxVariance = -1;\r\n let minVariance = Infinity;\r\n let zeroAngleVariance = 0;\r\n\r\n const startAngle = -opts.maxAngle;\r\n const endAngle = opts.maxAngle;\r\n const step = opts.angleStep;\r\n\r\n const variances: { angle: number; variance: number }[] = [];\r\n\r\n for (let angle = startAngle; angle <= endAngle; angle += step) {\r\n const roundedAngle = Math.round(angle * 100) / 100;\r\n const variance = evaluateProjectionVariance(edges, width, height, roundedAngle);\r\n\r\n variances.push({ angle: roundedAngle, variance });\r\n\r\n if (roundedAngle === 0) {\r\n zeroAngleVariance = variance;\r\n }\r\n\r\n if (variance > maxVariance) {\r\n maxVariance = variance;\r\n bestAngle = roundedAngle;\r\n }\r\n if (variance < minVariance) {\r\n minVariance = variance;\r\n }\r\n }\r\n\r\n // Compute confidence based on contrast between peak variance and baseline variance\r\n const baseline = (minVariance === Infinity || minVariance < 0) ? 0 : minVariance;\r\n const peakContrast = maxVariance > baseline && baseline > 0\r\n ? (maxVariance - baseline) / maxVariance\r\n : 0;\r\n\r\n const confidence = Number(Math.min(1.0, Math.max(0.0, peakContrast)).toFixed(3));\r\n\r\n // If confidence is too low (e.g. blank page or uniform texture), assume 0 skew\r\n const finalAngle = confidence >= opts.minConfidence ? bestAngle : 0;\r\n const absAngle = Math.abs(finalAngle);\r\n\r\n // Normalized score (0.0 = 0 deg, 1.0 = 45+ deg)\r\n const score = Number(Math.min(1.0, absAngle / 45).toFixed(4));\r\n const isSkewed = absAngle >= opts.angleThreshold;\r\n\r\n // Corrective rotation is opposite of detected angle\r\n const correctiveRotation = finalAngle === 0 ? 0 : Number((-finalAngle).toFixed(2));\r\n\r\n let quality: 'straight' | 'slight-tilt' | 'moderate-skew' | 'severe-skew';\r\n if (absAngle < 1.0) {\r\n quality = 'straight';\r\n } else if (absAngle <= opts.angleThreshold) {\r\n quality = 'slight-tilt';\r\n } else if (absAngle <= 15.0) {\r\n quality = 'moderate-skew';\r\n } else {\r\n quality = 'severe-skew';\r\n }\r\n\r\n const details: SkewDetails = {\r\n angle: finalAngle,\r\n confidence,\r\n width,\r\n height\r\n };\r\n\r\n return {\r\n angle: finalAngle,\r\n score,\r\n isSkewed,\r\n confidence,\r\n correctiveRotation,\r\n quality,\r\n details\r\n };\r\n}\r\n\r\n/**\r\n * Returns only the detected skew angle in degrees.\r\n */\r\nexport async function getSkewAngle(\r\n input: string | Buffer | Uint8Array,\r\n options?: SkewOptions\r\n): Promise<number> {\r\n const res = await analyzeSkew(input, options);\r\n return res.angle;\r\n}\r\n\r\n/**\r\n * Returns true if the image exceeds the skew threshold.\r\n */\r\nexport async function isSkewed(\r\n input: string | Buffer | Uint8Array,\r\n threshold?: number\r\n): Promise<boolean> {\r\n const options: SkewOptions = {};\r\n if (threshold !== undefined) {\r\n options.angleThreshold = threshold;\r\n }\r\n const res = await analyzeSkew(input, options);\r\n return res.isSkewed;\r\n}\r\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skew-score",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Quantifies document and image tilt/skew angle (-45° to +45°) using Radon projection profile analysis via Sharp. Optimized for pre-OCR document scanning, KYC ID verification, and receipt processing.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"require": "./dist/index.cjs"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup",
|
|
23
|
+
"dev": "tsup --watch",
|
|
24
|
+
"test": "vitest run",
|
|
25
|
+
"test:watch": "vitest",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"prepublishOnly": "npm run build && npm test"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"skew",
|
|
31
|
+
"skew-score",
|
|
32
|
+
"deskew",
|
|
33
|
+
"rotation",
|
|
34
|
+
"tilt-detection",
|
|
35
|
+
"image-processing",
|
|
36
|
+
"ocr-preprocessing",
|
|
37
|
+
"kyc-document",
|
|
38
|
+
"sharp",
|
|
39
|
+
"blur-score",
|
|
40
|
+
"exposure-score",
|
|
41
|
+
"glare-score",
|
|
42
|
+
"contrast-score"
|
|
43
|
+
],
|
|
44
|
+
"author": "Vijay Misal <misalvijay153@gmail.com>",
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/vjymisal0/skew-score.git"
|
|
48
|
+
},
|
|
49
|
+
"bugs": {
|
|
50
|
+
"url": "https://github.com/vjymisal0/skew-score/issues"
|
|
51
|
+
},
|
|
52
|
+
"homepage": "https://github.com/vjymisal0/skew-score#readme",
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"sharp": "^0.33.5"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^22.10.2",
|
|
59
|
+
"tsup": "^8.3.5",
|
|
60
|
+
"typescript": "^5.7.2",
|
|
61
|
+
"vitest": "^2.1.8"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=18.0.0"
|
|
65
|
+
}
|
|
66
|
+
}
|