retinaface.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. # Copyright 2025 Yakhyokhuja Valikhujaev
  2. # Author: Yakhyokhuja Valikhujaev
  3. # GitHub: https://github.com/yakhyo
  4. from typing import Any, Dict, List, Literal, Tuple
  5. import numpy as np
  6. from uniface.common import (
  7. decode_boxes,
  8. decode_landmarks,
  9. generate_anchors,
  10. non_max_suppression,
  11. resize_image,
  12. )
  13. from uniface.constants import RetinaFaceWeights
  14. from uniface.log import Logger
  15. from uniface.model_store import verify_model_weights
  16. from uniface.onnx_utils import create_onnx_session
  17. from .base import BaseDetector
  18. class RetinaFace(BaseDetector):
  19. """
  20. Face detector based on the RetinaFace architecture.
  21. Title: "RetinaFace: Single-stage Dense Face Localisation in the Wild"
  22. Paper: https://arxiv.org/abs/1905.00641
  23. Args:
  24. **kwargs: Keyword arguments passed to BaseDetector and RetinaFace. Supported keys include:
  25. model_name (RetinaFaceWeights, optional): Model weights to use. Defaults to `RetinaFaceWeights.MNET_V2`.
  26. conf_thresh (float, optional): Confidence threshold for filtering detections. Defaults to 0.5.
  27. nms_thresh (float, optional): Non-maximum suppression (NMS) IoU threshold. Defaults to 0.4.
  28. pre_nms_topk (int, optional): Number of top-scoring boxes considered before NMS. Defaults to 5000.
  29. post_nms_topk (int, optional): Max number of detections kept after NMS. Defaults to 750.
  30. dynamic_size (bool, optional): If True, generate anchors dynamically per input image. Defaults to False.
  31. input_size (Tuple[int, int], optional): Fixed input size (width, height) if `dynamic_size=False`.
  32. Defaults to (640, 640).
  33. Attributes:
  34. model_name (RetinaFaceWeights): Selected model variant.
  35. conf_thresh (float): Threshold for confidence-based filtering.
  36. nms_thresh (float): IoU threshold used for NMS.
  37. pre_nms_topk (int): Limit on proposals before applying NMS.
  38. post_nms_topk (int): Limit on retained detections after NMS.
  39. dynamic_size (bool): Flag indicating dynamic or static input sizing.
  40. input_size (Tuple[int, int]): Static input size if `dynamic_size=False`.
  41. _model_path (str): Absolute path to the verified model weights.
  42. _priors (np.ndarray): Precomputed anchor boxes (if static size).
  43. _supports_landmarks (bool): Indicates landmark prediction support.
  44. Raises:
  45. ValueError: If the model weights are invalid or not found.
  46. RuntimeError: If the ONNX model fails to load or initialize.
  47. """
  48. def __init__(self, **kwargs) -> None:
  49. super().__init__(**kwargs)
  50. self._supports_landmarks = True # RetinaFace supports landmarks
  51. self.model_name = kwargs.get('model_name', RetinaFaceWeights.MNET_V2)
  52. self.conf_thresh = kwargs.get('conf_thresh', 0.5)
  53. self.nms_thresh = kwargs.get('nms_thresh', 0.4)
  54. self.pre_nms_topk = kwargs.get('pre_nms_topk', 5000)
  55. self.post_nms_topk = kwargs.get('post_nms_topk', 750)
  56. self.dynamic_size = kwargs.get('dynamic_size', False)
  57. self.input_size = kwargs.get('input_size', (640, 640))
  58. Logger.info(
  59. f'Initializing RetinaFace with model={self.model_name}, conf_thresh={self.conf_thresh}, '
  60. f'nms_thresh={self.nms_thresh}, input_size={self.input_size}'
  61. )
  62. # Get path to model weights
  63. self._model_path = verify_model_weights(self.model_name)
  64. Logger.info(f'Verified model weights located at: {self._model_path}')
  65. # Precompute anchors if using static size
  66. if not self.dynamic_size and self.input_size is not None:
  67. self._priors = generate_anchors(image_size=self.input_size)
  68. Logger.debug('Generated anchors for static input size.')
  69. # Initialize model
  70. self._initialize_model(self._model_path)
  71. def _initialize_model(self, model_path: str) -> None:
  72. """
  73. Initializes an ONNX model session from the given path.
  74. Args:
  75. model_path (str): The file path to the ONNX model.
  76. Raises:
  77. RuntimeError: If the model fails to load, logs an error and raises an exception.
  78. """
  79. try:
  80. self.session = create_onnx_session(model_path)
  81. self.input_names = self.session.get_inputs()[0].name
  82. self.output_names = [x.name for x in self.session.get_outputs()]
  83. Logger.info(f'Successfully initialized the model from {model_path}')
  84. except Exception as e:
  85. Logger.error(f"Failed to load model from '{model_path}': {e}", exc_info=True)
  86. raise RuntimeError(f"Failed to initialize model session for '{model_path}'") from e
  87. def preprocess(self, image: np.ndarray) -> np.ndarray:
  88. """Preprocess input image for model inference.
  89. Args:
  90. image (np.ndarray): Input image.
  91. Returns:
  92. np.ndarray: Preprocessed image tensor with shape (1, C, H, W)
  93. """
  94. image = np.float32(image) - np.array([104, 117, 123], dtype=np.float32)
  95. image = image.transpose(2, 0, 1) # HWC to CHW
  96. image = np.expand_dims(image, axis=0) # Add batch dimension (1, C, H, W)
  97. return image
  98. def inference(self, input_tensor: np.ndarray) -> List[np.ndarray]:
  99. """Perform model inference on the preprocessed image tensor.
  100. Args:
  101. input_tensor (np.ndarray): Preprocessed input tensor.
  102. Returns:
  103. Tuple[np.ndarray, np.ndarray]: Raw model outputs.
  104. """
  105. return self.session.run(self.output_names, {self.input_names: input_tensor})
  106. def detect(
  107. self,
  108. image: np.ndarray,
  109. max_num: int = 0,
  110. metric: Literal['default', 'max'] = 'max',
  111. center_weight: float = 2.0,
  112. ) -> List[Dict[str, Any]]:
  113. """
  114. Perform face detection on an input image and return bounding boxes and facial landmarks.
  115. Args:
  116. image (np.ndarray): Input image as a NumPy array of shape (H, W, C).
  117. max_num (int): Maximum number of detections to return. Use 0 to return all detections. Defaults to 0.
  118. metric (Literal["default", "max"]): Metric for ranking detections when `max_num` is limited.
  119. - "default": Prioritize detections closer to the image center.
  120. - "max": Prioritize detections with larger bounding box areas.
  121. center_weight (float): Weight for penalizing detections farther from the image center
  122. when using the "default" metric. Defaults to 2.0.
  123. Returns:
  124. List[Dict[str, Any]]: List of face detection dictionaries, each containing:
  125. - 'bbox' (np.ndarray): Bounding box coordinates with shape (4,) as [x1, y1, x2, y2]
  126. - 'confidence' (float): Detection confidence score (0.0 to 1.0)
  127. - 'landmarks' (np.ndarray): 5-point facial landmarks with shape (5, 2)
  128. Example:
  129. >>> faces = detector.detect(image)
  130. >>> for face in faces:
  131. ... bbox = face['bbox'] # np.ndarray with shape (4,)
  132. ... confidence = face['confidence'] # float
  133. ... landmarks = face['landmarks'] # np.ndarray with shape (5, 2)
  134. ... # Can pass landmarks directly to recognition
  135. ... embedding = recognizer.get_normalized_embedding(image, landmarks)
  136. """
  137. original_height, original_width = image.shape[:2]
  138. if self.dynamic_size:
  139. height, width, _ = image.shape
  140. self._priors = generate_anchors(image_size=(height, width)) # generate anchors for each input image
  141. resize_factor = 1.0 # No resizing
  142. else:
  143. image, resize_factor = resize_image(image, target_shape=self.input_size)
  144. height, width, _ = image.shape
  145. image_tensor = self.preprocess(image)
  146. # ONNXRuntime inference
  147. outputs = self.inference(image_tensor)
  148. # Postprocessing
  149. detections, landmarks = self.postprocess(outputs, resize_factor, shape=(width, height))
  150. if max_num > 0 and detections.shape[0] > max_num:
  151. # Calculate area of detections
  152. areas = (detections[:, 2] - detections[:, 0]) * (detections[:, 3] - detections[:, 1])
  153. # Calculate offsets from image center
  154. center = (original_height // 2, original_width // 2)
  155. offsets = np.vstack(
  156. [
  157. (detections[:, 0] + detections[:, 2]) / 2 - center[1],
  158. (detections[:, 1] + detections[:, 3]) / 2 - center[0],
  159. ]
  160. )
  161. offset_dist_squared = np.sum(np.power(offsets, 2.0), axis=0)
  162. # Calculate scores based on the chosen metric
  163. if metric == 'max':
  164. scores = areas
  165. else:
  166. scores = areas - offset_dist_squared * center_weight
  167. # Sort by scores and select top `max_num`
  168. sorted_indices = np.argsort(scores)[::-1][:max_num]
  169. detections = detections[sorted_indices]
  170. landmarks = landmarks[sorted_indices]
  171. faces = []
  172. for i in range(detections.shape[0]):
  173. face_dict = {
  174. 'bbox': detections[i, :4].astype(np.float32),
  175. 'confidence': float(detections[i, 4]),
  176. 'landmarks': landmarks[i].astype(np.float32),
  177. }
  178. faces.append(face_dict)
  179. return faces
  180. def postprocess(
  181. self, outputs: List[np.ndarray], resize_factor: float, shape: Tuple[int, int]
  182. ) -> Tuple[np.ndarray, np.ndarray]:
  183. """
  184. Process the model outputs into final detection results.
  185. Args:
  186. outputs (List[np.ndarray]): Raw outputs from the detection model.
  187. - outputs[0]: Location predictions (bounding box coordinates).
  188. - outputs[1]: Class confidence scores.
  189. - outputs[2]: Landmark predictions.
  190. resize_factor (float): Factor used to resize the input image during preprocessing.
  191. shape (Tuple[int, int]): Original shape of the image as (height, width).
  192. Returns:
  193. Tuple[np.ndarray, np.ndarray]: Processed results containing:
  194. - detections (np.ndarray): Array of detected bounding boxes with confidence scores.
  195. Shape: (num_detections, 5), where each row is [x_min, y_min, x_max, y_max, score].
  196. - landmarks (np.ndarray): Array of detected facial landmarks.
  197. Shape: (num_detections, 5, 2), where each row contains 5 landmark points (x, y).
  198. """
  199. loc, conf, landmarks = (
  200. outputs[0].squeeze(0),
  201. outputs[1].squeeze(0),
  202. outputs[2].squeeze(0),
  203. )
  204. # Decode boxes and landmarks
  205. boxes = decode_boxes(loc, self._priors)
  206. landmarks = decode_landmarks(landmarks, self._priors)
  207. boxes, landmarks = self._scale_detections(boxes, landmarks, resize_factor, shape=(shape[0], shape[1]))
  208. # Extract confidence scores for the face class
  209. scores = conf[:, 1]
  210. mask = scores > self.conf_thresh
  211. # Filter by confidence threshold
  212. boxes, landmarks, scores = boxes[mask], landmarks[mask], scores[mask]
  213. # Sort by scores
  214. order = scores.argsort()[::-1][: self.pre_nms_topk]
  215. boxes, landmarks, scores = boxes[order], landmarks[order], scores[order]
  216. # Apply NMS
  217. detections = np.hstack((boxes, scores[:, np.newaxis])).astype(np.float32, copy=False)
  218. keep = non_max_suppression(detections, self.nms_thresh)
  219. detections, landmarks = detections[keep], landmarks[keep]
  220. # Keep top-k detections
  221. detections, landmarks = (
  222. detections[: self.post_nms_topk],
  223. landmarks[: self.post_nms_topk],
  224. )
  225. landmarks = landmarks.reshape(-1, 5, 2).astype(np.int32)
  226. return detections, landmarks
  227. def _scale_detections(
  228. self,
  229. boxes: np.ndarray,
  230. landmarks: np.ndarray,
  231. resize_factor: float,
  232. shape: Tuple[int, int],
  233. ) -> Tuple[np.ndarray, np.ndarray]:
  234. # Scale bounding boxes and landmarks to the original image size.
  235. bbox_scale = np.array([shape[0], shape[1]] * 2)
  236. boxes = boxes * bbox_scale / resize_factor
  237. landmark_scale = np.array([shape[0], shape[1]] * 5)
  238. landmarks = landmarks * landmark_scale / resize_factor
  239. return boxes, landmarks
  240. # TODO: below is only for testing, remove it later
  241. def draw_bbox(frame, bbox, score, color=(0, 255, 0), thickness=2):
  242. x1, y1, x2, y2 = map(int, bbox) # Unpack 4 bbox values
  243. cv2.rectangle(frame, (x1, y1), (x2, y2), color, thickness)
  244. cv2.putText(frame, f'{score:.2f}', (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
  245. def draw_keypoints(frame, points, color=(0, 0, 255), radius=2):
  246. for x, y in points.astype(np.int32):
  247. cv2.circle(frame, (int(x), int(y)), radius, color, -1)
  248. if __name__ == '__main__':
  249. import cv2
  250. detector = RetinaFace(model_name=RetinaFaceWeights.MNET_050)
  251. print(detector.get_info())
  252. cap = cv2.VideoCapture(0)
  253. if not cap.isOpened():
  254. print('Failed to open webcam.')
  255. exit()
  256. print("Webcam started. Press 'q' to exit.")
  257. while True:
  258. ret, frame = cap.read()
  259. if not ret:
  260. print('Failed to read frame.')
  261. break
  262. # Get face detections as list of dictionaries
  263. faces = detector.detect(frame)
  264. # Process each detected face
  265. for face in faces:
  266. # Extract bbox and landmarks from dictionary
  267. bbox = face['bbox'] # [x1, y1, x2, y2]
  268. landmarks = face['landmarks'] # [[x1, y1], [x2, y2], ...]
  269. confidence = face['confidence']
  270. # Pass bbox and confidence separately
  271. draw_bbox(frame, bbox, confidence)
  272. # Convert landmarks to numpy array format if needed
  273. if landmarks is not None and len(landmarks) > 0:
  274. # Convert list of [x, y] pairs to numpy array
  275. points = np.array(landmarks, dtype=np.float32) # Shape: (5, 2)
  276. draw_keypoints(frame, points)
  277. # Display face count
  278. cv2.putText(
  279. frame,
  280. f'Faces: {len(faces)}',
  281. (10, 30),
  282. cv2.FONT_HERSHEY_SIMPLEX,
  283. 0.7,
  284. (255, 255, 255),
  285. 2,
  286. )
  287. cv2.imshow('FaceDetection', frame)
  288. if cv2.waitKey(1) & 0xFF == ord('q'):
  289. break
  290. cap.release()
  291. cv2.destroyAllWindows()