FINGERQUALITYAPI / finger_quality.py
sol9x-sagar's picture
initial setup
e735bf3
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()