Anjith George commited on
Commit
fc96fb2
·
1 Parent(s): bbb9c6c

First version of the demo

Browse files
.gitattributes CHANGED
@@ -6,6 +6,7 @@
6
  *.ftz filter=lfs diff=lfs merge=lfs -text
7
  *.gz filter=lfs diff=lfs merge=lfs -text
8
  *.h5 filter=lfs diff=lfs merge=lfs -text
 
9
  *.joblib filter=lfs diff=lfs merge=lfs -text
10
  *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
  *.mlmodel filter=lfs diff=lfs merge=lfs -text
 
6
  *.ftz filter=lfs diff=lfs merge=lfs -text
7
  *.gz filter=lfs diff=lfs merge=lfs -text
8
  *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
10
  *.joblib filter=lfs diff=lfs merge=lfs -text
11
  *.lfs.* filter=lfs diff=lfs merge=lfs -text
12
  *.mlmodel filter=lfs diff=lfs merge=lfs -text
LICENSES/BSD-3-Clause.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright (c) <year> <owner>.
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10
+
11
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
README.md CHANGED
@@ -1,14 +1,13 @@
1
  ---
2
  title: EdgeFace
3
- emoji: 🌍
4
  colorFrom: red
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 5.26.0
8
  app_file: app.py
9
  pinned: false
10
  license: bsd-3-clause
11
  short_description: EdgeFace Demo
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
  ---
2
  title: EdgeFace
3
+ emoji:
4
  colorFrom: red
5
+ colorTo: yellow
6
  sdk: gradio
7
  sdk_version: 5.26.0
8
  app_file: app.py
9
  pinned: false
10
  license: bsd-3-clause
11
  short_description: EdgeFace Demo
12
+ python_version: "3.11"
13
+ ---
 
