vitor.fran commited on
Commit
be42261
·
1 Parent(s): 767b86a

fix: correctly preprocess input image with LineartDetector before passing to Stable Diffusion ControlNet to eliminate ripples

Browse files
Files changed (1) hide show
  1. faceless_processing.py +348 -69
faceless_processing.py CHANGED
@@ -1,26 +1,19 @@
1
- """
2
- faceless_lineart_pro.py - VERSÃO CORRIGIDA E SANITIZADA
3
- """
4
-
5
- from __future__ import annotations
6
  import base64
7
  import io
8
- import logging
 
 
 
 
9
  import cv2
10
  import numpy as np
11
  import torch
12
- from dataclasses import dataclass
13
- from functools import lru_cache
14
- from pathlib import Path
15
- from typing import Optional
16
- from PIL import Image, ImageFilter
17
  from diffusers import StableDiffusionControlNetPipeline, ControlNetModel
18
 
19
- # ── Logging ──────────────────────────────────────────────────────────────────
20
- logging.basicConfig(level=logging.INFO)
21
- logger = logging.getLogger(__name__)
22
-
23
- # ── Variável Global ──────────────────────────────────────────────────────────
24
  _SD_PIPELINE = None
25
 
26
  @dataclass(frozen=True)
@@ -36,7 +29,6 @@ class LineartConfig:
36
  prompt: str = "technical line art, clean vector, simple continuous lines, white background"
37
  negative_prompt: str = "shading, realism, messy lines, noisy, double lines, background clutter"
38
 
39
- # ── Carregamento Otimizado ───────────────────────────────────────────────────
40
  def get_sd_pipeline(cfg: LineartConfig):
41
  global _SD_PIPELINE
42
  if _SD_PIPELINE is not None:
@@ -44,7 +36,7 @@ def get_sd_pipeline(cfg: LineartConfig):
44
 
45
  device = "cuda" if torch.cuda.is_available() else "cpu"
46
  dtype = torch.float16 if device == "cuda" else torch.float32
47
- logger.info(f"Carregando IA no dispositivo: {device} com precisao: {dtype}...")
48
  try:
