saliacoel commited on
Commit
90fbd5f
·
verified ·
1 Parent(s): f998766

Upload dn04.py

Browse files
Files changed (1) hide show
  1. dn04.py +606 -0
dn04.py ADDED
@@ -0,0 +1,606 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # FaceDetailerStandalone_MIN_FIXED_FAST_EMBEDDED_SAM.py
2
+ # One-node Face Detailer (image-only) with fixed settings + embedded Ultralytics bbox detector + embedded SAM loader.
3
+ # - Output parity with Impact Pack Face Detailer at the same settings
4
+ # - No separate bbox-detector node; detector is cached/constructed internally
5
+ # - No separate SAM loader node; SAM is cached/constructed internally
6
+ # - Lightweight runtime overhead (cached imports, inference_mode, fused layers, TF32, FP16 on CUDA)
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+ from typing import List, Tuple, Optional
11
+
12
+ import warnings
13
+ warnings.filterwarnings("ignore")
14
+
15
+ # Silence OpenCV before importing it (env var) and after (setLogLevel)
16
+ os.environ["OPENCV_LOG_LEVEL"] = "ERROR"
17
+
18
+ import numpy as np
19
+ import torch
20
+ import comfy
21
+ from PIL import Image
22
+ import cv2
23
+
24
+ try:
25
+ if hasattr(cv2, "setLogLevel"):
26
+ try:
27
+ lvl = cv2.LOG_LEVEL_ERROR if hasattr(cv2, "LOG_LEVEL_ERROR") else 3 # 3 == error
28
+ cv2.setLogLevel(lvl)
29
+ except Exception:
30
+ pass
31
+ except Exception:
32
+ pass
33
+
34
+ # ---------------- Fixed FaceDetailer settings (do not expose in UI) ----------------
35
+ # GUIDE_SIZE = 512
36
+ # GUIDE_SIZE_FOR_BBOX = True
37
+ # MAX_SIZE = 1024
38
+ # STEPS = 30
39
+ # CFG = 7.0
40
+ # SCHEDULER = "simple"
41
+ # DENOISE = 0.5
42
+ # FEATHER = 5
43
+ # NOISE_MASK = True
44
+ # FORCE_INPAINT = True
45
+ # BBOX_THRESHOLD = 0.5
46
+ # BBOX_DILATION = 10
47
+ # BBOX_CROP_FACTOR = 3.0
48
+ # DROP_SIZE = 10
49
+ # SAM_DETECTION_HINT = "center-1"
50
+ # SAM_DILATION = 0
51
+ # SAM_THRESHOLD = 0.93
52
+ # SAM_BBOX_EXPANSION = 0
53
+ # SAM_MASK_HINT_THRESHOLD = 0.7
54
+ # SAM_MASK_HINT_USE_NEGATIVE = "False"
55
+ # WILDCARD = ""
56
+ # CYCLE = 1
57
+ # INPAINT_MODEL = False
58
+ # NOISE_MASK_FEATHER = 20
59
+ # TILED_ENCODE = False
60
+ # TILED_DECODE = False
61
+ # ---------------------------------------------------------------------
62
+
63
+ # ---------------- Ultralytics / YOLO detector integration (embedded) ----------------
64
+
65
+ # Torch runtime perf switches
66
+ torch.backends.cudnn.benchmark = True # autotune best conv algorithms
67
+ if torch.cuda.is_available():
68
+ torch.backends.cuda.matmul.allow_tf32 = True
69
+ torch.backends.cudnn.allow_tf32 = True
70
+ try:
71
+ torch.set_float32_matmul_precision("high") # PyTorch 2.x
72
+ except Exception:
73
+ pass
74
+
75
+ # Optional Impact Pack interop (SEG type)
76
+ try:
77
+ # If Impact Pack is installed, use its SEG to be perfectly compatible.
78
+ from impact.core import SEG as _IMPACT_SEG # type: ignore
79
+ _USE_IMPACT_SEG = True
80
+ except Exception:
81
+ _USE_IMPACT_SEG = False
82
+
83
+ @dataclass
84
+ class _LocalSEG:
85
+ cropped_image: Optional[torch.Tensor]
86
+ cropped_mask: np.ndarray # 2D float32 [0..1]
87
+ confidence: float
88
+ crop_region: Tuple[int, int, int, int] # (x1,y1,x2,y2)
89
+ bbox: Tuple[int, int, int, int] # (x1,y1,x2,y2)
90
+ label: str
91
+ control_net_wrapper: Optional[object] = None
92
+
93
+ SEG = _IMPACT_SEG if _USE_IMPACT_SEG else _LocalSEG
94
+
95
+ # ---------------------------------------------------------------------
96
+ # LOCAL ASSET PATHS (no hardcoded absolute paths)
97
+ # ---------------------------------------------------------------------
98
+
99
+ # Base directory of this node file (cross-platform, works on RunPod/ComfyUI)
100
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
101
+
102
+ # Local YOLO model path inside this custom node folder
103
+ YOLO_MODEL_PATH = os.path.join(BASE_DIR, "assets", "face_yolov8m_salia.pt")
104
+ YOLO_IMGSZ = 640
105
+
106
+ # Local SAM checkpoint path inside this custom node folder
107
+ SAM_CKPT_PATH = os.path.join(BASE_DIR, "assets", "sam_vit_b_01ec64_salia.pth")
108
+
109
+ # Cached instances (process-local)
110
+ _CACHED_YOLO_MODEL = None
111
+ _CACHED_ULTRA_DETECTOR = None
112
+
113
+
114
+ def _tensor_to_pil(image: torch.Tensor) -> Image.Image:
115
+ # image: [1, H, W, 3], float(0..1)
116
+ img = image[0].detach().cpu().clamp(0, 1).numpy()
117
+ img = (img * 255.0).round().astype(np.uint8) # (H, W, 3) RGB
118
+ return Image.fromarray(img, mode="RGB")
119
+
120
+
121
+ def _make_crop_region(w: int, h: int, bbox_xyxy, crop_factor: float) -> Tuple[int, int, int, int]:
122
+ x1, y1, x2, y2 = map(int, bbox_xyxy)
123
+ cx = (x1 + x2) * 0.5
124
+ cy = (y1 + y2) * 0.5
125
+ bw = (x2 - x1)
126
+ bh = (y2 - y1)
127
+ new_w = max(1, int(bw * crop_factor))
128
+ new_h = max(1, int(bh * crop_factor))
129
+ # center to image
130
+ nx1 = int(max(0, round(cx - new_w * 0.5)))
131
+ ny1 = int(max(0, round(cy - new_h * 0.5)))
132
+ nx2 = int(min(w, nx1 + new_w))
133
+ ny2 = int(min(h, ny1 + new_h))
134
+ # clamp again
135
+ nx1 = max(0, min(nx1, w - 1))
136
+ ny1 = max(0, min(ny1, h - 1))
137
+ nx2 = max(nx1 + 1, min(nx2, w))
138
+ ny2 = max(ny1 + 1, min(ny2, h))
139
+ return (nx1, ny1, nx2, ny2)
140
+
141
+
142
+ def _crop_tensor_image(image: torch.Tensor, crop: Tuple[int, int, int, int]) -> torch.Tensor:
143
+ # image: [1,H,W,3]; crop: (x1,y1,x2,y2)
144
+ x1, y1, xb, yb = crop
145
+ return image[:, y1:yb, x1:xb, :].contiguous()
146
+
147
+
148
+ def _crop_ndarray(mask: np.ndarray, crop: Tuple[int, int, int, int]) -> np.ndarray:
149
+ # mask: [H,W] float/bool/uint8; crop: (x1,y1,x2,y2)
150
+ x1, y1, xb, yb = crop
151
+ return mask[int(y1):int(yb), int(x1):int(xb)]
152
+
153
+
154
+ def _dilate_masks(segmasks: List[Tuple[np.ndarray, np.ndarray, float]], factor: int):
155
+ if factor == 0 or not segmasks:
156
+ return segmasks
157
+ k = abs(int(factor))
158
+ if k < 1:
159
+ return segmasks
160
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
161
+ do_dilate = factor > 0
162
+ out = []
163
+ for (bbox, m, conf) in segmasks:
164
+ u8 = (m * 255.0).astype(np.uint8) if m.dtype != np.uint8 else m
165
+ d = cv2.dilate(u8, kernel, iterations=1) if do_dilate else cv2.erode(u8, kernel, iterations=1)
166
+ out.append((bbox, d.astype(np.float32) / 255.0, conf))
167
+ return out
168
+
169
+
170
+ def _combine_masks(segmasks: List[Tuple[np.ndarray, np.ndarray, float]]) -> Optional[torch.Tensor]:
171
+ if not segmasks:
172
+ return None
173
+ h = segmasks[0][1].shape[0]
174
+ w = segmasks[0][1].shape[1]
175
+ acc = np.zeros((h, w), dtype=np.uint8)
176
+ for _, m, _ in segmasks:
177
+ u8 = (m * 255.0).astype(np.uint8) if m.dtype != np.uint8 else m
178
+ acc = cv2.bitwise_or(acc, u8)
179
+ return torch.from_numpy(acc.astype(np.float32) / 255.0) # [H,W], float32 0..1 CPU
180
+
181
+
182
+ def _pick_device_str(user_device: str = "") -> str:
183
+ if user_device:
184
+ return user_device
185
+ return "cuda" if torch.cuda.is_available() else "cpu"
186
+
187
+
188
+ @torch.inference_mode()
189
+ def _inference_bbox(model, image_pil: Image.Image, confidence: float = 0.3, device: str = ""):
190
+ """
191
+ Returns results = [labels(str), bboxes(xyxy), segms(full-image bool masks), conf(float)]
192
+ For bbox models, segm "masks" are rectangles from the boxes (Subpack parity).
193
+ """
194
+ pred = model(
195
+ image_pil,
196
+ conf=confidence,
197
+ device=_pick_device_str(device),
198
+ verbose=False,
199
+ imgsz=YOLO_IMGSZ, # fixed size can be faster
200
+ )
201
+
202
+ p0 = pred[0]
203
+ boxes = p0.boxes
204
+ bboxes = boxes.xyxy.detach().cpu().numpy() # (N,4) float, xyxy
205
+
206
+ W, H = image_pil.size
207
+ segms = []
208
+ for x0, y0, x1, y1 in bboxes:
209
+ m = np.zeros((H, W), np.uint8)
210
+ cv2.rectangle(m, (int(x0), int(y0)), (int(x1), int(y1)), 255, -1)
211
+ segms.append(m.astype(bool))
212
+
213
+ if bboxes.shape[0] == 0:
214
+ return [[], [], [], []]
215
+
216
+ results = [[], [], [], []]
217
+ names = p0.names
218
+ for i, (bbox, segm) in enumerate(zip(bboxes, segms)):
219
+ cls_i = int(boxes.cls[i].item())
220
+ results[0].append(names[cls_i])
221
+ results[1].append(bbox)
222
+ results[2].append(segm)
223
+ results[3].append(float(boxes.conf[i].item()))
224
+ return results
225
+
226
+
227
+ def _create_segmasks(results):
228
+ bboxes = results[1]
229
+ segms = results[2]
230
+ confs = results[3]
231
+ out = []
232
+ for i in range(len(segms)):
233
+ out.append((bboxes[i], segms[i].astype(np.float32), confs[i]))
234
+ return out
235
+
236
+
237
+ class UltraBBoxDetector:
238
+ def __init__(self, yolo_model):
239
+ self.bbox_model = yolo_model
240
+
241
+ def detect(self, image, threshold, dilation, crop_factor, drop_size=1, detailer_hook=None):
242
+ drop_size = max(int(drop_size), 1)
243
+ detected = _inference_bbox(self.bbox_model, _tensor_to_pil(image), threshold)
244
+ segmasks = _create_segmasks(detected)
245
+ if int(dilation) != 0:
246
+ segmasks = _dilate_masks(segmasks, int(dilation))
247
+
248
+ H = int(image.shape[1])
249
+ W = int(image.shape[2])
250
+ items = []
251
+ for (bbox_xyxy, full_mask, conf), label in zip(segmasks, detected[0]):
252
+ x1, y1, x2, y2 = map(int, bbox_xyxy)
253
+ if (x2 - x1) > drop_size and (y2 - y1) > drop_size:
254
+ crop_region = _make_crop_region(W, H, (x1, y1, x2, y2), float(crop_factor))
255
+ if detailer_hook is not None and hasattr(detailer_hook, "post_crop_region"):
256
+ crop_region = detailer_hook.post_crop_region(W, H, (x1, y1, x2, y2), crop_region)
257
+
258
+ cropped_image = _crop_tensor_image(image, crop_region)
259
+ cropped_mask = _crop_ndarray(full_mask, crop_region).astype(np.float32)
260
+ items.append(SEG(cropped_image, cropped_mask, float(conf), crop_region, (x1, y1, x2, y2), str(label), None))
261
+
262
+ segs = ((H, W), items)
263
+ if detailer_hook is not None and hasattr(detailer_hook, "post_detection"):
264
+ segs = detailer_hook.post_detection(segs)
265
+ return segs
266
+
267
+ def detect_combined(self, image, threshold, dilation):
268
+ detected = _inference_bbox(self.bbox_model, _tensor_to_pil(image), threshold)
269
+ segmasks = _create_segmasks(detected)
270
+ if int(dilation) != 0:
271
+ segmasks = _dilate_masks(segmasks, int(dilation))
272
+ return _combine_masks(segmasks)
273
+
274
+ def setAux(self, x):
275
+ # kept for signature parity
276
+ pass
277
+
278
+
279
+ def _load_ultralytics_model(model_path: str):
280
+ # Import here so that module import doesn't hard-fail if ultralytics is missing
281
+ try:
282
+ from ultralytics import YOLO
283
+ except Exception as e:
284
+ raise RuntimeError(
285
+ "[FaceDetailerStandalone] The 'ultralytics' package is required for the embedded bbox detector.\n"
286
+ "Install in your ComfyUI python: python -m pip install --upgrade ultralytics"
287
+ ) from e
288
+
289
+ if not os.path.isfile(model_path):
290
+ raise FileNotFoundError(
291
+ "[FaceDetailerStandalone] Embedded YOLO model file not found.\n"
292
+ f"Expected at: {model_path}\n"
293
+ "Please place 'face_yolov8m_salia.pt' in the 'assets' folder next to this node."
294
+ )
295
+
296
+ yolo = YOLO(model_path)
297
+
298
+ # One-time graph/model optimizations
299
+ try:
300
+ dev = _pick_device_str()
301
+ try:
302
+ yolo.to(dev) # newer Ultralytics
303
+ except Exception:
304
+ yolo.model.to(dev) # older versions
305
+ except Exception:
306
+ pass
307
+
308
+ # Fuse Conv+BN where possible (small speedup)
309
+ try:
310
+ yolo.fuse()
311
+ except Exception:
312
+ pass
313
+
314
+ # Use half precision weights on CUDA (big win; safe for inference)
315
+ try:
316
+ if torch.cuda.is_available():
317
+ yolo.model.half()
318
+ except Exception:
319
+ pass
320
+
321
+ return yolo
322
+
323
+
324
+ def _get_embedded_detector():
325
+ global _CACHED_YOLO_MODEL, _CACHED_ULTRA_DETECTOR
326
+ if _CACHED_ULTRA_DETECTOR is not None:
327
+ return _CACHED_ULTRA_DETECTOR
328
+ if _CACHED_YOLO_MODEL is None:
329
+ _CACHED_YOLO_MODEL = _load_ultralytics_model(YOLO_MODEL_PATH)
330
+ _CACHED_ULTRA_DETECTOR = UltraBBoxDetector(_CACHED_YOLO_MODEL)
331
+ return _CACHED_ULTRA_DETECTOR
332
+
333
+ # ---------------- Embedded SAM loader (GPU-only, hardcoded path, reuse one predictor) ----------------
334
+ # Matches your SAMLoaderStandalone design, but embedded + cached.
335
+
336
+
337
+ def _to_numpy_rgb(image_tensor):
338
+ """
339
+ Comfy 'IMAGE' is NHWC in [0..1]. Convert to uint8 HxWx3 RGB numpy.
340
+ Accepts torch.Tensor (NHWC) or numpy already in HWC.
341
+ """
342
+ if isinstance(image_tensor, torch.Tensor):
343
+ img = image_tensor
344
+ if img.dim() == 4 and img.shape[0] == 1:
345
+ img = img[0]
346
+ img = (img.clamp(0, 1) * 255.0).to(torch.uint8).cpu().numpy() # HWC
347
+ return img
348
+ elif isinstance(image_tensor, np.ndarray):
349
+ if image_tensor.dtype != np.uint8:
350
+ img = np.clip(image_tensor, 0, 255).astype(np.uint8)
351
+ else:
352
+ img = image_tensor
353
+ return img
354
+ else:
355
+ raise TypeError(f"Unsupported image type for SAM: {type(image_tensor)}")
356
+
357
+
358
+ class _SAMWrapperGPUOnlyFast:
359
+ """
360
+ FaceDetailer-compatible wrapper:
361
+ - Stays on CUDA
362
+ - Reuses a single SamPredictor
363
+ - predict(image, points, plabs, bbox, threshold) -> list[HxW float32 CPU masks]
364
+ """
365
+ def __init__(self, model):
366
+ self.model = model
367
+ dev = comfy.model_management.get_torch_device()
368
+ if "cuda" not in str(dev).lower():
369
+ raise RuntimeError(
370
+ f"[FaceDetailerStandalone] GPU-only SAM: CUDA device not available (got '{dev}')."
371
+ )
372
+ self._device = dev
373
+ self.model.to(self._device).eval()
374
+ # Lazy import for segment_anything predictor
375
+ from segment_anything import SamPredictor # type: ignore
376
+ # Reuse one predictor instance (cheaper than re-creating every call)
377
+ self._predictor = SamPredictor(self.model)
378
+
379
+ def prepare_device(self):
380
+ if "cuda" not in str(self._device).lower():
381
+ raise RuntimeError("[FaceDetailerStandalone] CUDA device lost/unavailable for SAM.")
382
+
383
+ def release_device(self):
384
+ # GPU-only; keep on GPU (no-op)
385
+ pass
386
+
387
+ @torch.inference_mode()
388
+ def predict(self, image, points, plabs, bbox, threshold: float):
389
+ """
390
+ image: Comfy IMAGE (NHWC, [0..1]) or numpy
391
+ points: list[[x,y], ...] or None
392
+ plabs: list[int] (1=fg, 0=bg) or None
393
+ bbox: [x1,y1,x2,y2] or None
394
+ threshold: float in [0..1]
395
+ returns: list of HxW float32 CPU masks (0/1)
396
+ """
397
+ self.prepare_device()
398
+
399
+ np_img = _to_numpy_rgb(image)
400
+ # Some builds call set_image(img, "RGB"); accept both signatures.
401
+ try:
402
+ self._predictor.set_image(np_img, "RGB")
403
+ except TypeError:
404
+ self._predictor.set_image(np_img)
405
+
406
+ pc = np.array(points, dtype=np.float32) if points else None
407
+ pl = np.array(plabs, dtype=np.int32) if plabs else None
408
+ bx = np.array(bbox, dtype=np.float32) if bbox is not None else None
409
+
410
+ # Keep provided behavior: multimask_output=False
411
+ masks, scores, _ = self._predictor.predict(
412
+ point_coords=pc,
413
+ point_labels=pl,
414
+ box=bx,
415
+ multimask_output=False
416
+ )
417
+
418
+ out = []
419
+ if masks is not None and scores is not None:
420
+ for m, s in zip(masks, scores):
421
+ if float(s) >= float(threshold):
422
+ if isinstance(m, torch.Tensor):
423
+ t = m.to(torch.float32).cpu()
424
+ else:
425
+ t = torch.from_numpy(m.astype(np.float32)).cpu()
426
+ out.append(t)
427
+ return out
428
+
429
+
430
+ # Cache for SAM
431
+ _CACHED_SAM_MODEL = None
432
+
433
+
434
+ def _get_embedded_sam():
435
+ """Load SAM vit_b from SAM_CKPT_PATH and attach GPU-only fast wrapper, cached."""
436
+ global _CACHED_SAM_MODEL
437
+ if _CACHED_SAM_MODEL is not None:
438
+ return _CACHED_SAM_MODEL
439
+
440
+ if not os.path.isfile(SAM_CKPT_PATH):
441
+ raise FileNotFoundError(
442
+ f"[FaceDetailerStandalone] SAM checkpoint not found:\n {SAM_CKPT_PATH}\n"
443
+ f"Place 'sam_vit_b_01ec64_salia.pth' in the 'assets' folder next to this node."
444
+ )
445
+
446
+ # Import here to avoid module import failure at file load time
447
+ try:
448
+ from segment_anything import sam_model_registry # type: ignore
449
+ except Exception as e:
450
+ raise RuntimeError(
451
+ "[FaceDetailerStandalone] 'segment_anything' is not installed for embedded SAM. "
452
+ "Install in your Comfy python, e.g.: python -m pip install "
453
+ "git+https://github.com/facebookresearch/segment-anything"
454
+ ) from e
455
+
456
+ # Fixed to vit_b (matches 'sam_vit_b_01ec64' weights)
457
+ sam = sam_model_registry['vit_b'](checkpoint=SAM_CKPT_PATH)
458
+ sam.eval() # ensure eval mode
459
+
460
+ # Attach GPU-only, faster wrapper
461
+ wrapper = _SAMWrapperGPUOnlyFast(sam)
462
+ sam.sam_wrapper = wrapper
463
+
464
+ _CACHED_SAM_MODEL = sam
465
+ return _CACHED_SAM_MODEL
466
+
467
+ # ---------------- Impact Pack Face Detailer binding ----------------
468
+ _ENHANCE_FACE = None
469
+ _IMPORT_ERR = None
470
+ try:
471
+ from impact.impact_pack import FaceDetailer as _FD
472
+ _ENHANCE_FACE = _FD.enhance_face
473
+ except Exception as _e:
474
+ _IMPORT_ERR = _e
475
+ _ENHANCE_FACE = None
476
+
477
+ # ---------------- Single public node ----------------
478
+ class dn_04:
479
+ @classmethod
480
+ def INPUT_TYPES(cls):
481
+ # Only essential, connectable parts remain editable. (No bbox or SAM inputs.)
482
+ return {
483
+ "required": {
484
+ "image": ("IMAGE",),
485
+ "model": ("MODEL", {"tooltip": "If `ImpactDummyInput` is connected to model, inference is skipped."}),
486
+ "clip": ("CLIP",),
487
+ "vae": ("VAE",),
488
+
489
+ # Keep sampler selectable; all other knobs are fixed
490
+ "sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
491
+
492
+ # Conditioning stays connectable
493
+ "positive": ("CONDITIONING",),
494
+ "negative": ("CONDITIONING",),
495
+
496
+ # Keep seed editable but fixed after generate for reproducibility
497
+ "seed": ("INT", {
498
+ "default": 0,
499
+ "min": 0,
500
+ "max": 0xffffffffffffffff,
501
+ "step": 1,
502
+ "control_after_generate": "fixed",
503
+ }),
504
+ },
505
+ "optional": {
506
+ # No external SAM input; embedded
507
+ }
508
+ }
509
+
510
+ RETURN_TYPES = ("IMAGE",)
511
+ RETURN_NAMES = ("image",)
512
+ FUNCTION = "doit"
513
+ CATEGORY = "ImpactPack/Standalone"
514
+ DESCRIPTION = (
515
+ "Face Detailer with requested parameters hardcoded (non-editable), "
516
+ "and embedded Ultralytics face bbox detector + embedded SAM (no external input nodes). "
517
+ "Optimized call path (cached imports + inference_mode) for lower overhead; "
518
+ "results identical to Impact Pack Face Detailer at the same settings."
519
+ )
520
+
521
+ def doit(
522
+ self,
523
+ image, model, clip, vae,
524
+ sampler_name,
525
+ positive, negative,
526
+ seed,
527
+ ):
528
+ if _ENHANCE_FACE is None:
529
+ raise RuntimeError(
530
+ "ComfyUI-Impact-Pack is required for Face Detailer logic. "
531
+ "Please install/enable ComfyUI-Impact-Pack."
532
+ ) from _IMPORT_ERR
533
+
534
+ # Embedded detector & SAM (cached)
535
+ bbox_detector = _get_embedded_detector()
536
+ sam_model_opt = _get_embedded_sam()
537
+
538
+ enhance = _ENHANCE_FACE
539
+
540
+ # Determine batch size safely
541
+ B = image.shape[0] if (hasattr(image, "shape") and image.ndim == 4) else 1
542
+
543
+ # No autograd, faster kernel choices, identical math for inference
544
+ with torch.inference_mode():
545
+ if B == 1:
546
+ # Fast-path for single image (avoid list + cat)
547
+ single = image[0] if image.ndim == 4 else image # [H,W,C]
548
+ enhanced_img, _, _, _, _ = enhance(
549
+ single.unsqueeze(0), # -> [1,H,W,C]
550
+ model, clip, vae,
551
+ 512, True, 1024, # guide_size, guide_for_bbox, max_size
552
+ seed, 38, 7.0, # steps, cfg
553
+ sampler_name, "simple", # scheduler name
554
+ positive, negative,
555
+ 0.4, 5, True, True, # denoise, feather, noise_mask, force_inpaint
556
+ 0.5, 10, 3.0, # bbox_threshold, bbox_dilation, bbox_crop_factor
557
+ "center-1", 0, 0.93, 0, # sam_detection_hint, sam_dilation, sam_threshold, sam_bbox_expansion
558
+ 0.7, "False", # sam_mask_hint_threshold, sam_mask_hint_use_negative
559
+ 10, bbox_detector, # drop_size, bbox_detector
560
+ # Internals not exposed (kept fixed/None)
561
+ segm_detector=None, sam_model_opt=sam_model_opt,
562
+ wildcard_opt="", detailer_hook=None,
563
+ refiner_ratio=None, refiner_model=None, refiner_clip=None,
564
+ refiner_positive=None, refiner_negative=None,
565
+ cycle=1, inpaint_model=False,
566
+ noise_mask_feather=20,
567
+ scheduler_func_opt=None,
568
+ tiled_encode=False, tiled_decode=False,
569
+ )
570
+ return (enhanced_img,)
571
+
572
+ # Batch of images; per-frame process with seed+i
573
+ out_imgs = []
574
+ for i, single in enumerate(image.unbind(0)):
575
+ enhanced_img, _, _, _, _ = enhance(
576
+ single.unsqueeze(0), # [1,H,W,C]
577
+ model, clip, vae,
578
+ 512, True, 1024,
579
+ seed + i, 30, 7.0,
580
+ sampler_name, "simple",
581
+ positive, negative,
582
+ 0.5, 5, True, True,
583
+ 0.5, 10, 3.0,
584
+ "center-1", 0, 0.93, 0,
585
+ 0.7, "False",
586
+ 10, bbox_detector,
587
+ segm_detector=None, sam_model_opt=sam_model_opt,
588
+ wildcard_opt="", detailer_hook=None,
589
+ refiner_ratio=None, refiner_model=None, refiner_clip=None,
590
+ refiner_positive=None, refiner_negative=None,
591
+ cycle=1, inpaint_model=False,
592
+ noise_mask_feather=20,
593
+ scheduler_func_opt=None,
594
+ tiled_encode=False, tiled_decode=False,
595
+ )
596
+ out_imgs.append(enhanced_img)
597
+
598
+ return (torch.cat(out_imgs, dim=0),)
599
+
600
+
601
+ NODE_CLASS_MAPPINGS = {
602
+ "dn_04": dn_04,
603
+ }
604
+ NODE_DISPLAY_NAME_MAPPINGS = {
605
+ "dn_04": "dn_04",
606
+ }