skew-score 1.0.0 → 1.1.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/CHANGELOG.md +27 -0
- package/LICENSE +21 -21
- package/README.md +139 -101
- package/dist/index.cjs +34 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +34 -22
- package/dist/index.js.map +1 -1
- package/package.json +80 -66
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.1.1
|
|
4
|
+
|
|
5
|
+
- Fixed TypeScript types for CommonJS consumers: `require` now resolves `dist/index.d.cts` instead of the ESM declarations ("masquerading as ESM" under `node16`/`nodenext`).
|
|
6
|
+
- Exposed `./package.json` in the exports map.
|
|
7
|
+
|
|
8
|
+
## 1.1.0
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- Perfectly aligned documents reported `confidence: 0` and could be reported as tilted by ±0.5°. The projection profile now uses diagonal-sized bins and takes the variance over all bins, so detection is exact across the full ±45° range and straightened images come back to 0°.
|
|
13
|
+
- `angleStep: 0` or a negative step caused an infinite loop. All options are now validated and invalid values throw a `RangeError`.
|
|
14
|
+
- `isSkewed` was `true` at exactly `angleThreshold` while `quality` said `'slight-tilt'`. It is now `true` only when the angle exceeds the threshold, as documented.
|
|
15
|
+
- Documented `minConfidence` default corrected to `0.2`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Upgraded Sharp to 0.35.5.
|
|
20
|
+
- Raised the minimum supported Node.js version to 20.9 (Node 18 is end-of-life).
|
|
21
|
+
- Upgraded dev tooling (Vitest 5, TypeScript 5.9, tsup 8.5) and resolved all audit advisories.
|
|
22
|
+
- Expanded the test suite from 3 to 37 tests and rewrote the README with a full API reference.
|
|
23
|
+
- Added CI (Node 22 and 24, plus a Node 20 smoke test of the packed package) and a tag-driven release workflow with npm provenance.
|
|
24
|
+
|
|
25
|
+
## 1.0.0
|
|
26
|
+
|
|
27
|
+
- Initial release.
|
package/LICENSE
CHANGED
|
@@ -1,21 +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.
|
|
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
CHANGED
|
@@ -1,101 +1,139 @@
|
|
|
1
|
-
# skew-score
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/skew-score)
|
|
4
|
+
[](https://github.com/vjymisal0/skew-score/actions/workflows/ci.yml)
|
|
5
|
+
[](https://github.com/vjymisal0/skew-score/blob/main/LICENSE)
|
|
6
|
+
[](https://www.typescriptlang.org)
|
|
7
|
+
[](https://www.npmjs.com/package/skew-score)
|
|
8
|
+
|
|
9
|
+
> Measure document and image tilt (-45° to +45°) with Radon projection-profile analysis via [Sharp](https://sharp.pixelplumbing.com). Built for pre-OCR document scanning, KYC ID capture and receipt processing.
|
|
10
|
+
|
|
11
|
+
## Why skew-score?
|
|
12
|
+
|
|
13
|
+
When people photograph ID cards (PAN, Aadhaar, driving licences), cheques or receipts on a phone, a few degrees of tilt is enough to hurt OCR accuracy. `skew-score` tells you:
|
|
14
|
+
|
|
15
|
+
- **The tilt angle**, accurate to the search step (0.5° by default)
|
|
16
|
+
- **The corrective rotation** to pass straight to `sharp().rotate()`
|
|
17
|
+
- **A severity score** from 0.0 (aligned) to 1.0 (45° or more)
|
|
18
|
+
- **A confidence value**, so blank or featureless images are not reported as tilted
|
|
19
|
+
|
|
20
|
+
Ships ESM + CommonJS builds with TypeScript types. 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), [`contrast-score`](https://www.npmjs.com/package/contrast-score) and [`shadow-score`](https://www.npmjs.com/package/shadow-score).
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install skew-score
|
|
26
|
+
# pnpm add skew-score
|
|
27
|
+
# yarn add skew-score
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Requires Node.js 20.9 or newer. `sharp` is installed as a dependency.
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import { analyzeSkew } from 'skew-score';
|
|
36
|
+
|
|
37
|
+
const result = await analyzeSkew('path/to/document.jpg');
|
|
38
|
+
/*
|
|
39
|
+
{
|
|
40
|
+
angle: 8.5,
|
|
41
|
+
score: 0.1889,
|
|
42
|
+
isSkewed: true,
|
|
43
|
+
confidence: 0.81,
|
|
44
|
+
correctiveRotation: -8.5,
|
|
45
|
+
quality: 'moderate-skew',
|
|
46
|
+
details: { angle: 8.5, confidence: 0.81, width: 400, height: 260 }
|
|
47
|
+
}
|
|
48
|
+
*/
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Straighten a document
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import sharp from 'sharp';
|
|
55
|
+
import { analyzeSkew } from 'skew-score';
|
|
56
|
+
|
|
57
|
+
const result = await analyzeSkew(imageBuffer);
|
|
58
|
+
if (result.isSkewed && result.confidence > 0.5) {
|
|
59
|
+
const straightened = await sharp(imageBuffer)
|
|
60
|
+
.rotate(result.correctiveRotation, { background: '#ffffff' })
|
|
61
|
+
.toBuffer();
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Quick helpers
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { getSkewAngle, isSkewed } from 'skew-score';
|
|
69
|
+
|
|
70
|
+
const angle = await getSkewAngle(buffer); // e.g. -4.5
|
|
71
|
+
|
|
72
|
+
if (await isSkewed(buffer)) {
|
|
73
|
+
console.log('Please hold the camera level with your document.');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await isSkewed(buffer, 5); // custom threshold in degrees
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## API
|
|
80
|
+
|
|
81
|
+
### `analyzeSkew(input, options?): Promise<SkewResult>`
|
|
82
|
+
|
|
83
|
+
`input` is a file path, `Buffer` or `Uint8Array` in any format Sharp can read (JPEG, PNG, WebP, TIFF, AVIF, …).
|
|
84
|
+
|
|
85
|
+
| Field | Type | Description |
|
|
86
|
+
| --- | --- | --- |
|
|
87
|
+
| `angle` | `number` | Detected tilt in degrees. Positive means the content is rotated clockwise (the same convention as `sharp().rotate()`). `0` when confidence is below `minConfidence`. |
|
|
88
|
+
| `score` | `number` | `abs(angle) / 45`, clamped to 0–1. |
|
|
89
|
+
| `isSkewed` | `boolean` | `true` when `abs(angle)` is greater than `angleThreshold`. |
|
|
90
|
+
| `confidence` | `number` | 0–1. How strongly the best angle stands out from the others. `0` for blank or featureless images. |
|
|
91
|
+
| `correctiveRotation` | `number` | Degrees to pass to `sharp().rotate()` to straighten the image (`-angle`). |
|
|
92
|
+
| `quality` | `string` | `'straight'` (< 1°), `'slight-tilt'` (up to `angleThreshold`), `'moderate-skew'` (up to 15°) or `'severe-skew'`. |
|
|
93
|
+
| `details` | `SkewDetails` | `angle`, `confidence`, and the `width`/`height` actually analyzed after downsampling. |
|
|
94
|
+
|
|
95
|
+
### `getSkewAngle(input, options?): Promise<number>`
|
|
96
|
+
|
|
97
|
+
Returns only `angle`.
|
|
98
|
+
|
|
99
|
+
### `isSkewed(input, threshold?): Promise<boolean>`
|
|
100
|
+
|
|
101
|
+
Returns `isSkewed` using `threshold` (degrees, default `3`).
|
|
102
|
+
|
|
103
|
+
### `SkewOptions`
|
|
104
|
+
|
|
105
|
+
| Option | Default | Valid range | Description |
|
|
106
|
+
| --- | --- | --- | --- |
|
|
107
|
+
| `angleThreshold` | `3` | `>= 0` | Tilt above which `isSkewed` is `true`. |
|
|
108
|
+
| `minConfidence` | `0.2` | `0`–`1` | Below this, `angle` is reported as `0`. |
|
|
109
|
+
| `maxAngle` | `45` | `> 0`, `<= 90` | Search range is `[-maxAngle, +maxAngle]`. |
|
|
110
|
+
| `angleStep` | `0.5` | `> 0`, `<= maxAngle` | Search resolution in degrees. Smaller is more precise and slower. |
|
|
111
|
+
| `downsampleWidth` | `400` | integer `>= 16` | Images wider than this are resized before analysis. Smaller images are never enlarged. |
|
|
112
|
+
|
|
113
|
+
Invalid options throw a `RangeError`. Unreadable input rejects with Sharp's error.
|
|
114
|
+
|
|
115
|
+
## How it works
|
|
116
|
+
|
|
117
|
+
1. The image is downsampled, converted to grayscale, and run through a vertical Sobel filter so horizontal edges (text baselines, document borders) stand out.
|
|
118
|
+
2. For each candidate angle, edge pixels are projected onto a rotated vertical axis (a Radon transform at that angle).
|
|
119
|
+
3. When the projection axis matches the document's lines, edge energy collapses into a few sharp peaks. That maximizes the variance of the projection profile.
|
|
120
|
+
4. The angle with the highest variance wins. Confidence is how far that peak sits above the weakest angle.
|
|
121
|
+
|
|
122
|
+
## Limitations
|
|
123
|
+
|
|
124
|
+
- It needs line structure: text, ruled lines or document edges. Photos without dominant horizontal features get low confidence and report `0`.
|
|
125
|
+
- It measures in-plane rotation only, not perspective (keystone) distortion.
|
|
126
|
+
- Rotations beyond ±45° are ambiguous for line-based content (a 90° turn looks straight). Use EXIF orientation or a separate orientation check for those.
|
|
127
|
+
|
|
128
|
+
## Development
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
npm install
|
|
132
|
+
npm test # vitest
|
|
133
|
+
npm run typecheck
|
|
134
|
+
npm run build # tsup → dist/ (ESM + CJS + .d.ts)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT © [Vijay Misal](https://github.com/vjymisal0)
|
package/dist/index.cjs
CHANGED
|
@@ -8,13 +8,29 @@ var sharp__default = /*#__PURE__*/_interopDefault(sharp);
|
|
|
8
8
|
|
|
9
9
|
// src/detector.ts
|
|
10
10
|
function normalizeOptions(options) {
|
|
11
|
-
|
|
11
|
+
const opts = {
|
|
12
12
|
angleThreshold: options?.angleThreshold ?? 3,
|
|
13
13
|
minConfidence: options?.minConfidence ?? 0.2,
|
|
14
14
|
maxAngle: options?.maxAngle ?? 45,
|
|
15
15
|
angleStep: options?.angleStep ?? 0.5,
|
|
16
16
|
downsampleWidth: options?.downsampleWidth ?? 400
|
|
17
17
|
};
|
|
18
|
+
if (!Number.isFinite(opts.angleThreshold) || opts.angleThreshold < 0) {
|
|
19
|
+
throw new RangeError("angleThreshold must be a finite number >= 0.");
|
|
20
|
+
}
|
|
21
|
+
if (!Number.isFinite(opts.minConfidence) || opts.minConfidence < 0 || opts.minConfidence > 1) {
|
|
22
|
+
throw new RangeError("minConfidence must be between 0 and 1.");
|
|
23
|
+
}
|
|
24
|
+
if (!Number.isFinite(opts.maxAngle) || opts.maxAngle <= 0 || opts.maxAngle > 90) {
|
|
25
|
+
throw new RangeError("maxAngle must be greater than 0 and at most 90.");
|
|
26
|
+
}
|
|
27
|
+
if (!Number.isFinite(opts.angleStep) || opts.angleStep <= 0 || opts.angleStep > opts.maxAngle) {
|
|
28
|
+
throw new RangeError("angleStep must be greater than 0 and at most maxAngle.");
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(opts.downsampleWidth) || opts.downsampleWidth < 16) {
|
|
31
|
+
throw new RangeError("downsampleWidth must be an integer >= 16.");
|
|
32
|
+
}
|
|
33
|
+
return opts;
|
|
18
34
|
}
|
|
19
35
|
function evaluateProjectionVariance(edgeMap, width, height, angleDegrees) {
|
|
20
36
|
const rad = angleDegrees * Math.PI / 180;
|
|
@@ -22,8 +38,10 @@ function evaluateProjectionVariance(edgeMap, width, height, angleDegrees) {
|
|
|
22
38
|
const cos = Math.cos(rad);
|
|
23
39
|
const cx = width / 2;
|
|
24
40
|
const cy = height / 2;
|
|
25
|
-
const
|
|
26
|
-
const
|
|
41
|
+
const diagonal = Math.ceil(Math.hypot(width, height));
|
|
42
|
+
const binCount = diagonal + 1;
|
|
43
|
+
const offset = diagonal / 2;
|
|
44
|
+
const projectedRows = new Float64Array(binCount);
|
|
27
45
|
for (let y = 0; y < height; y++) {
|
|
28
46
|
const dy = y - cy;
|
|
29
47
|
const rowOffset = y * width;
|
|
@@ -31,27 +49,19 @@ function evaluateProjectionVariance(edgeMap, width, height, angleDegrees) {
|
|
|
31
49
|
const val = edgeMap[rowOffset + x];
|
|
32
50
|
if (val === 0) continue;
|
|
33
51
|
const dx = x - cx;
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
projectedRows[rotY] += val;
|
|
37
|
-
countPerBin[rotY] += 1;
|
|
38
|
-
}
|
|
52
|
+
const bin = Math.round(-dx * sin + dy * cos + offset);
|
|
53
|
+
projectedRows[bin] += val;
|
|
39
54
|
}
|
|
40
55
|
}
|
|
41
56
|
let sum = 0;
|
|
42
57
|
let sumSq = 0;
|
|
43
|
-
let
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
sum += val;
|
|
48
|
-
sumSq += val * val;
|
|
49
|
-
count++;
|
|
50
|
-
}
|
|
58
|
+
for (let i = 0; i < binCount; i++) {
|
|
59
|
+
const val = projectedRows[i];
|
|
60
|
+
sum += val;
|
|
61
|
+
sumSq += val * val;
|
|
51
62
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return sumSq / count - mean * mean;
|
|
63
|
+
const mean = sum / binCount;
|
|
64
|
+
return sumSq / binCount - mean * mean;
|
|
55
65
|
}
|
|
56
66
|
async function analyzeSkew(input, options) {
|
|
57
67
|
const opts = normalizeOptions(options);
|
|
@@ -82,7 +92,9 @@ async function analyzeSkew(input, options) {
|
|
|
82
92
|
for (let angle = startAngle; angle <= endAngle; angle += step) {
|
|
83
93
|
const roundedAngle = Math.round(angle * 100) / 100;
|
|
84
94
|
const variance = evaluateProjectionVariance(edges, width, height, roundedAngle);
|
|
85
|
-
|
|
95
|
+
const tolerance = Math.max(1e-9, Math.abs(maxVariance) * 1e-12);
|
|
96
|
+
const isTie = Math.abs(variance - maxVariance) <= tolerance;
|
|
97
|
+
if (variance > maxVariance && !isTie || isTie && Math.abs(roundedAngle) < Math.abs(bestAngle)) {
|
|
86
98
|
maxVariance = variance;
|
|
87
99
|
bestAngle = roundedAngle;
|
|
88
100
|
}
|
|
@@ -91,12 +103,12 @@ async function analyzeSkew(input, options) {
|
|
|
91
103
|
}
|
|
92
104
|
}
|
|
93
105
|
const baseline = minVariance === Infinity || minVariance < 0 ? 0 : minVariance;
|
|
94
|
-
const peakContrast = maxVariance > baseline
|
|
106
|
+
const peakContrast = maxVariance > baseline ? (maxVariance - baseline) / maxVariance : 0;
|
|
95
107
|
const confidence = Number(Math.min(1, Math.max(0, peakContrast)).toFixed(3));
|
|
96
108
|
const finalAngle = confidence >= opts.minConfidence ? bestAngle : 0;
|
|
97
109
|
const absAngle = Math.abs(finalAngle);
|
|
98
110
|
const score = Number(Math.min(1, absAngle / 45).toFixed(4));
|
|
99
|
-
const isSkewed2 = absAngle
|
|
111
|
+
const isSkewed2 = absAngle > opts.angleThreshold;
|
|
100
112
|
const correctiveRotation = finalAngle === 0 ? 0 : Number((-finalAngle).toFixed(2));
|
|
101
113
|
let quality;
|
|
102
114
|
if (absAngle < 1) {
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"sources":["../src/detector.ts"],"names":["sharp","isSkewed"],"mappings":"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","file":"index.cjs","sourcesContent":["import sharp from 'sharp';\nimport type { SkewOptions, SkewResult, SkewDetails } from './types.js';\n\nexport function normalizeOptions(options?: SkewOptions): Required<SkewOptions> {\n const opts = {\n angleThreshold: options?.angleThreshold ?? 3.0,\n minConfidence: options?.minConfidence ?? 0.20,\n maxAngle: options?.maxAngle ?? 45,\n angleStep: options?.angleStep ?? 0.5,\n downsampleWidth: options?.downsampleWidth ?? 400\n };\n\n if (!Number.isFinite(opts.angleThreshold) || opts.angleThreshold < 0) {\n throw new RangeError('angleThreshold must be a finite number >= 0.');\n }\n if (!Number.isFinite(opts.minConfidence) || opts.minConfidence < 0 || opts.minConfidence > 1) {\n throw new RangeError('minConfidence must be between 0 and 1.');\n }\n if (!Number.isFinite(opts.maxAngle) || opts.maxAngle <= 0 || opts.maxAngle > 90) {\n throw new RangeError('maxAngle must be greater than 0 and at most 90.');\n }\n // A zero or negative step would never terminate the angular sweep.\n if (!Number.isFinite(opts.angleStep) || opts.angleStep <= 0 || opts.angleStep > opts.maxAngle) {\n throw new RangeError('angleStep must be greater than 0 and at most maxAngle.');\n }\n if (!Number.isInteger(opts.downsampleWidth) || opts.downsampleWidth < 16) {\n throw new RangeError('downsampleWidth must be an integer >= 16.');\n }\n\n return opts;\n}\n\n/**\n * Computes horizontal projection profile variance at a specific rotation angle.\n * When text lines or document edges are aligned horizontally, the variance of row sums is maximized.\n *\n * Bins span the image diagonal so no edge pixels are dropped at any angle, and the\n * variance is taken over every bin. With a constant total edge energy this makes\n * the variance proportional to how concentrated the projection is, so perfectly\n * aligned rows score highest instead of collapsing to zero.\n */\nfunction evaluateProjectionVariance(\n edgeMap: Uint8Array,\n width: number,\n height: number,\n angleDegrees: number\n): number {\n const rad = (angleDegrees * Math.PI) / 180;\n const sin = Math.sin(rad);\n const cos = Math.cos(rad);\n\n const cx = width / 2;\n const cy = height / 2;\n\n const diagonal = Math.ceil(Math.hypot(width, height));\n const binCount = diagonal + 1;\n const offset = diagonal / 2;\n const projectedRows = new Float64Array(binCount);\n\n for (let y = 0; y < height; y++) {\n const dy = y - cy;\n const rowOffset = y * width;\n for (let x = 0; x < width; x++) {\n const val = edgeMap[rowOffset + x]!;\n if (val === 0) continue;\n\n const dx = x - cx;\n // Rotated Y coordinate, shifted into the diagonal-sized bin range\n const bin = Math.round(-dx * sin + dy * cos + offset);\n projectedRows[bin]! += val;\n }\n }\n\n let sum = 0;\n let sumSq = 0;\n for (let i = 0; i < binCount; i++) {\n const val = projectedRows[i]!;\n sum += val;\n sumSq += val * val;\n }\n\n const mean = sum / binCount;\n return (sumSq / binCount) - (mean * mean);\n}\n\n/**\n * Analyzes document and text skew using Radon / Horizontal Projection Profile variance.\n *\n * @param input - File path, Buffer, or Uint8Array representing an image.\n * @param options - Configurable parameters.\n * @returns Promise resolving to a SkewResult.\n */\nexport async function analyzeSkew(\n input: string | Buffer | Uint8Array,\n options?: SkewOptions\n): Promise<SkewResult> {\n const opts = normalizeOptions(options);\n\n // Load image, grayscale, downsample\n const { data: rawGray, info } = await sharp(input)\n .resize({ width: opts.downsampleWidth, withoutEnlargement: true })\n .grayscale()\n .raw()\n .toBuffer({ resolveWithObject: true });\n\n const width = info.width;\n const height = info.height;\n const totalPixels = width * height;\n\n if (totalPixels === 0) {\n throw new Error('Image contains no pixel data.');\n }\n\n // Fast Sobel horizontal edge filtering (horizontal edges are most sensitive to tilt)\n const edges = new Uint8Array(totalPixels);\n for (let y = 1; y < height - 1; y++) {\n const rPrev = (y - 1) * width;\n const rCurr = y * width;\n const rNext = (y + 1) * width;\n\n for (let x = 1; x < width - 1; x++) {\n // Sobel Y (horizontal gradients)\n const gy =\n -(rawGray[rPrev + x - 1]! + 2 * rawGray[rPrev + x]! + rawGray[rPrev + x + 1]!) +\n (rawGray[rNext + x - 1]! + 2 * rawGray[rNext + x]! + rawGray[rNext + x + 1]!);\n\n const mag = Math.abs(gy);\n // Threshold edges to binary structure\n edges[rCurr + x] = mag > 45 ? 255 : 0;\n }\n }\n\n // Angular sweep to find peak variance\n let bestAngle = 0;\n let maxVariance = -1;\n let minVariance = Infinity;\n\n const startAngle = -opts.maxAngle;\n const endAngle = opts.maxAngle;\n const step = opts.angleStep;\n\n for (let angle = startAngle; angle <= endAngle; angle += step) {\n const roundedAngle = Math.round(angle * 100) / 100;\n const variance = evaluateProjectionVariance(edges, width, height, roundedAngle);\n\n // Neighbouring angles can project identically on small images; prefer the\n // angle closest to 0 so a straight document is not reported as tilted.\n const tolerance = Math.max(1e-9, Math.abs(maxVariance) * 1e-12);\n const isTie = Math.abs(variance - maxVariance) <= tolerance;\n if (\n (variance > maxVariance && !isTie) ||\n (isTie && Math.abs(roundedAngle) < Math.abs(bestAngle))\n ) {\n maxVariance = variance;\n bestAngle = roundedAngle;\n }\n if (variance < minVariance) {\n minVariance = variance;\n }\n }\n\n // Compute confidence based on contrast between peak variance and baseline variance\n const baseline = (minVariance === Infinity || minVariance < 0) ? 0 : minVariance;\n const peakContrast = maxVariance > baseline\n ? (maxVariance - baseline) / maxVariance\n : 0;\n\n const confidence = Number(Math.min(1.0, Math.max(0.0, peakContrast)).toFixed(3));\n\n // If confidence is too low (e.g. blank page or uniform texture), assume 0 skew\n const finalAngle = confidence >= opts.minConfidence ? bestAngle : 0;\n const absAngle = Math.abs(finalAngle);\n\n // Normalized score (0.0 = 0 deg, 1.0 = 45+ deg)\n const score = Number(Math.min(1.0, absAngle / 45).toFixed(4));\n const isSkewed = absAngle > opts.angleThreshold;\n\n // Corrective rotation is opposite of detected angle\n const correctiveRotation = finalAngle === 0 ? 0 : Number((-finalAngle).toFixed(2));\n\n let quality: 'straight' | 'slight-tilt' | 'moderate-skew' | 'severe-skew';\n if (absAngle < 1.0) {\n quality = 'straight';\n } else if (absAngle <= opts.angleThreshold) {\n quality = 'slight-tilt';\n } else if (absAngle <= 15.0) {\n quality = 'moderate-skew';\n } else {\n quality = 'severe-skew';\n }\n\n const details: SkewDetails = {\n angle: finalAngle,\n confidence,\n width,\n height\n };\n\n return {\n angle: finalAngle,\n score,\n isSkewed,\n confidence,\n correctiveRotation,\n quality,\n details\n };\n}\n\n/**\n * Returns only the detected skew angle in degrees.\n */\nexport async function getSkewAngle(\n input: string | Buffer | Uint8Array,\n options?: SkewOptions\n): Promise<number> {\n const res = await analyzeSkew(input, options);\n return res.angle;\n}\n\n/**\n * Returns true if the image exceeds the skew threshold.\n */\nexport async function isSkewed(\n input: string | Buffer | Uint8Array,\n threshold?: number\n): Promise<boolean> {\n const options: SkewOptions = {};\n if (threshold !== undefined) {\n options.angleThreshold = threshold;\n }\n const res = await analyzeSkew(input, options);\n return res.isSkewed;\n}\n"]}
|
package/dist/index.d.cts
CHANGED
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -2,13 +2,29 @@ import sharp from 'sharp';
|
|
|
2
2
|
|
|
3
3
|
// src/detector.ts
|
|
4
4
|
function normalizeOptions(options) {
|
|
5
|
-
|
|
5
|
+
const opts = {
|
|
6
6
|
angleThreshold: options?.angleThreshold ?? 3,
|
|
7
7
|
minConfidence: options?.minConfidence ?? 0.2,
|
|
8
8
|
maxAngle: options?.maxAngle ?? 45,
|
|
9
9
|
angleStep: options?.angleStep ?? 0.5,
|
|
10
10
|
downsampleWidth: options?.downsampleWidth ?? 400
|
|
11
11
|
};
|
|
12
|
+
if (!Number.isFinite(opts.angleThreshold) || opts.angleThreshold < 0) {
|
|
13
|
+
throw new RangeError("angleThreshold must be a finite number >= 0.");
|
|
14
|
+
}
|
|
15
|
+
if (!Number.isFinite(opts.minConfidence) || opts.minConfidence < 0 || opts.minConfidence > 1) {
|
|
16
|
+
throw new RangeError("minConfidence must be between 0 and 1.");
|
|
17
|
+
}
|
|
18
|
+
if (!Number.isFinite(opts.maxAngle) || opts.maxAngle <= 0 || opts.maxAngle > 90) {
|
|
19
|
+
throw new RangeError("maxAngle must be greater than 0 and at most 90.");
|
|
20
|
+
}
|
|
21
|
+
if (!Number.isFinite(opts.angleStep) || opts.angleStep <= 0 || opts.angleStep > opts.maxAngle) {
|
|
22
|
+
throw new RangeError("angleStep must be greater than 0 and at most maxAngle.");
|
|
23
|
+
}
|
|
24
|
+
if (!Number.isInteger(opts.downsampleWidth) || opts.downsampleWidth < 16) {
|
|
25
|
+
throw new RangeError("downsampleWidth must be an integer >= 16.");
|
|
26
|
+
}
|
|
27
|
+
return opts;
|
|
12
28
|
}
|
|
13
29
|
function evaluateProjectionVariance(edgeMap, width, height, angleDegrees) {
|
|
14
30
|
const rad = angleDegrees * Math.PI / 180;
|
|
@@ -16,8 +32,10 @@ function evaluateProjectionVariance(edgeMap, width, height, angleDegrees) {
|
|
|
16
32
|
const cos = Math.cos(rad);
|
|
17
33
|
const cx = width / 2;
|
|
18
34
|
const cy = height / 2;
|
|
19
|
-
const
|
|
20
|
-
const
|
|
35
|
+
const diagonal = Math.ceil(Math.hypot(width, height));
|
|
36
|
+
const binCount = diagonal + 1;
|
|
37
|
+
const offset = diagonal / 2;
|
|
38
|
+
const projectedRows = new Float64Array(binCount);
|
|
21
39
|
for (let y = 0; y < height; y++) {
|
|
22
40
|
const dy = y - cy;
|
|
23
41
|
const rowOffset = y * width;
|
|
@@ -25,27 +43,19 @@ function evaluateProjectionVariance(edgeMap, width, height, angleDegrees) {
|
|
|
25
43
|
const val = edgeMap[rowOffset + x];
|
|
26
44
|
if (val === 0) continue;
|
|
27
45
|
const dx = x - cx;
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
projectedRows[rotY] += val;
|
|
31
|
-
countPerBin[rotY] += 1;
|
|
32
|
-
}
|
|
46
|
+
const bin = Math.round(-dx * sin + dy * cos + offset);
|
|
47
|
+
projectedRows[bin] += val;
|
|
33
48
|
}
|
|
34
49
|
}
|
|
35
50
|
let sum = 0;
|
|
36
51
|
let sumSq = 0;
|
|
37
|
-
let
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
sum += val;
|
|
42
|
-
sumSq += val * val;
|
|
43
|
-
count++;
|
|
44
|
-
}
|
|
52
|
+
for (let i = 0; i < binCount; i++) {
|
|
53
|
+
const val = projectedRows[i];
|
|
54
|
+
sum += val;
|
|
55
|
+
sumSq += val * val;
|
|
45
56
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return sumSq / count - mean * mean;
|
|
57
|
+
const mean = sum / binCount;
|
|
58
|
+
return sumSq / binCount - mean * mean;
|
|
49
59
|
}
|
|
50
60
|
async function analyzeSkew(input, options) {
|
|
51
61
|
const opts = normalizeOptions(options);
|
|
@@ -76,7 +86,9 @@ async function analyzeSkew(input, options) {
|
|
|
76
86
|
for (let angle = startAngle; angle <= endAngle; angle += step) {
|
|
77
87
|
const roundedAngle = Math.round(angle * 100) / 100;
|
|
78
88
|
const variance = evaluateProjectionVariance(edges, width, height, roundedAngle);
|
|
79
|
-
|
|
89
|
+
const tolerance = Math.max(1e-9, Math.abs(maxVariance) * 1e-12);
|
|
90
|
+
const isTie = Math.abs(variance - maxVariance) <= tolerance;
|
|
91
|
+
if (variance > maxVariance && !isTie || isTie && Math.abs(roundedAngle) < Math.abs(bestAngle)) {
|
|
80
92
|
maxVariance = variance;
|
|
81
93
|
bestAngle = roundedAngle;
|
|
82
94
|
}
|
|
@@ -85,12 +97,12 @@ async function analyzeSkew(input, options) {
|
|
|
85
97
|
}
|
|
86
98
|
}
|
|
87
99
|
const baseline = minVariance === Infinity || minVariance < 0 ? 0 : minVariance;
|
|
88
|
-
const peakContrast = maxVariance > baseline
|
|
100
|
+
const peakContrast = maxVariance > baseline ? (maxVariance - baseline) / maxVariance : 0;
|
|
89
101
|
const confidence = Number(Math.min(1, Math.max(0, peakContrast)).toFixed(3));
|
|
90
102
|
const finalAngle = confidence >= opts.minConfidence ? bestAngle : 0;
|
|
91
103
|
const absAngle = Math.abs(finalAngle);
|
|
92
104
|
const score = Number(Math.min(1, absAngle / 45).toFixed(4));
|
|
93
|
-
const isSkewed2 = absAngle
|
|
105
|
+
const isSkewed2 = absAngle > opts.angleThreshold;
|
|
94
106
|
const correctiveRotation = finalAngle === 0 ? 0 : Number((-finalAngle).toFixed(2));
|
|
95
107
|
let quality;
|
|
96
108
|
if (absAngle < 1) {
|
package/dist/index.js.map
CHANGED
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"sources":["../src/detector.ts"],"names":["isSkewed"],"mappings":"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","file":"index.js","sourcesContent":["import sharp from 'sharp';\nimport type { SkewOptions, SkewResult, SkewDetails } from './types.js';\n\nexport function normalizeOptions(options?: SkewOptions): Required<SkewOptions> {\n const opts = {\n angleThreshold: options?.angleThreshold ?? 3.0,\n minConfidence: options?.minConfidence ?? 0.20,\n maxAngle: options?.maxAngle ?? 45,\n angleStep: options?.angleStep ?? 0.5,\n downsampleWidth: options?.downsampleWidth ?? 400\n };\n\n if (!Number.isFinite(opts.angleThreshold) || opts.angleThreshold < 0) {\n throw new RangeError('angleThreshold must be a finite number >= 0.');\n }\n if (!Number.isFinite(opts.minConfidence) || opts.minConfidence < 0 || opts.minConfidence > 1) {\n throw new RangeError('minConfidence must be between 0 and 1.');\n }\n if (!Number.isFinite(opts.maxAngle) || opts.maxAngle <= 0 || opts.maxAngle > 90) {\n throw new RangeError('maxAngle must be greater than 0 and at most 90.');\n }\n // A zero or negative step would never terminate the angular sweep.\n if (!Number.isFinite(opts.angleStep) || opts.angleStep <= 0 || opts.angleStep > opts.maxAngle) {\n throw new RangeError('angleStep must be greater than 0 and at most maxAngle.');\n }\n if (!Number.isInteger(opts.downsampleWidth) || opts.downsampleWidth < 16) {\n throw new RangeError('downsampleWidth must be an integer >= 16.');\n }\n\n return opts;\n}\n\n/**\n * Computes horizontal projection profile variance at a specific rotation angle.\n * When text lines or document edges are aligned horizontally, the variance of row sums is maximized.\n *\n * Bins span the image diagonal so no edge pixels are dropped at any angle, and the\n * variance is taken over every bin. With a constant total edge energy this makes\n * the variance proportional to how concentrated the projection is, so perfectly\n * aligned rows score highest instead of collapsing to zero.\n */\nfunction evaluateProjectionVariance(\n edgeMap: Uint8Array,\n width: number,\n height: number,\n angleDegrees: number\n): number {\n const rad = (angleDegrees * Math.PI) / 180;\n const sin = Math.sin(rad);\n const cos = Math.cos(rad);\n\n const cx = width / 2;\n const cy = height / 2;\n\n const diagonal = Math.ceil(Math.hypot(width, height));\n const binCount = diagonal + 1;\n const offset = diagonal / 2;\n const projectedRows = new Float64Array(binCount);\n\n for (let y = 0; y < height; y++) {\n const dy = y - cy;\n const rowOffset = y * width;\n for (let x = 0; x < width; x++) {\n const val = edgeMap[rowOffset + x]!;\n if (val === 0) continue;\n\n const dx = x - cx;\n // Rotated Y coordinate, shifted into the diagonal-sized bin range\n const bin = Math.round(-dx * sin + dy * cos + offset);\n projectedRows[bin]! += val;\n }\n }\n\n let sum = 0;\n let sumSq = 0;\n for (let i = 0; i < binCount; i++) {\n const val = projectedRows[i]!;\n sum += val;\n sumSq += val * val;\n }\n\n const mean = sum / binCount;\n return (sumSq / binCount) - (mean * mean);\n}\n\n/**\n * Analyzes document and text skew using Radon / Horizontal Projection Profile variance.\n *\n * @param input - File path, Buffer, or Uint8Array representing an image.\n * @param options - Configurable parameters.\n * @returns Promise resolving to a SkewResult.\n */\nexport async function analyzeSkew(\n input: string | Buffer | Uint8Array,\n options?: SkewOptions\n): Promise<SkewResult> {\n const opts = normalizeOptions(options);\n\n // Load image, grayscale, downsample\n const { data: rawGray, info } = await sharp(input)\n .resize({ width: opts.downsampleWidth, withoutEnlargement: true })\n .grayscale()\n .raw()\n .toBuffer({ resolveWithObject: true });\n\n const width = info.width;\n const height = info.height;\n const totalPixels = width * height;\n\n if (totalPixels === 0) {\n throw new Error('Image contains no pixel data.');\n }\n\n // Fast Sobel horizontal edge filtering (horizontal edges are most sensitive to tilt)\n const edges = new Uint8Array(totalPixels);\n for (let y = 1; y < height - 1; y++) {\n const rPrev = (y - 1) * width;\n const rCurr = y * width;\n const rNext = (y + 1) * width;\n\n for (let x = 1; x < width - 1; x++) {\n // Sobel Y (horizontal gradients)\n const gy =\n -(rawGray[rPrev + x - 1]! + 2 * rawGray[rPrev + x]! + rawGray[rPrev + x + 1]!) +\n (rawGray[rNext + x - 1]! + 2 * rawGray[rNext + x]! + rawGray[rNext + x + 1]!);\n\n const mag = Math.abs(gy);\n // Threshold edges to binary structure\n edges[rCurr + x] = mag > 45 ? 255 : 0;\n }\n }\n\n // Angular sweep to find peak variance\n let bestAngle = 0;\n let maxVariance = -1;\n let minVariance = Infinity;\n\n const startAngle = -opts.maxAngle;\n const endAngle = opts.maxAngle;\n const step = opts.angleStep;\n\n for (let angle = startAngle; angle <= endAngle; angle += step) {\n const roundedAngle = Math.round(angle * 100) / 100;\n const variance = evaluateProjectionVariance(edges, width, height, roundedAngle);\n\n // Neighbouring angles can project identically on small images; prefer the\n // angle closest to 0 so a straight document is not reported as tilted.\n const tolerance = Math.max(1e-9, Math.abs(maxVariance) * 1e-12);\n const isTie = Math.abs(variance - maxVariance) <= tolerance;\n if (\n (variance > maxVariance && !isTie) ||\n (isTie && Math.abs(roundedAngle) < Math.abs(bestAngle))\n ) {\n maxVariance = variance;\n bestAngle = roundedAngle;\n }\n if (variance < minVariance) {\n minVariance = variance;\n }\n }\n\n // Compute confidence based on contrast between peak variance and baseline variance\n const baseline = (minVariance === Infinity || minVariance < 0) ? 0 : minVariance;\n const peakContrast = maxVariance > baseline\n ? (maxVariance - baseline) / maxVariance\n : 0;\n\n const confidence = Number(Math.min(1.0, Math.max(0.0, peakContrast)).toFixed(3));\n\n // If confidence is too low (e.g. blank page or uniform texture), assume 0 skew\n const finalAngle = confidence >= opts.minConfidence ? bestAngle : 0;\n const absAngle = Math.abs(finalAngle);\n\n // Normalized score (0.0 = 0 deg, 1.0 = 45+ deg)\n const score = Number(Math.min(1.0, absAngle / 45).toFixed(4));\n const isSkewed = absAngle > opts.angleThreshold;\n\n // Corrective rotation is opposite of detected angle\n const correctiveRotation = finalAngle === 0 ? 0 : Number((-finalAngle).toFixed(2));\n\n let quality: 'straight' | 'slight-tilt' | 'moderate-skew' | 'severe-skew';\n if (absAngle < 1.0) {\n quality = 'straight';\n } else if (absAngle <= opts.angleThreshold) {\n quality = 'slight-tilt';\n } else if (absAngle <= 15.0) {\n quality = 'moderate-skew';\n } else {\n quality = 'severe-skew';\n }\n\n const details: SkewDetails = {\n angle: finalAngle,\n confidence,\n width,\n height\n };\n\n return {\n angle: finalAngle,\n score,\n isSkewed,\n confidence,\n correctiveRotation,\n quality,\n details\n };\n}\n\n/**\n * Returns only the detected skew angle in degrees.\n */\nexport async function getSkewAngle(\n input: string | Buffer | Uint8Array,\n options?: SkewOptions\n): Promise<number> {\n const res = await analyzeSkew(input, options);\n return res.angle;\n}\n\n/**\n * Returns true if the image exceeds the skew threshold.\n */\nexport async function isSkewed(\n input: string | Buffer | Uint8Array,\n threshold?: number\n): Promise<boolean> {\n const options: SkewOptions = {};\n if (threshold !== undefined) {\n options.angleThreshold = threshold;\n }\n const res = await analyzeSkew(input, options);\n return res.isSkewed;\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,66 +1,80 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "skew-score",
|
|
3
|
-
"version": "1.
|
|
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
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
"
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "skew-score",
|
|
3
|
+
"version": "1.1.1",
|
|
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
|
+
"import": {
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"require": {
|
|
16
|
+
"types": "./dist/index.d.cts",
|
|
17
|
+
"default": "./dist/index.cjs"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"CHANGELOG.md"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsup",
|
|
30
|
+
"dev": "tsup --watch",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm test"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"skew",
|
|
38
|
+
"skew-score",
|
|
39
|
+
"deskew",
|
|
40
|
+
"rotation",
|
|
41
|
+
"tilt-detection",
|
|
42
|
+
"image-processing",
|
|
43
|
+
"ocr-preprocessing",
|
|
44
|
+
"kyc-document",
|
|
45
|
+
"sharp",
|
|
46
|
+
"blur-score",
|
|
47
|
+
"exposure-score",
|
|
48
|
+
"glare-score",
|
|
49
|
+
"contrast-score"
|
|
50
|
+
],
|
|
51
|
+
"author": "Vijay Misal <misalvijay153@gmail.com>",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/vjymisal0/skew-score.git"
|
|
55
|
+
},
|
|
56
|
+
"bugs": {
|
|
57
|
+
"url": "https://github.com/vjymisal0/skew-score/issues"
|
|
58
|
+
},
|
|
59
|
+
"homepage": "https://github.com/vjymisal0/skew-score#readme",
|
|
60
|
+
"license": "MIT",
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"sharp": "0.35.5"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/node": "^26.6.4",
|
|
66
|
+
"tsup": "^8.5.1",
|
|
67
|
+
"typescript": "~5.9.3",
|
|
68
|
+
"vitest": "^5.0.3"
|
|
69
|
+
},
|
|
70
|
+
"engines": {
|
|
71
|
+
"node": ">=20.9.0"
|
|
72
|
+
},
|
|
73
|
+
"overrides": {
|
|
74
|
+
"esbuild": "^0.28.2"
|
|
75
|
+
},
|
|
76
|
+
"sideEffects": false,
|
|
77
|
+
"publishConfig": {
|
|
78
|
+
"access": "public"
|
|
79
|
+
}
|
|
80
|
+
}
|