Upload backend/face_detection.py with huggingface_hub
Browse files- backend/face_detection.py +200 -0
backend/face_detection.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import cv2
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
MODEL_DIR = os.path.join(os.path.dirname(__file__), "models")
|
| 6 |
+
MODEL_PATH = os.path.join(MODEL_DIR, "blaze_face_short_range.tflite")
|
| 7 |
+
_detector = None
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _get_detector():
|
| 11 |
+
from mediapipe.tasks.python import BaseOptions
|
| 12 |
+
from mediapipe.tasks.python.vision.face_detector import FaceDetector, FaceDetectorOptions
|
| 13 |
+
global _detector
|
| 14 |
+
if _detector is None:
|
| 15 |
+
base = BaseOptions(model_asset_path=MODEL_PATH)
|
| 16 |
+
opts = FaceDetectorOptions(base_options=base, min_detection_confidence=0.5)
|
| 17 |
+
_detector = FaceDetector.create_from_options(opts)
|
| 18 |
+
return _detector
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ββ Skin color fallback ββββββββββββββββββββββββββββββββββββββββββββ
|
| 22 |
+
def _detect_skin_region(image):
|
| 23 |
+
"""Detect skin-colored region using HSV. Returns (x, y, w, h) or None."""
|
| 24 |
+
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
|
| 25 |
+
|
| 26 |
+
lower1 = np.array([0, 20, 70], dtype=np.uint8)
|
| 27 |
+
upper1 = np.array([20, 255, 255], dtype=np.uint8)
|
| 28 |
+
lower2 = np.array([160, 20, 70], dtype=np.uint8)
|
| 29 |
+
upper2 = np.array([180, 255, 255], dtype=np.uint8)
|
| 30 |
+
|
| 31 |
+
mask1 = cv2.inRange(hsv, lower1, upper1)
|
| 32 |
+
mask2 = cv2.inRange(hsv, lower2, upper2)
|
| 33 |
+
mask = cv2.bitwise_or(mask1, mask2)
|
| 34 |
+
|
| 35 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
|
| 36 |
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
| 37 |
+
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
|
| 38 |
+
|
| 39 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 40 |
+
if not contours:
|
| 41 |
+
return None
|
| 42 |
+
|
| 43 |
+
largest = max(contours, key=cv2.contourArea)
|
| 44 |
+
img_area = image.shape[0] * image.shape[1]
|
| 45 |
+
if cv2.contourArea(largest) < img_area * 0.05:
|
| 46 |
+
return None
|
| 47 |
+
|
| 48 |
+
x, y, bw, bh = cv2.boundingRect(largest)
|
| 49 |
+
return (x, y, bw, bh)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ββ Helper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
+
def _crop_region(image, x, y, bw, bh, padding=0.2, shift_up=0.0):
|
| 54 |
+
"""Crop region with padding, clamped to image bounds.
|
| 55 |
+
|
| 56 |
+
shift_up: fraction of face height to shift crop upward
|
| 57 |
+
(keeps same crop size, moves window up)
|
| 58 |
+
"""
|
| 59 |
+
h, w = image.shape[:2]
|
| 60 |
+
pad_x = int(bw * padding)
|
| 61 |
+
pad_y = int(bh * padding)
|
| 62 |
+
shift_px = int(bh * shift_up)
|
| 63 |
+
|
| 64 |
+
crop_h = bh + 2 * pad_y # desired crop height
|
| 65 |
+
crop_w = bw + 2 * pad_x
|
| 66 |
+
|
| 67 |
+
# Center on face, then shift up
|
| 68 |
+
cx = x + bw // 2
|
| 69 |
+
cy = y + bh // 2 - shift_px
|
| 70 |
+
|
| 71 |
+
x1 = max(0, cx - crop_w // 2)
|
| 72 |
+
y1 = max(0, cy - crop_h // 2)
|
| 73 |
+
x2 = min(w, x1 + crop_w)
|
| 74 |
+
y2 = min(h, y1 + crop_h)
|
| 75 |
+
|
| 76 |
+
# Re-adjust if clipped at top boundary
|
| 77 |
+
if y1 == 0 and y2 - y1 < crop_h:
|
| 78 |
+
y2 = min(h, crop_h)
|
| 79 |
+
|
| 80 |
+
return image[y1:y2, x1:x2]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ββ Orientation ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 84 |
+
def _classify_orientation(face_result, detection=None):
|
| 85 |
+
"""Klasifikasi orientasi wajah: 'frontal' atau 'side_profile'.
|
| 86 |
+
|
| 87 |
+
Menggunakan keypoints MediaPipe (mata/telinga) jika tersedia,
|
| 88 |
+
fallback ke aspect ratio bounding box.
|
| 89 |
+
"""
|
| 90 |
+
x, y, w, h = face_result["bounds"]
|
| 91 |
+
aspect = h / max(w, 1)
|
| 92 |
+
|
| 93 |
+
# Jika ada keypoints dari MediaPipe
|
| 94 |
+
if detection is not None and hasattr(detection, 'keypoints') and len(detection.keypoints) >= 6:
|
| 95 |
+
kp = detection.keypoints
|
| 96 |
+
# keypoints: 0=right_eye, 1=left_eye, 2=nose, 3=mouth, 4=right_ear, 5=left_ear
|
| 97 |
+
reye, leye = kp[0], kp[1]
|
| 98 |
+
rear, lear = kp[4], kp[5]
|
| 99 |
+
# Jarak horizontal mata (normalized 0-1)
|
| 100 |
+
eye_dist = abs(leye.x - reye.x)
|
| 101 |
+
# Frontal: kedua mata terpisah lebar, ear di sisi luar
|
| 102 |
+
if eye_dist > 0.12:
|
| 103 |
+
return "frontal"
|
| 104 |
+
# Side profile: mata berdekatan, atau ear mendekati center
|
| 105 |
+
if eye_dist < 0.08 or abs(rear.x - lear.x) < 0.03:
|
| 106 |
+
return "side_profile"
|
| 107 |
+
|
| 108 |
+
# Fallback: aspect ratio
|
| 109 |
+
# Frontal ~1.0-1.6, side profile ~1.6-2.5
|
| 110 |
+
return "side_profile" if aspect > 1.7 else "frontal"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 114 |
+
def detect_face(image):
|
| 115 |
+
"""Detect face in image and return info dict or None.
|
| 116 |
+
|
| 117 |
+
Returns:
|
| 118 |
+
dict with keys:
|
| 119 |
+
- bounds: (x, y, w, h) face bounding box on original image
|
| 120 |
+
- score: confidence score (0-1)
|
| 121 |
+
- method: "mediapipe" | "skin"
|
| 122 |
+
- orientation: "frontal" | "side_profile" (mediapipe only)
|
| 123 |
+
or None if no face found.
|
| 124 |
+
"""
|
| 125 |
+
# Step 1: MediaPipe Face Detection
|
| 126 |
+
try:
|
| 127 |
+
from mediapipe.tasks.python.vision.core.image import Image, ImageFormat
|
| 128 |
+
detector = _get_detector()
|
| 129 |
+
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 130 |
+
mp_img = Image(image_format=ImageFormat.SRGB, data=rgb)
|
| 131 |
+
result = detector.detect(mp_img)
|
| 132 |
+
if result.detections:
|
| 133 |
+
best = max(result.detections, key=lambda d: d.categories[0].score)
|
| 134 |
+
bb = best.bounding_box
|
| 135 |
+
x = int(bb.origin_x)
|
| 136 |
+
y = int(bb.origin_y)
|
| 137 |
+
fw = int(bb.width)
|
| 138 |
+
fh = int(bb.height)
|
| 139 |
+
face_result = {
|
| 140 |
+
"bounds": (x, y, fw, fh),
|
| 141 |
+
"score": float(best.categories[0].score),
|
| 142 |
+
"method": "mediapipe",
|
| 143 |
+
}
|
| 144 |
+
face_result["orientation"] = _classify_orientation(face_result, detection=best)
|
| 145 |
+
return face_result
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"[face_detection] MediaPipe failed: {e}")
|
| 148 |
+
|
| 149 |
+
# Step 2: Skin color fallback
|
| 150 |
+
skin = _detect_skin_region(image)
|
| 151 |
+
if skin is not None:
|
| 152 |
+
x, y, fw, fh = int(skin[0]), int(skin[1]), int(skin[2]), int(skin[3])
|
| 153 |
+
return {"bounds": (x, y, fw, fh), "score": 1.0, "method": "skin", "orientation": "frontal"}
|
| 154 |
+
|
| 155 |
+
return None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def draw_face_box(image, face_result, color=(0, 255, 0), thickness=3):
|
| 159 |
+
"""Draw face bounding box on a copy of the image."""
|
| 160 |
+
result = image.copy()
|
| 161 |
+
if face_result is None:
|
| 162 |
+
return result
|
| 163 |
+
x, y, w, h = face_result["bounds"]
|
| 164 |
+
cv2.rectangle(result, (x, y), (x + w, y + h), color, thickness)
|
| 165 |
+
|
| 166 |
+
orientation = face_result.get("orientation", "?")
|
| 167 |
+
label = f"face ({face_result['method']}) {orientation} {face_result['score']:.2f}"
|
| 168 |
+
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
|
| 169 |
+
cv2.rectangle(result, (x, y - th - 6), (x + tw + 4, y), color, -1)
|
| 170 |
+
cv2.putText(result, label, (x + 2, y - 3),
|
| 171 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 0), 1)
|
| 172 |
+
|
| 173 |
+
return result
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def crop_face(image, padding=0.2, shift_up=0.15,
|
| 177 |
+
padding_side=0.30, shift_up_side=0.15):
|
| 178 |
+
"""Detect face area and crop image with orientation-aware settings.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
image: numpy array (BGR image from cv2.imread)
|
| 182 |
+
padding: face padding for frontal faces
|
| 183 |
+
shift_up: face shift for frontal faces
|
| 184 |
+
padding_side: face padding for side profiles
|
| 185 |
+
shift_up_side: face shift for side profiles
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
Cropped face image, or original image if nothing detected.
|
| 189 |
+
"""
|
| 190 |
+
face_result = detect_face(image)
|
| 191 |
+
if face_result is None:
|
| 192 |
+
return image
|
| 193 |
+
|
| 194 |
+
orientation = face_result.get("orientation", "frontal")
|
| 195 |
+
if orientation == "side_profile":
|
| 196 |
+
padding = padding_side
|
| 197 |
+
shift_up = shift_up_side
|
| 198 |
+
|
| 199 |
+
x, y, fw, fh = face_result["bounds"]
|
| 200 |
+
return _crop_region(image, x, y, fw, fh, padding, shift_up=shift_up)
|