49
  controlnet = ControlNetModel.from_pretrained(
50
  cfg.controlnet_model_id,
@@ -61,64 +53,351 @@ def get_sd_pipeline(cfg: LineartConfig):
61
  _SD_PIPELINE.enable_attention_slicing()
62
  return _SD_PIPELINE
63
  except Exception as exc:
64
- logger.error(f"Falha ao carregar IA no dispositivo {device}: {exc}")
65
  return None
66
 
67
- # ── Módulos Extras ───────────────────────────────────────────────────────────
68
- try:
69
- from rembg import remove, new_session as _rembg_new_session
70
- _REMBG_AVAILABLE = True
71
- except ImportError:
72
- remove, _REMBG_AVAILABLE = None, False
73
 
74
- @lru_cache(maxsize=2)
75
- def _get_rembg_session(model_name: str):
76
- if not _REMBG_AVAILABLE: return None
77
- try: return _rembg_new_session(model_name)
78
- except Exception: return None
79
 
80
- # ── Lógica de Processamento ──────────────────────────────────────────────────
81
- def make_faceless_lineart(img: Image.Image, cfg: LineartConfig = LineartConfig()) -> Image.Image:
82
- # 1. Background
 
 
 
 
 
83
  rgba = img.convert("RGBA")
84
- session = _get_rembg_session(cfg.rembg_model)
85
- if session and remove:
86
- mask = remove(rgba, session=session, only_mask=True)
87
- rgba.putalpha(mask.convert("L"))
88
-
89
- # 2. Resize para 512x512 para o SD
90
- rgb_input = Image.new("RGB", (512, 512), (255, 255, 255))
91
- resized_subject = rgba.resize((512, 512), Image.Resampling.LANCZOS)
92
- rgb_input.paste(resized_subject, mask=resized_subject.split()[3])
93
-
94
- # 3. IA Inference
95
- pipe = get_sd_pipeline(cfg)
96
- if pipe:
97
- logger.info("Executando IA...")
98
- generated = pipe(
99
- cfg.prompt,
100
- negative_prompt=cfg.negative_prompt,
101
- image=rgb_input,
102
- controlnet_conditioning_scale=cfg.controlnet_conditioning_scale,
103
- num_inference_steps=cfg.num_inference_steps
104
- ).images[0]
105
- else:
106
- generated = rgb_input
107
-
108
- # 4. Binário e Afinamento
109
- gray = cv2.cvtColor(np.array(generated), cv2.COLOR_RGB2GRAY)
110
- _, binary = cv2.threshold(gray, 120, 255, cv2.THRESH_BINARY_INV)
111
-
 
 
 
112
  try:
113
- binary = cv2.ximgproc.thinning(binary, thinningType=cv2.ximgproc.THINNING_ZHANGSUEN)
114
- except: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
 
116
- # 5. Final
117
- final = Image.fromarray(binary).resize(img.size, Image.Resampling.NEAREST)
118
- out = Image.new("RGBA", img.size, (0, 0, 0, 0))
119
- out.putalpha(final)
 
120
  return out
121
 
122
- def build_faceless_embroidery_assets(img: Image.Image, cfg: Optional[LineartConfig] = None) -> tuple[Image.Image, Image.Image]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  cfg = cfg or LineartConfig()
124
- return make_faceless_lineart(img, cfg), img.convert("RGBA")
 
 
 
 
 
 
 
 
1
  import base64
2
  import io
3
+ import os
4
+ import tempfile
5
+ from typing import Tuple, Optional
6
+ from dataclasses import dataclass
7
+
8
  import cv2
9
  import numpy as np
10
  import torch
11
+ from PIL import Image, ImageChops, ImageFilter, ImageOps, ImageStat
12
+ from rembg import remove, new_session
 
 
 
13
  from diffusers import StableDiffusionControlNetPipeline, ControlNetModel
14
 
15
+ _SESSION_ISNET = new_session("isnet-general-use")
16
+ _LINEART_DETECTOR = None
 
 
 
17
  _SD_PIPELINE = None
18
 
19
  @dataclass(frozen=True)
 
29
  prompt: str = "technical line art, clean vector, simple continuous lines, white background"
30
  negative_prompt: str = "shading, realism, messy lines, noisy, double lines, background clutter"
31
 
 
32
  def get_sd_pipeline(cfg: LineartConfig):
33
  global _SD_PIPELINE
34
  if _SD_PIPELINE is not None:
 
36
 
37
  device = "cuda" if torch.cuda.is_available() else "cpu"
38
  dtype = torch.float16 if device == "cuda" else torch.float32
39
+ print(f"[AI] Loading SD pipeline on {device} with {dtype}...")
40
  try:
41
  controlnet = ControlNetModel.from_pretrained(
42
  cfg.controlnet_model_id,
 
53
  _SD_PIPELINE.enable_attention_slicing()
54
  return _SD_PIPELINE
55
  except Exception as exc:
56
+ print(f"[AI] Failed to load SD pipeline on {device}: {exc}")
57
  return None
58
 
 
 
 
 
 
 
59
 
60
+ def decode_base64_image(image_data: str) -> Image.Image:
61
+ payload = image_data.split(",", 1)[1] if "," in image_data else image_data
62
+ decoded = base64.b64decode(payload)
63
+ return Image.open(io.BytesIO(decoded)).convert("RGBA")
 
64
 
65
+
66
+ def encode_png_data_url(image: Image.Image) -> str:
67
+ buffer = io.BytesIO()
68
+ image.save(buffer, format="PNG")
69
+ return f"data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode('utf-8')}"
70
+
71
+
72
+ def _has_real_transparency(img: Image.Image) -> bool:
73
  rgba = img.convert("RGBA")
74
+ alpha = rgba.getchannel("A")
75
+ sample = list(alpha.getdata())
76
+ total = 0
77
+ visible = 0
78
+ for index in range(0, len(sample), 12):
79
+ total += 1
80
+ if sample[index] < 245:
81
+ visible += 1
82
+ return total > 0 and (visible / total) >= 0.03
83
+
84
+
85
+ def remove_background_subject(img: Image.Image) -> Image.Image:
86
+ rgba = img.convert("RGBA")
87
+ if _has_real_transparency(rgba):
88
+ return rgba
89
+
90
+ mask = remove(rgba, session=_SESSION_ISNET, only_mask=True)
91
+ alpha = mask.convert("L") if isinstance(mask, Image.Image) else Image.open(io.BytesIO(mask)).convert("L")
92
+ if alpha.size != rgba.size:
93
+ alpha = alpha.resize(rgba.size, Image.LANCZOS)
94
+ alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.8))
95
+
96
+ r, g, b, _a = rgba.split()
97
+ return Image.merge("RGBA", (r, g, b, alpha))
98
+
99
+
100
+ def _get_lineart_detector():
101
+ global _LINEART_DETECTOR
102
+ if _LINEART_DETECTOR is not None:
103
+ return _LINEART_DETECTOR
104
+
105
  try:
