Spaces:
Running
Running
| 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 | |
| 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 ---------- | |
| 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 ---------- | |
| 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 ---------- | |
| 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()) | |
| 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 ---------- | |
| 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() |