fast-face-python 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fast_face/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .models.factory import FaceModelFactory
2
+ from .models.yunet import YuNet
3
+ from .models.retinaface import RetinaFace
4
+ from .models.adaface import AdaFace
5
+
6
+ __all__ = ["FaceModelFactory", "YuNet", "RetinaFace", "AdaFace"]
@@ -0,0 +1,11 @@
1
+ from .base import BaseFaceModel
2
+ from .factory import FaceModelFactory
3
+ from .session import ONNXSession
4
+ from .yunet import YuNet
5
+
6
+ __all__ = [
7
+ "BaseFaceModel",
8
+ "FaceModelFactory",
9
+ "ONNXSession",
10
+ "YuNet",
11
+ ]
@@ -0,0 +1,91 @@
1
+ from typing import Any
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+ from ..schema import ProviderType
7
+ from ..tools import align_face
8
+ from .base_recognition import BaseRecognitionModel
9
+
10
+
11
+ class AdaFace(BaseRecognitionModel):
12
+ """AdaFace face recognition model for embedding extraction.
13
+
14
+ Supports IR-18, IR-50, and IR-101 backbones.
15
+ Expects RGB input images; converts to BGR internally as required by AdaFace.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ model_path: str,
21
+ align_size: tuple[int, int] = (112, 112),
22
+ providers: list[ProviderType] | None = None,
23
+ **kwargs,
24
+ ):
25
+ if providers is None:
26
+ providers = ["CPUExecutionProvider"]
27
+ super().__init__(
28
+ model_path=model_path,
29
+ providers=providers,
30
+ sess_options=kwargs.get("sess_options"),
31
+ )
32
+ self.align_size = align_size
33
+
34
+ def preprocess(self, imgs: list[np.ndarray]) -> np.ndarray:
35
+ """Preprocess aligned face images: RGB->BGR, normalize to [-1, 1], transpose to NCHW."""
36
+ processed = []
37
+ for img in imgs:
38
+ bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
39
+
40
+ h, w = bgr.shape[:2]
41
+ if (w, h) != self.align_size:
42
+ bgr = cv2.resize(bgr, self.align_size)
43
+
44
+ processed.append(bgr)
45
+
46
+ batch = np.stack(processed, axis=0).astype(np.float32)
47
+
48
+ batch = (batch / 255.0 - 0.5) / 0.5
49
+
50
+ batch = np.transpose(batch, (0, 3, 1, 2))
51
+ return batch
52
+
53
+ def extract(
54
+ self,
55
+ imgs: list[np.ndarray] | np.ndarray,
56
+ landmarks: list[np.ndarray | dict[str, Any]] | None = None,
57
+ ) -> np.ndarray:
58
+ """Extract L2-normalized face embeddings from images.
59
+
60
+ Args:
61
+ imgs: List of face images in RGB, or a single image array.
62
+ If landmarks are provided, images should be the full/cropped
63
+ frames from which faces will be aligned.
64
+ landmarks: Optional list of landmarks for face alignment.
65
+ Each element can be an np.ndarray of shape (5, 2) or (10,),
66
+ or a dictionary with landmark keys.
67
+
68
+ Returns:
69
+ np.ndarray: L2-normalized embeddings of shape (N, embedding_dim).
70
+ """
71
+ if isinstance(imgs, np.ndarray) and imgs.ndim == 3:
72
+ imgs = [imgs]
73
+
74
+ if landmarks is not None:
75
+ aligned_imgs = []
76
+ for img, lmk in zip(imgs, landmarks):
77
+ aligned = align_face(img, lmk, align_size=self.align_size)
78
+ aligned_imgs.append(aligned)
79
+ else:
80
+ aligned_imgs = imgs
81
+
82
+ batch = self.preprocess(aligned_imgs)
83
+ outputs = self.session(batch)
84
+
85
+ embeddings = outputs[0]
86
+
87
+ norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
88
+ norms = np.maximum(norms, 1e-10)
89
+ embeddings = embeddings / norms
90
+
91
+ return embeddings
@@ -0,0 +1,108 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ import cv2
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+
8
+ from ..schema import ProviderType
9
+ from ..tools import nms, parse_det
10
+ from .session import ONNXSession
11
+
12
+
13
+ class BaseFaceModel(ABC):
14
+ def __init__(
15
+ self,
16
+ model_path: str,
17
+ conf_threshold: float = 0.6,
18
+ nms_threshold: float = 0.4,
19
+ top_k: int = 5000,
20
+ keep_top_k: int = 1000,
21
+ providers: list[ProviderType] | None = None,
22
+ sess_options: ort.SessionOptions | None = None,
23
+ ):
24
+ if providers is None:
25
+ providers = ["CPUExecutionProvider"]
26
+ self.conf_threshold = conf_threshold
27
+ self.nms_threshold = nms_threshold
28
+ self.top_k = top_k
29
+ self.keep_top_k = keep_top_k
30
+
31
+ self.session = ONNXSession(
32
+ model_path=model_path, providers=providers, sess_options=sess_options
33
+ )
34
+ self.input_name = self.session.session.get_inputs()[0].name
35
+
36
+ @abstractmethod
37
+ def preprocess(self, imgs: np.ndarray) -> np.ndarray:
38
+ """Preprocess images for the specific model."""
39
+
40
+ @abstractmethod
41
+ def post_process(
42
+ self,
43
+ outputs: list[np.ndarray],
44
+ original_shapes: list[tuple[int, int]],
45
+ preprocessed_shape: tuple[int, int],
46
+ ) -> list[np.ndarray]:
47
+ """Post-process ONNX outputs to bounding boxes, confidence, and landmarks."""
48
+
49
+ def detect(
50
+ self,
51
+ imgs: str | list[str] | np.ndarray | list[np.ndarray],
52
+ return_dict: bool = False,
53
+ ) -> list[np.ndarray | list[dict[str, Any]]]:
54
+ """Run face detection inference on input images."""
55
+ original_shapes = []
56
+ if isinstance(imgs, str):
57
+ imgs = [imgs]
58
+
59
+ if isinstance(imgs, list):
60
+ raw_imgs = []
61
+ for item in imgs:
62
+ if isinstance(item, str):
63
+ img = cv2.imread(item)
64
+ if img is None:
65
+ raise ValueError(f"Could not load image from {item}")
66
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
67
+ else:
68
+ img = item
69
+ original_shapes.append(img.shape)
70
+ raw_imgs.append(img)
71
+ preprocessed_imgs = self.preprocess(raw_imgs)
72
+ else:
73
+ if len(imgs.shape) == 3: # (H, W, C)
74
+ original_shapes.append(imgs.shape)
75
+ preprocessed_imgs = self.preprocess([imgs])
76
+ else:
77
+ for idx in range(imgs.shape[0]):
78
+ original_shapes.append(imgs[idx].shape)
79
+ preprocessed_imgs = self.preprocess(
80
+ [imgs[idx] for idx in range(imgs.shape[0])]
81
+ )
82
+
83
+ _, _, h, w = preprocessed_imgs.shape
84
+ preprocessed_shape = (h, w)
85
+
86
+ outputs = self.session(preprocessed_imgs)
87
+
88
+ batch_dets = self.post_process(outputs, original_shapes, preprocessed_shape)
89
+
90
+ results = []
91
+ for i in range(len(batch_dets)):
92
+ dets = batch_dets[i]
93
+ if dets.shape[0] == 0:
94
+ results.append(
95
+ [] if return_dict else np.empty((0, 15), dtype=np.float32)
96
+ )
97
+ continue
98
+
99
+ keep = nms(dets, self.nms_threshold)
100
+ dets = dets[keep, :]
101
+
102
+ dets = dets[: self.keep_top_k, :]
103
+
104
+ if return_dict:
105
+ dets = [parse_det(x) for x in dets]
106
+ results.append(dets)
107
+
108
+ return results
@@ -0,0 +1,46 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any
3
+
4
+ import numpy as np
5
+ import onnxruntime as ort
6
+
7
+ from ..schema import ProviderType
8
+ from .session import ONNXSession
9
+
10
+
11
+ class BaseRecognitionModel(ABC):
12
+ """Base class for face recognition models that extract embeddings."""
13
+
14
+ def __init__(
15
+ self,
16
+ model_path: str,
17
+ providers: list[ProviderType] | None = None,
18
+ sess_options: ort.SessionOptions | None = None,
19
+ ):
20
+ if providers is None:
21
+ providers = ["CPUExecutionProvider"]
22
+
23
+ self.session = ONNXSession(
24
+ model_path=model_path, providers=providers, sess_options=sess_options
25
+ )
26
+ self.input_name = self.session.session.get_inputs()[0].name
27
+
28
+ @abstractmethod
29
+ def preprocess(self, imgs: list[np.ndarray]) -> np.ndarray:
30
+ """Preprocess aligned face images for the specific model."""
31
+
32
+ @abstractmethod
33
+ def extract(
34
+ self,
35
+ imgs: list[np.ndarray],
36
+ landmarks: list[np.ndarray | dict[str, Any]] | None = None,
37
+ ) -> np.ndarray:
38
+ """Extract face embeddings from images.
39
+
40
+ Args:
41
+ imgs: List of face images (RGB).
42
+ landmarks: Optional list of landmarks for alignment.
43
+
44
+ Returns:
45
+ np.ndarray: L2-normalized embeddings of shape (N, embedding_dim).
46
+ """
@@ -0,0 +1,46 @@
1
+ import os
2
+ import urllib.request
3
+ import logging
4
+ import shutil
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ BASE_URL = "https://mwmaulana.my.id/fast_face_onnx"
9
+
10
+
11
+ def download_model(model_filename: str, target_path: str):
12
+ """Download the model from the remote server if it doesn't exist.
13
+
14
+ Args:
15
+ model_filename (str): The name of the file to download (e.g. 'yunet.onnx').
16
+ target_path (str): The local path where the file should be saved.
17
+ """
18
+ if os.path.exists(target_path):
19
+ return
20
+
21
+ url = f"{BASE_URL}/{model_filename}"
22
+
23
+ os.makedirs(os.path.dirname(target_path), exist_ok=True)
24
+
25
+ logger.info(f"Model not found locally. Downloading {model_filename} from {url}...")
26
+ print(f"Downloading {model_filename} from {url}...")
27
+
28
+ try:
29
+ req = urllib.request.Request(
30
+ url, headers={"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
31
+ )
32
+ with (
33
+ urllib.request.urlopen(req) as response,
34
+ open(target_path, "wb") as out_file,
35
+ ):
36
+ shutil.copyfileobj(response, out_file)
37
+
38
+ logger.info(f"Successfully downloaded to {target_path}")
39
+ print(f"Successfully downloaded to {target_path}")
40
+ except Exception as e:
41
+ logger.error(f"Failed to download model {model_filename}: {e}")
42
+ if os.path.exists(target_path):
43
+ os.remove(target_path)
44
+ raise RuntimeError(
45
+ f"Failed to download {model_filename} from {url}. Error: {e}"
46
+ ) from e
@@ -0,0 +1,69 @@
1
+ import os
2
+ from typing import Union
3
+
4
+ from .base import BaseFaceModel
5
+ from .base_recognition import BaseRecognitionModel
6
+ from .yunet import YuNet
7
+ from .retinaface import RetinaFace
8
+ from .adaface import AdaFace
9
+ from .downloader import download_model
10
+ from ..schema import MODEL_FILENAMES
11
+
12
+
13
+ class FaceModelFactory:
14
+ """Factory to instantiate face detection models."""
15
+
16
+ _models = {
17
+ "YUNET": YuNet,
18
+ "RETINAFACE_MOBILENET": RetinaFace,
19
+ "RETINAFACE_RESNET50": RetinaFace,
20
+ "ADAFACE_IR101": AdaFace,
21
+ "ADAFACE_IR50": AdaFace,
22
+ "ADAFACE_IR18": AdaFace,
23
+ # "SCRFD": SCRFD, # To be implemented
24
+ }
25
+
26
+ @classmethod
27
+ def get_model(
28
+ cls, model_type: str, **kwargs
29
+ ) -> Union[BaseFaceModel, BaseRecognitionModel]:
30
+ """Get an instance of a face detection or recognition model.
31
+
32
+ Args:
33
+ model_type (str): The type of model to instantiate (e.g., "YUNET", "ADAFACE_IR50").
34
+ **kwargs: Arguments to pass to the model's constructor.
35
+
36
+ Returns:
37
+ Union[BaseFaceModel, BaseRecognitionModel]: An instance of the requested model.
38
+ """
39
+ model_type = model_type.upper()
40
+ if model_type not in cls._models:
41
+ raise ValueError(
42
+ f"Model type '{model_type}' is not supported. Supported models: {list(cls._models.keys())}"
43
+ )
44
+
45
+ canonical_filename = MODEL_FILENAMES.get(
46
+ model_type, f"{model_type.lower()}.onnx"
47
+ )
48
+ if "model_path" not in kwargs:
49
+ kwargs["model_path"] = os.path.join(
50
+ os.path.dirname(os.path.abspath(__file__)), canonical_filename
51
+ )
52
+
53
+ model_path = kwargs["model_path"]
54
+ if not os.path.exists(model_path):
55
+ os.makedirs(os.path.dirname(model_path), exist_ok=True)
56
+ download_model(canonical_filename, model_path)
57
+
58
+ model_class = cls._models[model_type]
59
+ return model_class(**kwargs)
60
+
61
+ @classmethod
62
+ def register_model(cls, model_type: str, model_class: type[BaseFaceModel]):
63
+ """Register a custom model with the factory.
64
+
65
+ Args:
66
+ model_type (str): The identifier for the model.
67
+ model_class (Type[BaseFaceModel]): The model class.
68
+ """
69
+ cls._models[model_type.upper()] = model_class
@@ -0,0 +1,109 @@
1
+ import cv2
2
+ import numpy as np
3
+
4
+ from ..schema import ProviderType
5
+ from ..tools import decode, decode_landmark, get_priorbox
6
+ from .base import BaseFaceModel
7
+
8
+
9
+ class RetinaFace(BaseFaceModel):
10
+ """RetinaFace detector supporting MobileNet and ResNet50 backbones."""
11
+
12
+ def __init__(
13
+ self,
14
+ model_path: str,
15
+ input_size: int = 640,
16
+ conf_threshold: float = 0.6,
17
+ nms_threshold: float = 0.2,
18
+ top_k: int = 10000,
19
+ keep_top_k: int = 1000,
20
+ variance: tuple[float, float] = (0.1, 0.2),
21
+ providers: list[ProviderType] | None = None,
22
+ **kwargs,
23
+ ):
24
+ if providers is None:
25
+ providers = ["CPUExecutionProvider"]
26
+ super().__init__(
27
+ model_path=model_path,
28
+ conf_threshold=conf_threshold,
29
+ nms_threshold=nms_threshold,
30
+ top_k=top_k,
31
+ keep_top_k=keep_top_k,
32
+ providers=providers,
33
+ sess_options=kwargs.get("sess_options"),
34
+ )
35
+ self.input_size = input_size
36
+ self.variance = variance
37
+
38
+ def _resize_single(self, image: np.ndarray) -> np.ndarray:
39
+ """Letterbox-resize a single image to (input_size x input_size)."""
40
+ h, w = image.shape[:2]
41
+ scale = self.input_size / max(h, w)
42
+ new_w, new_h = int(w * scale), int(h * scale)
43
+ resized = cv2.resize(image, (new_w, new_h))
44
+ pad_w = self.input_size - new_w
45
+ pad_h = self.input_size - new_h
46
+ return cv2.copyMakeBorder(
47
+ resized, 0, pad_h, 0, pad_w, cv2.BORDER_CONSTANT, value=(0, 0, 0)
48
+ )
49
+
50
+ def preprocess(self, imgs: list[np.ndarray]) -> np.ndarray:
51
+ """Preprocess images: resize, normalize, transpose to NCHW."""
52
+ processed = []
53
+ for img in imgs:
54
+ resized = self._resize_single(img)
55
+ processed.append(resized)
56
+
57
+ batch = np.stack(processed, axis=0).astype(np.float32)
58
+
59
+ # RetinaFace normalization: subtract mean, no std division
60
+ mean = np.array([104.0, 117.0, 123.0], dtype=np.float32)
61
+ batch -= mean
62
+
63
+ # HWC -> CHW
64
+ batch = np.transpose(batch, (0, 3, 1, 2))
65
+ return batch
66
+
67
+ def post_process(
68
+ self,
69
+ outputs: list[np.ndarray],
70
+ original_shapes: list[tuple[int, int]],
71
+ preprocessed_shape: tuple[int, int],
72
+ ) -> list[np.ndarray]:
73
+ """Decode RetinaFace outputs into [x1, y1, x2, y2, score, landmarks...] arrays."""
74
+ loc, conf, landms = outputs
75
+ h, w = preprocessed_shape
76
+ prior_data = get_priorbox(image_size=(h, w))
77
+
78
+ results = []
79
+ for i in range(loc.shape[0]):
80
+ orig_h, orig_w = original_shapes[i][:2]
81
+ padded_size = max(orig_h, orig_w)
82
+ scale_box = np.array([padded_size] * 4, dtype=np.float32)
83
+ scale_lm = np.array([padded_size] * 10, dtype=np.float32)
84
+
85
+ boxes = decode(loc[i], prior_data, self.variance) * scale_box
86
+ landmarks = decode_landmark(landms[i], prior_data, self.variance) * scale_lm
87
+ scores = conf[i][:, 1]
88
+
89
+ inds = np.where(scores > self.conf_threshold)[0]
90
+ boxes = boxes[inds]
91
+ landmarks = landmarks[inds]
92
+ scores = scores[inds]
93
+
94
+ order = scores.argsort()[::-1][: self.top_k]
95
+ boxes = boxes[order]
96
+ landmarks = landmarks[order]
97
+ scores = scores[order]
98
+
99
+ if boxes.shape[0] == 0:
100
+ results.append(np.empty((0, 15), dtype=np.float32))
101
+ continue
102
+
103
+ dets = np.hstack((boxes, scores[:, np.newaxis], landmarks)).astype(
104
+ np.float32, copy=False
105
+ )
106
+
107
+ results.append(dets)
108
+
109
+ return results
@@ -0,0 +1,29 @@
1
+ import numpy as np
2
+ import onnxruntime as ort
3
+
4
+
5
+ from ..schema import ProviderType
6
+
7
+
8
+ class ONNXSession:
9
+ def __init__(
10
+ self,
11
+ model_path: str,
12
+ providers: list[ProviderType] | None = None,
13
+ sess_options: ort.SessionOptions | None = None,
14
+ ):
15
+
16
+ if providers is None:
17
+ providers = ["CPUExecutionProvider"]
18
+ self.session = ort.InferenceSession(
19
+ model_path, providers=providers, sess_options=sess_options
20
+ )
21
+
22
+ def process(self, inputs: np.ndarray):
23
+ assert len(inputs.shape) == 4, "Inputs shape length != 4"
24
+ input_name = self.session.get_inputs()[0].name
25
+ outputs = self.session.run(None, {input_name: inputs})
26
+ return outputs
27
+
28
+ def __call__(self, inputs: np.ndarray):
29
+ return self.process(inputs=inputs)