File size: 14,073 Bytes
e735bf3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
from dataclasses import dataclass
from typing import Optional, Tuple

import cv2
import numpy as np
from dataclasses import asdict
import json
from typing import Any

@dataclass
class FingerQualityResult:
    # Raw scores
    blur_score: float
    illumination_score: float
    coverage_ratio: float
    orientation_angle_deg: float  # angle of main axis w.r.t. x-axis

    # Per-metric pass/fail
    blur_pass: bool
    illumination_pass: bool
    coverage_pass: bool
    orientation_pass: bool

    # Overall
    quality_score: float       # 0–1
    overall_pass: bool

    # Debug / geometry
    bbox: Optional[Tuple[int, int, int, int]]  # x, y, w, h of finger bounding box
    contour_area: float


class FingerQualityAssessor:
    """
    End-to-end finger quality computation on single-finger mobile images.

    Pipeline:
    1. Preprocess (resize, blur, colorspace).
    2. Skin-based finger segmentation (YCbCr + morphology).
    3. Largest contour -> bounding box + PCA orientation.
    4. Metrics on finger ROI (blur, illumination, coverage, orientation).
    """

    def __init__(
        self,
        target_width: int = 640,
        min_contour_area_ratio: float = 0.02,
        # Thresholds (tune for your data/device):
        blur_min: float = 60.0,              # variance-of-Laplacian; > threshold = sharp
        illum_min: float = 50.0,             # mean gray lower bound
        illum_max: float = 200.0,            # mean gray upper bound
        coverage_min: float = 0.10,          # fraction of frame area covered by finger
        orientation_max_deviation: float = 45.0,  # degrees from vertical or horizontal (tunable)
        vertical_expected: bool = True       # if True, expect finger roughly vertical
    ):
        self.target_width = target_width
        self.min_contour_area_ratio = min_contour_area_ratio
        self.blur_min = blur_min
        self.illum_min = illum_min
        self.illum_max = illum_max
        self.coverage_min = coverage_min
        self.orientation_max_deviation = orientation_max_deviation
        self.vertical_expected = vertical_expected

    # ---------- Public API ----------

    def assess(
        self,
        bgr: np.ndarray,
        draw_debug: bool = False
    ) -> Tuple[FingerQualityResult, Optional[np.ndarray]]:
        """
        Main entrypoint.

        :param bgr: HxWx3 uint8 BGR finger image from mobile camera.
        :param draw_debug: If True, returns image with bbox and orientation visualized.
        :return: (FingerQualityResult, debug_image or None)
        """
        if bgr is None or bgr.size == 0:
            raise ValueError("Input image is empty")

        # 1) Resize for consistent metrics
        img = self._resize_keep_aspect(bgr, self.target_width)
        h, w = img.shape[:2]
        frame_area = float(h * w)

        # 2) Segment finger (skin) and find largest contour
        mask = self._segment_skin_ycbcr(img)
        contour = self._find_largest_contour(mask, frame_area)
        if contour is None:
            # No valid finger found; everything fails.
            result = FingerQualityResult(
                blur_score=0.0,
                illumination_score=0.0,
                coverage_ratio=0.0,
                orientation_angle_deg=0.0,
                blur_pass=False,
                illumination_pass=False,
                coverage_pass=False,
                orientation_pass=False,
                quality_score=0.0,
                overall_pass=False,
                bbox=None,
                contour_area=0.0
            )
            return result, img if draw_debug else None

        contour_area = cv2.contourArea(contour)
        x, y, w_box, h_box = cv2.boundingRect(contour)
        bbox = (x, y, w_box, h_box)

        # ROI around finger
        roi = img[y:y + h_box, x:x + w_box]
        roi_gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

        # Recompute mask for ROI to calculate coverage accurately
        mask_roi = mask[y:y + h_box, x:x + w_box]

        # 3) Metrics
        blur_score = self._blur_score_laplacian(roi_gray)
        illumination_score = float(roi_gray.mean())
        coverage_ratio = float(np.count_nonzero(mask_roi)) / float(frame_area)

        orientation_angle_deg = self._orientation_pca(contour)

        # 4) Per-metric pass/fail
        blur_pass = blur_score >= self.blur_min
        illum_pass = self.illum_min <= illumination_score <= self.illum_max
        coverage_pass = coverage_ratio >= self.coverage_min
        orientation_pass = self._orientation_pass(orientation_angle_deg)

        # 5) Quality score (simple weighted average; tune as needed)
        # Scale each metric to [0,1], then weight.
        blur_norm = np.clip(blur_score / (self.blur_min * 2.0), 0.0, 1.0)
        illum_center = (self.illum_min + self.illum_max) / 2.0
        illum_range = (self.illum_max - self.illum_min) / 2.0
        illum_norm = 1.0 - np.clip(abs(illumination_score - illum_center) / (illum_range + 1e-6), 0.0, 1.0)
        coverage_norm = np.clip(coverage_ratio / (self.coverage_min * 2.0), 0.0, 1.0)
        orient_norm = 1.0 if orientation_pass else 0.0

        # weights: prioritize blur and coverage for biometrics
        w_blur, w_illum, w_cov, w_orient = 0.35, 0.25, 0.25, 0.15
        quality_score = float(
            w_blur * blur_norm +
            w_illum * illum_norm +
            w_cov * coverage_norm +
            w_orient * orient_norm
        )

        # Comment strict condition - for tuning other metrics
        # overall_pass = blur_pass and illum_pass and coverage_pass and orientation_pass
        overall_pass = quality_score >= 0.7

        result = FingerQualityResult(
            blur_score=float(blur_score),
            illumination_score=float(illumination_score),
            coverage_ratio=float(coverage_ratio),
            orientation_angle_deg=float(orientation_angle_deg),
            blur_pass=blur_pass,
            illumination_pass=illum_pass,
            coverage_pass=coverage_pass,
            orientation_pass=orientation_pass,
            quality_score=quality_score,
            overall_pass=overall_pass,
            bbox=bbox,
            contour_area=float(contour_area),
        )

        debug_img = None
        if draw_debug:
            debug_img = img.copy()
            self._draw_debug(debug_img, contour, bbox, orientation_angle_deg, result)

        return result, debug_img

    # ---------- Preprocessing ----------

    @staticmethod
    def _resize_keep_aspect(img: np.ndarray, target_width: int) -> np.ndarray:
        h, w = img.shape[:2]
        if w == target_width:
            return img
        scale = target_width / float(w)
        new_size = (target_width, int(round(h * scale)))
        return cv2.resize(img, new_size, interpolation=cv2.INTER_AREA)

    # ---------- Segmentation ----------

    @staticmethod
    def _segment_skin_ycbcr(img: np.ndarray) -> np.ndarray:
        """
        Segment skin using YCbCr range commonly used for hand/finger. [web:12][web:15]
        Returns binary mask (uint8 0/255).
        """
        ycbcr = cv2.cvtColor(img, cv2.COLOR_BGR2YCrCb)
        # These ranges are a reasonable starting point for many Asian/Indian skin tones;
        # tweak per competition dataset. [web:12]
        lower = np.array([0, 133, 77], dtype=np.uint8)
        upper = np.array([255, 173, 127], dtype=np.uint8)
        mask = cv2.inRange(ycbcr, lower, upper)

        # Morphology to clean noise
        kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
        mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=2)
        mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)

        return mask

    def _find_largest_contour(
        self,
        mask: np.ndarray,
        frame_area: float
    ) -> Optional[np.ndarray]:
        contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
        if not contours:
            return None
        # Filter by area
        min_area = self.min_contour_area_ratio * frame_area
        valid = [c for c in contours if cv2.contourArea(c) >= min_area]
        if not valid:
            return None
        # Largest contour
        largest = max(valid, key=cv2.contourArea)
        return largest

    # ---------- Metrics ----------

    @staticmethod
    def _blur_score_laplacian(gray: np.ndarray) -> float:
        # Standard variance-of-Laplacian focus measure. [web:7][web:10][web:16]
        lap = cv2.Laplacian(gray, cv2.CV_64F)
        return float(lap.var())

    @staticmethod
    def _orientation_pca(contour: np.ndarray) -> float:
        """
        Compute orientation using PCA on contour points. [web:8][web:11][web:14]

        :return: angle in degrees in range [-90, 90] w.r.t. x-axis.
        """
        pts = contour.reshape(-1, 2).astype(np.float64)
        mean, eigenvectors, eigenvalues = cv2.PCACompute2(pts, mean=np.empty(0))
        # First principal component
        vx, vy = eigenvectors[0]
        angle_rad = np.arctan2(vy, vx)
        angle_deg = np.degrees(angle_rad)
        # Normalize angle for convenience
        if angle_deg < -90:
            angle_deg += 180
        elif angle_deg > 90:
            angle_deg -= 180
        return float(angle_deg)

    def _orientation_pass(self, angle_deg: float) -> bool:
        """
        Check if orientation is close to expected vertical/horizontal.

        vertical_expected=True  -> near 90 or -90 degrees
        vertical_expected=False -> near 0 degrees
        """
        if self.vertical_expected:
            # distance from ±90
            dev = min(abs(abs(angle_deg) - 90.0), abs(angle_deg))
        else:
            # distance from 0
            dev = abs(angle_deg)
        return dev <= self.orientation_max_deviation

    # ---------- Debug drawing ----------

    @staticmethod
    def _draw_axis(img, center, vec, length, color, thickness=2):
        x0, y0 = center
        x1 = int(x0 + length * vec[0])
        y1 = int(y0 + length * vec[1])
        cv2.arrowedLine(img, (x0, y0), (x1, y1), color, thickness, tipLength=0.2)

    def _draw_debug(
        self,
        img: np.ndarray,
        contour: np.ndarray,
        bbox: Tuple[int, int, int, int],
        angle_deg: float,
        result: FingerQualityResult
    ) -> None:
        x, y, w_box, h_box = bbox
        # Bounding box
        cv2.rectangle(img, (x, y), (x + w_box, y + h_box), (0, 255, 0), 2)

        # Draw contour
        cv2.drawContours(img, [contour], -1, (255, 0, 0), 2)

        # PCA axis
        pts = contour.reshape(-1, 2).astype(np.float64)
        mean, eigenvectors, eigenvalues = cv2.PCACompute2(pts, mean=np.empty(0))
        center = (int(mean[0, 0]), int(mean[0, 1]))
        main_vec = eigenvectors[0]
        self._draw_axis(img, center, main_vec, length=80, color=(0, 0, 255), thickness=2)

        # Overlay text
        text_lines = [
            f"Blur: {result.blur_score:.1f} ({'OK' if result.blur_pass else 'BAD'})",
            f"Illum: {result.illumination_score:.1f} ({'OK' if result.illumination_pass else 'BAD'})",
            f"Coverage: {result.coverage_ratio*100:.1f}% ({'OK' if result.coverage_pass else 'BAD'})",
            f"Angle: {angle_deg:.1f} deg ({'OK' if result.orientation_pass else 'BAD'})",
            f"Quality: {result.quality_score:.2f} ({'PASS' if result.overall_pass else 'FAIL'})",
        ]
        y0 = 25
        for line in text_lines:
            cv2.putText(
                img,
                line,
                (10, y0),
                cv2.FONT_HERSHEY_SIMPLEX,
                0.6,
                (0, 255, 0) if "OK" in line or "PASS" in line else (0, 0, 255),
                2,
                cv2.LINE_AA
            )
            y0 += 22


