vitor.fran commited on
Commit
02522a2
·
1 Parent(s): ba8b12f

feat: improve line art quality - dual-pass Canny, CLAHE face detect, up to 6 faces/eyebrows, embroidery stitch filter

Browse files
Files changed (1) hide show
  1. faceless_processing.py +108 -47
faceless_processing.py CHANGED
@@ -68,9 +68,8 @@ class LineartConfig:
68
  transparency_threshold: float = 0.03
69
  detect_eyebrows: bool = True
70
 
71
-
72
  # Subject mask
73
- white_bg_threshold: int = 245 # pixels above this = background (raised to avoid erasing light subjects)
74
  mask_close_iterations: int = 4
75
  mask_open_iterations: int = 1
76
  mask_smooth_ksize: int = 5 # GaussianBlur kernel before silhouette (must be odd; 0 = disabled)
@@ -78,32 +77,50 @@ 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
94
  face_scale_factor: float = 1.05
95
  face_min_neighbors: int = 3
96
- face_min_size: Tuple[int, int] = (25, 25)
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
 
108
 
109
  def _nms_faces(faces: list, iou_thresh: float) -> list:
@@ -130,13 +147,15 @@ def _nms_faces(faces: list, iou_thresh: float) -> list:
130
 
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')
@@ -147,7 +166,6 @@ def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
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'),
@@ -156,14 +174,16 @@ def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
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
 
@@ -172,6 +192,13 @@ def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
172
 
173
  # Remove duplicates
174
  faces = _nms_faces(faces, cfg.face_nms_iou_thresh)
 
 
 
 
 
 
 
175
  return faces
176
 
177
 
@@ -226,13 +253,38 @@ def build_face_erase_mask(faces: list, shape: Tuple[int, int], cfg: LineartConfi
226
 
227
 
228
  def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
229
- """Detect structural body lines using Canny, filtering noise."""
 
 
 
 
 
 
230
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
231
- blurred = cv2.GaussianBlur(gray, (cfg.gaussian_ksize, cfg.gaussian_ksize), 0)
232
- blurred = cv2.bilateralFilter(blurred, cfg.bilateral_d, cfg.bilateral_sigma_color, cfg.bilateral_sigma_space)
233
- edges = cv2.Canny(blurred, cfg.canny_low, cfg.canny_high)
234
-
235
- # Erode the subject mask so Canny doesn't catch the outer boundary twice
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  erode_size = max(5, cfg.silhouette_line_thickness * 2 + 3)
237
  if erode_size % 2 == 0:
238
  erode_size += 1
@@ -240,16 +292,23 @@ def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: Lineart
240
  eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1)
241
  edges = cv2.bitwise_and(edges, eroded_mask)
242
 
243
- # Filter out short/noisy contours
 
 
244
  contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
245
  filtered = np.zeros_like(edges)
246
  for cnt in contours:
247
  perimeter = cv2.arcLength(cnt, False)
248
- if perimeter > cfg.min_contour_perimeter:
249
- # Simplify interior edges slightly to remove Canny wobbles while keeping natural flow
250
- eps = 0.0012 * perimeter
251
- smooth = cv2.approxPolyDP(cnt, eps, False)
252
- cv2.drawContours(filtered, [smooth], -1, 255, cfg.interior_line_thickness)
 
 
 
 
 
253
 
254
  # Mild dilation for uniform, solid strokes
255
  kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
@@ -259,10 +318,12 @@ 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 +333,10 @@ 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
 