106
+ from controlnet_aux import LineartDetector
107
+
108
+ _LINEART_DETECTOR = LineartDetector.from_pretrained("lllyasviel/Annotators")
109
+ except Exception as exc:
110
+ print(f"[AI] LineartDetector unavailable: {exc}")
111
+ _LINEART_DETECTOR = False
112
+ return _LINEART_DETECTOR or None
113
+
114
+
115
+ def _box_blur_rgba_region(image: Image.Image, top_ratio: float, height_ratio: float, blur_radius: int) -> Image.Image:
116
+ rgba = image.convert("RGBA")
117
+ width, height = rgba.size
118
+ top = max(0, int(height * top_ratio))
119
+ region_height = max(1, int(height * height_ratio))
120
+ bottom = min(height, top + region_height)
121
+ if bottom <= top:
122
+ return rgba
123
+
124
+ blurred = rgba.filter(ImageFilter.GaussianBlur(radius=blur_radius))
125
+ result = rgba.copy()
126
+ region = blurred.crop((0, top, width, bottom))
127
+ result.paste(region, (0, top))
128
+ return result
129
+
130
+
131
+ def _mask_to_transparent_lineart(lineart: Image.Image) -> Image.Image:
132
+ rgba = lineart.convert("RGBA")
133
+ gray = ImageOps.grayscale(rgba)
134
+ gray = ImageOps.autocontrast(gray)
135
+ # Increased MedianFilter and GaussianBlur to smooth out tiny clothing wrinkles and details
136
+ gray = gray.filter(ImageFilter.MedianFilter(5))
137
+ gray = gray.filter(ImageFilter.GaussianBlur(radius=0.75))
138
+
139
+ stat = ImageStat.Stat(gray)
140
+ mean = stat.mean[0] if stat.mean else 0.0
141
+ threshold = max(160, min(240, int(mean + 35)))
142
+ alpha = gray.point(lambda p: 255 if p < threshold else 0, mode="L")
143
+ alpha = alpha.filter(ImageFilter.MaxFilter(3))
144
+ alpha = alpha.filter(ImageFilter.MinFilter(3))
145
+
146
+ if alpha.getbbox() is None:
147
+ alpha = gray.point(lambda p: 255 if p < 245 else 0, mode="L")
148
+
149
+ # Clean up small busy lines/details using OpenCV contours
150
+ alpha_np = np.array(alpha)
151
+ contours, _ = cv2.findContours(alpha_np, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
152
+ mask = np.zeros_like(alpha_np)
153
+ for cnt in contours:
154
+ # Keep only contours with perimeter larger than 35 pixels (keeps main lines only)
155
+ if cv2.arcLength(cnt, False) > 35:
156
+ cv2.drawContours(mask, [cnt], -1, 255, -1)
157
 
158
+ alpha_filtered = cv2.bitwise_and(alpha_np, mask)
159
+ alpha = Image.fromarray(alpha_filtered)
160
+
161
+ out = Image.new("RGBA", alpha.size, (0, 0, 0, 0))
162
+ out.putalpha(alpha)
163
  return out
164
 
165
+
166
+ def _merge_outline_and_detail(outline: Image.Image, detail: Image.Image) -> Image.Image:
167
+ outline_l = outline.convert("RGBA")
168
+ detail_l = detail.convert("RGBA")
169
+ outline_a = outline_l.getchannel("A")
170
+ detail_a = detail_l.getchannel("A")
171
+ combined = ImageChops.lighter(outline_a, detail_a)
172
+ combined = combined.filter(ImageFilter.MaxFilter(3))
173
+ combined = combined.filter(ImageFilter.MinFilter(3))
174
+ result = Image.new("RGBA", combined.size, (0, 0, 0, 0))
175
+ result.putalpha(combined)
176
+ return result
177
+
178
+
179
+ def _alpha_outline(alpha: Image.Image) -> Image.Image:
180
+ alpha = alpha.convert("L")
181
+ alpha = ImageOps.autocontrast(alpha)
182
+ alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.4))
183
+ alpha = alpha.filter(ImageFilter.MedianFilter(3))
184
+
185
+ outer = alpha.filter(ImageFilter.MaxFilter(5))
186
+ inner = alpha.filter(ImageFilter.MinFilter(5))
187
+ outline = ImageChops.difference(outer, inner)
188
+ outline = ImageOps.autocontrast(outline)
189
+ outline = outline.point(lambda p: 255 if p >= 22 else 0, mode="L")
190
+ outline = outline.filter(ImageFilter.MaxFilter(3))
191
+ outline = outline.filter(ImageFilter.MinFilter(3))
192
+
193
+ if outline.getbbox() is None:
194
+ outline = alpha.point(lambda p: 255 if p >= 40 else 0, mode="L")
195
+ outline = outline.filter(ImageFilter.MaxFilter(3))
196
+
197
+ return outline
198
+
199
+
200
+ def blur_faces_smooth(image: Image.Image) -> tuple[Image.Image, Image.Image, list]:
201
+ rgba = image.convert("RGBA")
202
+ width, height = rgba.size
203
+ open_cv_image = np.array(rgba.convert("RGB"))
204
+ open_cv_image = open_cv_image[:, :, ::-1].copy()
205
+ gray = cv2.cvtColor(open_cv_image, cv2.COLOR_BGR2GRAY)
206
+
207
+ face_cascade_default = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_default.xml')
208
+ face_cascade_alt = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_frontalface_alt2.xml')
209
+ profile_cascade = cv2.CascadeClassifier(cv2.data.haarcascades + 'haarcascade_profileface.xml')
210
+
211
+ faces_default = face_cascade_default.detectMultiScale(gray, scaleFactor=1.05, minNeighbors=3, minSize=(20, 20))
212
+ faces_alt = face_cascade_alt.detectMultiScale(gray, scaleFactor=1.05, minNeighbors=3, minSize=(20, 20))
213
+ faces_profile = profile_cascade.detectMultiScale(gray, scaleFactor=1.05, minNeighbors=3, minSize=(20, 20))
214
+
215
+ faces = []
216
+ if len(faces_default) > 0: faces.extend(faces_default)
217
+ if len(faces_alt) > 0: faces.extend(faces_alt)
218
+ if len(faces_profile) > 0: faces.extend(faces_profile)
219
+
220
+ mask = Image.new("L", (width, height), 0)
221
+ if len(faces) == 0:
222
+ mask_np = np.array(mask)
223
+ cx, cy = width // 2, int(height * 0.32)
224
+ rx, ry = int(width * 0.25), int(height * 0.18)
225
+ cv2.ellipse(mask_np, (cx, cy), (rx, ry), 0, 0, 360, 255, -1)
226
+ mask = Image.fromarray(mask_np).filter(ImageFilter.GaussianBlur(radius=20))
227
+ blurred_img = rgba.filter(ImageFilter.GaussianBlur(radius=25))
228
+ return Image.composite(blurred_img, rgba, mask), mask, []
229
+
230
+ mask_np = np.array(mask)
231
+ for (x, y, w, h) in faces:
232
+ cx = x + w // 2
233
+ rx = int(w * 0.48)
234
+ ry = int(h * 0.32)
235
+ cy_shifted = y + int(h * 0.65)
236
+ cv2.ellipse(mask_np, (cx, cy_shifted), (rx, ry), 0, 0, 360, 255, -1)
237
+
238
+ mask = Image.fromarray(mask_np)
239
+ mask_feathered = mask.filter(ImageFilter.GaussianBlur(radius=10))
240
+ blurred_img = rgba.filter(ImageFilter.GaussianBlur(radius=35))
241
+ return Image.composite(blurred_img, rgba, mask_feathered), mask, list(faces)
242
+
243
+
244
+ def make_faceless_cutout(img: Image.Image) -> Image.Image:
245
+ return remove_background_subject(img)
246
+
247
+
248
+ def make_faceless_lineart(img: Image.Image, cfg: LineartConfig = LineartConfig()) -> Image.Image:
249
+ subject = remove_background_subject(img)
250
+
251
+ # 1. Get face erase stencil mask
252
+ _pre, face_mask, faces = blur_faces_smooth(subject)
253
+
254
+ detector = _get_lineart_detector()
255
+ lineart_out = None
256
+
257
+ if detector is not None:
258
+ try:
259
+ # Blur the face/hair region to prevent ControlNet from seeing face details
260
+ preprocessed = _box_blur_rgba_region(subject, top_ratio=0.16, height_ratio=0.52, blur_radius=18)
261
+
262
+ # Extract lineart preprocessed map (black and white lines)
263
+ detected = detector(preprocessed.convert("RGB"), coarse=False)
264
+ if not isinstance(detected, Image.Image):
265
+ detected = Image.open(io.BytesIO(detected))
266
+
267
+ # Resize detected lines to 512x512 for Stable Diffusion ControlNet
268
+ detected_resized = detected.resize((512, 512), Image.Resampling.LANCZOS)
269
+
270
+ # Run Stable Diffusion ControlNet to clean up and stylize the lineart
271
+ pipe = get_sd_pipeline(cfg)
272
+ if pipe:
273
+ print("[AI] Running Stable Diffusion to generate lineart...")
274
+ generated = pipe(
275
+ cfg.prompt,
276
+ negative_prompt=cfg.negative_prompt,
277
+ image=detected_resized,
278
+ controlnet_conditioning_scale=cfg.controlnet_conditioning_scale,
279
+ num_inference_steps=cfg.num_inference_steps
280
+ ).images[0]
281
+
282
+ # Resize generated back to original size
283
+ generated_resized = generated.resize(subject.size, Image.Resampling.LANCZOS)
284
+ cleaned = _mask_to_transparent_lineart(generated_resized)
285
+ else:
286
+ cleaned = _mask_to_transparent_lineart(detected)
287
+
288
+ silhouette = _alpha_outline(subject.getchannel("A"))
289
+ lineart_out = _merge_outline_and_detail(silhouette, cleaned)
290
+ except Exception as exc:
291
+ print(f"[AI] SD/LineartDetector pipeline failed, using local outline fallback: {exc}")
292
+
293
+ if lineart_out is None:
294
+ try:
295
+ open_cv_image = np.array(subject.convert("RGB"))
296
+ open_cv_image = open_cv_image[:, :, ::-1].copy()
297
+ gray = cv2.cvtColor(open_cv_image, cv2.COLOR_BGR2GRAY)
298
+ smoothed = cv2.bilateralFilter(gray, d=9, sigmaColor=75, sigmaSpace=75)
299
+ # Higher thresholds to ignore fabric textures and faint details
300
+ edges = cv2.Canny(smoothed, 45, 120)
301
+
302
+ # Prune small busy lines using contour filtering
303
+ contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
304
+ filtered_edges = np.zeros_like(edges)
305
+ for cnt in contours:
306
+ if cv2.arcLength(cnt, False) > 35:
307
+ cv2.drawContours(filtered_edges, [cnt], -1, 255, 1)
308
+ edges = filtered_edges
309
+
310
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
311
+ edges_dilated = cv2.dilate(edges, kernel, iterations=1)
312
+ details_alpha = Image.fromarray(edges_dilated).convert("L")
313
+
314
+ silhouette = _alpha_outline(subject.getchannel("A"))
315
+ combined = ImageChops.lighter(silhouette, details_alpha)
316
+
317
+ subject_alpha = subject.getchannel("A")
318
+ combined = ImageChops.darker(combined, subject_alpha)
319
+
320
+ lineart_out = Image.new("RGBA", combined.size, (0, 0, 0, 0))
321
+ lineart_out.putalpha(combined)
322
+ except Exception as local_err:
323
+ print(f"[AI] Local Canny fallback failed: {local_err}")
324
+ outline = _alpha_outline(subject.getchannel("A"))
325
+ lineart_out = Image.new("RGBA", outline.size, (0, 0, 0, 0))
326
+ lineart_out.putalpha(outline)
327
+
328
+ # 2. Erase face details in lineart using stencil mask
329
+ lineart_rgba = lineart_out.convert("RGBA")
330
+ lineart_alpha = lineart_rgba.getchannel("A")
331
+ black_img = Image.new("L", lineart_alpha.size, 0)
332
+ clean_alpha = Image.composite(black_img, lineart_alpha, face_mask)
333
+
334
+ r, g, b, _ = lineart_rgba.split()
335
+ return Image.merge("RGBA", (r, g, b, clean_alpha))
336
+
337
+
338
+ def try_huggingface_faceless(img: Image.Image, prompt: str, negative_prompt: str) -> Image.Image | None:
339
+ token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
340
+ model_id = os.environ.get("HF_FACELESS_MODEL") or os.environ.get("HUGGINGFACE_FACELESS_MODEL")
341
+ space_id = os.environ.get("HF_SPACE_ID") or os.environ.get("HUGGINGFACE_SPACE_ID")
342
+
343
+ if not token or (not model_id and not space_id):
344
+ return None
345
+
346
+ try:
347
+ if space_id:
348
+ from gradio_client import Client, handle_file
349
+
350
+ api_name = os.environ.get("HF_SPACE_API_NAME") or os.environ.get("HUGGINGFACE_SPACE_API_NAME") or "/predict"
351
+ with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
352
+ img.save(temp_file.name, format="PNG")
353
+ temp_path = temp_file.name
354
+ try:
355
+ client = Client(space_id, token=token)
356
+ generated = client.predict(
357
+ handle_file(temp_path),
358
+ prompt,
359
+ negative_prompt,
360
+ api_name=api_name,
361
+ )
362
+ finally:
363
+ try:
364
+ os.unlink(temp_path)
365
+ except OSError:
366
+ pass
367
+ if isinstance(generated, str) and generated.startswith("data:image"):
368
+ return Image.open(io.BytesIO(base64.b64decode(generated.split(",", 1)[1]))).convert("RGBA")
369
+ if isinstance(generated, Image.Image):
370
+ return generated.convert("RGBA")
371
+ if isinstance(generated, (list, tuple)) and generated:
372
+ first = generated[0]
373
+ if isinstance(first, str) and first.startswith("data:image"):
374
+ return Image.open(io.BytesIO(base64.b64decode(first.split(",", 1)[1]))).convert("RGBA")
375
+ if isinstance(first, Image.Image):
376
+ return first.convert("RGBA")
377
+ else:
378
+ from huggingface_hub import InferenceClient
379
+
380
+ client = InferenceClient(model=model_id, token=token, timeout=float(os.environ.get("HF_TIMEOUT_SECONDS", "45")))
381
+ generated = client.image_to_image(
382
+ image=img,
383
+ prompt=prompt,
384
+ negative_prompt=negative_prompt,
385
+ num_inference_steps=20,
386
+ guidance_scale=7.0,
387
+ )
388
+ if isinstance(generated, Image.Image):
389
+ return generated.convert("RGBA")
390
+ except Exception as exc:
391
+ print(f"[AI] Hugging Face faceless processing failed: {exc}")
392
+ return None
393
+
394
+
395
+ def build_faceless_embroidery_assets(
396
+ img: Image.Image,
397
+ cfg: Optional[LineartConfig] = None
398
+ ) -> Tuple[Image.Image, Image.Image]:
399
+ subject = remove_background_subject(img)
400
  cfg = cfg or LineartConfig()
401
+ lineart = make_faceless_lineart(subject, cfg)
402
+ cutout = make_faceless_cutout(subject)
403
+ return lineart, cutout