shadow-score 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +333 -0
- package/dist/index.cjs +330 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +124 -0
- package/dist/index.d.ts +124 -0
- package/dist/index.js +320 -0
- package/dist/index.js.map +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vijay Misal <misalvijay153@gmail.com>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
# shadow-score 🌓
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/shadow-score)
|
|
4
|
+
[](https://github.com/vjymisal0/shadow-score/blob/main/LICENSE)
|
|
5
|
+
[](https://www.typescriptlang.org)
|
|
6
|
+
[](https://github.com/vjymisal0/shadow-score)
|
|
7
|
+
[](https://www.npmjs.com/package/shadow-score)
|
|
8
|
+
|
|
9
|
+
> Detect and quantify **harsh directional shadows** cast across documents, paper, or identity cards (such as smartphone camera shadows during document capture) using **Otsu bimodal luminance segmentation** and **spatial illumination gradient analysis** with [`sharp`](https://sharp.pixelplumbing.com/).
|
|
10
|
+
|
|
11
|
+
Companion package 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).
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 🌟 Why `shadow-score`?
|
|
16
|
+
|
|
17
|
+
When users scan identity cards, receipts, or contracts with a smartphone, holding the phone directly over the document casts a harsh, dark shadow. Overhead room lights exacerbate this by creating high-contrast illumination gradients across the paper.
|
|
18
|
+
|
|
19
|
+
These directional shadows severely impair downstream computer vision and OCR:
|
|
20
|
+
- **Binarization failures**: Standard adaptive thresholding algorithms (Otsu, Bradley, Sauvola) create heavy dark blotches or wash out text along shadow boundaries.
|
|
21
|
+
- **Silent OCR character dropouts**: Character recognition confidence plummets across shadowed regions in Tesseract, AWS Textract, and Google Cloud Vision.
|
|
22
|
+
- **KYC rejection**: ID card verifiers fail automated document authenticity checks due to non-uniform illumination.
|
|
23
|
+
|
|
24
|
+
`shadow-score` separates the low-frequency **illumination layer** from high-frequency text reflectance, runs **Otsu bimodal segmentation**, and evaluates the **spatial boundary gradient** to reliably measure shadow severity in milliseconds.
|
|
25
|
+
|
|
26
|
+
### Key Highlights:
|
|
27
|
+
- ⚡ **Sub-Millisecond Core Processing**: Optimized TypedArray operations and downsampling via native `sharp` C++ bindings.
|
|
28
|
+
- 🎯 **Robust Illumination Separation**: Separable box filtering isolates macro illumination, preventing black text characters from being falsely classified as shadows.
|
|
29
|
+
- 📐 **Bimodal & Spatial Analysis**: Evaluates Otsu between-class variance ($\sigma_B^2 / \sigma_T^2$), illumination delta, boundary gradient, and spatial cluster contiguity.
|
|
30
|
+
- 📦 **Dual ESM & CommonJS**: Ships with complete ES Modules and CommonJS bundles plus strict TypeScript definitions.
|
|
31
|
+
- 🛡️ **Zero Runtime Dependencies**: Depends strictly on standard `sharp`.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## 📦 Installation
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
npm install shadow-score sharp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or using your preferred package manager:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# pnpm
|
|
45
|
+
pnpm add shadow-score sharp
|
|
46
|
+
|
|
47
|
+
# yarn
|
|
48
|
+
yarn add shadow-score sharp
|
|
49
|
+
|
|
50
|
+
# bun
|
|
51
|
+
bun add shadow-score sharp
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> **Note**: `sharp` is required as a peer/direct dependency for high-performance image decoding and downsampling.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 🚀 Quick Start
|
|
59
|
+
|
|
60
|
+
### Basic Usage
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
import { analyzeShadow, hasShadow, getShadowScore } from 'shadow-score';
|
|
64
|
+
|
|
65
|
+
// 1. Boolean check (ideal for client retake prompts)
|
|
66
|
+
const isShadowed = await hasShadow('./scanned-id.jpg');
|
|
67
|
+
if (isShadowed) {
|
|
68
|
+
console.log('⚠️ Harsh shadow detected! Please adjust your lighting or angle.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. Normalized shadow score (0.0 = uniform lighting, 1.0 = heavy dark shadow)
|
|
72
|
+
const score = await getShadowScore('./receipt.png');
|
|
73
|
+
console.log(`Shadow Score: ${score}`); // e.g. 0.4215
|
|
74
|
+
|
|
75
|
+
// 3. Full analysis with illumination metrics and quality rating
|
|
76
|
+
const result = await analyzeShadow('./passport-page.jpg');
|
|
77
|
+
console.log(result);
|
|
78
|
+
/*
|
|
79
|
+
{
|
|
80
|
+
score: 0.4821,
|
|
81
|
+
hasShadow: true,
|
|
82
|
+
shadowAreaPercentage: 38.45,
|
|
83
|
+
illuminationDelta: 118.60,
|
|
84
|
+
quality: 'poor',
|
|
85
|
+
details: {
|
|
86
|
+
litLuminance: 224.15,
|
|
87
|
+
shadowLuminance: 105.55,
|
|
88
|
+
otsuThreshold: 156,
|
|
89
|
+
separability: 0.7842,
|
|
90
|
+
boundaryGradient: 14.82
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
*/
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 📖 API Reference
|
|
99
|
+
|
|
100
|
+
### `analyzeShadow(input, options?): Promise<ShadowResult>`
|
|
101
|
+
|
|
102
|
+
Performs comprehensive directional shadow analysis on an image.
|
|
103
|
+
|
|
104
|
+
- **`input`**: File path string, `Buffer`, or `Uint8Array`.
|
|
105
|
+
- **`options`**: Optional configuration object ([`ShadowOptions`](#shadowoptions)).
|
|
106
|
+
- **Returns**: `Promise<ShadowResult>`
|
|
107
|
+
|
|
108
|
+
#### `ShadowResult`
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
export interface ShadowResult {
|
|
112
|
+
/**
|
|
113
|
+
* Normalized shadow severity score from 0.0 (uniform lighting / shadow-free) to 1.0 (heavy dark shadow).
|
|
114
|
+
*/
|
|
115
|
+
score: number;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Whether the image exceeds the shadow threshold (score >= threshold).
|
|
119
|
+
* @default threshold: 0.2
|
|
120
|
+
*/
|
|
121
|
+
hasShadow: boolean;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Percentage of total image area under shadow (0.0 to 100.0).
|
|
125
|
+
*/
|
|
126
|
+
shadowAreaPercentage: number;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Difference in average luminance between lit and shadowed regions (0.0 to 255.0).
|
|
130
|
+
*/
|
|
131
|
+
illuminationDelta: number;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Qualitative lighting classification:
|
|
135
|
+
* - 'excellent': Uniform, pristine illumination (< 0.10).
|
|
136
|
+
* - 'good': Minor lighting variation or faint shadow, fully legible (< 0.20).
|
|
137
|
+
* - 'fair': Noticeable directional shadow, may degrade OCR accuracy (< 0.45).
|
|
138
|
+
* - 'poor': Severe, harsh shadow obscuring document content (>= 0.45).
|
|
139
|
+
*/
|
|
140
|
+
quality: 'excellent' | 'good' | 'fair' | 'poor';
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Low-level illumination and segmentation diagnostics.
|
|
144
|
+
*/
|
|
145
|
+
details: ShadowDetails;
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
#### `ShadowDetails`
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
export interface ShadowDetails {
|
|
153
|
+
/** Average luminance of the lit region (0.0 to 255.0). */
|
|
154
|
+
litLuminance: number;
|
|
155
|
+
/** Average luminance of the shadowed region (0.0 to 255.0). */
|
|
156
|
+
shadowLuminance: number;
|
|
157
|
+
/** Optimal Otsu threshold (0-255) computed across the illumination field. */
|
|
158
|
+
otsuThreshold: number;
|
|
159
|
+
/** Otsu bimodal separability ratio (eta = between-class variance / total variance, 0.0 to 1.0). */
|
|
160
|
+
separability: number;
|
|
161
|
+
/** Average directional spatial gradient magnitude along the shadow transition boundary. */
|
|
162
|
+
boundaryGradient: number;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
### `getShadowScore(input, options?): Promise<number>`
|
|
169
|
+
|
|
170
|
+
Convenience method that returns only the normalized shadow severity score (`0.0` to `1.0`).
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
const score = await getShadowScore(imageBuffer);
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
### `hasShadow(input, thresholdOrOptions?): Promise<boolean>`
|
|
179
|
+
|
|
180
|
+
Convenience method returning `true` if the detected shadow score meets or exceeds the threshold.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// Using default threshold (0.2)
|
|
184
|
+
const flagged = await hasShadow('./document.jpg');
|
|
185
|
+
|
|
186
|
+
// Custom numeric threshold
|
|
187
|
+
const strict = await hasShadow('./document.jpg', 0.15);
|
|
188
|
+
|
|
189
|
+
// Custom options object
|
|
190
|
+
const custom = await hasShadow('./document.jpg', {
|
|
191
|
+
threshold: 0.25,
|
|
192
|
+
downsampleWidth: 384,
|
|
193
|
+
});
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### `ShadowOptions`
|
|
199
|
+
|
|
200
|
+
Configurable parameters to customize detection sensitivity:
|
|
201
|
+
|
|
202
|
+
| Option | Type | Default | Description |
|
|
203
|
+
| :--- | :--- | :--- | :--- |
|
|
204
|
+
| `threshold` | `number` | `0.2` | Score cutoff for `hasShadow`. Image is flagged when `score >= threshold`. |
|
|
205
|
+
| `downsampleWidth` | `number` | `256` | Target width downsampled before analysis. Ensures sub-millisecond execution. |
|
|
206
|
+
| `minIlluminationDelta` | `number` | `20` | Minimum luminance drop (0-255) between lit and shadow zones to trigger detection. |
|
|
207
|
+
| `smoothingRadius` | `number` | `4` | Radius of the separable 2D box filter to eliminate text characters and isolate illumination. |
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 🔬 How the Algorithm Works
|
|
212
|
+
|
|
213
|
+
```
|
|
214
|
+
Raw Input Image
|
|
215
|
+
│
|
|
216
|
+
▼
|
|
217
|
+
Downsample (e.g. 256px) & Grayscale Conversion
|
|
218
|
+
│
|
|
219
|
+
▼
|
|
220
|
+
Separable 2D Box Filter (Eliminates fine text & isolates illumination field L)
|
|
221
|
+
│
|
|
222
|
+
▼
|
|
223
|
+
Otsu Bimodal Thresholding (Maximizes between-class variance σ_B² / σ_T²)
|
|
224
|
+
│
|
|
225
|
+
├───────────────────────────────┐
|
|
226
|
+
▼ ▼
|
|
227
|
+
Illumination Delta & Area % Boundary Spatial Gradient Analysis
|
|
228
|
+
(Lit Mean vs. Shadow Mean) (|∇L| step along shadow penumbra)
|
|
229
|
+
│ │
|
|
230
|
+
└───────────────┬───────────────┘
|
|
231
|
+
▼
|
|
232
|
+
Spatial Cluster Contiguity
|
|
233
|
+
(Connected Component Analysis)
|
|
234
|
+
│
|
|
235
|
+
▼
|
|
236
|
+
Normalized Shadow Score (0.0 to 1.0)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
1. **Illumination Field Extraction**: High-resolution documents contain high-frequency reflectance details (ink letters, barcodes, signatures). A 2D separable box filter attenuates sharp, thin edges, leaving the macro ambient illumination field $L(x, y)$.
|
|
240
|
+
2. **Otsu Bimodal Segmentation**: Evaluates histogram bimodality across $L(x, y)$ by finding threshold $t^*$ that maximizes between-class variance $\sigma_B^2(t)$. This segments candidate lit and candidate shadowed zones.
|
|
241
|
+
3. **Boundary Spatial Gradient**: Directional shadows created by phones or hands feature a distinct penumbra boundary with a sharp illumination drop $|\nabla L|$. Diffuse room light gradients have near-zero boundary gradient and are scored low.
|
|
242
|
+
4. **Spatial Cluster Contiguity**: Connected component analysis ensures that shadow pixels form a cohesive regional obstruction rather than scattered noise or image borders.
|
|
243
|
+
5. **Calibrated Severity Score**: The combined factors produce a normalized, scale-invariant score from `0.0` (perfectly uniform) to `1.0` (harsh, dark shadow obscuring document).
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## 💡 Practical Examples
|
|
248
|
+
|
|
249
|
+
### Pre-OCR Document Quality Gate
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
import { analyzeShadow } from 'shadow-score';
|
|
253
|
+
|
|
254
|
+
async function preprocessDocument(imagePath: string) {
|
|
255
|
+
const shadow = await analyzeShadow(imagePath);
|
|
256
|
+
|
|
257
|
+
if (shadow.score >= 0.45) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`Document rejected: heavy shadow detected (${shadow.shadowAreaPercentage}% of image affected). Please retake photo with even lighting.`
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (shadow.hasShadow) {
|
|
264
|
+
console.warn(
|
|
265
|
+
`Moderate shadow detected (score: ${shadow.score}). Applying local adaptive illumination correction before OCR...`
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return shadow;
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### KYC ID Card Verification Pipeline
|
|
274
|
+
|
|
275
|
+
Combine `shadow-score` with its companion libraries for comprehensive quality verification:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
import { hasBlur } from 'blur-score';
|
|
279
|
+
import { hasGlare } from 'glare-score';
|
|
280
|
+
import { hasShadow } from 'shadow-score';
|
|
281
|
+
|
|
282
|
+
async function validateIdCard(imageBuffer: Buffer) {
|
|
283
|
+
const [blurry, glared, shadowed] = await Promise.all([
|
|
284
|
+
hasBlur(imageBuffer),
|
|
285
|
+
hasGlare(imageBuffer),
|
|
286
|
+
hasShadow(imageBuffer),
|
|
287
|
+
]);
|
|
288
|
+
|
|
289
|
+
if (blurry) return { valid: false, reason: 'Image is blurry. Please hold camera steady.' };
|
|
290
|
+
if (glared) return { valid: false, reason: 'Flash reflection detected. Please turn off camera flash.' };
|
|
291
|
+
if (shadowed) return { valid: false, reason: 'Phone shadow detected. Please avoid blocking overhead light.' };
|
|
292
|
+
|
|
293
|
+
return { valid: true };
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## 🔗 Companion Packages
|
|
300
|
+
|
|
301
|
+
Build an end-to-end automated image quality and document preprocessing pipeline:
|
|
302
|
+
|
|
303
|
+
| Package | Purpose | Detection Method |
|
|
304
|
+
| :--- | :--- | :--- |
|
|
305
|
+
| [**`shadow-score`**](https://www.npmjs.com/package/shadow-score) | Harsh directional shadows & lighting gradients | Otsu bimodal segmentation & spatial gradient |
|
|
306
|
+
| [**`glare-score`**](https://www.npmjs.com/package/glare-score) | Specular flash hotspots & reflection glare | Connected component labeling & boundary contrast |
|
|
307
|
+
| [**`blur-score`**](https://www.npmjs.com/package/blur-score) | Defocus and motion blur detection | Modified Laplacian variance & frequency analysis |
|
|
308
|
+
| [**`exposure-score`**](https://www.npmjs.com/package/exposure-score) | Underexposure and overexposure detection | Luminance histogram percentile distribution |
|
|
309
|
+
| [**`contrast-score`**](https://www.npmjs.com/package/contrast-score) | Low contrast and washed-out text | RMS contrast & Michelson contrast metrics |
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## 🛠️ Development & Testing
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
# Install dependencies
|
|
317
|
+
npm install
|
|
318
|
+
|
|
319
|
+
# Run test suite (15+ unit tests using synthetic test images)
|
|
320
|
+
npm test
|
|
321
|
+
|
|
322
|
+
# Build dual ESM/CJS bundles with TypeScript declarations
|
|
323
|
+
npm run build
|
|
324
|
+
|
|
325
|
+
# Run typecheck
|
|
326
|
+
npm run typecheck
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## 📄 License
|
|
332
|
+
|
|
333
|
+
[MIT](LICENSE) © [Vijay Misal](mailto:misalvijay153@gmail.com)
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var sharp = require('sharp');
|
|
4
|
+
|
|
5
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
6
|
+
|
|
7
|
+
var sharp__default = /*#__PURE__*/_interopDefault(sharp);
|
|
8
|
+
|
|
9
|
+
// src/detector.ts
|
|
10
|
+
|
|
11
|
+
// src/utils.ts
|
|
12
|
+
function validateInput(input) {
|
|
13
|
+
if (input === null || input === void 0) {
|
|
14
|
+
throw new TypeError("Invalid image input: input must be a file path string, Buffer, or Uint8Array.");
|
|
15
|
+
}
|
|
16
|
+
if (typeof input === "string") {
|
|
17
|
+
if (input.trim().length === 0) {
|
|
18
|
+
throw new Error("Invalid image input: file path string cannot be empty.");
|
|
19
|
+
}
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
if (Buffer.isBuffer(input) || input instanceof Uint8Array) {
|
|
23
|
+
if (input.length === 0) {
|
|
24
|
+
throw new Error("Invalid image input: buffer cannot be empty.");
|
|
25
|
+
}
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
throw new TypeError("Invalid image input: expected a file path string, Buffer, or Uint8Array.");
|
|
29
|
+
}
|
|
30
|
+
function normalizeOptions(options) {
|
|
31
|
+
const threshold = options?.threshold ?? 0.2;
|
|
32
|
+
const downsampleWidth = options?.downsampleWidth ?? 256;
|
|
33
|
+
const minIlluminationDelta = options?.minIlluminationDelta ?? 20;
|
|
34
|
+
const smoothingRadius = options?.smoothingRadius ?? 4;
|
|
35
|
+
if (typeof threshold !== "number" || Number.isNaN(threshold) || threshold < 0 || threshold > 1) {
|
|
36
|
+
throw new RangeError(`Invalid option 'threshold': expected a number between 0 and 1, got ${threshold}.`);
|
|
37
|
+
}
|
|
38
|
+
if (typeof downsampleWidth !== "number" || Number.isNaN(downsampleWidth) || downsampleWidth < 16 || !Number.isInteger(downsampleWidth)) {
|
|
39
|
+
throw new RangeError(
|
|
40
|
+
`Invalid option 'downsampleWidth': expected an integer >= 16, got ${downsampleWidth}.`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
if (typeof minIlluminationDelta !== "number" || Number.isNaN(minIlluminationDelta) || minIlluminationDelta < 0 || minIlluminationDelta > 255) {
|
|
44
|
+
throw new RangeError(
|
|
45
|
+
`Invalid option 'minIlluminationDelta': expected a number between 0 and 255, got ${minIlluminationDelta}.`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
if (typeof smoothingRadius !== "number" || Number.isNaN(smoothingRadius) || smoothingRadius < 1 || smoothingRadius > 32 || !Number.isInteger(smoothingRadius)) {
|
|
49
|
+
throw new RangeError(
|
|
50
|
+
`Invalid option 'smoothingRadius': expected an integer between 1 and 32, got ${smoothingRadius}.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
threshold,
|
|
55
|
+
downsampleWidth,
|
|
56
|
+
minIlluminationDelta,
|
|
57
|
+
smoothingRadius
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// src/detector.ts
|
|
62
|
+
function extractIlluminationMap(src, width, height, radius) {
|
|
63
|
+
const temp = new Uint8Array(width * height);
|
|
64
|
+
const dst = new Uint8Array(width * height);
|
|
65
|
+
const div = 2 * radius + 1;
|
|
66
|
+
for (let y = 0; y < height; y++) {
|
|
67
|
+
const row = y * width;
|
|
68
|
+
let sum = 0;
|
|
69
|
+
for (let i = -radius; i <= radius; i++) {
|
|
70
|
+
const c = i < 0 ? 0 : i >= width ? width - 1 : i;
|
|
71
|
+
sum += src[row + c];
|
|
72
|
+
}
|
|
73
|
+
for (let x = 0; x < width; x++) {
|
|
74
|
+
temp[row + x] = Math.round(sum / div);
|
|
75
|
+
const removeX = x - radius;
|
|
76
|
+
const addX = x + radius + 1;
|
|
77
|
+
const removeVal = src[row + (removeX < 0 ? 0 : removeX)];
|
|
78
|
+
const addVal = src[row + (addX >= width ? width - 1 : addX)];
|
|
79
|
+
sum += addVal - removeVal;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (let x = 0; x < width; x++) {
|
|
83
|
+
let sum = 0;
|
|
84
|
+
for (let i = -radius; i <= radius; i++) {
|
|
85
|
+
const r = i < 0 ? 0 : i >= height ? height - 1 : i;
|
|
86
|
+
sum += temp[r * width + x];
|
|
87
|
+
}
|
|
88
|
+
for (let y = 0; y < height; y++) {
|
|
89
|
+
dst[y * width + x] = Math.round(sum / div);
|
|
90
|
+
const removeY = y - radius;
|
|
91
|
+
const addY = y + radius + 1;
|
|
92
|
+
const removeVal = temp[(removeY < 0 ? 0 : removeY) * width + x];
|
|
93
|
+
const addVal = temp[(addY >= height ? height - 1 : addY) * width + x];
|
|
94
|
+
sum += addVal - removeVal;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return dst;
|
|
98
|
+
}
|
|
99
|
+
function computeContiguity(isShadow, width, height, totalShadowPixels) {
|
|
100
|
+
if (totalShadowPixels === 0) return 0;
|
|
101
|
+
const totalPixels = width * height;
|
|
102
|
+
const visited = new Uint8Array(totalPixels);
|
|
103
|
+
const queue = new Int32Array(totalPixels);
|
|
104
|
+
let maxClusterSize = 0;
|
|
105
|
+
for (let y = 0; y < height; y++) {
|
|
106
|
+
for (let x = 0; x < width; x++) {
|
|
107
|
+
const startIdx = y * width + x;
|
|
108
|
+
if (visited[startIdx] === 1 || isShadow[startIdx] === 0) continue;
|
|
109
|
+
let head = 0;
|
|
110
|
+
let tail = 0;
|
|
111
|
+
queue[tail++] = startIdx;
|
|
112
|
+
visited[startIdx] = 1;
|
|
113
|
+
let clusterSize = 0;
|
|
114
|
+
while (head < tail) {
|
|
115
|
+
const curr = queue[head++];
|
|
116
|
+
clusterSize++;
|
|
117
|
+
const cy = Math.floor(curr / width);
|
|
118
|
+
const cx = curr % width;
|
|
119
|
+
if (cx > 0) {
|
|
120
|
+
const n = curr - 1;
|
|
121
|
+
if (visited[n] === 0 && isShadow[n] === 1) {
|
|
122
|
+
visited[n] = 1;
|
|
123
|
+
queue[tail++] = n;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (cx < width - 1) {
|
|
127
|
+
const n = curr + 1;
|
|
128
|
+
if (visited[n] === 0 && isShadow[n] === 1) {
|
|
129
|
+
visited[n] = 1;
|
|
130
|
+
queue[tail++] = n;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (cy > 0) {
|
|
134
|
+
const n = curr - width;
|
|
135
|
+
if (visited[n] === 0 && isShadow[n] === 1) {
|
|
136
|
+
visited[n] = 1;
|
|
137
|
+
queue[tail++] = n;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (cy < height - 1) {
|
|
141
|
+
const n = curr + width;
|
|
142
|
+
if (visited[n] === 0 && isShadow[n] === 1) {
|
|
143
|
+
visited[n] = 1;
|
|
144
|
+
queue[tail++] = n;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (clusterSize > maxClusterSize) {
|
|
149
|
+
maxClusterSize = clusterSize;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return maxClusterSize / totalShadowPixels;
|
|
154
|
+
}
|
|
155
|
+
async function analyzeShadow(input, options) {
|
|
156
|
+
validateInput(input);
|
|
157
|
+
const opts = normalizeOptions(options);
|
|
158
|
+
const imagePipeline = sharp__default.default(input);
|
|
159
|
+
const { data, info } = await imagePipeline.resize({ width: opts.downsampleWidth, withoutEnlargement: true }).grayscale().raw().toBuffer({ resolveWithObject: true });
|
|
160
|
+
const width = info.width;
|
|
161
|
+
const height = info.height;
|
|
162
|
+
const totalPixels = width * height;
|
|
163
|
+
if (totalPixels === 0) {
|
|
164
|
+
throw new Error("Image contains no pixel data.");
|
|
165
|
+
}
|
|
166
|
+
const illum = extractIlluminationMap(data, width, height, opts.smoothingRadius);
|
|
167
|
+
const hist = new Int32Array(256);
|
|
168
|
+
let totalSum = 0;
|
|
169
|
+
for (let i = 0; i < totalPixels; i++) {
|
|
170
|
+
const val = illum[i];
|
|
171
|
+
hist[val]++;
|
|
172
|
+
totalSum += val;
|
|
173
|
+
}
|
|
174
|
+
const meanT = totalSum / totalPixels;
|
|
175
|
+
let varT = 0;
|
|
176
|
+
for (let i = 0; i < 256; i++) {
|
|
177
|
+
const count = hist[i];
|
|
178
|
+
if (count > 0) {
|
|
179
|
+
const diff = i - meanT;
|
|
180
|
+
varT += count * diff * diff;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
varT /= totalPixels;
|
|
184
|
+
if (varT < 1) {
|
|
185
|
+
const roundedMean = Number(meanT.toFixed(2));
|
|
186
|
+
return {
|
|
187
|
+
score: 0,
|
|
188
|
+
hasShadow: false,
|
|
189
|
+
shadowAreaPercentage: 0,
|
|
190
|
+
illuminationDelta: 0,
|
|
191
|
+
quality: "excellent",
|
|
192
|
+
details: {
|
|
193
|
+
litLuminance: roundedMean,
|
|
194
|
+
shadowLuminance: roundedMean,
|
|
195
|
+
otsuThreshold: Math.round(meanT),
|
|
196
|
+
separability: 0,
|
|
197
|
+
boundaryGradient: 0
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
let bestT = 128;
|
|
202
|
+
let maxBetweenVar = 0;
|
|
203
|
+
let weight0 = 0;
|
|
204
|
+
let sum0 = 0;
|
|
205
|
+
for (let t = 0; t < 255; t++) {
|
|
206
|
+
const count = hist[t];
|
|
207
|
+
weight0 += count;
|
|
208
|
+
if (weight0 === 0) continue;
|
|
209
|
+
const weight1 = totalPixels - weight0;
|
|
210
|
+
if (weight1 === 0) break;
|
|
211
|
+
sum0 += t * count;
|
|
212
|
+
const mean0 = sum0 / weight0;
|
|
213
|
+
const mean1 = (totalSum - sum0) / weight1;
|
|
214
|
+
const meanDiff = mean1 - mean0;
|
|
215
|
+
const betweenVar = weight0 * weight1 * meanDiff * meanDiff / (totalPixels * totalPixels);
|
|
216
|
+
if (betweenVar > maxBetweenVar) {
|
|
217
|
+
maxBetweenVar = betweenVar;
|
|
218
|
+
bestT = t;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
let shadowCount = 0;
|
|
222
|
+
let shadowSum = 0;
|
|
223
|
+
let litCount = 0;
|
|
224
|
+
let litSum = 0;
|
|
225
|
+
const isShadow = new Uint8Array(totalPixels);
|
|
226
|
+
for (let i = 0; i < totalPixels; i++) {
|
|
227
|
+
const val = illum[i];
|
|
228
|
+
if (val <= bestT) {
|
|
229
|
+
isShadow[i] = 1;
|
|
230
|
+
shadowCount++;
|
|
231
|
+
shadowSum += val;
|
|
232
|
+
} else {
|
|
233
|
+
litCount++;
|
|
234
|
+
litSum += val;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const shadowLuminance = shadowCount > 0 ? shadowSum / shadowCount : 0;
|
|
238
|
+
const litLuminance = litCount > 0 ? litSum / litCount : meanT;
|
|
239
|
+
const illuminationDelta = Math.max(0, litLuminance - shadowLuminance);
|
|
240
|
+
const shadowAreaPercentage = shadowCount / totalPixels * 100;
|
|
241
|
+
const separability = Math.min(1, maxBetweenVar / varT);
|
|
242
|
+
let boundaryGradientSum = 0;
|
|
243
|
+
let boundaryPixelCount = 0;
|
|
244
|
+
for (let y = 1; y < height - 1; y++) {
|
|
245
|
+
const rowOffset = y * width;
|
|
246
|
+
for (let x = 1; x < width - 1; x++) {
|
|
247
|
+
const idx = rowOffset + x;
|
|
248
|
+
if (isShadow[idx] === 1) {
|
|
249
|
+
if (isShadow[idx - 1] === 0 || isShadow[idx + 1] === 0 || isShadow[idx - width] === 0 || isShadow[idx + width] === 0) {
|
|
250
|
+
const gx = (illum[idx + 1] - illum[idx - 1]) * 0.5;
|
|
251
|
+
const gy = (illum[idx + width] - illum[idx - width]) * 0.5;
|
|
252
|
+
boundaryGradientSum += Math.sqrt(gx * gx + gy * gy);
|
|
253
|
+
boundaryPixelCount++;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const avgBoundaryGradient = boundaryPixelCount > 0 ? boundaryGradientSum / boundaryPixelCount : 0;
|
|
259
|
+
const contiguity = computeContiguity(isShadow, width, height, shadowCount);
|
|
260
|
+
let score = 0;
|
|
261
|
+
if (illuminationDelta >= opts.minIlluminationDelta && shadowAreaPercentage > 0) {
|
|
262
|
+
const deltaRange = Math.max(1, 120 - opts.minIlluminationDelta);
|
|
263
|
+
const fDelta = Math.min(1, (illuminationDelta - opts.minIlluminationDelta) / deltaRange);
|
|
264
|
+
const fSep = Math.min(1, Math.max(0, (separability - 0.25) / 0.55));
|
|
265
|
+
const contrastRatio = illuminationDelta / (litLuminance + shadowLuminance + 1e-5);
|
|
266
|
+
const fContrast = Math.min(1, contrastRatio / 0.45);
|
|
267
|
+
const fGrad = Math.min(1, avgBoundaryGradient / 8);
|
|
268
|
+
const gradientTerm = 0.65 + 0.35 * fGrad;
|
|
269
|
+
let fArea = 1;
|
|
270
|
+
if (shadowAreaPercentage < 5) {
|
|
271
|
+
fArea = Math.max(0, shadowAreaPercentage / 5);
|
|
272
|
+
} else if (shadowAreaPercentage > 75) {
|
|
273
|
+
fArea = Math.max(0, (95 - shadowAreaPercentage) / 20);
|
|
274
|
+
}
|
|
275
|
+
const fLit = Math.min(1, litLuminance / 60);
|
|
276
|
+
const fContig = 0.4 + 0.6 * Math.min(1, Math.max(0, (contiguity - 0.2) / 0.5));
|
|
277
|
+
const contrastTerm = 0.5 * fSep + 0.5 * fContrast;
|
|
278
|
+
const rawScore = fDelta * contrastTerm * gradientTerm * fArea * fLit * fContig;
|
|
279
|
+
score = Math.max(0, Math.min(1, rawScore));
|
|
280
|
+
}
|
|
281
|
+
score = Number(score.toFixed(4));
|
|
282
|
+
const hasShadowResult = score >= opts.threshold;
|
|
283
|
+
let quality;
|
|
284
|
+
if (score < 0.1) {
|
|
285
|
+
quality = "excellent";
|
|
286
|
+
} else if (score < 0.2) {
|
|
287
|
+
quality = "good";
|
|
288
|
+
} else if (score < 0.45) {
|
|
289
|
+
quality = "fair";
|
|
290
|
+
} else {
|
|
291
|
+
quality = "poor";
|
|
292
|
+
}
|
|
293
|
+
const details = {
|
|
294
|
+
litLuminance: Number(litLuminance.toFixed(2)),
|
|
295
|
+
shadowLuminance: Number(shadowLuminance.toFixed(2)),
|
|
296
|
+
otsuThreshold: bestT,
|
|
297
|
+
separability: Number(separability.toFixed(4)),
|
|
298
|
+
boundaryGradient: Number(avgBoundaryGradient.toFixed(2))
|
|
299
|
+
};
|
|
300
|
+
return {
|
|
301
|
+
score,
|
|
302
|
+
hasShadow: hasShadowResult,
|
|
303
|
+
shadowAreaPercentage: Number(shadowAreaPercentage.toFixed(2)),
|
|
304
|
+
illuminationDelta: Number(illuminationDelta.toFixed(2)),
|
|
305
|
+
quality,
|
|
306
|
+
details
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
async function getShadowScore(input, options) {
|
|
310
|
+
const result = await analyzeShadow(input, options);
|
|
311
|
+
return result.score;
|
|
312
|
+
}
|
|
313
|
+
async function hasShadow(input, thresholdOrOptions) {
|
|
314
|
+
let opts;
|
|
315
|
+
if (typeof thresholdOrOptions === "number") {
|
|
316
|
+
opts = { threshold: thresholdOrOptions };
|
|
317
|
+
} else if (thresholdOrOptions !== void 0) {
|
|
318
|
+
opts = thresholdOrOptions;
|
|
319
|
+
}
|
|
320
|
+
const result = await analyzeShadow(input, opts);
|
|
321
|
+
return result.hasShadow;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
exports.analyzeShadow = analyzeShadow;
|
|
325
|
+
exports.getShadowScore = getShadowScore;
|
|
326
|
+
exports.hasShadow = hasShadow;
|
|
327
|
+
exports.normalizeOptions = normalizeOptions;
|
|
328
|
+
exports.validateInput = validateInput;
|
|
329
|
+
//# sourceMappingURL=index.cjs.map
|
|
330
|
+
//# sourceMappingURL=index.cjs.map
|