Spaces:
Running
Running
Anjith George
commited on
Commit
·
fc96fb2
1
Parent(s):
bbb9c6c
First version of the demo
Browse files- .gitattributes +1 -0
- LICENSES/BSD-3-Clause.txt +11 -0
- README.md +4 -5
- __init__.py +0 -0
- app.py +293 -0
- data/person1.jpeg +3 -0
- data/person2.jpeg +3 -0
- data/person3.jpeg +3 -0
- data/person4.jpeg +3 -0
- data/person5.jpeg +3 -0
- requirements.txt +7 -0
- utils.py +205 -0
.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:
|
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> •
|
230 |
+
<a href="https://publications.idiap.ch/attachments/papers/2024/George_IEEETBIOM_2024.pdf">Paper</a> •
|
231 |
+
<a href="https://arxiv.org/abs/2307.01838">arXiv</a> •
|
232 |
+
<a href="https://gitlab.idiap.ch/bob/bob.paper.tbiom2023_edgeface">Code</a> •
|
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
|
data/person2.jpeg
ADDED
![]() |
Git LFS Details
|
data/person3.jpeg
ADDED
![]() |
Git LFS Details
|
data/person4.jpeg
ADDED
![]() |
Git LFS Details
|
data/person5.jpeg
ADDED
![]() |
Git LFS Details
|
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
|