vitor.fran commited on
Commit
69e2bdc
·
1 Parent(s): 1b86ffe

fix: face detection models directory conflict and profile support

Browse files
__pycache__/faceless_processing.cpython-312.pyc ADDED
Binary file (31 kB). View file
 
faceless_processing.py CHANGED
@@ -24,7 +24,8 @@ def _download_assets_globally():
24
  os.makedirs(cascade_dir, exist_ok=True)
25
  xml_urls = {
26
  'haarcascade_frontalface_default.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml',
27
- 'haarcascade_frontalface_alt2.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt2.xml'
 
28
  }
29
  for name, url in xml_urls.items():
30
  path = os.path.join(cascade_dir, name)
@@ -78,16 +79,16 @@ class LineartConfig:
78
  min_silhouette_area: float = 200.0 # drop tiny isolated blobs from the silhouette (px²)
79
 
80
  # Silhouette outline
81
- silhouette_line_thickness: int = 3 # outer contour stroke width (px); match interior for uniform look
82
 
83
  # Edge detection
84
- canny_low: int = 40
85
- canny_high: int = 110
86
  bilateral_d: int = 11
87
  bilateral_sigma_color: int = 80
88
  bilateral_sigma_space: int = 80
89
  gaussian_ksize: int = 9
90
- min_contour_perimeter: float = 50.0 # filters out short noisy lines
91
  interior_line_thickness: int = 3 # thickness of interior detail lines (>=1)
92
 
93
  # Face detection
@@ -97,11 +98,11 @@ class LineartConfig:
97
  face_max_y_ratio: float = 0.6 # ignore faces below 60% image height
98
  face_nms_iou_thresh: float = 0.4
99
 
100
- # Face erase ellipse (ratios relative to face bbox) — smaller = more conservative
101
  face_cx_ratio: float = 0.5
102
- face_cy_ratio: float = 0.45
103
- face_rx_ratio: float = 0.35 # reduced from 0.40 to avoid erasing neck/ears
104
- face_ry_ratio: float = 0.38 # reduced from 0.42 to avoid erasing chin/neck
105
  face_erase_blur: int = 8
106
  face_erase_threshold: int = 130 # higher = sharper face mask edge, less spill
107
 
@@ -131,46 +132,87 @@ def _nms_faces(faces: list, iou_thresh: float) -> list:
131
 
132
  def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
133
  """Detect faces and filter out false positives in lower body."""
134
- h = rgb.shape[0]
135
- gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
136
  faces = []
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  try:
139
- # Load local classifiers from downloaded folder
 
 
 
140
  cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades")
141
  path_default = os.path.join(cascade_dir, 'haarcascade_frontalface_default.xml')
142
  path_alt = os.path.join(cascade_dir, 'haarcascade_frontalface_alt2.xml')
 
143
 
144
  classifiers = []
145
  if os.path.exists(path_default):
146
  classifiers.append(cv2.CascadeClassifier(path_default))
147
  if os.path.exists(path_alt):
148
  classifiers.append(cv2.CascadeClassifier(path_alt))
 
 
149
 
150
- # Fallback to cv2 data directory if local files are missing
151
  if not classifiers or all(c.empty() for c in classifiers):
152
  classifiers = [
153
  cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'),
154
- cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_alt2.xml')
 
155
  ]
156
-
157
  for cascade in classifiers:
158
  if cascade and not cascade.empty():
159
- detected = cascade.detectMultiScale(
160
- gray,
161
- scaleFactor=cfg.face_scale_factor,
162
- minNeighbors=cfg.face_min_neighbors,
163
- minSize=cfg.face_min_size
164
- )
165
- if len(detected) > 0:
166
- faces.extend(detected.tolist())
 
167
  except Exception as e:
168
- print(f"[AI] Face detection failed or cascades unavailable: {e}")
169
 
170
- # Remove detections in lower body (false positives)
171
  faces = [f for f in faces if f[1] < h * cfg.face_max_y_ratio]
172
-
173
- # Remove duplicates
174
  faces = _nms_faces(faces, cfg.face_nms_iou_thresh)
175
  return faces
176
 
@@ -192,35 +234,72 @@ def build_subject_mask(rgb: np.ndarray, cfg: LineartConfig, alpha_channel: Optio
192
  def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
193
  """Draw the subject silhouette as a smooth, solid-stroke contour."""
194
  mask = subject_mask.copy()
195
-
196
- # Smooth the mask boundary to eliminate staircase jaggies before tracing
197
- if cfg.mask_smooth_ksize > 1:
198
- # Use a larger kernel (at least 15) for beautiful organic curves
199
- k = max(15, cfg.mask_smooth_ksize if cfg.mask_smooth_ksize % 2 == 1 else cfg.mask_smooth_ksize + 1)
200
- mask = cv2.GaussianBlur(mask, (k, k), 0)
201
- _, mask = cv2.threshold(mask, cfg.mask_smooth_threshold, 255, cv2.THRESH_BINARY)
 
202
 
203
  contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_TC89_KCOS)