# 1) Load your finger image (replace with your path)
img = cv2.imread(r"finger_inputs\clear_thumb.jpeg")
if img is None:
    raise RuntimeError("Image not found or path is wrong")

# 2) Create assessor (tune thresholds later if needed)
assessor = FingerQualityAssessor(
    target_width=640,
    blur_min=60.0,
    illum_min=50.0,
    illum_max=200.0,
    coverage_min=0.10,
    orientation_max_deviation=45.0,
    vertical_expected=True
)

# 3) Run assessment.
result, debug_image = assessor.assess(img, draw_debug=True)

def _round_value(value: Any) -> Any:
    """
    Recursively round floats to 2 decimal places for JSON output.
    """
    if isinstance(value, float):
        return round(value, 2)
    if isinstance(value, dict):
        return {k: _round_value(v) for k, v in value.items()}
    if isinstance(value, (list, tuple)):
        return [_round_value(v) for v in value]
    return value

def finger_quality_result_to_json(result: FingerQualityResult) -> str:
    """
    Convert FingerQualityResult to a JSON string suitable for frontend usage.
    """
    data = asdict(result)

    # Ensure bbox is frontend-friendly
    if data["bbox"] is not None:
        data["bbox"] = {
            "x": data["bbox"][0],
            "y": data["bbox"][1],
            "width": data["bbox"][2],
            "height": data["bbox"][3],
        }
    data = _round_value(data)
    return json.dumps(data, indent=2)

quality_json = finger_quality_result_to_json(result)
print(quality_json)

with open("output_dir/finger_quality_result.json", "w") as f:
    f.write(quality_json)

# 4) Print all scores and flags.
print("Blur score:", result.blur_score, "pass:", result.blur_pass)
print("Illumination:", result.illumination_score, "pass:", result.illumination_pass)
print("Coverage ratio:", result.coverage_ratio, "pass:", result.coverage_pass)
print("Orientation angle:", result.orientation_angle_deg, "pass:", result.orientation_pass)
print("Quality score:", result.quality_score, "OVERALL PASS:", result.overall_pass)

# 5) Show debug image with bounding box and text.
if debug_image is not None:
    cv2.imshow("Finger Quality Debug", debug_image)
    cv2.waitKey(0)          # wait until key press to close window [web:18][web:24]
    cv2.destroyAllWindows()