blur-score 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/package.json +36 -0
- package/src/index.d.ts +27 -0
- package/src/index.js +28 -0
- package/src/laplacian.js +63 -0
- package/src/loadImage.js +31 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# blur-score
|
|
2
|
+
|
|
3
|
+
Detect how blurry an image is. Returns a sharpness score from `0` (very blurry) to `1` (very sharp) using the [variance of Laplacian](https://en.wikipedia.org/wiki/Discrete_Laplace_operator) method.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install blur-score
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Node.js >= 18. Uses [`sharp`](https://sharp.pixelplumbing.com/) internally for image decoding.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```js
|
|
16
|
+
import { getBlurScore, isBlurry } from 'blur-score';
|
|
17
|
+
|
|
18
|
+
// From a file path
|
|
19
|
+
const score = await getBlurScore('photo.jpg'); // e.g. 0.83
|
|
20
|
+
|
|
21
|
+
// From a Buffer
|
|
22
|
+
const score2 = await getBlurScore(fs.readFileSync('photo.jpg'));
|
|
23
|
+
|
|
24
|
+
// From a URL
|
|
25
|
+
const score3 = await getBlurScore('https://example.com/photo.jpg');
|
|
26
|
+
|
|
27
|
+
// Check against a threshold - true if score is below the threshold
|
|
28
|
+
const blurry = await isBlurry('photo.jpg', 0.5);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
CommonJS is also supported via dynamic import:
|
|
32
|
+
|
|
33
|
+
```js
|
|
34
|
+
const { getBlurScore, isBlurry } = await import('blur-score');
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## API
|
|
38
|
+
|
|
39
|
+
### `getBlurScore(input, options?)`
|
|
40
|
+
|
|
41
|
+
Returns `Promise<number>` - a sharpness score between 0 and 1.
|
|
42
|
+
|
|
43
|
+
- `input`: a file path (`string`), an `http(s)` URL (`string`), or image bytes (`Buffer` / `Uint8Array`).
|
|
44
|
+
- `options.calibration` (default `60`): the Laplacian variance value that maps to a score of `0.5`. Raise it if sharp images score too high for your dataset; lower it if blurry images score too high.
|
|
45
|
+
|
|
46
|
+
### `isBlurry(input, threshold?, options?)`
|
|
47
|
+
|
|
48
|
+
Returns `Promise<boolean>` - `true` when the image's sharpness score is **below** `threshold`.
|
|
49
|
+
|
|
50
|
+
- `threshold` (default `0.5`): the cutoff sharpness score.
|
|
51
|
+
- `options`: same as `getBlurScore`.
|
|
52
|
+
|
|
53
|
+
## How it works
|
|
54
|
+
|
|
55
|
+
1. The image is converted to greyscale.
|
|
56
|
+
2. A Laplacian kernel is convolved over the pixels to highlight edges.
|
|
57
|
+
3. The variance of the Laplacian response is computed - sharp images with lots of edges produce high variance, blurry images produce low variance.
|
|
58
|
+
4. The variance is mapped to a `0-1` score via `variance / (variance + calibration)`.
|
|
59
|
+
|
|
60
|
+
Because the raw variance is unbounded and depends heavily on image content (not just blur), the `calibration` constant may need tuning for your specific use case.
|
|
61
|
+
|
|
62
|
+
## Calibration notes
|
|
63
|
+
|
|
64
|
+
Real Laplacian variance is much lower than you might expect: a genuinely sharp handheld photo typically lands in the 20-100 range, while a UI screenshot with crisp text can hit 10,000+. The default `calibration: 60` is tuned against real photos (not screenshots) so that:
|
|
65
|
+
|
|
66
|
+
- Sharp, in-focus photos score roughly 0.5-0.7
|
|
67
|
+
- Mild, barely-visible blur drops that to ~0.2-0.3
|
|
68
|
+
- Clearly blurred images drop below 0.1
|
|
69
|
+
|
|
70
|
+
**Known limitation:** this method measures edge/high-frequency content, not "blur" directly. A photo with genuinely low detail - a soft-focus shot, a shallow depth-of-field background, a smooth studio backdrop - can score similarly low even when perfectly sharp, because there just isn't much high-frequency content to measure. If your images are visually distinctive in this way (product shots on plain backgrounds, portraits, macro photography), test against a representative sample and adjust `calibration` or your threshold accordingly rather than trusting the default blindly.
|
|
71
|
+
|
|
72
|
+
## License
|
|
73
|
+
|
|
74
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "blur-score",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Detect how blurry an image is (0-1 sharpness score) using Laplacian variance",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./src/index.d.ts",
|
|
11
|
+
"import": "./src/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test test/**/*.test.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"blur",
|
|
22
|
+
"blur-detection",
|
|
23
|
+
"image-processing",
|
|
24
|
+
"sharpness",
|
|
25
|
+
"laplacian",
|
|
26
|
+
"sharp"
|
|
27
|
+
],
|
|
28
|
+
"author": "",
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"dependencies": {
|
|
31
|
+
"sharp": "^0.35.3"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18"
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface BlurScoreOptions {
|
|
2
|
+
/**
|
|
3
|
+
* Laplacian variance value that maps to a sharpness score of 0.5.
|
|
4
|
+
* Raise it if sharp images are being scored too high; lower it if
|
|
5
|
+
* blurry images are being scored too high. Default: 60.
|
|
6
|
+
*/
|
|
7
|
+
calibration?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Computes a sharpness score for an image in the range [0, 1].
|
|
12
|
+
* 1 means very sharp, 0 means very blurry.
|
|
13
|
+
*/
|
|
14
|
+
export function getBlurScore(
|
|
15
|
+
input: string | Buffer | Uint8Array,
|
|
16
|
+
options?: BlurScoreOptions
|
|
17
|
+
): Promise<number>;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Determines whether an image is blurry relative to a sharpness threshold.
|
|
21
|
+
* An image is considered blurry when its sharpness score is below `threshold`.
|
|
22
|
+
*/
|
|
23
|
+
export function isBlurry(
|
|
24
|
+
input: string | Buffer | Uint8Array,
|
|
25
|
+
threshold?: number,
|
|
26
|
+
options?: BlurScoreOptions
|
|
27
|
+
): Promise<boolean>;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { loadImageBuffer } from './loadImage.js';
|
|
2
|
+
import { computeLaplacianVariance, varianceToScore } from './laplacian.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Computes a sharpness score for an image in the range [0, 1].
|
|
6
|
+
* 1 means very sharp, 0 means very blurry.
|
|
7
|
+
* @param {string | Buffer | Uint8Array} input - File path, http(s) URL, or image bytes.
|
|
8
|
+
* @param {{ calibration?: number }} [options]
|
|
9
|
+
* @returns {Promise<number>}
|
|
10
|
+
*/
|
|
11
|
+
export async function getBlurScore(input, options = {}) {
|
|
12
|
+
const buffer = await loadImageBuffer(input);
|
|
13
|
+
const variance = await computeLaplacianVariance(buffer);
|
|
14
|
+
return varianceToScore(variance, options.calibration);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Determines whether an image is blurry relative to a sharpness threshold.
|
|
19
|
+
* An image is considered blurry when its sharpness score is below `threshold`.
|
|
20
|
+
* @param {string | Buffer | Uint8Array} input - File path, http(s) URL, or image bytes.
|
|
21
|
+
* @param {number} [threshold=0.5] - Sharpness score below which an image counts as blurry (0-1).
|
|
22
|
+
* @param {{ calibration?: number }} [options]
|
|
23
|
+
* @returns {Promise<boolean>}
|
|
24
|
+
*/
|
|
25
|
+
export async function isBlurry(input, threshold = 0.5, options = {}) {
|
|
26
|
+
const score = await getBlurScore(input, options);
|
|
27
|
+
return score < threshold;
|
|
28
|
+
}
|
package/src/laplacian.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
// Standard 4-neighbor discrete Laplacian kernel used for edge/focus detection.
|
|
4
|
+
const KERNEL_OFFSETS = [
|
|
5
|
+
[0, -1, 1],
|
|
6
|
+
[-1, 0, 1],
|
|
7
|
+
[0, 0, -4],
|
|
8
|
+
[1, 0, 1],
|
|
9
|
+
[0, 1, 1],
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Computes the variance of the Laplacian of an image buffer.
|
|
14
|
+
* Sharp, high-detail images produce large edge responses and high variance;
|
|
15
|
+
* blurry images produce weak, uniform responses and low variance.
|
|
16
|
+
* @param {Buffer} imageBuffer
|
|
17
|
+
* @returns {Promise<number>}
|
|
18
|
+
*/
|
|
19
|
+
export async function computeLaplacianVariance(imageBuffer) {
|
|
20
|
+
const { data, info } = await sharp(imageBuffer)
|
|
21
|
+
.greyscale()
|
|
22
|
+
.raw()
|
|
23
|
+
.toBuffer({ resolveWithObject: true });
|
|
24
|
+
|
|
25
|
+
const { width, height } = info;
|
|
26
|
+
|
|
27
|
+
if (width < 3 || height < 3) {
|
|
28
|
+
throw new Error('Image must be at least 3x3 pixels to compute a Laplacian');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let sum = 0;
|
|
32
|
+
let sumSquares = 0;
|
|
33
|
+
let count = 0;
|
|
34
|
+
|
|
35
|
+
for (let y = 1; y < height - 1; y++) {
|
|
36
|
+
for (let x = 1; x < width - 1; x++) {
|
|
37
|
+
let response = 0;
|
|
38
|
+
for (const [dx, dy, weight] of KERNEL_OFFSETS) {
|
|
39
|
+
response += weight * data[(y + dy) * width + (x + dx)];
|
|
40
|
+
}
|
|
41
|
+
sum += response;
|
|
42
|
+
sumSquares += response * response;
|
|
43
|
+
count++;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const mean = sum / count;
|
|
48
|
+
return sumSquares / count - mean * mean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Maps an unbounded Laplacian variance to a 0-1 sharpness score.
|
|
53
|
+
* Higher variance (sharper, more edges) approaches 1; low variance (blurry) approaches 0.
|
|
54
|
+
* `calibration` is the variance value that maps to a score of 0.5 - tune it against
|
|
55
|
+
* representative images if the defaults misclassify your dataset.
|
|
56
|
+
* @param {number} variance
|
|
57
|
+
* @param {number} calibration
|
|
58
|
+
* @returns {number}
|
|
59
|
+
*/
|
|
60
|
+
export function varianceToScore(variance, calibration = 60) {
|
|
61
|
+
const score = variance / (variance + calibration);
|
|
62
|
+
return Math.min(1, Math.max(0, score));
|
|
63
|
+
}
|
package/src/loadImage.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
const URL_PATTERN = /^https?:\/\//i;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolves a path, URL, or Buffer/Uint8Array input into an image Buffer.
|
|
7
|
+
* @param {string | Buffer | Uint8Array} input
|
|
8
|
+
* @returns {Promise<Buffer>}
|
|
9
|
+
*/
|
|
10
|
+
export async function loadImageBuffer(input) {
|
|
11
|
+
if (Buffer.isBuffer(input)) {
|
|
12
|
+
return input;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (input instanceof Uint8Array) {
|
|
16
|
+
return Buffer.from(input);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (typeof input === 'string') {
|
|
20
|
+
if (URL_PATTERN.test(input)) {
|
|
21
|
+
const response = await fetch(input);
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(`Failed to fetch image from URL: ${response.status} ${response.statusText}`);
|
|
24
|
+
}
|
|
25
|
+
return Buffer.from(await response.arrayBuffer());
|
|
26
|
+
}
|
|
27
|
+
return readFile(input);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
throw new TypeError('Image input must be a file path, an http(s) URL, or a Buffer/Uint8Array');
|
|
31
|
+
}
|