204
  silhouette = np.zeros_like(subject_mask)
205
  for cnt in contours:
206
  if cv2.contourArea(cnt) >= cfg.min_silhouette_area:
207
- # Smooth contour slightly without polygonal blockiness
208
  eps = 0.0012 * cv2.arcLength(cnt, True)
209
  smooth = cv2.approxPolyDP(cnt, eps, True)
210
- cv2.drawContours(silhouette, [smooth], -1, 255, cfg.silhouette_line_thickness)
211
  return silhouette
212
 
213
 
214
- def build_face_erase_mask(faces: list, shape: Tuple[int, int], cfg: LineartConfig) -> np.ndarray:
215
- """Build a soft binary mask covering face interior regions."""
216
- h, w = shape
217
  mask = np.zeros((h, w), dtype=np.uint8)
218
- for (fx, fy, fw, fh) in faces:
219
- cx = int(fx + fw * cfg.face_cx_ratio)
220
- cy = int(fy + fh * cfg.face_cy_ratio)
221
- rx = int(fw * cfg.face_rx_ratio)
222
- ry = int(fh * cfg.face_ry_ratio)
223
- cv2.ellipse(mask, (cx, cy), (rx, ry), 0, 0, 360, 255, -1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  soft = np.array(Image.fromarray(mask).filter(ImageFilter.GaussianBlur(cfg.face_erase_blur)))
225
  return (soft > cfg.face_erase_threshold).astype(np.uint8) * 255
226
 
@@ -259,10 +338,11 @@ def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: Lineart
259
 
260
  _LANDMARKER_LOCK = threading.Lock()
261
  _LANDMARKER_CACHE = None
 
262
 
263
- def get_landmarker(model_path):
264
- global _LANDMARKER_CACHE
265
- if _LANDMARKER_CACHE is None:
266
  import mediapipe as mp
267
  from mediapipe.tasks import python
268
  from mediapipe.tasks.python import vision
@@ -272,9 +352,13 @@ def get_landmarker(model_path):
272
  base_options=base_options,
273
  output_face_blendshapes=False,
274
  output_facial_transformation_matrixes=False,
275
- num_faces=1
 
 
 
276
  )
277
  _LANDMARKER_CACHE = vision.FaceLandmarker.create_from_options(options)
 
278
  return _LANDMARKER_CACHE
279
 
280
 
