dreamlessx commited on
Commit
b46d78e
·
verified ·
1 Parent(s): ff7e8d0

Upload landmarkdiff/landmarks.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. landmarkdiff/landmarks.py +258 -0
landmarkdiff/landmarks.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MediaPipe Face Mesh v2 landmark extraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import cv2
10
+ import mediapipe as mp
11
+ import numpy as np
12
+
13
+ # Region color map for visualization (BGR)
14
+ REGION_COLORS: dict[str, tuple[int, int, int]] = {
15
+ "jawline": (255, 255, 255), # white
16
+ "eyebrow_left": (0, 255, 0), # green
17
+ "eyebrow_right": (0, 255, 0),
18
+ "eye_left": (255, 255, 0), # cyan
19
+ "eye_right": (255, 255, 0),
20
+ "nose": (0, 255, 255), # yellow
21
+ "lips": (0, 0, 255), # red
22
+ "iris_left": (255, 0, 255), # magenta
23
+ "iris_right": (255, 0, 255),
24
+ "face_oval": (200, 200, 200), # light gray
25
+ }
26
+
27
+ # MediaPipe landmark index groups by anatomical region
28
+ LANDMARK_REGIONS: dict[str, list[int]] = {
29
+ "jawline": [
30
+ 10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288,
31
+ 397, 365, 379, 378, 400, 377, 152, 148, 176, 149, 150, 136,
32
+ 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109,
33
+ ],
34
+ "eye_left": [
35
+ 33, 7, 163, 144, 145, 153, 154, 155, 133, 173, 157, 158, 159, 160, 161, 246,
36
+ ],
37
+ "eye_right": [
38
+ 362, 382, 381, 380, 374, 373, 390, 249, 263, 466, 388, 387, 386, 385, 384, 398,
39
+ ],
40
+ "eyebrow_left": [70, 63, 105, 66, 107, 55, 65, 52, 53, 46],
41
+ "eyebrow_right": [300, 293, 334, 296, 336, 285, 295, 282, 283, 276],
42
+ "nose": [
43
+ 1, 2, 4, 5, 6, 19, 94, 141, 168, 195, 197, 236, 240,
44
+ 274, 275, 278, 279, 294, 326, 327, 360, 363, 370, 456, 460,
45
+ ],
46
+ "lips": [
47
+ 61, 146, 91, 181, 84, 17, 314, 405, 321, 375, 291,
48
+ 308, 324, 318, 402, 317, 14, 87, 178, 88, 95, 78,
49
+ ],
50
+ "iris_left": [468, 469, 470, 471, 472],
51
+ "iris_right": [473, 474, 475, 476, 477],
52
+ }
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class FaceLandmarks:
57
+ """478 face landmarks + image size + detection confidence."""
58
+
59
+ landmarks: np.ndarray # (478, 3) normalized (x, y, z)
60
+ image_width: int
61
+ image_height: int
62
+ confidence: float
63
+
64
+ @property
65
+ def pixel_coords(self) -> np.ndarray:
66
+ """Normalized -> pixel coords, shape (478, 2)."""
67
+ coords = self.landmarks[:, :2].copy()
68
+ coords[:, 0] *= self.image_width
69
+ coords[:, 1] *= self.image_height
70
+ return coords
71
+
72
+ def get_region(self, region: str) -> np.ndarray:
73
+ """Return landmarks for the given region name."""
74
+ indices = LANDMARK_REGIONS.get(region, [])
75
+ return self.landmarks[indices]
76
+
77
+
78
+ def extract_landmarks(
79
+ image: np.ndarray,
80
+ min_detection_confidence: float = 0.5,
81
+ min_tracking_confidence: float = 0.5,
82
+ ) -> Optional[FaceLandmarks]:
83
+ """Run MediaPipe Face Mesh on a BGR image, return FaceLandmarks or None."""
84
+ h, w = image.shape[:2]
85
+ rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
86
+
87
+ # Tasks API first, fall back to legacy solutions API
88
+ try:
89
+ landmarks, confidence = _extract_tasks_api(rgb, min_detection_confidence)
90
+ except Exception:
91
+ try:
92
+ landmarks, confidence = _extract_solutions_api(rgb, min_detection_confidence, min_tracking_confidence)
93
+ except Exception:
94
+ return None
95
+
96
+ if landmarks is None:
97
+ return None
98
+
99
+ return FaceLandmarks(
100
+ landmarks=landmarks,
101
+ image_width=w,
102
+ image_height=h,
103
+ confidence=confidence,
104
+ )
105
+
106
+
107
+ def _extract_tasks_api(
108
+ rgb: np.ndarray,
109
+ min_confidence: float,
110
+ ) -> tuple[Optional[np.ndarray], float]:
111
+ """Tasks API path (mediapipe >= 0.10.20)."""
112
+ FaceLandmarker = mp.tasks.vision.FaceLandmarker
113
+ FaceLandmarkerOptions = mp.tasks.vision.FaceLandmarkerOptions
114
+ RunningMode = mp.tasks.vision.RunningMode
115
+ BaseOptions = mp.tasks.BaseOptions
116
+ import urllib.request
117
+ import tempfile
118
+
119
+ # Download model if not cached
120
+ model_path = Path(tempfile.gettempdir()) / "face_landmarker_v2_with_blendshapes.task"
121
+ if not model_path.exists():
122
+ url = "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task"
123
+ urllib.request.urlretrieve(url, str(model_path))
124
+
125
+ options = FaceLandmarkerOptions(
126
+ base_options=BaseOptions(model_asset_path=str(model_path)),
127
+ running_mode=RunningMode.IMAGE,
128
+ num_faces=1,
129
+ min_face_detection_confidence=min_confidence,
130
+ output_face_blendshapes=False,
131
+ output_facial_transformation_matrixes=False,
132
+ )
133
+
134
+ with FaceLandmarker.create_from_options(options) as landmarker:
135
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
136
+ result = landmarker.detect(mp_image)
137
+
138
+ if not result.face_landmarks:
139
+ return None, 0.0
140
+
141
+ face_lms = result.face_landmarks[0]
142
+ landmarks = np.array(
143
+ [(lm.x, lm.y, lm.z) for lm in face_lms],
144
+ dtype=np.float32,
145
+ )
146
+
147
+ return landmarks, min_confidence
148
+
149
+
150
+ def _extract_solutions_api(
151
+ rgb: np.ndarray,
152
+ min_detection_confidence: float,
153
+ min_tracking_confidence: float,
154
+ ) -> tuple[Optional[np.ndarray], float]:
155
+ """Legacy solutions API fallback."""
156
+ with mp.solutions.face_mesh.FaceMesh(
157
+ static_image_mode=True,
158
+ max_num_faces=1,
159
+ refine_landmarks=True,
160
+ min_detection_confidence=min_detection_confidence,
161
+ min_tracking_confidence=min_tracking_confidence,
162
+ ) as face_mesh:
163
+ results = face_mesh.process(rgb)
164
+
165
+ if not results.multi_face_landmarks:
166
+ return None, 0.0
167
+
168
+ face = results.multi_face_landmarks[0]
169
+ landmarks = np.array(
170
+ [(lm.x, lm.y, lm.z) for lm in face.landmark],
171
+ dtype=np.float32,
172
+ )
173
+ return landmarks, min(min_detection_confidence, min_tracking_confidence)
174
+
175
+
176
+ def visualize_landmarks(
177
+ image: np.ndarray,
178
+ face: FaceLandmarks,
179
+ radius: int = 1,
180
+ draw_regions: bool = True,
181
+ ) -> np.ndarray:
182
+ """Draw colored landmark dots on a copy of the image."""
183
+ canvas = image.copy()
184
+ coords = face.pixel_coords
185
+
186
+ if draw_regions:
187
+ # Build index -> color mapping
188
+ idx_to_color: dict[int, tuple[int, int, int]] = {}
189
+ for region, indices in LANDMARK_REGIONS.items():
190
+ color = REGION_COLORS.get(region, (255, 255, 255))
191
+ for idx in indices:
192
+ idx_to_color[idx] = color
193
+
194
+ for i, (x, y) in enumerate(coords):
195
+ color = idx_to_color.get(i, (128, 128, 128))
196
+ cv2.circle(canvas, (int(x), int(y)), radius, color, -1)
197
+ else:
198
+ for x, y in coords:
199
+ cv2.circle(canvas, (int(x), int(y)), radius, (255, 255, 255), -1)
200
+
201
+ return canvas
202
+
203
+
204
+ def render_landmark_image(
205
+ face: FaceLandmarks,
206
+ width: Optional[int] = None,
207
+ height: Optional[int] = None,
208
+ radius: int = 2,
209
+ ) -> np.ndarray:
210
+ """Render tessellation mesh on black canvas. Falls back to dots if no connections."""
211
+ w = width or face.image_width
212
+ h = height or face.image_height
213
+ canvas = np.zeros((h, w, 3), dtype=np.uint8)
214
+
215
+ coords = face.landmarks[:, :2].copy()
216
+ coords[:, 0] *= w
217
+ coords[:, 1] *= h
218
+ pts = coords.astype(np.int32)
219
+
220
+ # Draw tessellation mesh (what CrucibleAI ControlNet expects)
221
+ try:
222
+ from mediapipe.tasks.python.vision.face_landmarker import FaceLandmarksConnections
223
+ tessellation = FaceLandmarksConnections.FACE_LANDMARKS_TESSELATION
224
+ contours = FaceLandmarksConnections.FACE_LANDMARKS_CONTOURS
225
+
226
+ # Draw tessellation edges (thin, gray-white)
227
+ for conn in tessellation:
228
+ p1 = tuple(pts[conn.start])
229
+ p2 = tuple(pts[conn.end])
230
+ cv2.line(canvas, p1, p2, (192, 192, 192), 1, cv2.LINE_AA)
231
+
232
+ # Draw contour edges on top (brighter, key features)
233
+ for conn in contours:
234
+ p1 = tuple(pts[conn.start])
235
+ p2 = tuple(pts[conn.end])
236
+ cv2.line(canvas, p1, p2, (255, 255, 255), 1, cv2.LINE_AA)
237
+
238
+ except ImportError:
239
+ # Fallback: draw colored dots if tessellation not available
240
+ idx_to_color: dict[int, tuple[int, int, int]] = {}
241
+ for region, indices in LANDMARK_REGIONS.items():
242
+ color = REGION_COLORS.get(region, (128, 128, 128))
243
+ for idx in indices:
244
+ idx_to_color[idx] = color
245
+
246
+ for i, (x, y) in enumerate(coords):
247
+ color = idx_to_color.get(i, (128, 128, 128))
248
+ cv2.circle(canvas, (int(x), int(y)), radius, color, -1)
249
+
250
+ return canvas
251
+
252
+
253
+ def load_image(path: str | Path) -> np.ndarray:
254
+ """Load image as BGR numpy array, raises FileNotFoundError on failure."""
255
+ img = cv2.imread(str(path))
256
+ if img is None:
257
+ raise FileNotFoundError(f"Could not load image: {path}")
258
+ return img