__init__.py ADDED
File without changes
app.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Idiap Research Institute
2
+ # SPDX-FileContributor: Anjith George
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ """EdgeFace demo"""
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+ import cv2
10
+ import gradio as gr
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn.functional as F
14
+ from torchvision import transforms
15
+
16
+ from utils import align_crop
17
+
18
+ # ───────────────────────────────
19
+ # Data & models
20
+ # ───────────────────────────────
21
+ DATA_DIR = Path("data")
22
+ EXTS = (".jpg", ".jpeg", ".png", ".bmp", ".webp")
23
+ PRELOADED = sorted(p for p in DATA_DIR.iterdir() if p.suffix.lower() in EXTS)
24
+
25
+ EDGE_MODELS = [
26
+ "edgeface_base",
27
+ "edgeface_s_gamma_05",
28
+ "edgeface_xs_gamma_06",
29
+ "edgeface_xxs",
30
+ ]
31
+
32
+ # ───────────────────────────────
33
+ # Styling (orange palette)
34
+ # ───────────────────────────────
35
+ PRIMARY = "#F97316"
36
+ PRIMARY_DARK = "#C2410C"
37
+ ACCENT_LIGHT = "#FFEAD2"
38
+ BG_LIGHT = "#FFFBF7"
39
+ TEXT_DARK = "#0F172A"
40
+
41
+ CSS = f"""
42
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
43
+
44
+ /* ─── palette ───────────────────────────────────────────── */
45
+ body {{
46
+ font-family:'Inter',sans-serif;
47
+ background:{BG_LIGHT};
48
+ color:{TEXT_DARK};
49
+ }}
50
+
51
+ a {{
52
+ color:{PRIMARY};
53
+ text-decoration:none;
54
+ font-weight:600;
55
+ }}
56
+ a:hover {{color:{PRIMARY_DARK}}}
57
+
58
+ /* ─── headline ──────────────────────────────────────────── */
59
+ #titlebar {{
60
+ text-align:center;
61
+ margin-top:2.4rem;
62
+ margin-bottom:.9rem;
63
+ }}
64
+ #edgeface-title {{
65
+ font-size:2.6rem;
66
+ font-weight:800;
67
+ margin:0;
68
+ line-height:1.25;
69
+ }}
70
+ #edgeface-title .brand {{
71
+ background:linear-gradient(90deg,{PRIMARY} 0%,{PRIMARY_DARK} 90%);
72
+ -webkit-background-clip:text;
73
+ color:transparent;
74
+ }}
75
+
76
+ /* ─── card look ─────────────────────────────────────────── */
77
+ .gr-block,
78
+ .gr-box,
79
+ .gr-row,
80
+ #cite-wrapper {{
81
+ border:1px solid #F8C89B;
82
+ border-radius:10px;
83
+ background:#fff;
84
+ box-shadow:0 3px 6px rgba(0,0,0,.05);
85
+ }}
86
+ .gr-gallery-item {{background:#fff}}
87
+
88
+ /* ─── controls / inputs ─────────────────────────────────── */
89
+ .gr-button-primary,
90
+ #copy-btn {{
91
+ background:linear-gradient(90deg,{PRIMARY} 0%,{PRIMARY_DARK} 100%);
92
+ border:none;
93
+ color:#fff;
94
+ border-radius:6px;
95
+ font-weight:600;
96
+ transition:transform .12s ease,box-shadow .12s ease;
97
+ }}
98
+ .gr-button-primary:hover,
99
+ #copy-btn:hover {{
100
+ transform:translateY(-2px);
101
+ box-shadow:0 4px 12px rgba(249,115,22,.35);
102
+ }}
103
+ .gr-dropdown input {{border:1px solid {PRIMARY}99}}
104
+
105
+ .preview img,
106
+ .preview canvas {{object-fit:contain!important}}
107
+
108
+ /* ─── hero section ─────────────────────────────────────── */
109
+ #hero-wrapper {{text-align:center}}
110
+
111
+ #hero-badge {{
112
+ display:inline-block;
113
+ padding:.85rem 1.2rem;
114
+ border-radius:8px;
115
+ background:{ACCENT_LIGHT};
116
+ border:1px solid {PRIMARY}55;
117
+ font-size:.95rem;
118
+ font-weight:600;
119
+ margin-bottom:.5rem;
120
+ }}
121
+
122
+ #hero-links {{
123
+ font-size:.95rem;
124
+ font-weight:600;
125
+ margin-bottom:1.6rem;
126
+ }}
127
+ #hero-links img {{
128
+ height:22px;
129
+ vertical-align:middle;
130
+ margin-left:.55rem;
131
+ }}
132
+
133
+ /* ─── score area ───────────────────────────────────────── */
134
+ #score-area {{
135
+ text-align:center; /* ← centres the badge */
136
+ }}
137
+
138
+ .match-badge {{
139
+ display:inline-block;
140
+ padding:.35rem .9rem;
141
+ border-radius:9999px;
142
+ font-weight:600;
143
+ font-size:1.25rem; /* ← slightly larger */
144
+ }}
145
+
146
+ /* ─── citation card ────────────────────────────────────── */
147
+ #cite-wrapper {{
148
+ position:relative;
149
+ padding:.9rem 1rem;
150
+ margin-top:2rem;
151
+ }}
152
+ #cite-wrapper code {{
153
+ font-family:SFMono-Regular,Consolas,monospace;
154
+ font-size:.84rem;
155
+ white-space:pre-wrap;
156
+ }}
157
+ #copy-btn {{
158
+ position:absolute;
159
+ top:.55rem;
160
+ right:.6rem;
161
+ padding:.18rem .7rem;
162
+ font-size:.72rem;
163
+ line-height:1;
164
+ }}
165
+ """
166
+
167
+
168
+ # ───────────────────────────────
169
+ # Torch / transforms
170
+ # ───────────────────────────────
171
+ _tx = transforms.Compose([
172
+ transforms.ToTensor(),
173
+ transforms.Normalize([0.5, 0.5, 0.5],[0.5, 0.5, 0.5]),
174
+ ])
175
+ def get_edge_model(name:str)->torch.nn.Module:
176
+ if name not in get_edge_model.cache:
177
+ mdl=torch.hub.load("otroshi/edgeface",name,source="github",pretrained=True).eval()
178
+ mdl.to("cuda" if torch.cuda.is_available() else "cpu")
179
+ get_edge_model.cache[name]=mdl
180
+ return get_edge_model.cache[name]
181
+ get_edge_model.cache={}
182
+
183
+ # ───────────────────────────────
184
+ # Helpers
185
+ # ───────────────────────────────
186
+ def _as_rgb(path:Path)->np.ndarray:
187
+ return cv2.cvtColor(cv2.imread(str(path)),cv2.COLOR_BGR2RGB)
188
+
189
+ def badge(text:str,colour:str)->str:
190
+ return f'<div class="match-badge" style="background:{colour}22;color:{colour}">{text}</div>'
191
+
192
+ # ───────────────────────────────
193
+ # Face comparison
194
+ # ───────────────────────────────
195
+ def compare(img_left,img_right,variant):
196
+ crop_a,crop_b=align_crop(img_left),align_crop(img_right)
197
+ if crop_a is None and crop_b is None:
198
+ return None,None,badge("No face detected","#DC2626")
199
+ if crop_a is None:
200
+ return None,None,badge("No face in A","#DC2626")
201
+ if crop_b is None:
202
+ return None,None,badge("No face in B","#DC2626")
203
+ mdl=get_edge_model(variant);dev=next(mdl.parameters()).device
204
+ with torch.no_grad():
205
+ ea=mdl(_tx(cv2.cvtColor(crop_a,cv2.COLOR_RGB2BGR))[None].to(dev))[0]
206
+ eb=mdl(_tx(cv2.cvtColor(crop_b,cv2.COLOR_RGB2BGR))[None].to(dev))[0]
207
+ pct=float(F.cosine_similarity(ea[None],eb[None]).item()*100)
208
+ pct=max(0,min(100,pct))
209
+ colour="#15803D" if pct>=80 else "#CA8A04" if pct>=50 else "#DC2626"
210
+ return crop_a,crop_b,badge(f"{pct:.2f}% match",colour)
211
+
212
+ # ───────────────────────────────
213
+ # Static HTML
214
+ # ───────────────────────────────
215
+ TITLE_HTML = """
216
+ <h1 id='edgeface-title'>
217
+ <span class="brand">EdgeFace:</span> Efficient Face Recognition Model for Edge Devices
218
+ </h1>
219
+ """
220
+
221
+ # <div id="hero-badge">
222
+ # 🏆 Winner of IJCB 2023 Efficient Face Recognition Competition
223
+ # </div><br/>
224
+
225
+ HERO_HTML = f"""
226
+ <div id="hero-wrapper">
227
+
228
+ <div id="hero-links">
229
+ <a href="https://www.idiap.ch/paper/edgeface/">Project</a>&nbsp;•&nbsp;
230
+ <a href="https://publications.idiap.ch/attachments/papers/2024/George_IEEETBIOM_2024.pdf">Paper</a>&nbsp;•&nbsp;
231
+ <a href="https://arxiv.org/abs/2307.01838">arXiv</a>&nbsp;•&nbsp;
232
+ <a href="https://gitlab.idiap.ch/bob/bob.paper.tbiom2023_edgeface">Code</a>&nbsp;•&nbsp;
233
+ <img src="https://hitscounter.dev/api/hit?url=https%3A%2F%2Fhuggingface.co%2Fspaces%2idiap%2FEdgeFace&label=Visitors&icon=award-fill&color=%23dc3545" alt="Visitors">
234
+ </div>
235
+ </div>
236
+ """
237
+
238
+ CITATION_HTML = """
239
+ <div id="cite-wrapper">
240
+ <button id="copy-btn" onclick="
241
+ navigator.clipboard.writeText(document.getElementById('bibtex').innerText)
242
+ .then(()=>{this.textContent='✔︎';setTimeout(()=>this.textContent='Copy',1500);});
243
+ ">Copy</button>
244
+ <code id="bibtex">@article{{edgeface,
245
+ title = {{EdgeFace: Efficient Face Recognition Model for Edge Devices}},
246
+ author = {{George, A. and Ecabert, C. and Otraoshi Shahreza, H. and Kotwal, K. and Marcel, S.}},
247
+ journal= {{IEEE Trans. Biometrics, Behavior, & Identity Science}},
248
+ year = {{2024}}
249
+ }}</code>
250
+ </div>
251
+ """
252
+
253
+ # ───────────────────────────────
254
+ # Gradio UI
255
+ # ───────────────────────────────
256
+ with gr.Blocks(css=CSS, title="EdgeFace Demo") as demo:
257
+ gr.HTML(TITLE_HTML, elem_id="titlebar")
258
+ gr.HTML(HERO_HTML)
259
+
260
+ with gr.Row():
261
+ gal_a = gr.Gallery(PRELOADED, columns=[5], height=120,
262
+ label="Image A", object_fit="contain")
263
+ gal_b = gr.Gallery(PRELOADED, columns=[5], height=120,
264
+ label="Image B", object_fit="contain")
265
+
266
+ with gr.Row():
267
+ img_a = gr.Image(type="numpy", height=300, label="Image A",
268
+ elem_classes="preview")
269
+ img_b = gr.Image(type="numpy", height=300, label="Image B",
270
+ elem_classes="preview")
271
+
272
+ def _fill(evt: gr.SelectData):
273
+ return _as_rgb(PRELOADED[evt.index]) if evt.index is not None else None
274
+ gal_a.select(_fill, outputs=img_a)
275
+ gal_b.select(_fill, outputs=img_b)
276
+
277
+ variant_dd = gr.Dropdown(EDGE_MODELS, value="edgeface_base",
278
+ label="Model variant")
279
+ btn = gr.Button("Compare", variant="primary")
280
+
281
+ with gr.Row():
282
+ out_a = gr.Image(label="Aligned A (112×112)")
283
+ out_b = gr.Image(label="Aligned B (112×112)")
284
+ score_html = gr.HTML(elem_id="score-area")
285
+
286
+ btn.click(compare, [img_a, img_b, variant_dd],
287
+ [out_a, out_b, score_html])
288
+
289
+ gr.HTML(CITATION_HTML)
290
+
291
+ # ───────────────��───────────────
292
+ if __name__ == "__main__":
293
+ demo.launch(share=True)
data/person1.jpeg ADDED