@@ -425,7 +509,7 @@ def make_faceless_lineart(
425
  interior = build_interior_edges(rgb, subject_mask, cfg)
426
 
427
  # 5. Erase face details (keep silhouette outline of head shape)
428
- face_erase = build_face_erase_mask(faces, (h, w), cfg)
429
  interior_clean = cv2.bitwise_and(interior, cv2.bitwise_not(face_erase))
430
 
431
  # 6. Combine
 
24
  os.makedirs(cascade_dir, exist_ok=True)
25
  xml_urls = {
26
  'haarcascade_frontalface_default.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_default.xml',
27
+ 'haarcascade_frontalface_alt2.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt2.xml',
28
+ 'haarcascade_profileface.xml': 'https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_profileface.xml'
29
  }
30
  for name, url in xml_urls.items():
31
  path = os.path.join(cascade_dir, name)
 
79
  min_silhouette_area: float = 200.0 # drop tiny isolated blobs from the silhouette (px²)
80
 
81
  # Silhouette outline
82
+ silhouette_line_thickness: int = 4 # outer contour stroke width (px); match interior for uniform look
83
 
84
  # Edge detection
85
+ canny_low: int = 35
86
+ canny_high: int = 85
87
  bilateral_d: int = 11
88
  bilateral_sigma_color: int = 80
89
  bilateral_sigma_space: int = 80
90
  gaussian_ksize: int = 9
91
+ min_contour_perimeter: float = 25.0 # filters out short noisy lines
92
  interior_line_thickness: int = 3 # thickness of interior detail lines (>=1)
93
 
94
  # Face detection
 
98
  face_max_y_ratio: float = 0.6 # ignore faces below 60% image height
99
  face_nms_iou_thresh: float = 0.4
100
 
101
+ # Face erase ellipse (ratios relative to face bbox) — fallback parameters
102
  face_cx_ratio: float = 0.5
103
+ face_cy_ratio: float = 0.50
104
+ face_rx_ratio: float = 0.44 # adjusted for better fallback erasure
105
+ face_ry_ratio: float = 0.46
106
  face_erase_blur: int = 8
107
  face_erase_threshold: int = 130 # higher = sharper face mask edge, less spill
108
 
 
132
 
133
  def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
134
  """Detect faces and filter out false positives in lower body."""
135
+ h, w = rgb.shape[:2]
 
136
  faces = []
137
 
138
+ # 1. MediaPipe face detection (highly robust for profile/side/tilted faces)
139
+ try:
140
+ import mediapipe as mp
141
+ model_path = cfg.mediapipe_model_path
142
+ if not model_path or not os.path.exists(model_path):
143
+ model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
144
+ model_path = os.path.join(model_dir, "face_landmarker.task")
145
+
146
+ if os.path.exists(model_path):
147
+ num_faces = 6
148
+ with _LANDMARKER_LOCK:
149
+ landmarker = get_landmarker(model_path, num_faces=num_faces)
150
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
151
+ mp_result = landmarker.detect(mp_image)
152
+
153
+ if mp_result.face_landmarks:
154
+ for face_lms in mp_result.face_landmarks:
155
+ xs = [lm.x * w for lm in face_lms]
156
+ ys = [lm.y * h for lm in face_lms]
157
+ min_x, max_x = min(xs), max(xs)
158
+ min_y, max_y = min(ys), max(ys)
159
+
160
+ bw = max_x - min_x
161
+ bh = max_y - min_y
162
+
163
+ pad_x = bw * 0.15
164
+ pad_y = bh * 0.15
165
+
166
+ fx = int(max(0, min_x - pad_x))
167
+ fy = int(max(0, min_y - pad_y))
168
+ fw = int(min(w - fx, bw + 2 * pad_x))
169
+ fh = int(min(h - fy, bh + 2 * pad_y))
170
+
171
+ faces.append([fx, fy, fw, fh])
172
+ except Exception as mp_err:
173
+ print(f"[AI] MediaPipe face detection failed in detect_faces: {mp_err}")
174
+
175
+ # 2. Haar Cascades (frontal and profile)
176
  try:
177
+ gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
178
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
179
+ gray_eq = clahe.apply(gray)
180
+
181
  cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades")
182
  path_default = os.path.join(cascade_dir, 'haarcascade_frontalface_default.xml')
183
  path_alt = os.path.join(cascade_dir, 'haarcascade_frontalface_alt2.xml')
184
+ path_profile = os.path.join(cascade_dir, 'haarcascade_profileface.xml')
185
 
186
  classifiers = []
187
  if os.path.exists(path_default):
188
  classifiers.append(cv2.CascadeClassifier(path_default))
189
  if os.path.exists(path_alt):
190
  classifiers.append(cv2.CascadeClassifier(path_alt))
191
+ if os.path.exists(path_profile):
192
+ classifiers.append(cv2.CascadeClassifier(path_profile))
193
 
 
194
  if not classifiers or all(c.empty() for c in classifiers):
195
  classifiers = [
196
  cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'),
197
+ cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_alt2.xml'),
198
+ cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_profileface.xml')
199
  ]
 
200
  for cascade in classifiers:
201
  if cascade and not cascade.empty():
202
+ for img_gray in [gray, gray_eq]:
203
+ detected = cascade.detectMultiScale(
204
+ img_gray,
205
+ scaleFactor=cfg.face_scale_factor,
206
+ minNeighbors=cfg.face_min_neighbors,
207
+ minSize=cfg.face_min_size
208
+ )
209
+ if len(detected) > 0:
210
+ faces.extend(detected.tolist())
211
  except Exception as e:
212
+ print(f"[AI] Haar Cascade face detection failed: {e}")
213
 
214
+ # 3. Filter and Deduplicate
215
  faces = [f for f in faces if f[1] < h * cfg.face_max_y_ratio]
 
 
216
  faces = _nms_faces(faces, cfg.face_nms_iou_thresh)
217
  return faces
218
 
 
234
  def build_silhouette(subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
235
  """Draw the subject silhouette as a smooth, solid-stroke contour."""
236
  mask = subject_mask.copy()
237
+ h, w = mask.shape[:2]
238
+ # Dynamic proportional smoothing based on image size to eliminate jaggies completely
239
+ k = int(max(w, h) * 0.035)
240
+ if k % 2 == 0:
241
+ k += 1
242
+ k = max(25, k)
243
+ mask = cv2.GaussianBlur(mask, (k, k), 0)
244
+ _, mask = cv2.threshold(mask, cfg.mask_smooth_threshold, 255, cv2.THRESH_BINARY)
245
 
246
  contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_TC89_KCOS)
247
  silhouette = np.zeros_like(subject_mask)
248
  for cnt in contours:
249
  if cv2.contourArea(cnt) >= cfg.min_silhouette_area:
 
250
  eps = 0.0012 * cv2.arcLength(cnt, True)
251
  smooth = cv2.approxPolyDP(cnt, eps, True)
252
+ cv2.drawContours(silhouette, [smooth], -1, 255, cfg.silhouette_line_thickness, lineType=cv2.LINE_AA)
253
  return silhouette
254
 
255
 
256
+ def build_face_erase_mask(rgb: np.ndarray, faces: list, cfg: LineartConfig) -> np.ndarray:
257
+ """Build a soft binary mask covering face interior regions using MediaPipe polygon or fallback ellipse."""
258
+ h, w = rgb.shape[:2]
259
  mask = np.zeros((h, w), dtype=np.uint8)
260
+
261
+ # 1. Exact polygon face erasure using MediaPipe FACE_OVAL landmarks (100% covers mouth/smile/eyes/nose exactly)
262
+ has_mp_erase = False
263
+ try:
264
+ import mediapipe as mp
265
+ model_path = cfg.mediapipe_model_path
266
+ if not model_path or not os.path.exists(model_path):
267
+ model_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "models")
268
+ model_path = os.path.join(model_dir, "face_landmarker.task")
269
+
270
+ if os.path.exists(model_path):
271
+ num_faces = 6
272
+ with _LANDMARKER_LOCK:
273
+ landmarker = get_landmarker(model_path, num_faces=num_faces)
274
+ mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
275
+ mp_result = landmarker.detect(mp_image)
276
+
277
+ if mp_result.face_landmarks:
278
+ FACE_OVAL = [10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288,
279
+ 397, 365, 379, 378, 400, 377, 152, 148, 176, 149, 150, 136,
280
+ 172, 58, 132, 93, 234, 127, 162, 21, 54, 103, 67, 109]
281
+ for face_lms in mp_result.face_landmarks:
282
+ pts = []
283
+ for idx in FACE_OVAL:
284
+ lm = face_lms[idx]
285
+ px = int(lm.x * w)
286
+ py = int(lm.y * h)
287
+ pts.append([px, py])
288
+ pts_np = np.array(pts, np.int32).reshape((-1, 1, 2))
289
+ cv2.fillPoly(mask, [pts_np], 255)
290
+ has_mp_erase = True
291
+ except Exception as mp_err:
292
+ print(f"[AI] MediaPipe face contour polygon erase failed: {mp_err}")
293
+
294
+ # 2. Bounding-box Ellipse Fallback (if MediaPipe fails or finds no faces)
295
+ if not has_mp_erase or not np.any(mask):
296
+ for (fx, fy, fw, fh) in faces:
297
+ cx = int(fx + fw * cfg.face_cx_ratio)
298
+ cy = int(fy + fh * cfg.face_cy_ratio)
299
+ rx = int(fw * cfg.face_rx_ratio)
300
+ ry = int(fh * cfg.face_ry_ratio)
301
+ cv2.ellipse(mask, (cx, cy), (rx, ry), 0, 0, 360, 255, -1, lineType=cv2.LINE_AA)
302
+
303
  soft = np.array(Image.fromarray(mask).filter(ImageFilter.GaussianBlur(cfg.face_erase_blur)))
