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