similarfaces 1.0.0__tar.gz
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.
- similarfaces-1.0.0/PKG-INFO +117 -0
- similarfaces-1.0.0/README.md +100 -0
- similarfaces-1.0.0/pyproject.toml +30 -0
- similarfaces-1.0.0/setup.cfg +4 -0
- similarfaces-1.0.0/similarfaces/__init__.py +93 -0
- similarfaces-1.0.0/similarfaces/aligner.py +57 -0
- similarfaces-1.0.0/similarfaces/detector.py +238 -0
- similarfaces-1.0.0/similarfaces/encoder.py +101 -0
- similarfaces-1.0.0/similarfaces/models.py +59 -0
- similarfaces-1.0.0/similarfaces/scorer.py +96 -0
- similarfaces-1.0.0/similarfaces/utils.py +75 -0
- similarfaces-1.0.0/similarfaces.egg-info/PKG-INFO +117 -0
- similarfaces-1.0.0/similarfaces.egg-info/SOURCES.txt +14 -0
- similarfaces-1.0.0/similarfaces.egg-info/dependency_links.txt +1 -0
- similarfaces-1.0.0/similarfaces.egg-info/requires.txt +7 -0
- similarfaces-1.0.0/similarfaces.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: similarfaces
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A production-ready Face Recognition library powered by ONNX
|
|
5
|
+
Author-email: Narek <your.email@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/narek/similarfaces
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: numpy>=1.20.0
|
|
11
|
+
Requires-Dist: opencv-python>=4.5.1
|
|
12
|
+
Requires-Dist: onnxruntime>=1.15.0
|
|
13
|
+
Requires-Dist: scikit-image>=0.19.0
|
|
14
|
+
Requires-Dist: scipy>=1.7.0
|
|
15
|
+
Requires-Dist: Pillow>=9.0.0
|
|
16
|
+
Requires-Dist: matplotlib>=3.5.0
|
|
17
|
+
|
|
18
|
+
# similarfaces: High-Performance Face Recognition
|
|
19
|
+
|
|
20
|
+
A production-ready, clean, and robust face recognition pipeline powered by ONNX Runtime.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 🌟 Overview
|
|
25
|
+
|
|
26
|
+
**similarfaces** is a streamlined, high-performance Python library for face detection, alignment, quality assessment, and recognition. Designed with modularity and ease of use in mind, it provides a functional API that leverages state-of-the-art models optimized for ONNX Runtime.
|
|
27
|
+
|
|
28
|
+
### ✨ New Key Features
|
|
29
|
+
|
|
30
|
+
- 🏗️ **Functional API**: Clean and intuitive functional wrappers (`detect_faces`, `extract_features`, `compare_faces`, `align_face`) without the need to manually manage processor objects.
|
|
31
|
+
- 📦 **Structured Data Models**: All functions utilize a unified `Face` dataclass, ensuring type safety and easy access to bounding boxes, landmarks, quality scores, and embeddings.
|
|
32
|
+
- 🎯 **Integrated Detection & Quality**: `detect_faces()` now performs both robust face localization and automatic quality assessment in a single, efficient pass.
|
|
33
|
+
- 📐 **Optimal Alignment**: Similarity transforms for standardized 112x112 face cropping.
|
|
34
|
+
- 🧠 **High-accuracy Recognition**: Extract deep feature embeddings for high-accuracy face comparison.
|
|
35
|
+
- ⚡ **ONNX Powered**: Sub-millisecond inference speeds with minimal dependencies across CPU and GPU environments.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 🚀 Quick Start
|
|
40
|
+
|
|
41
|
+
### Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -r requirements.txt
|
|
45
|
+
pip install -e . # Install in editable mode for development
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Basic Usage
|
|
49
|
+
|
|
50
|
+
Compare two faces with high-quality filtering using the functional API:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import cv2
|
|
54
|
+
from similarfaces import detect_faces, extract_features, compare_faces
|
|
55
|
+
|
|
56
|
+
# Load images (cv2 loads as BGR)
|
|
57
|
+
img1 = cv2.imread("images/image1.png")
|
|
58
|
+
img2 = cv2.imread("images/image2.png")
|
|
59
|
+
|
|
60
|
+
# Detect faces (includes quality scores by default)
|
|
61
|
+
faces1 = detect_faces(img1)
|
|
62
|
+
faces2 = detect_faces(img2)
|
|
63
|
+
|
|
64
|
+
if faces1 and faces2:
|
|
65
|
+
# Pick the best face from each image based on quality score
|
|
66
|
+
face1 = max(faces1, key=lambda x: x.quality_score)
|
|
67
|
+
face2 = max(faces2, key=lambda x: x.quality_score)
|
|
68
|
+
|
|
69
|
+
# Extract embeddings
|
|
70
|
+
face1.embedding = extract_features(img1, face1)
|
|
71
|
+
face2.embedding = extract_features(img2, face2)
|
|
72
|
+
|
|
73
|
+
# Compare faces
|
|
74
|
+
similarity = compare_faces(face1, face2)
|
|
75
|
+
print(f"Similarity: {similarity:.4f}")
|
|
76
|
+
|
|
77
|
+
if similarity > 0.6:
|
|
78
|
+
print("Outcome: Matches (Same Person)")
|
|
79
|
+
else:
|
|
80
|
+
print("Outcome: No Match")
|
|
81
|
+
else:
|
|
82
|
+
print("Error: Could not find faces in one or both images.")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 🛠 Project Structure
|
|
88
|
+
|
|
89
|
+
The library is designed to be developer-friendly and easy to extend:
|
|
90
|
+
|
|
91
|
+
- `similarfaces.detector`: High-performance face detection logic.
|
|
92
|
+
- `similarfaces.aligner`: Face alignment and warping.
|
|
93
|
+
- `similarfaces.scorer`: Quality assessment model.
|
|
94
|
+
- `similarfaces.encoder`: Feature embedding extraction.
|
|
95
|
+
- `similarfaces.models`: Data models including the unified `Face` dataclass.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 📊 Performance
|
|
100
|
+
|
|
101
|
+
| Module | Model | Input Size | Accuracy |
|
|
102
|
+
| :--- | :--- | :--- | :--- |
|
|
103
|
+
| **Detection** | MobileNet-based | 640x640 | High |
|
|
104
|
+
| **Recognition** | IR50-based | 112x112 | SOTA |
|
|
105
|
+
| **Quality** | FaceQuality-ONNX | 128x128 | Robust |
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 📝 License
|
|
110
|
+
|
|
111
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
<p align="center">
|
|
116
|
+
Developed with ❤️ by Narek Bektashyan
|
|
117
|
+
</p>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# similarfaces: High-Performance Face Recognition
|
|
2
|
+
|
|
3
|
+
A production-ready, clean, and robust face recognition pipeline powered by ONNX Runtime.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🌟 Overview
|
|
8
|
+
|
|
9
|
+
**similarfaces** is a streamlined, high-performance Python library for face detection, alignment, quality assessment, and recognition. Designed with modularity and ease of use in mind, it provides a functional API that leverages state-of-the-art models optimized for ONNX Runtime.
|
|
10
|
+
|
|
11
|
+
### ✨ New Key Features
|
|
12
|
+
|
|
13
|
+
- 🏗️ **Functional API**: Clean and intuitive functional wrappers (`detect_faces`, `extract_features`, `compare_faces`, `align_face`) without the need to manually manage processor objects.
|
|
14
|
+
- 📦 **Structured Data Models**: All functions utilize a unified `Face` dataclass, ensuring type safety and easy access to bounding boxes, landmarks, quality scores, and embeddings.
|
|
15
|
+
- 🎯 **Integrated Detection & Quality**: `detect_faces()` now performs both robust face localization and automatic quality assessment in a single, efficient pass.
|
|
16
|
+
- 📐 **Optimal Alignment**: Similarity transforms for standardized 112x112 face cropping.
|
|
17
|
+
- 🧠 **High-accuracy Recognition**: Extract deep feature embeddings for high-accuracy face comparison.
|
|
18
|
+
- ⚡ **ONNX Powered**: Sub-millisecond inference speeds with minimal dependencies across CPU and GPU environments.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## 🚀 Quick Start
|
|
23
|
+
|
|
24
|
+
### Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install -r requirements.txt
|
|
28
|
+
pip install -e . # Install in editable mode for development
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Basic Usage
|
|
32
|
+
|
|
33
|
+
Compare two faces with high-quality filtering using the functional API:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import cv2
|
|
37
|
+
from similarfaces import detect_faces, extract_features, compare_faces
|
|
38
|
+
|
|
39
|
+
# Load images (cv2 loads as BGR)
|
|
40
|
+
img1 = cv2.imread("images/image1.png")
|
|
41
|
+
img2 = cv2.imread("images/image2.png")
|
|
42
|
+
|
|
43
|
+
# Detect faces (includes quality scores by default)
|
|
44
|
+
faces1 = detect_faces(img1)
|
|
45
|
+
faces2 = detect_faces(img2)
|
|
46
|
+
|
|
47
|
+
if faces1 and faces2:
|
|
48
|
+
# Pick the best face from each image based on quality score
|
|
49
|
+
face1 = max(faces1, key=lambda x: x.quality_score)
|
|
50
|
+
face2 = max(faces2, key=lambda x: x.quality_score)
|
|
51
|
+
|
|
52
|
+
# Extract embeddings
|
|
53
|
+
face1.embedding = extract_features(img1, face1)
|
|
54
|
+
face2.embedding = extract_features(img2, face2)
|
|
55
|
+
|
|
56
|
+
# Compare faces
|
|
57
|
+
similarity = compare_faces(face1, face2)
|
|
58
|
+
print(f"Similarity: {similarity:.4f}")
|
|
59
|
+
|
|
60
|
+
if similarity > 0.6:
|
|
61
|
+
print("Outcome: Matches (Same Person)")
|
|
62
|
+
else:
|
|
63
|
+
print("Outcome: No Match")
|
|
64
|
+
else:
|
|
65
|
+
print("Error: Could not find faces in one or both images.")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## 🛠 Project Structure
|
|
71
|
+
|
|
72
|
+
The library is designed to be developer-friendly and easy to extend:
|
|
73
|
+
|
|
74
|
+
- `similarfaces.detector`: High-performance face detection logic.
|
|
75
|
+
- `similarfaces.aligner`: Face alignment and warping.
|
|
76
|
+
- `similarfaces.scorer`: Quality assessment model.
|
|
77
|
+
- `similarfaces.encoder`: Feature embedding extraction.
|
|
78
|
+
- `similarfaces.models`: Data models including the unified `Face` dataclass.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 📊 Performance
|
|
83
|
+
|
|
84
|
+
| Module | Model | Input Size | Accuracy |
|
|
85
|
+
| :--- | :--- | :--- | :--- |
|
|
86
|
+
| **Detection** | MobileNet-based | 640x640 | High |
|
|
87
|
+
| **Recognition** | IR50-based | 112x112 | SOTA |
|
|
88
|
+
| **Quality** | FaceQuality-ONNX | 128x128 | Robust |
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## 📝 License
|
|
93
|
+
|
|
94
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
<p align="center">
|
|
99
|
+
Developed with ❤️ by Narek Bektashyan
|
|
100
|
+
</p>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "similarfaces"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "A production-ready Face Recognition library powered by ONNX"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Narek", email = "your.email@example.com"}
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"numpy>=1.20.0",
|
|
17
|
+
"opencv-python>=4.5.1",
|
|
18
|
+
"onnxruntime>=1.15.0",
|
|
19
|
+
"scikit-image>=0.19.0",
|
|
20
|
+
"scipy>=1.7.0",
|
|
21
|
+
"Pillow>=9.0.0",
|
|
22
|
+
"matplotlib>=3.5.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/narek/similarfaces"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["."]
|
|
30
|
+
include = ["similarfaces*"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from .detector import FaceDetector
|
|
2
|
+
from .encoder import FaceEncoder
|
|
3
|
+
from .aligner import FaceAligner
|
|
4
|
+
from .scorer import FaceQualityScorer
|
|
5
|
+
from .models import Face
|
|
6
|
+
|
|
7
|
+
__version__ = "1.0.0"
|
|
8
|
+
|
|
9
|
+
# Lazy-loaded model instances
|
|
10
|
+
_detector = None
|
|
11
|
+
_encoder = None
|
|
12
|
+
|
|
13
|
+
def _get_detector():
|
|
14
|
+
"""Internal helper for lazy initialization of the FaceDetector."""
|
|
15
|
+
global _detector
|
|
16
|
+
if _detector is None:
|
|
17
|
+
_detector = FaceDetector()
|
|
18
|
+
return _detector
|
|
19
|
+
|
|
20
|
+
def _get_encoder():
|
|
21
|
+
"""Internal helper for lazy initialization of the FaceEncoder."""
|
|
22
|
+
global _encoder
|
|
23
|
+
if _encoder is None:
|
|
24
|
+
_encoder = FaceEncoder()
|
|
25
|
+
return _encoder
|
|
26
|
+
|
|
27
|
+
def detect_faces(image: "np.ndarray", score_quality: bool = True) -> "List[Face]":
|
|
28
|
+
"""
|
|
29
|
+
Detect faces in an image and calculate their quality scores.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
image (np.ndarray): Image in BGR format.
|
|
33
|
+
score_quality (bool): Whether to calculate quality scores (default: True).
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
List[Face]: List of detected faces with bounding boxes, landmarks, and quality.
|
|
37
|
+
"""
|
|
38
|
+
return _get_detector().detect(image, score_quality=score_quality)
|
|
39
|
+
|
|
40
|
+
def extract_features(image: "np.ndarray", face: "Face") -> "np.ndarray":
|
|
41
|
+
"""
|
|
42
|
+
Extract facial features (512-d embedding) from an image.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
image (np.ndarray): Original full image (BGR).
|
|
46
|
+
face (Face): Face object containing landmarks.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
np.ndarray: L2-normalized face embedding vector.
|
|
50
|
+
"""
|
|
51
|
+
return _get_encoder().encode(image, face.landmarks)
|
|
52
|
+
|
|
53
|
+
def align_face(image: "np.ndarray", face: "Face") -> "np.ndarray":
|
|
54
|
+
"""
|
|
55
|
+
Align a face to a standard 112x112 size for identification tasks.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
image (np.ndarray): Original full image (BGR).
|
|
59
|
+
face (Face): Face object containing landmarks.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
np.ndarray: Aligned 112x112 face image.
|
|
63
|
+
"""
|
|
64
|
+
aligned, _ = _get_encoder().aligner.align(image, face.landmarks)
|
|
65
|
+
return aligned
|
|
66
|
+
|
|
67
|
+
def compare_faces(face1: "Face", face2: "Face") -> float:
|
|
68
|
+
"""
|
|
69
|
+
Compute similarity between two faces using their embeddings.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
face1 (Face): First face with embedding.
|
|
73
|
+
face2 (Face): Second face with embedding.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
float: Similarity score (0.0 to 1.0).
|
|
77
|
+
"""
|
|
78
|
+
if face1.embedding is None or face2.embedding is None:
|
|
79
|
+
raise ValueError("Both faces must have embeddings for comparison.")
|
|
80
|
+
import numpy as np
|
|
81
|
+
return float(np.dot(face1.embedding, face2.embedding))
|
|
82
|
+
|
|
83
|
+
__all__ = [
|
|
84
|
+
"Face",
|
|
85
|
+
"FaceDetector",
|
|
86
|
+
"FaceEncoder",
|
|
87
|
+
"FaceAligner",
|
|
88
|
+
"FaceQualityScorer",
|
|
89
|
+
"detect_faces",
|
|
90
|
+
"extract_features",
|
|
91
|
+
"align_face",
|
|
92
|
+
"compare_faces"
|
|
93
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
from skimage.transform import SimilarityTransform
|
|
4
|
+
from typing import Tuple, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FaceAligner:
|
|
8
|
+
"""
|
|
9
|
+
Face Alignement utility using 5-point facial landmarks.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
# Standard 5-point facial landmark reference (ArcFace-style)
|
|
13
|
+
STANDARD_LANDMARK_TEMPLATE = np.array([
|
|
14
|
+
[38.2946, 51.6963], # Left eye
|
|
15
|
+
[73.5318, 51.5014], # Right eye
|
|
16
|
+
[56.0252, 71.7366], # Nose tip
|
|
17
|
+
[41.5493, 92.3655], # Left mouth corner
|
|
18
|
+
[70.7299, 92.2041], # Right mouth corner
|
|
19
|
+
], dtype=np.float32)
|
|
20
|
+
|
|
21
|
+
def __init__(self, target_size: int = 112) -> None:
|
|
22
|
+
self.target_size = target_size
|
|
23
|
+
|
|
24
|
+
def compute_alignment_matrix(self, points: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
25
|
+
"""
|
|
26
|
+
Compute the transformation matrix to align a face.
|
|
27
|
+
"""
|
|
28
|
+
points = np.array(points, dtype=np.float32)
|
|
29
|
+
if points.shape != (5, 2):
|
|
30
|
+
raise ValueError(f"Expected 5-point landmarks, got {points.shape}")
|
|
31
|
+
|
|
32
|
+
scale = self.target_size / 112.0
|
|
33
|
+
ref_points = self.STANDARD_LANDMARK_TEMPLATE.copy() * scale
|
|
34
|
+
|
|
35
|
+
transform = SimilarityTransform()
|
|
36
|
+
transform.estimate(points, ref_points)
|
|
37
|
+
|
|
38
|
+
affine = transform.params[:2, :]
|
|
39
|
+
affine_inv = np.linalg.inv(transform.params)[:2, :]
|
|
40
|
+
return affine, affine_inv
|
|
41
|
+
|
|
42
|
+
def align(self, image: np.ndarray, keypoints: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
|
43
|
+
"""
|
|
44
|
+
Perform facial alignment.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
image: Original image (HWC).
|
|
48
|
+
keypoints: 5-point landmarks (5, 2).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
The aligned face image and the inverse affine matrix.
|
|
52
|
+
"""
|
|
53
|
+
affine_matrix, inverse_matrix = self.compute_alignment_matrix(keypoints)
|
|
54
|
+
aligned_face = cv2.warpAffine(
|
|
55
|
+
image, affine_matrix, (self.target_size, self.target_size), borderValue=0.0
|
|
56
|
+
)
|
|
57
|
+
return aligned_face, inverse_matrix
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import cv2
|
|
2
|
+
import numpy as np
|
|
3
|
+
import onnxruntime as ort
|
|
4
|
+
from typing import List, Tuple, Any
|
|
5
|
+
from .utils import get_model_path
|
|
6
|
+
|
|
7
|
+
from .models import Face
|
|
8
|
+
from .utils import get_model_path
|
|
9
|
+
from .aligner import FaceAligner
|
|
10
|
+
from .scorer import FaceQualityScorer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def letterbox_resize(image: np.ndarray, target_size: Tuple[int, int]) -> Tuple[np.ndarray, float, Tuple[int, int]]:
|
|
14
|
+
"""
|
|
15
|
+
Resize image with unchanged aspect ratio using padding (letterbox).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
image (np.ndarray): Input image in BGR format.
|
|
19
|
+
target_size (tuple): Target (width, height).
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
tuple: (padded_image, scale_factor, (dw, dh) padding).
|
|
23
|
+
"""
|
|
24
|
+
img_h, img_w = image.shape[:2]
|
|
25
|
+
target_w, target_h = target_size
|
|
26
|
+
|
|
27
|
+
scale = min(target_w / img_w, target_h / img_h)
|
|
28
|
+
new_w = int(img_w * scale)
|
|
29
|
+
new_h = int(img_h * scale)
|
|
30
|
+
|
|
31
|
+
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
|
|
32
|
+
|
|
33
|
+
dw = target_w - new_w
|
|
34
|
+
dh = target_h - new_h
|
|
35
|
+
top, bottom = dh // 2, dh - (dh // 2)
|
|
36
|
+
left, right = dw // 2, dw - (dw // 2)
|
|
37
|
+
|
|
38
|
+
padded = cv2.copyMakeBorder(
|
|
39
|
+
resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=(0, 0, 0)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
return padded, scale, (dw, dh)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class FaceDetector:
|
|
46
|
+
"""
|
|
47
|
+
High-performance face detector for localization and landmark extraction.
|
|
48
|
+
Integrates quality scoring to provide a comprehensive detection result.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
model_path: str = None,
|
|
54
|
+
input_size: Tuple[int, int] = (640, 640),
|
|
55
|
+
score_threshold: float = 0.8,
|
|
56
|
+
nms_threshold: float = 0.4,
|
|
57
|
+
use_letterbox: bool = True,
|
|
58
|
+
providers: List[str] = ["CPUExecutionProvider"]
|
|
59
|
+
):
|
|
60
|
+
"""
|
|
61
|
+
Initialize the FaceDetector.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
model_path (str, optional): Path to the detection ONNX model.
|
|
65
|
+
input_size (tuple): Model input size (width, height). Defaults to (640, 640).
|
|
66
|
+
score_threshold (float): Confidence threshold for detection. Defaults to 0.8.
|
|
67
|
+
nms_threshold (float): Non-maximum suppression threshold. Defaults to 0.4.
|
|
68
|
+
use_letterbox (bool): Whether to use letterbox resizing. Defaults to True.
|
|
69
|
+
providers (list): ONNX Runtime execution providers.
|
|
70
|
+
"""
|
|
71
|
+
self.model_path = model_path or get_model_path("detection.onnx")
|
|
72
|
+
self.input_width, self.input_height = input_size
|
|
73
|
+
self.score_threshold = score_threshold
|
|
74
|
+
self.nms_threshold = nms_threshold
|
|
75
|
+
self.use_letterbox = use_letterbox
|
|
76
|
+
self.providers = providers
|
|
77
|
+
|
|
78
|
+
# Anchor settings
|
|
79
|
+
self.min_sizes = [[16, 32], [64, 128], [256, 512]]
|
|
80
|
+
self.steps = [8, 16, 32]
|
|
81
|
+
self.variance = [0.1, 0.2]
|
|
82
|
+
|
|
83
|
+
# Core session
|
|
84
|
+
try:
|
|
85
|
+
self.session = ort.InferenceSession(self.model_path, providers=providers)
|
|
86
|
+
self.input_name = self.session.get_inputs()[0].name
|
|
87
|
+
self.output_names = [out.name for out in self.session.get_outputs()]
|
|
88
|
+
except Exception as e:
|
|
89
|
+
raise RuntimeError(f"Failed to load detection model: {e}")
|
|
90
|
+
|
|
91
|
+
# Lazy-loaded assistants for integrated quality scoring
|
|
92
|
+
self._aligner = None
|
|
93
|
+
self._scorer = None
|
|
94
|
+
|
|
95
|
+
self.priors = self._generate_priors((self.input_height, self.input_width))
|
|
96
|
+
|
|
97
|
+
def _generate_priors(self, image_size: Tuple[int, int]) -> np.ndarray:
|
|
98
|
+
"""Generate anchor boxes for detection."""
|
|
99
|
+
anchors = []
|
|
100
|
+
im_h, im_w = image_size
|
|
101
|
+
|
|
102
|
+
for k, step in enumerate(self.steps):
|
|
103
|
+
f_h = im_h // step
|
|
104
|
+
f_w = im_w // step
|
|
105
|
+
for i in range(f_h):
|
|
106
|
+
for j in range(f_w):
|
|
107
|
+
for min_size in self.min_sizes[k]:
|
|
108
|
+
s_kx = min_size / im_w
|
|
109
|
+
s_ky = min_size / im_h
|
|
110
|
+
cx = (j + 0.5) * step / im_w
|
|
111
|
+
cy = (i + 0.5) * step / im_h
|
|
112
|
+
anchors.append([cx, cy, s_kx, s_ky])
|
|
113
|
+
return np.array(anchors, dtype=np.float32)
|
|
114
|
+
|
|
115
|
+
def _decode_boxes(self, loc: np.ndarray, priors: np.ndarray) -> np.ndarray:
|
|
116
|
+
"""Decode bounding boxes from model output and priors."""
|
|
117
|
+
boxes = np.concatenate([
|
|
118
|
+
priors[:, :2] + loc[:, :2] * self.variance[0] * priors[:, 2:],
|
|
119
|
+
priors[:, 2:] * np.exp(loc[:, 2:] * self.variance[1])
|
|
120
|
+
], axis=1)
|
|
121
|
+
boxes[:, :2] -= boxes[:, 2:] / 2
|
|
122
|
+
boxes[:, 2:] += boxes[:, :2]
|
|
123
|
+
return boxes
|
|
124
|
+
|
|
125
|
+
def _decode_landmarks(self, ldm: np.ndarray, priors: np.ndarray) -> np.ndarray:
|
|
126
|
+
"""Decode facial landmarks from model output and priors."""
|
|
127
|
+
landmarks = np.concatenate([
|
|
128
|
+
priors[:, :2] + ldm[:, i:i+2] * self.variance[0] * priors[:, 2:]
|
|
129
|
+
for i in range(0, 10, 2)
|
|
130
|
+
], axis=1)
|
|
131
|
+
return landmarks.reshape(-1, 5, 2)
|
|
132
|
+
|
|
133
|
+
def preprocess(self, image: np.ndarray) -> Tuple[np.ndarray, float, Tuple[int, int]]:
|
|
134
|
+
"""Preprocess image for detection."""
|
|
135
|
+
if self.use_letterbox:
|
|
136
|
+
resized, scale, pad = letterbox_resize(image, (self.input_width, self.input_height))
|
|
137
|
+
else:
|
|
138
|
+
resized = cv2.resize(image, (self.input_width, self.input_height))
|
|
139
|
+
scale = self.input_width / image.shape[1]
|
|
140
|
+
pad = (0, 0)
|
|
141
|
+
|
|
142
|
+
img = resized.astype(np.float32)
|
|
143
|
+
img -= np.array([104, 117, 123], dtype=np.float32)
|
|
144
|
+
img = img.transpose(2, 0, 1)
|
|
145
|
+
img = np.expand_dims(img, axis=0)
|
|
146
|
+
return img, scale, pad
|
|
147
|
+
|
|
148
|
+
def postprocess(
|
|
149
|
+
self,
|
|
150
|
+
outputs: List[np.ndarray],
|
|
151
|
+
scale: float,
|
|
152
|
+
pad: Tuple[int, int],
|
|
153
|
+
orig_shape: Tuple[int, int]
|
|
154
|
+
) -> List[Face]:
|
|
155
|
+
"""Convert model outputs into Face objects."""
|
|
156
|
+
loc, conf, landms = outputs[0][0], outputs[1][0], outputs[2][0]
|
|
157
|
+
|
|
158
|
+
scores = conf[:, 1]
|
|
159
|
+
mask = scores > self.score_threshold
|
|
160
|
+
if not np.any(mask):
|
|
161
|
+
return []
|
|
162
|
+
|
|
163
|
+
loc, scores, priors, landms = loc[mask], scores[mask], self.priors[mask], landms[mask]
|
|
164
|
+
|
|
165
|
+
boxes = self._decode_boxes(loc, priors)
|
|
166
|
+
landmarks = self._decode_landmarks(landms, priors)
|
|
167
|
+
|
|
168
|
+
# Rescale
|
|
169
|
+
boxes[:, [0, 2]] *= self.input_width
|
|
170
|
+
boxes[:, [1, 3]] *= self.input_height
|
|
171
|
+
landmarks[:, :, 0] *= self.input_width
|
|
172
|
+
landmarks[:, :, 1] *= self.input_height
|
|
173
|
+
|
|
174
|
+
# Subtract padding
|
|
175
|
+
dw, dh = pad
|
|
176
|
+
boxes[:, [0, 2]] -= dw // 2
|
|
177
|
+
boxes[:, [1, 3]] -= dh // 2
|
|
178
|
+
landmarks[:, :, 0] -= dw // 2
|
|
179
|
+
landmarks[:, :, 1] -= dh // 2
|
|
180
|
+
|
|
181
|
+
# Final scale
|
|
182
|
+
boxes /= scale
|
|
183
|
+
landmarks /= scale
|
|
184
|
+
|
|
185
|
+
# Clip
|
|
186
|
+
orig_h, orig_w = orig_shape
|
|
187
|
+
boxes[:, [0, 2]] = np.clip(boxes[:, [0, 2]], 0, orig_w)
|
|
188
|
+
boxes[:, [1, 3]] = np.clip(boxes[:, [1, 3]], 0, orig_h)
|
|
189
|
+
landmarks[:, :, 0] = np.clip(landmarks[:, :, 0], 0, orig_w)
|
|
190
|
+
landmarks[:, :, 1] = np.clip(landmarks[:, :, 1], 0, orig_h)
|
|
191
|
+
|
|
192
|
+
# NMS
|
|
193
|
+
keep = cv2.dnn.NMSBoxes(boxes.tolist(), scores.tolist(), 0.1, self.nms_threshold)
|
|
194
|
+
|
|
195
|
+
results = []
|
|
196
|
+
if len(keep) > 0:
|
|
197
|
+
indices = np.array(keep).flatten()
|
|
198
|
+
for idx in indices:
|
|
199
|
+
results.append(Face(
|
|
200
|
+
bbox=boxes[idx],
|
|
201
|
+
score=float(scores[idx]),
|
|
202
|
+
landmarks=landmarks[idx]
|
|
203
|
+
))
|
|
204
|
+
return results
|
|
205
|
+
|
|
206
|
+
def detect(self, image: np.ndarray, score_quality: bool = True) -> List[Face]:
|
|
207
|
+
"""
|
|
208
|
+
Detect faces and optionally assess their quality.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
image (np.ndarray): Image in BGR format.
|
|
212
|
+
score_quality (bool): Whether to calculate quality scores for each face.
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
List[Face]: Detected faces.
|
|
216
|
+
"""
|
|
217
|
+
if image is None or image.size == 0:
|
|
218
|
+
return []
|
|
219
|
+
|
|
220
|
+
# 1. Base Detection
|
|
221
|
+
orig_shape = image.shape[:2]
|
|
222
|
+
input_tensor, scale, pad = self.preprocess(image)
|
|
223
|
+
outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
|
|
224
|
+
faces = self.postprocess(outputs, scale, pad, orig_shape)
|
|
225
|
+
|
|
226
|
+
# 2. Integrated Quality Check
|
|
227
|
+
if score_quality and faces:
|
|
228
|
+
if self._aligner is None:
|
|
229
|
+
self._aligner = FaceAligner()
|
|
230
|
+
if self._scorer is None:
|
|
231
|
+
self._scorer = FaceQualityScorer(providers=self.providers)
|
|
232
|
+
|
|
233
|
+
for face in faces:
|
|
234
|
+
aligned, _ = self._aligner.align(image, face.landmarks)
|
|
235
|
+
face.quality_score = self._scorer.score(aligned)
|
|
236
|
+
|
|
237
|
+
return faces
|
|
238
|
+
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import cv2
|
|
3
|
+
import numpy as np
|
|
4
|
+
from onnxruntime import InferenceSession
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
from .utils import get_model_path
|
|
7
|
+
from .aligner import FaceAligner
|
|
8
|
+
|
|
9
|
+
from .models import Face
|
|
10
|
+
from .utils import get_model_path
|
|
11
|
+
from .aligner import FaceAligner
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FaceEncoder:
|
|
15
|
+
"""
|
|
16
|
+
Face Encoder for extracting robust face embeddings.
|
|
17
|
+
Uses ONNX Runtime for inference. Default embedding size is 512.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, model_path: Optional[str] = None, providers: List[str] = ["CPUExecutionProvider"]) -> None:
|
|
21
|
+
"""
|
|
22
|
+
Initialize the FaceEncoder.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
model_path (str, optional): Path to the embedding ONNX model.
|
|
26
|
+
providers (list): ONNX Runtime execution providers.
|
|
27
|
+
"""
|
|
28
|
+
self.model_path = model_path or get_model_path("features_extraction.onnx")
|
|
29
|
+
self.input_size = (112, 112)
|
|
30
|
+
self.normalization_mean = 127.5
|
|
31
|
+
self.normalization_scale = 127.5
|
|
32
|
+
self.aligner = FaceAligner()
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
self.session = InferenceSession(self.model_path, providers=providers)
|
|
36
|
+
input_config = self.session.get_inputs()[0]
|
|
37
|
+
self.input_name = input_config.name
|
|
38
|
+
self.output_names = [o.name for o in self.session.get_outputs()]
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise RuntimeError(f"Failed to initialize Face Embedding model: {e}")
|
|
41
|
+
|
|
42
|
+
def preprocess(self, face_image: np.ndarray) -> np.ndarray:
|
|
43
|
+
"""
|
|
44
|
+
Resize, normalize and convert cropped face to NCHW format.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
face_image (np.ndarray): Cropped face image (BGR).
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
np.ndarray: Preprocessed tensor (1, 3, 112, 112).
|
|
51
|
+
"""
|
|
52
|
+
resized_face = cv2.resize(face_image, self.input_size)
|
|
53
|
+
face_blob = cv2.dnn.blobFromImage(
|
|
54
|
+
resized_face,
|
|
55
|
+
scalefactor=1.0 / self.normalization_scale,
|
|
56
|
+
size=self.input_size,
|
|
57
|
+
mean=(self.normalization_mean,) * 3,
|
|
58
|
+
swapRB=True
|
|
59
|
+
)
|
|
60
|
+
return face_blob
|
|
61
|
+
|
|
62
|
+
def encode_aligned(self, aligned_face: np.ndarray, normalize: bool = True) -> np.ndarray:
|
|
63
|
+
"""
|
|
64
|
+
Extract embedding from an already aligned face image.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
aligned_face (np.ndarray): Aligned face image (112x112).
|
|
68
|
+
normalize (bool): Whether to L2-normalize the output vector.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
np.ndarray: Face embedding vector (512-d).
|
|
72
|
+
"""
|
|
73
|
+
face_blob = self.preprocess(aligned_face)
|
|
74
|
+
embedding = self.session.run(self.output_names, {self.input_name: face_blob})[0]
|
|
75
|
+
embedding = embedding.flatten()
|
|
76
|
+
|
|
77
|
+
if normalize:
|
|
78
|
+
norm = np.linalg.norm(embedding)
|
|
79
|
+
if norm > 1e-6:
|
|
80
|
+
embedding /= norm
|
|
81
|
+
return embedding
|
|
82
|
+
|
|
83
|
+
def encode(
|
|
84
|
+
self,
|
|
85
|
+
image: np.ndarray,
|
|
86
|
+
keypoints: np.ndarray,
|
|
87
|
+
normalize: bool = True
|
|
88
|
+
) -> np.ndarray:
|
|
89
|
+
"""
|
|
90
|
+
Align face and extract embedding in one step.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
image (np.ndarray): Full image in BGR.
|
|
94
|
+
keypoints (np.ndarray): 5-point facial landmarks.
|
|
95
|
+
normalize (bool): Whether to L2-normalize the output vector.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
np.ndarray: Face embedding vector.
|
|
99
|
+
"""
|
|
100
|
+
aligned_face, _ = self.aligner.align(image, keypoints)
|
|
101
|
+
return self.encode_aligned(aligned_face, normalize=normalize)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import List, Optional, Union
|
|
3
|
+
import numpy as np
|
|
4
|
+
import cv2
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Face:
|
|
8
|
+
"""
|
|
9
|
+
Structured representation of a detected face.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
bbox (np.ndarray): Bounding box in [x1, y1, x2, y2] format.
|
|
13
|
+
score (float): Confidence score of the detection.
|
|
14
|
+
landmarks (Optional[np.ndarray]): 5-point facial landmarks (eyes, nose, mouth corners).
|
|
15
|
+
quality_score (Optional[float]): Quality score of the face (0.0 to 1.0).
|
|
16
|
+
embedding (Optional[np.ndarray]): 512-dimensional face embedding (L2 normalized).
|
|
17
|
+
"""
|
|
18
|
+
bbox: np.ndarray
|
|
19
|
+
score: float
|
|
20
|
+
landmarks: Optional[np.ndarray] = None
|
|
21
|
+
quality_score: Optional[float] = None
|
|
22
|
+
embedding: Optional[np.ndarray] = None
|
|
23
|
+
|
|
24
|
+
def to_dict(self) -> dict:
|
|
25
|
+
"""Convert the Face object to a JSON-serializable dictionary."""
|
|
26
|
+
return {
|
|
27
|
+
"bbox": self.bbox.tolist(),
|
|
28
|
+
"score": float(self.score),
|
|
29
|
+
"landmarks": self.landmarks.tolist() if self.landmarks is not None else None,
|
|
30
|
+
"quality_score": float(self.quality_score) if self.quality_score is not None else None,
|
|
31
|
+
"embedding": self.embedding.tolist() if self.embedding is not None else None,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
def draw(self, image: np.ndarray, color=(0, 255, 0), thickness=2) -> np.ndarray:
|
|
35
|
+
"""
|
|
36
|
+
Draw bounding box and landmarks on the provided image.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
image (np.ndarray): Image to draw on (BGR).
|
|
40
|
+
color (tuple): BGR color for the box and points.
|
|
41
|
+
thickness (int): Line thickness.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
np.ndarray: Image with visualizations.
|
|
45
|
+
"""
|
|
46
|
+
img = image.copy()
|
|
47
|
+
x1, y1, x2, y2 = map(int, self.bbox)
|
|
48
|
+
cv2.rectangle(img, (x1, y1), (x2, y2), color, thickness)
|
|
49
|
+
|
|
50
|
+
if self.landmarks is not None:
|
|
51
|
+
for pt in self.landmarks:
|
|
52
|
+
cv2.circle(img, (int(pt[0]), int(pt[1])), 2, color, -1)
|
|
53
|
+
|
|
54
|
+
# Draw quality score if available
|
|
55
|
+
if self.quality_score is not None:
|
|
56
|
+
cv2.putText(img, f"Q: {self.quality_score:.2f}", (x1, y1 - 10),
|
|
57
|
+
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
|
|
58
|
+
|
|
59
|
+
return img
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import onnxruntime as ort
|
|
2
|
+
import numpy as np
|
|
3
|
+
import cv2
|
|
4
|
+
from PIL import Image
|
|
5
|
+
from typing import List, Union, Optional
|
|
6
|
+
from .utils import get_model_path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
from .models import Face
|
|
10
|
+
from .utils import get_model_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FaceQualityScorer:
|
|
14
|
+
"""
|
|
15
|
+
ONNX-based Face Quality Scorer.
|
|
16
|
+
Determines if a face image (aligned or cropped) is suitable for recognition.
|
|
17
|
+
A higher score (closer to 1.0) means higher quality.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, model_path: Optional[str] = None, providers: List[str] = ["CPUExecutionProvider"]):
|
|
21
|
+
"""
|
|
22
|
+
Initialize the FaceQualityScorer.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
model_path (str, optional): Path to the quality assessment ONNX model.
|
|
26
|
+
providers (list): ONNX Runtime execution providers.
|
|
27
|
+
"""
|
|
28
|
+
self.model_path = model_path or get_model_path("quality_assessment.onnx")
|
|
29
|
+
|
|
30
|
+
# Ensure the external weights data file is downloaded
|
|
31
|
+
if not model_path:
|
|
32
|
+
get_model_path("model.onnx.data")
|
|
33
|
+
try:
|
|
34
|
+
self.session = ort.InferenceSession(self.model_path, providers=providers)
|
|
35
|
+
self.input_name = self.session.get_inputs()[0].name
|
|
36
|
+
except Exception as e:
|
|
37
|
+
raise RuntimeError(f"Failed to load quality model from {self.model_path}: {e}")
|
|
38
|
+
|
|
39
|
+
# Normalization parameters (ImageNet)
|
|
40
|
+
self.input_size = (128, 128)
|
|
41
|
+
self.mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
|
42
|
+
self.std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
|
43
|
+
|
|
44
|
+
def _preprocess(self, image: Union[np.ndarray, Image.Image]) -> np.ndarray:
|
|
45
|
+
"""
|
|
46
|
+
Preprocess single image for quality scoring.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
image (Union[np.ndarray, Image.Image]): Input image.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
np.ndarray: Preprocessed tensor (1, 3, 128, 128).
|
|
53
|
+
"""
|
|
54
|
+
# Handle color channel conversion (assuming BGR for numpy array from cv2)
|
|
55
|
+
if isinstance(image, np.ndarray):
|
|
56
|
+
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
|
57
|
+
elif isinstance(image, Image.Image):
|
|
58
|
+
image = np.array(image.convert("RGB"))
|
|
59
|
+
|
|
60
|
+
# Resize
|
|
61
|
+
img = cv2.resize(image, self.input_size)
|
|
62
|
+
|
|
63
|
+
# Normalize
|
|
64
|
+
img = img.astype(np.float32) / 255.0
|
|
65
|
+
img = (img - self.mean) / self.std
|
|
66
|
+
|
|
67
|
+
# CHW + Batch
|
|
68
|
+
img = np.transpose(img, (2, 0, 1))
|
|
69
|
+
img = np.expand_dims(img, axis=0)
|
|
70
|
+
return img
|
|
71
|
+
|
|
72
|
+
def score(self, image: Union[np.ndarray, Image.Image]) -> float:
|
|
73
|
+
"""
|
|
74
|
+
Predict quality score for a single face image.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
image (Union[np.ndarray, Image.Image]): Cropped or aligned face image.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
float: Quality score, typically between 0.0 and 1.0.
|
|
81
|
+
"""
|
|
82
|
+
input_tensor = self._preprocess(image)
|
|
83
|
+
outputs = self.session.run(None, {self.input_name: input_tensor})
|
|
84
|
+
return float(outputs[0][0, 0])
|
|
85
|
+
|
|
86
|
+
def score_batch(self, images: List[Union[np.ndarray, Image.Image]]) -> List[float]:
|
|
87
|
+
"""
|
|
88
|
+
Predict quality scores for a batch of images.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
images (list): List of cropped/aligned face images.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
List[float]: List of quality scores.
|
|
95
|
+
"""
|
|
96
|
+
return [self.score(img) for img in images]
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import cv2
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
HF_MODEL_REPOS = {
|
|
9
|
+
"detection.onnx": "https://huggingface.co/similarfaces/face-detector/resolve/main/",
|
|
10
|
+
"features_extraction.onnx": "https://huggingface.co/similarfaces/face-features/resolve/main/",
|
|
11
|
+
"quality_assessment.onnx": "https://huggingface.co/similarfaces/face-quality/resolve/main/",
|
|
12
|
+
"model.onnx.data": "https://huggingface.co/similarfaces/face-quality/resolve/main/",
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
def download_model(model_name: str, model_path: str):
|
|
16
|
+
"""Download a model from Hugging Face if it doesn't exist locally."""
|
|
17
|
+
if not os.path.exists(model_path):
|
|
18
|
+
base_url = HF_MODEL_REPOS.get(model_name, "https://huggingface.co/similarfaces/face-quality/resolve/main/")
|
|
19
|
+
url = base_url + model_name
|
|
20
|
+
print(f"Downloading {model_name} from Hugging Face...")
|
|
21
|
+
try:
|
|
22
|
+
os.makedirs(os.path.dirname(model_path), exist_ok=True)
|
|
23
|
+
|
|
24
|
+
def reporthook(count, block_size, total_size):
|
|
25
|
+
if total_size > 0:
|
|
26
|
+
percent = int(count * block_size * 100 / total_size)
|
|
27
|
+
sys.stdout.write(f"\rDownloading... {min(percent, 100)}%")
|
|
28
|
+
sys.stdout.flush()
|
|
29
|
+
|
|
30
|
+
urllib.request.urlretrieve(url, model_path, reporthook=reporthook)
|
|
31
|
+
print() # new line after progress
|
|
32
|
+
except Exception as e:
|
|
33
|
+
print(f"\nFailed to download {model_name}: {e}")
|
|
34
|
+
raise RuntimeError(f"Could not download model {model_name} from {url}: {e}")
|
|
35
|
+
|
|
36
|
+
def get_model_path(model_name: str) -> str:
|
|
37
|
+
"""Get the absolute path to a model file within the package."""
|
|
38
|
+
model_path = str(Path(__file__).parent / "models" / model_name)
|
|
39
|
+
download_model(model_name, model_path)
|
|
40
|
+
return model_path
|
|
41
|
+
|
|
42
|
+
def crop_face(image: np.ndarray, box: np.ndarray) -> np.ndarray:
|
|
43
|
+
"""
|
|
44
|
+
Crop face from image based on bounding box.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
image: Original image in BGR/RGB format.
|
|
48
|
+
box: [x1, y1, x2, y2] bounding box.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Cropped face image.
|
|
52
|
+
"""
|
|
53
|
+
x1, y1, x2, y2 = map(int, box)
|
|
54
|
+
return image[max(0, y1):y2, max(0, x1):x2]
|
|
55
|
+
|
|
56
|
+
def adjust_keypoints(keypoints: np.ndarray, crop_box: np.ndarray) -> np.ndarray:
|
|
57
|
+
"""
|
|
58
|
+
Adjust keypoints from original image coordinates to cropped region coordinates.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
keypoints: Array of [x, y] points for the original image.
|
|
62
|
+
crop_box: [x1, y1, x2, y2] bounding box of the cropped region.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Adjusted keypoints for the cropped region.
|
|
66
|
+
"""
|
|
67
|
+
x1, y1, _, _ = map(int, crop_box)
|
|
68
|
+
keypoints = np.array(keypoints, dtype=np.float32)
|
|
69
|
+
keypoints[:, 0] -= x1
|
|
70
|
+
keypoints[:, 1] -= y1
|
|
71
|
+
return keypoints
|
|
72
|
+
|
|
73
|
+
def draw_point(image: np.ndarray, point: tuple, color=(0, 255, 0), radius=2):
|
|
74
|
+
"""Draw a point on the image."""
|
|
75
|
+
cv2.circle(image, (int(point[0]), int(point[1])), radius, color, -1)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: similarfaces
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A production-ready Face Recognition library powered by ONNX
|
|
5
|
+
Author-email: Narek <your.email@example.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/narek/similarfaces
|
|
8
|
+
Requires-Python: >=3.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: numpy>=1.20.0
|
|
11
|
+
Requires-Dist: opencv-python>=4.5.1
|
|
12
|
+
Requires-Dist: onnxruntime>=1.15.0
|
|
13
|
+
Requires-Dist: scikit-image>=0.19.0
|
|
14
|
+
Requires-Dist: scipy>=1.7.0
|
|
15
|
+
Requires-Dist: Pillow>=9.0.0
|
|
16
|
+
Requires-Dist: matplotlib>=3.5.0
|
|
17
|
+
|
|
18
|
+
# similarfaces: High-Performance Face Recognition
|
|
19
|
+
|
|
20
|
+
A production-ready, clean, and robust face recognition pipeline powered by ONNX Runtime.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 🌟 Overview
|
|
25
|
+
|
|
26
|
+
**similarfaces** is a streamlined, high-performance Python library for face detection, alignment, quality assessment, and recognition. Designed with modularity and ease of use in mind, it provides a functional API that leverages state-of-the-art models optimized for ONNX Runtime.
|
|
27
|
+
|
|
28
|
+
### ✨ New Key Features
|
|
29
|
+
|
|
30
|
+
- 🏗️ **Functional API**: Clean and intuitive functional wrappers (`detect_faces`, `extract_features`, `compare_faces`, `align_face`) without the need to manually manage processor objects.
|
|
31
|
+
- 📦 **Structured Data Models**: All functions utilize a unified `Face` dataclass, ensuring type safety and easy access to bounding boxes, landmarks, quality scores, and embeddings.
|
|
32
|
+
- 🎯 **Integrated Detection & Quality**: `detect_faces()` now performs both robust face localization and automatic quality assessment in a single, efficient pass.
|
|
33
|
+
- 📐 **Optimal Alignment**: Similarity transforms for standardized 112x112 face cropping.
|
|
34
|
+
- 🧠 **High-accuracy Recognition**: Extract deep feature embeddings for high-accuracy face comparison.
|
|
35
|
+
- ⚡ **ONNX Powered**: Sub-millisecond inference speeds with minimal dependencies across CPU and GPU environments.
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 🚀 Quick Start
|
|
40
|
+
|
|
41
|
+
### Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install -r requirements.txt
|
|
45
|
+
pip install -e . # Install in editable mode for development
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Basic Usage
|
|
49
|
+
|
|
50
|
+
Compare two faces with high-quality filtering using the functional API:
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
import cv2
|
|
54
|
+
from similarfaces import detect_faces, extract_features, compare_faces
|
|
55
|
+
|
|
56
|
+
# Load images (cv2 loads as BGR)
|
|
57
|
+
img1 = cv2.imread("images/image1.png")
|
|
58
|
+
img2 = cv2.imread("images/image2.png")
|
|
59
|
+
|
|
60
|
+
# Detect faces (includes quality scores by default)
|
|
61
|
+
faces1 = detect_faces(img1)
|
|
62
|
+
faces2 = detect_faces(img2)
|
|
63
|
+
|
|
64
|
+
if faces1 and faces2:
|
|
65
|
+
# Pick the best face from each image based on quality score
|
|
66
|
+
face1 = max(faces1, key=lambda x: x.quality_score)
|
|
67
|
+
face2 = max(faces2, key=lambda x: x.quality_score)
|
|
68
|
+
|
|
69
|
+
# Extract embeddings
|
|
70
|
+
face1.embedding = extract_features(img1, face1)
|
|
71
|
+
face2.embedding = extract_features(img2, face2)
|
|
72
|
+
|
|
73
|
+
# Compare faces
|
|
74
|
+
similarity = compare_faces(face1, face2)
|
|
75
|
+
print(f"Similarity: {similarity:.4f}")
|
|
76
|
+
|
|
77
|
+
if similarity > 0.6:
|
|
78
|
+
print("Outcome: Matches (Same Person)")
|
|
79
|
+
else:
|
|
80
|
+
print("Outcome: No Match")
|
|
81
|
+
else:
|
|
82
|
+
print("Error: Could not find faces in one or both images.")
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## 🛠 Project Structure
|
|
88
|
+
|
|
89
|
+
The library is designed to be developer-friendly and easy to extend:
|
|
90
|
+
|
|
91
|
+
- `similarfaces.detector`: High-performance face detection logic.
|
|
92
|
+
- `similarfaces.aligner`: Face alignment and warping.
|
|
93
|
+
- `similarfaces.scorer`: Quality assessment model.
|
|
94
|
+
- `similarfaces.encoder`: Feature embedding extraction.
|
|
95
|
+
- `similarfaces.models`: Data models including the unified `Face` dataclass.
|
|
96
|
+
|
|
97
|
+
---
|
|
98
|
+
|
|
99
|
+
## 📊 Performance
|
|
100
|
+
|
|
101
|
+
| Module | Model | Input Size | Accuracy |
|
|
102
|
+
| :--- | :--- | :--- | :--- |
|
|
103
|
+
| **Detection** | MobileNet-based | 640x640 | High |
|
|
104
|
+
| **Recognition** | IR50-based | 112x112 | SOTA |
|
|
105
|
+
| **Quality** | FaceQuality-ONNX | 128x128 | Robust |
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## 📝 License
|
|
110
|
+
|
|
111
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
<p align="center">
|
|
116
|
+
Developed with ❤️ by Narek Bektashyan
|
|
117
|
+
</p>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
similarfaces/__init__.py
|
|
4
|
+
similarfaces/aligner.py
|
|
5
|
+
similarfaces/detector.py
|
|
6
|
+
similarfaces/encoder.py
|
|
7
|
+
similarfaces/models.py
|
|
8
|
+
similarfaces/scorer.py
|
|
9
|
+
similarfaces/utils.py
|
|
10
|
+
similarfaces.egg-info/PKG-INFO
|
|
11
|
+
similarfaces.egg-info/SOURCES.txt
|
|
12
|
+
similarfaces.egg-info/dependency_links.txt
|
|
13
|
+
similarfaces.egg-info/requires.txt
|
|
14
|
+
similarfaces.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
similarfaces
|