skew-score 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## 1.1.0
4
+
5
+ ### Fixed
6
+
7
+ - 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°.
8
+ - `angleStep: 0` or a negative step caused an infinite loop. All options are now validated and invalid values throw a `RangeError`.
9
+ - `isSkewed` was `true` at exactly `angleThreshold` while `quality` said `'slight-tilt'`. It is now `true` only when the angle exceeds the threshold, as documented.
10
+ - Documented `minConfidence` default corrected to `0.2`.
11
+
12
+ ### Changed
13
+
14
+ - Upgraded Sharp to 0.35.5.
15
+ - Raised the minimum supported Node.js version to 20.9 (Node 18 is end-of-life).
16
+ - Upgraded dev tooling (Vitest 5, TypeScript 5.9, tsup 8.5) and resolved all audit advisories.
17
+ - Expanded the test suite from 3 to 37 tests and rewrote the README with a full API reference.
18
+ - 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.
19
+
20
+ ## 1.0.0
21
+
22
+ - 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
- > 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
- [![npm version](https://img.shields.io/npm/v/skew-score.svg)](https://www.npmjs.com/package/skew-score)
6
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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)
1
+ # skew-score
2
+
3
+ [![npm version](https://img.shields.io/npm/v/skew-score.svg?style=flat-square)](https://www.npmjs.com/package/skew-score)
4
+ [![CI](https://github.com/vjymisal0/skew-score/actions/workflows/ci.yml/badge.svg)](https://github.com/vjymisal0/skew-score/actions/workflows/ci.yml)
5
+ [![license](https://img.shields.io/npm/l/skew-score.svg?style=flat-square)](https://github.com/vjymisal0/skew-score/blob/main/LICENSE)
6
+ [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg?style=flat-square)](https://www.typescriptlang.org)
7
+ [![Downloads](https://img.shields.io/npm/dm/skew-score.svg?style=flat-square)](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
- return {
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 projectedRows = new Float64Array(height);
26
- const countPerBin = new Int32Array(height);
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 rotY = Math.round(-dx * sin + dy * cos + cy);
35
- if (rotY >= 0 && rotY < height) {
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 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
- }
58
+ for (let i = 0; i < binCount; i++) {
59
+ const val = projectedRows[i];
60
+ sum += val;
61
+ sumSq += val * val;
51
62
  }
52
- if (count <= 1) return 0;
53
- const mean = sum / count;
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
- if (variance > maxVariance) {
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 && baseline > 0 ? (maxVariance - baseline) / maxVariance : 0;
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 >= opts.angleThreshold;
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) {
@@ -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
@@ -24,7 +24,7 @@ interface SkewOptions {
24
24
  angleThreshold?: number;
25
25
  /**
26
26
  * Minimum confidence score (0.0 to 1.0) required to consider skew detection valid.
27
- * @default 0.25
27
+ * @default 0.20
28
28
  */
29
29
  minConfidence?: number;
30
30
  /**
package/dist/index.d.ts CHANGED
@@ -24,7 +24,7 @@ interface SkewOptions {
24
24
  angleThreshold?: number;
25
25
  /**
26
26
  * Minimum confidence score (0.0 to 1.0) required to consider skew detection valid.
27
- * @default 0.25
27
+ * @default 0.20
28
28
  */
29
29
  minConfidence?: number;
30
30
  /**
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
- return {
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 projectedRows = new Float64Array(height);
20
- const countPerBin = new Int32Array(height);
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 rotY = Math.round(-dx * sin + dy * cos + cy);
29
- if (rotY >= 0 && rotY < height) {
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 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
- }
52
+ for (let i = 0; i < binCount; i++) {
53
+ const val = projectedRows[i];
54
+ sum += val;
55
+ sumSq += val * val;
45
56
  }
46
- if (count <= 1) return 0;
47
- const mean = sum / count;
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
- if (variance > maxVariance) {
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 && baseline > 0 ? (maxVariance - baseline) / maxVariance : 0;
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 >= opts.angleThreshold;
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":";;;AAGO,SAAS,iBAAiB,OAAA,EAA8C;AAC7E,EAAA,MAAM,IAAA,GAAO;AAAA,IACX,cAAA,EAAgB,SAAS,cAAA,IAAkB,CAAA;AAAA,IAC3C,aAAA,EAAe,SAAS,aAAA,IAAiB,GAAA;AAAA,IACzC,QAAA,EAAU,SAAS,QAAA,IAAY,EAAA;AAAA,IAC/B,SAAA,EAAW,SAAS,SAAA,IAAa,GAAA;AAAA,IACjC,eAAA,EAAiB,SAAS,eAAA,IAAmB;AAAA,GAC/C;AAEA,EAAA,IAAI,CAAC,OAAO,QAAA,CAAS,IAAA,CAAK,cAAc,CAAA,IAAK,IAAA,CAAK,iBAAiB,CAAA,EAAG;AACpE,IAAA,MAAM,IAAI,WAAW,8CAA8C,CAAA;AAAA,EACrE;AACA,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,IAAA,CAAK,aAAa,CAAA,IAAK,IAAA,CAAK,aAAA,GAAgB,CAAA,IAAK,IAAA,CAAK,aAAA,GAAgB,CAAA,EAAG;AAC5F,IAAA,MAAM,IAAI,WAAW,wCAAwC,CAAA;AAAA,EAC/D;AACA,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,IAAA,CAAK,QAAQ,CAAA,IAAK,IAAA,CAAK,QAAA,IAAY,CAAA,IAAK,IAAA,CAAK,QAAA,GAAW,EAAA,EAAI;AAC/E,IAAA,MAAM,IAAI,WAAW,iDAAiD,CAAA;AAAA,EACxE;AAEA,EAAA,IAAI,CAAC,MAAA,CAAO,QAAA,CAAS,IAAA,CAAK,SAAS,CAAA,IAAK,IAAA,CAAK,SAAA,IAAa,CAAA,IAAK,IAAA,CAAK,SAAA,GAAY,IAAA,CAAK,QAAA,EAAU;AAC7F,IAAA,MAAM,IAAI,WAAW,wDAAwD,CAAA;AAAA,EAC/E;AACA,EAAA,IAAI,CAAC,OAAO,SAAA,CAAU,IAAA,CAAK,eAAe,CAAA,IAAK,IAAA,CAAK,kBAAkB,EAAA,EAAI;AACxE,IAAA,MAAM,IAAI,WAAW,2CAA2C,CAAA;AAAA,EAClE;AAEA,EAAA,OAAO,IAAA;AACT;AAWA,SAAS,0BAAA,CACP,OAAA,EACA,KAAA,EACA,MAAA,EACA,YAAA,EACQ;AACR,EAAA,MAAM,GAAA,GAAO,YAAA,GAAe,IAAA,CAAK,EAAA,GAAM,GAAA;AACvC,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,GAAG,CAAA;AACxB,EAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,GAAG,CAAA;AAExB,EAAA,MAAM,KAAK,KAAA,GAAQ,CAAA;AACnB,EAAA,MAAM,KAAK,MAAA,GAAS,CAAA;AAEpB,EAAA,MAAM,WAAW,IAAA,CAAK,IAAA,CAAK,KAAK,KAAA,CAAM,KAAA,EAAO,MAAM,CAAC,CAAA;AACpD,EAAA,MAAM,WAAW,QAAA,GAAW,CAAA;AAC5B,EAAA,MAAM,SAAS,QAAA,GAAW,CAAA;AAC1B,EAAA,MAAM,aAAA,GAAgB,IAAI,YAAA,CAAa,QAAQ,CAAA;AAE/C,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,MAAA,EAAQ,CAAA,EAAA,EAAK;AAC/B,IAAA,MAAM,KAAK,CAAA,GAAI,EAAA;AACf,IAAA,MAAM,YAAY,CAAA,GAAI,KAAA;AACtB,IAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,KAAA,EAAO,CAAA,EAAA,EAAK;AAC9B,MAAA,MAAM,GAAA,GAAM,OAAA,CAAQ,SAAA,GAAY,CAAC,CAAA;AACjC,MAAA,IAAI,QAAQ,CAAA,EAAG;AAEf,MAAA,MAAM,KAAK,CAAA,GAAI,EAAA;AAEf,MAAA,MAAM,GAAA,GAAM,KAAK,KAAA,CAAM,CAAC,KAAK,GAAA,GAAM,EAAA,GAAK,MAAM,MAAM,CAAA;AACpD,MAAA,aAAA,CAAc,GAAG,CAAA,IAAM,GAAA;AAAA,IACzB;AAAA,EACF;AAEA,EAAA,IAAI,GAAA,GAAM,CAAA;AACV,EAAA,IAAI,KAAA,GAAQ,CAAA;AACZ,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,QAAA,EAAU,CAAA,EAAA,EAAK;AACjC,IAAA,MAAM,GAAA,GAAM,cAAc,CAAC,CAAA;AAC3B,IAAA,GAAA,IAAO,GAAA;AACP,IAAA,KAAA,IAAS,GAAA,GAAM,GAAA;AAAA,EACjB;AAEA,EAAA,MAAM,OAAO,GAAA,GAAM,QAAA;AACnB,EAAA,OAAQ,KAAA,GAAQ,WAAa,IAAA,GAAO,IAAA;AACtC;AASA,eAAsB,WAAA,CACpB,OACA,OAAA,EACqB;AACrB,EAAA,MAAM,IAAA,GAAO,iBAAiB,OAAO,CAAA;AAGrC,EAAA,MAAM,EAAE,IAAA,EAAM,OAAA,EAAS,IAAA,EAAK,GAAI,MAAM,KAAA,CAAM,KAAK,CAAA,CAC9C,MAAA,CAAO,EAAE,KAAA,EAAO,IAAA,CAAK,iBAAiB,kBAAA,EAAoB,IAAA,EAAM,CAAA,CAChE,SAAA,EAAU,CACV,GAAA,EAAI,CACJ,QAAA,CAAS,EAAE,iBAAA,EAAmB,IAAA,EAAM,CAAA;AAEvC,EAAA,MAAM,QAAQ,IAAA,CAAK,KAAA;AACnB,EAAA,MAAM,SAAS,IAAA,CAAK,MAAA;AACpB,EAAA,MAAM,cAAc,KAAA,GAAQ,MAAA;AAE5B,EAAA,IAAI,gBAAgB,CAAA,EAAG;AACrB,IAAA,MAAM,IAAI,MAAM,+BAA+B,CAAA;AAAA,EACjD;AAGA,EAAA,MAAM,KAAA,GAAQ,IAAI,UAAA,CAAW,WAAW,CAAA;AACxC,EAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,MAAA,GAAS,GAAG,CAAA,EAAA,EAAK;AACnC,IAAA,MAAM,KAAA,GAAA,CAAS,IAAI,CAAA,IAAK,KAAA;AACxB,IAAA,MAAM,QAAQ,CAAA,GAAI,KAAA;AAClB,IAAA,MAAM,KAAA,GAAA,CAAS,IAAI,CAAA,IAAK,KAAA;AAExB,IAAA,KAAA,IAAS,CAAA,GAAI,CAAA,EAAG,CAAA,GAAI,KAAA,GAAQ,GAAG,CAAA,EAAA,EAAK;AAElC,MAAA,MAAM,EAAA,GACJ,EAAE,OAAA,CAAQ,KAAA,GAAQ,CAAA,GAAI,CAAC,CAAA,GAAK,CAAA,GAAI,OAAA,CAAQ,KAAA,GAAQ,CAAC,CAAA,GAAK,QAAQ,KAAA,GAAQ,CAAA,GAAI,CAAC,CAAA,CAAA,IAC1E,OAAA,CAAQ,KAAA,GAAQ,CAAA,GAAI,CAAC,CAAA,GAAK,CAAA,GAAI,OAAA,CAAQ,KAAA,GAAQ,CAAC,CAAA,GAAK,OAAA,CAAQ,KAAA,GAAQ,IAAI,CAAC,CAAA,CAAA;AAE5E,MAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,EAAE,CAAA;AAEvB,MAAA,KAAA,CAAM,KAAA,GAAQ,CAAC,CAAA,GAAI,GAAA,GAAM,KAAK,GAAA,GAAM,CAAA;AAAA,IACtC;AAAA,EACF;AAGA,EAAA,IAAI,SAAA,GAAY,CAAA;AAChB,EAAA,IAAI,WAAA,GAAc,EAAA;AAClB,EAAA,IAAI,WAAA,GAAc,QAAA;AAElB,EAAA,MAAM,UAAA,GAAa,CAAC,IAAA,CAAK,QAAA;AACzB,EAAA,MAAM,WAAW,IAAA,CAAK,QAAA;AACtB,EAAA,MAAM,OAAO,IAAA,CAAK,SAAA;AAElB,EAAA,KAAA,IAAS,KAAA,GAAQ,UAAA,EAAY,KAAA,IAAS,QAAA,EAAU,SAAS,IAAA,EAAM;AAC7D,IAAA,MAAM,YAAA,GAAe,IAAA,CAAK,KAAA,CAAM,KAAA,GAAQ,GAAG,CAAA,GAAI,GAAA;AAC/C,IAAA,MAAM,QAAA,GAAW,0BAAA,CAA2B,KAAA,EAAO,KAAA,EAAO,QAAQ,YAAY,CAAA;AAI9E,IAAA,MAAM,SAAA,GAAY,KAAK,GAAA,CAAI,IAAA,EAAM,KAAK,GAAA,CAAI,WAAW,IAAI,KAAK,CAAA;AAC9D,IAAA,MAAM,KAAA,GAAQ,IAAA,CAAK,GAAA,CAAI,QAAA,GAAW,WAAW,CAAA,IAAK,SAAA;AAClD,IAAA,IACG,QAAA,GAAW,WAAA,IAAe,CAAC,KAAA,IAC3B,KAAA,IAAS,IAAA,CAAK,GAAA,CAAI,YAAY,CAAA,GAAI,IAAA,CAAK,GAAA,CAAI,SAAS,CAAA,EACrD;AACA,MAAA,WAAA,GAAc,QAAA;AACd,MAAA,SAAA,GAAY,YAAA;AAAA,IACd;AACA,IAAA,IAAI,WAAW,WAAA,EAAa;AAC1B,MAAA,WAAA,GAAc,QAAA;AAAA,IAChB;AAAA,EACF;AAGA,EAAA,MAAM,QAAA,GAAY,WAAA,KAAgB,QAAA,IAAY,WAAA,GAAc,IAAK,CAAA,GAAI,WAAA;AACrE,EAAA,MAAM,YAAA,GAAe,WAAA,GAAc,QAAA,GAAA,CAC9B,WAAA,GAAc,YAAY,WAAA,GAC3B,CAAA;AAEJ,EAAA,MAAM,UAAA,GAAa,MAAA,CAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAK,IAAA,CAAK,GAAA,CAAI,CAAA,EAAK,YAAY,CAAC,CAAA,CAAE,OAAA,CAAQ,CAAC,CAAC,CAAA;AAG/E,EAAA,MAAM,UAAA,GAAa,UAAA,IAAc,IAAA,CAAK,aAAA,GAAgB,SAAA,GAAY,CAAA;AAClE,EAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,CAAI,UAAU,CAAA;AAGpC,EAAA,MAAM,KAAA,GAAQ,MAAA,CAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAK,WAAW,EAAE,CAAA,CAAE,OAAA,CAAQ,CAAC,CAAC,CAAA;AAC5D,EAAA,MAAMA,SAAAA,GAAW,WAAW,IAAA,CAAK,cAAA;AAGjC,EAAA,MAAM,kBAAA,GAAqB,eAAe,CAAA,GAAI,CAAA,GAAI,QAAQ,CAAC,UAAA,EAAY,OAAA,CAAQ,CAAC,CAAC,CAAA;AAEjF,EAAA,IAAI,OAAA;AACJ,EAAA,IAAI,WAAW,CAAA,EAAK;AAClB,IAAA,OAAA,GAAU,UAAA;AAAA,EACZ,CAAA,MAAA,IAAW,QAAA,IAAY,IAAA,CAAK,cAAA,EAAgB;AAC1C,IAAA,OAAA,GAAU,aAAA;AAAA,EACZ,CAAA,MAAA,IAAW,YAAY,EAAA,EAAM;AAC3B,IAAA,OAAA,GAAU,eAAA;AAAA,EACZ,CAAA,MAAO;AACL,IAAA,OAAA,GAAU,aAAA;AAAA,EACZ;AAEA,EAAA,MAAM,OAAA,GAAuB;AAAA,IAC3B,KAAA,EAAO,UAAA;AAAA,IACP,UAAA;AAAA,IACA,KAAA;AAAA,IACA;AAAA,GACF;AAEA,EAAA,OAAO;AAAA,IACL,KAAA,EAAO,UAAA;AAAA,IACP,KAAA;AAAA,IACA,QAAA,EAAAA,SAAAA;AAAA,IACA,UAAA;AAAA,IACA,kBAAA;AAAA,IACA,OAAA;AAAA,IACA;AAAA,GACF;AACF;AAKA,eAAsB,YAAA,CACpB,OACA,OAAA,EACiB;AACjB,EAAA,MAAM,GAAA,GAAM,MAAM,WAAA,CAAY,KAAA,EAAO,OAAO,CAAA;AAC5C,EAAA,OAAO,GAAA,CAAI,KAAA;AACb;AAKA,eAAsB,QAAA,CACpB,OACA,SAAA,EACkB;AAClB,EAAA,MAAM,UAAuB,EAAC;AAC9B,EAAA,IAAI,cAAc,MAAA,EAAW;AAC3B,IAAA,OAAA,CAAQ,cAAA,GAAiB,SAAA;AAAA,EAC3B;AACA,EAAA,MAAM,GAAA,GAAM,MAAM,WAAA,CAAY,KAAA,EAAO,OAAO,CAAA;AAC5C,EAAA,OAAO,GAAA,CAAI,QAAA;AACb","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,74 @@
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
- }
1
+ {
2
+ "name": "skew-score",
3
+ "version": "1.1.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
+ "CHANGELOG.md"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsup",
24
+ "dev": "tsup --watch",
25
+ "test": "vitest run",
26
+ "test:watch": "vitest",
27
+ "typecheck": "tsc --noEmit",
28
+ "prepublishOnly": "npm run typecheck && npm run build && npm test"
29
+ },
30
+ "keywords": [
31
+ "skew",
32
+ "skew-score",
33
+ "deskew",
34
+ "rotation",
35
+ "tilt-detection",
36
+ "image-processing",
37
+ "ocr-preprocessing",
38
+ "kyc-document",
39
+ "sharp",
40
+ "blur-score",
41
+ "exposure-score",
42
+ "glare-score",
43
+ "contrast-score"
44
+ ],
45
+ "author": "Vijay Misal <misalvijay153@gmail.com>",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/vjymisal0/skew-score.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/vjymisal0/skew-score/issues"
52
+ },
53
+ "homepage": "https://github.com/vjymisal0/skew-score#readme",
54
+ "license": "MIT",
55
+ "dependencies": {
56
+ "sharp": "0.35.5"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^26.6.4",
60
+ "tsup": "^8.5.1",
61
+ "typescript": "~5.9.3",
62
+ "vitest": "^5.0.3"
63
+ },
64
+ "engines": {
65
+ "node": ">=20.9.0"
66
+ },
67
+ "overrides": {
68
+ "esbuild": "^0.28.2"
69
+ },
70
+ "sideEffects": false,
71
+ "publishConfig": {
72
+ "access": "public"
73
+ }
74
+ }