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()