@@ -317,7 +379,8 @@ def draw_eyebrows(rgb: np.ndarray, cfg: LineartConfig, faces: Optional[list] = N
317
  faces = detect_faces(rgb, cfg)
318
 
319
  with _LANDMARKER_LOCK:
320
- landmarker = get_landmarker(model_path)
 
321
 
322
  # Phase 1: Global detection (upright / clear faces)
323
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
@@ -354,9 +417,7 @@ def draw_eyebrows(rgb: np.ndarray, cfg: LineartConfig, faces: Optional[list] = N
354
  crop_cx = (x1 + crop_w / 2) / w
355
  crop_cy = (y1 + crop_h / 2) / h
356
  if is_duplicate(crop_cx, crop_cy):
357
- continue
358
-
359
-
360
 
361
  crop = rgb[y1:y2, x1:x2]
362
  crop_resized = cv2.resize(crop, (512, 512), interpolation=cv2.INTER_CUBIC)
 
68
  transparency_threshold: float = 0.03
69
  detect_eyebrows: bool = True
70
 
 
71
  # Subject mask
72
+ white_bg_threshold: int = 245 # pixels above this = background
73
  mask_close_iterations: int = 4
74
  mask_open_iterations: int = 1
75
  mask_smooth_ksize: int = 5 # GaussianBlur kernel before silhouette (must be odd; 0 = disabled)
 
77
  min_silhouette_area: float = 200.0 # drop tiny isolated blobs from the silhouette (px²)
78
 
79
  # Silhouette outline
80
+ silhouette_line_thickness: int = 3 # outer contour stroke width (px)
81
 
82
+ # Edge detection — primary pass (prominent structural lines)
83
+ canny_low: int = 35
84
+ canny_high: int = 100
85
  bilateral_d: int = 11
86
+ bilateral_sigma_color: int = 90
87
+ bilateral_sigma_space: int = 90
88
  gaussian_ksize: int = 9
89
+ min_contour_perimeter: float = 40.0 # primary minimum contour length (lowered to catch more detail)
90
+ interior_line_thickness: int = 3 # thickness of interior detail lines
91
+
92
+ # Edge detection — secondary detail pass (fine features, lower threshold)
93
+ enable_detail_pass: bool = True # enable secondary Canny pass for fine details
94
+ detail_canny_low: int = 15 # more sensitive lower threshold
95
+ detail_canny_high: int = 55 # more sensitive upper threshold
96
+ detail_gaussian_ksize: int = 5 # less blur to preserve fine lines
97
+ detail_min_perimeter: float = 30.0 # shorter minimum contour for fine details
98
+ detail_bilateral_d: int = 7 # smaller filter for detail preservation
99
+ detail_bilateral_sigma: int = 60
100
+
101
+ # Embroidery minimum stitch feasibility
102
+ # Lines shorter than this (in pixels relative to image width) are discarded as too thin to stitch
103
+ # Set to 0 to disable. Recommended: ~15px on a 512px-wide image
104
+ min_embroidery_stitch_px: int = 15
105
 
106
  # Face detection
107
  face_scale_factor: float = 1.05
108
  face_min_neighbors: int = 3
109
+ face_min_size: Tuple[int, int] = (20, 20) # lowered to detect smaller/tilted faces
110
+ face_max_y_ratio: float = 0.65 # ignore faces below 65% image height (slightly higher to catch low angles)
111
+ face_nms_iou_thresh: float = 0.35 # more aggressive dedup to handle crowded images
112
+ face_max_count: int = 6 # maximum number of faces to process
113
 
114
  # Face erase ellipse (ratios relative to face bbox) — smaller = more conservative
115
  face_cx_ratio: float = 0.5
116
  face_cy_ratio: float = 0.45
117
+ face_rx_ratio: float = 0.35
118
+ face_ry_ratio: float = 0.38
119
  face_erase_blur: int = 8
120
+ face_erase_threshold: int = 130
121
+
122
+ # MediaPipe face landmarker: max faces (up to 6 supported)
123
+ mediapipe_num_faces: int = 6
124
 
125
 
126
  def _nms_faces(faces: list, iou_thresh: float) -> list:
 
147
 
148
 
149
  def detect_faces(rgb: np.ndarray, cfg: LineartConfig) -> list:
150
+ """Detect up to cfg.face_max_count faces using dual Haar cascade + CLAHE preprocessing."""
151
  h = rgb.shape[0]
152
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
153
+ # CLAHE equalisation improves detection on uneven lighting / dark photos
154
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
155
+ gray_eq = clahe.apply(gray)
156
  faces = []
157
 
158
  try:
 
159
  cascade_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "cascades")
160
  path_default = os.path.join(cascade_dir, 'haarcascade_frontalface_default.xml')
161
  path_alt = os.path.join(cascade_dir, 'haarcascade_frontalface_alt2.xml')
 
166
  if os.path.exists(path_alt):
167
  classifiers.append(cv2.CascadeClassifier(path_alt))
168
 
 
169
  if not classifiers or all(c.empty() for c in classifiers):
170
  classifiers = [
171
  cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'),
 
174
 
175
  for cascade in classifiers:
176
  if cascade and not cascade.empty():
177
+ # Run on both raw and CLAHE-equalised gray for better recall
178
+ for img_gray in [gray, gray_eq]:
179
+ detected = cascade.detectMultiScale(
180
+ img_gray,
181
+ scaleFactor=cfg.face_scale_factor,
182
+ minNeighbors=cfg.face_min_neighbors,
183
+ minSize=cfg.face_min_size
184
+ )
185
+ if len(detected) > 0:
186
+ faces.extend(detected.tolist())
187
  except Exception as e:
188
  print(f"[AI] Face detection failed or cascades unavailable: {e}")
189
 
 
192
 
193
  # Remove duplicates
194
  faces = _nms_faces(faces, cfg.face_nms_iou_thresh)
195
+
196
+ # Enforce maximum face count
197
+ max_faces = getattr(cfg, 'face_max_count', 6)
198
+ if len(faces) > max_faces:
199
+ # Keep the largest faces (most prominent)
200
+ faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True)[:max_faces]
201
+
202
  return faces
203
 
204
 
 
253
 
254
 
255
  def build_interior_edges(rgb: np.ndarray, subject_mask: np.ndarray, cfg: LineartConfig) -> np.ndarray:
256
+ """
257
+ Detect structural body lines using a two-pass Canny strategy:
258
+ - Primary pass: standard parameters for prominent structural lines
259
+ - Detail pass (optional): more sensitive params to capture fine features
260
+ (hair texture, fabric folds, accessories) that would still be stitchable
261
+ Lines too short to translate to embroidery stitches are discarded.
262
+ """
263
  gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
264
+
265
+ # --- Primary pass ---
266
+ blurred_primary = cv2.bilateralFilter(gray, cfg.bilateral_d, cfg.bilateral_sigma_color, cfg.bilateral_sigma_space)
267
+ blurred_primary = cv2.GaussianBlur(blurred_primary, (cfg.gaussian_ksize, cfg.gaussian_ksize), 0)
268
+ edges_primary = cv2.Canny(blurred_primary, cfg.canny_low, cfg.canny_high)
269
+
270
+ # --- Detail pass (fine features) ---
271
+ edges_detail = np.zeros_like(edges_primary)
272
+ if getattr(cfg, 'enable_detail_pass', True):
273
+ detail_d = getattr(cfg, 'detail_bilateral_d', 7)
274
+ detail_sigma = getattr(cfg, 'detail_bilateral_sigma', 60)
275
+ detail_ksize = getattr(cfg, 'detail_gaussian_ksize', 5)
276
+ blurred_detail = cv2.bilateralFilter(gray, detail_d, detail_sigma, detail_sigma)
277
+ blurred_detail = cv2.GaussianBlur(blurred_detail, (detail_ksize, detail_ksize), 0)
278
+ edges_detail = cv2.Canny(
279
+ blurred_detail,
280
+ getattr(cfg, 'detail_canny_low', 15),
281
+ getattr(cfg, 'detail_canny_high', 55)
282
+ )
283
+
284
+ # Combine both passes
285
+ edges = cv2.bitwise_or(edges_primary, edges_detail)
286
+
287
+ # Erode subject mask to avoid catching the outer boundary twice
288
  erode_size = max(5, cfg.silhouette_line_thickness * 2 + 3)
289
  if erode_size % 2 == 0:
290
  erode_size += 1
 
292
  eroded_mask = cv2.erode(subject_mask, kernel_erode, iterations=1)
293
  edges = cv2.bitwise_and(edges, eroded_mask)
294
 
295
+ # Filter contours: remove noise and lines too short to be embroidered
296
+ min_perimeter = cfg.min_contour_perimeter
297
+ min_stitch = getattr(cfg, 'min_embroidery_stitch_px', 15)
298
  contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
299
  filtered = np.zeros_like(edges)
300
  for cnt in contours:
301
  perimeter = cv2.arcLength(cnt, False)
302
+ # Skip contours that are too short for embroidery
303
+ if perimeter < min_perimeter:
304
+ continue
305
+ # Additional embroidery feasibility check (absolute pixel length)
306
+ if min_stitch > 0 and perimeter < min_stitch:
307
+ continue
308
+ # Smooth contour to reduce Canny wobbles while preserving natural flow
309
+ eps = 0.0012 * perimeter
310
+ smooth = cv2.approxPolyDP(cnt, eps, False)
311
+ cv2.drawContours(filtered, [smooth], -1, 255, cfg.interior_line_thickness)
312
 
313
  # Mild dilation for uniform, solid strokes
314
  kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
 
318
 
319
  _LANDMARKER_LOCK = threading.Lock()
320
  _LANDMARKER_CACHE = None
321
+ _LANDMARKER_NUM_FACES = None
322
 
323
+ def get_landmarker(model_path, num_faces: int = 6):
324
+ global _LANDMARKER_CACHE, _LANDMARKER_NUM_FACES
325
+ # Re-create if num_faces changed
326
+ if _LANDMARKER_CACHE is None or _LANDMARKER_NUM_FACES != num_faces:
327
  import mediapipe as mp
328
  from mediapipe.tasks import python
329
  from mediapipe.tasks.python import vision
 
333
  base_options=base_options,
334
  output_face_blendshapes=False,
335
  output_facial_transformation_matrixes=False,
336
+ num_faces=num_faces
337
  )
338
  _LANDMARKER_CACHE = vision.FaceLandmarker.create_from_options(options)
339
+ _LANDMARKER_NUM_FACES = num_faces
340
  return _LANDMARKER_CACHE
341
 
342
 
 
379
  faces = detect_faces(rgb, cfg)
380
 
381
  with _LANDMARKER_LOCK:
382
+ num_faces = getattr(cfg, 'mediapipe_num_faces', 6)
383
+ landmarker = get_landmarker(model_path, num_faces=num_faces)
384
 
385
  # Phase 1: Global detection (upright / clear faces)
386
  mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=rgb)
 
417
  crop_cx = (x1 + crop_w / 2) / w
418
  crop_cy = (y1 + crop_h / 2) / h
419
  if is_duplicate(crop_cx, crop_cy):
420
+ continue
 
 
421
 
422
  crop = rgb[y1:y2, x1:x2]
423
  crop_resized = cv2.resize(crop, (512, 512), interpolation=cv2.INTER_CUBIC)