Git LFS Details

  • SHA256: 3090853095dbbba81e3239783ac02cfc89ec5e9afb2a4e9e32f561384b50f8d1
  • Pointer size: 131 Bytes
  • Size of remote file: 527 kB
data/person2.jpeg ADDED

Git LFS Details

  • SHA256: 32108fbc3b2bafeeadb630bd70feb4e5f405cd1020bf659be10f1d32729ef414
  • Pointer size: 131 Bytes
  • Size of remote file: 560 kB
data/person3.jpeg ADDED

Git LFS Details

  • SHA256: 2889ee82b541d6691f48e7011c46a711928eb10fa3fb9fde85b1d7d49a9bc8aa
  • Pointer size: 131 Bytes
  • Size of remote file: 553 kB
data/person4.jpeg ADDED

Git LFS Details

  • SHA256: 103ff5907cd405375d082a2f167cfe9c66785cff0a84f30a0e3d81504a444c84
  • Pointer size: 131 Bytes
  • Size of remote file: 615 kB
data/person5.jpeg ADDED

Git LFS Details

  • SHA256: 2e735fdbd5a31942b43781a35b78a8282915241fdbe17a839b3c60501ad0318c
  • Pointer size: 131 Bytes
  • Size of remote file: 522 kB
requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ numpy
2
+ opencv-python
3
+ mediapipe
4
+ gradio
5
+ torch
6
+ torchvision
7
+ timm
utils.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: 2025 Idiap Research Institute
2
+ # SPDX-FileContributor: Anjith George
3
+ # SPDX-License-Identifier: BSD-3-Clause
4
+
5
+ import cv2
6
+ import numpy as np
7
+ from numpy.linalg import inv, norm, lstsq, matrix_rank
8
+ import mediapipe as mp
9
+
10
+ # =============================================================================
11
+ # Constants
12
+ # =============================================================================
13
+
14
+ REFERENCE_FACIAL_POINTS = np.array([
15
+ [38.2946, 51.6963],
16
+ [73.5318, 51.5014],
17
+ [56.0252, 71.7366],
18
+ [41.5493, 92.3655],
19
+ [70.7299, 92.2041]
20
+ ], dtype=np.float32)
21
+
22
+
23
+ # =============================================================================
24
+ # Landmark Extraction
25
+ # =============================================================================
26
+
27
+ mp_face_mesh = mp.solutions.face_mesh
28
+ face_mesh = mp_face_mesh.FaceMesh(
29
+ static_image_mode=True,
30
+ refine_landmarks=True,
31
+ min_detection_confidence=0.5,
32
+ )
33
+
34
+ # =============================================================================
35
+ # Custom Exceptions
36
+ # =============================================================================
37
+
38
+ class MatlabCp2tormException(Exception):
39
+ def __str__(self):
40
+ return f"In File {__file__}: {super().__str__()}"
41
+
42
+ class FaceWarpException(Exception):
43
+ def __str__(self):
44
+ return f"In File {__file__}: {super().__str__()}"
45
+
46
+ # =============================================================================
47
+ # Similarity Transform Utilities
48
+ # =============================================================================
49
+
50
+ def tformfwd(trans: np.ndarray, uv: np.ndarray) -> np.ndarray:
51
+ """Apply forward affine transform."""
52
+ uv_h = np.hstack((uv, np.ones((uv.shape[0], 1))))
53
+ xy = uv_h @ trans
54
+ return xy[:, :-1]
55
+
56
+ def tforminv(trans: np.ndarray, uv: np.ndarray) -> np.ndarray:
57
+ """Apply inverse affine transform."""
58
+ return tformfwd(inv(trans), uv)
59
+
60
+ def findNonreflectiveSimilarity(uv: np.ndarray, xy: np.ndarray, options: dict = None):
61
+ """Find non-reflective similarity transform between uv and xy."""
62
+ K = options.get('K', 2) if options else 2
63
+ M = xy.shape[0]
64
+
65
+ x, y = xy[:, 0:1], xy[:, 1:2]
66
+ u, v = uv[:, 0:1], uv[:, 1:2]
67
+
68
+ X = np.vstack((
69
+ np.hstack((x, y, np.ones((M, 1)), np.zeros((M, 1)))),
70
+ np.hstack((y, -x, np.zeros((M, 1)), np.ones((M, 1))))
71
+ ))
72
+ U = np.vstack((u, v))
73
+
74
+ if matrix_rank(X) >= 2 * K:
75
+ r, _, _, _ = lstsq(X, U, rcond=None)
76
+ else:
77
+ raise ValueError("cp2tform:twoUniquePointsReq")
78
+
79
+ sc, ss, tx, ty = r.flatten()
80
+ Tinv = np.array([[sc, -ss, 0], [ss, sc, 0], [tx, ty, 1]])
81
+ T = inv(Tinv)
82
+ T[:, 2] = [0, 0, 1]
83
+ return T, Tinv
84
+
85
+ def findSimilarity(uv: np.ndarray, xy: np.ndarray, options: dict = None):
86
+ """Find similarity transform with optional reflection."""
87
+ trans1, trans1_inv = findNonreflectiveSimilarity(uv, xy, options)
88
+
89
+ xyR = xy.copy()
90
+ xyR[:, 0] *= -1
91
+ trans2r, _ = findNonreflectiveSimilarity(uv, xyR, options)
92
+
93
+ TreflectY = np.array([[-1, 0, 0], [0, 1, 0], [0, 0, 1]])
94
+ trans2 = trans2r @ TreflectY
95
+
96
+ norm1 = norm(tformfwd(trans1, uv) - xy)
97
+ norm2 = norm(tformfwd(trans2, uv) - xy)
98
+
99
+ return (trans1, trans1_inv) if norm1 <= norm2 else (trans2, inv(trans2))
100
+
101
+ def get_similarity_transform(src_pts, dst_pts, reflective=True):
102
+ """Get similarity transform between source and destination points."""
103
+ return findSimilarity(src_pts, dst_pts) if reflective else findNonreflectiveSimilarity(src_pts, dst_pts)
104
+
105
+ def cvt_tform_mat_for_cv2(trans: np.ndarray) -> np.ndarray:
106
+ """Convert transformation matrix to OpenCV-compatible format."""
107
+ return trans[:, :2].T
108
+
109
+ def get_similarity_transform_for_cv2(src_pts, dst_pts, reflective=True) -> np.ndarray:
110
+ """Get cv2-compatible affine transform matrix."""
111
+ trans, _ = get_similarity_transform(src_pts, dst_pts, reflective)
112
+ return cvt_tform_mat_for_cv2(trans)
113
+
114
+ # =============================================================================
115
+ # Face Warping
116
+ # =============================================================================
117
+
118
+ def warp_and_crop_face(src_img, facial_pts, reference_pts=REFERENCE_FACIAL_POINTS, crop_size=(112, 112), scale=1):
119
+ """Warp and crop face using similarity transform."""
120
+ ref_pts = reference_pts * scale
121
+ ref_pts += np.mean(reference_pts, axis=0) - np.mean(ref_pts, axis=0)
122
+
123
+ src_pts = np.array(facial_pts, dtype=np.float32)
124
+
125
+ if src_pts.shape != ref_pts.shape:
126
+ raise FaceWarpException("facial_pts and reference_pts must have the same shape")
127
+
128
+ tfm = get_similarity_transform_for_cv2(src_pts, ref_pts)
129
+ return cv2.warpAffine(src_img, tfm, crop_size)
130
+
131
+
132
+
133
+ def extract_landmarks(image) -> dict:
134
+ """Extract key facial landmarks using MediaPipe."""
135
+ img_h, img_w, _ = image.shape
136
+ image_rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
137
+ image_rgb.flags.writeable = False
138
+
139
+ results = face_mesh.process(image_rgb)
140
+ fr_landmarks = {}
141
+
142
+ if results.multi_face_landmarks:
143
+ key_mapping = {
144
+ 1: 'nose',
145
+ 287: 'mouthright',
146
+ 57: 'mouthleft',
147
+ 362: 'righteye_left',
148
+ 263: 'righteye_right',
149
+ 33: 'lefteye_left',
150
+ 243: 'lefteye_right'
151
+ }
152
+
153
+ for face_landmarks in results.multi_face_landmarks:
154
+ for idx, lm in enumerate(face_landmarks.landmark):
155
+ if idx in key_mapping:
156
+ x, y = int(lm.x * img_w), int(lm.y * img_h)
157
+ fr_landmarks[key_mapping[idx]] = (x, y)
158
+
159
+ if 'righteye_left' in fr_landmarks and 'righteye_right' in fr_landmarks:
160
+ fr_landmarks['reye'] = (
161
+ (fr_landmarks['righteye_left'][0] + fr_landmarks['righteye_right'][0]) // 2,
162
+ (fr_landmarks['righteye_left'][1] + fr_landmarks['righteye_right'][1]) // 2
163
+ )
164
+ if 'lefteye_left' in fr_landmarks and 'lefteye_right' in fr_landmarks:
165
+ fr_landmarks['leye'] = (
166
+ (fr_landmarks['lefteye_left'][0] + fr_landmarks['lefteye_right'][0]) // 2,
167
+ (fr_landmarks['lefteye_left'][1] + fr_landmarks['lefteye_right'][1]) // 2
168
+ )
169
+
170
+ for key in ['righteye_left', 'righteye_right', 'lefteye_left', 'lefteye_right']:
171
+ fr_landmarks.pop(key, None)
172
+
173
+ return fr_landmarks
174
+
175
+ # =============================================================================
176
+ # Face Alignment Pipeline
177
+ # =============================================================================
178
+
179
+ def align_face(frame, annotations: dict, scale=1, convention="yx"):
180
+ """Align face based on 5 landmarks."""
181
+ required_landmarks = ["reye", "leye", "nose", "mouthright", "mouthleft"]
182
+
183
+ if not set(required_landmarks).issubset(annotations):
184
+ raise ValueError("Annotations must contain required landmarks.")
185
+
186
+ facial5points = [
187
+ annotations[lm][::-1] if convention == "yx" else annotations[lm]
188
+ for lm in required_landmarks
189
+ ]
190
+
191
+ return warp_and_crop_face(frame, facial5points, scale=scale)
192
+
193
+ def align_crop(image):
194
+ """Extract and align face crop from an image."""
195
+ landmarks = extract_landmarks(image)
196
+ if not landmarks:
197
+ return None
198
+
199
+ try:
200
+ crop_img = align_face(image, landmarks, scale=1, convention="xy")
201
+ except Exception as e:
202
+ print(f"Error during face alignment: {e}")
203
+ return None
204
+
205
+ return crop_img