304
  return (soft > cfg.face_erase_threshold).astype(np.uint8) * 255
305
 
 
338
 
339
  _LANDMARKER_LOCK = threading.Lock()
340
  _LANDMARKER_CACHE = None
341
+ _LANDMARKER_NUM_FACES = None
342
 
343
+ def get_landmarker(model_path, num_faces: int = 1):
344
+ global _LANDMARKER_CACHE, _LANDMARKER_NUM_FACES
345
+ if _LANDMARKER_CACHE is None or _LANDMARKER_NUM_FACES != num_faces:
346
  import mediapipe as mp
347
  from mediapipe.tasks import python
348
  from mediapipe.tasks.python import vision
 
352
  base_options=base_options,
353
  output_face_blendshapes=False,
354
  output_facial_transformation_matrixes=False,
355
+ num_faces=num_faces,
356
+ min_face_detection_confidence=0.2,
357
+ min_face_presence_confidence=0.2,
358
+ min_tracking_confidence=0.2,
359
  )
360
  _LANDMARKER_CACHE = vision.FaceLandmarker.create_from_options(options)
361
+ _LANDMARKER_NUM_FACES = num_faces
362
  return _LANDMARKER_CACHE
363
 
364
 
 
509
  interior = build_interior_edges(rgb, subject_mask, cfg)
510
 
511
  # 5. Erase face details (keep silhouette outline of head shape)
512
+ face_erase = build_face_erase_mask(rgb, faces, cfg)
513
  interior_clean = cv2.bitwise_and(interior, cv2.bitwise_not(face_erase))
514
 
515
  # 6. Combine
models DELETED
File without changes