import os import numpy as np import torch import cv2 from insightface.app import FaceAnalysis _FACE_APP_CACHE = {} def _packs_under(root): out = [] try: for name in os.listdir(root): p = os.path.join(root, name) if os.path.isdir(p) and any(f.endswith(".onnx") for f in os.listdir(p)): out.append(name) except Exception: pass return out def _default_models_root(): local_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "models", "insightface")) home_root = os.path.expanduser("~/.insightface/models") return local_root if os.path.isdir(local_root) else home_root def _existing_pack_names(): candidates = ("antelopev2", "buffalo_l", "buffalo_m", "buffalo_s") roots = ( os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "models", "insightface")), os.path.expanduser("~/.insightface/models"), ) found = [] seen = set() for name in candidates: for root in roots: p = os.path.join(root, name) if os.path.isdir(p) and any(f.lower().endswith(".onnx") for f in os.listdir(p)): if name not in seen: found.append(name) seen.add(name) break if not found: return ("antelopev2",) return tuple(found) _PACK_CHOICES = _existing_pack_names() def _providers_from_choice(choice): if choice == "cpu_only": return ["CPUExecutionProvider"] return ["CUDAExecutionProvider", "CPUExecutionProvider"] def _get_face_app(model_name, providers_choice, det_size): model_root = _default_models_root() providers = _providers_from_choice(providers_choice) key = (os.path.abspath(model_root), model_name, tuple(providers), int(det_size)) app = _FACE_APP_CACHE.get(key) if app is None: app = FaceAnalysis(name=model_name, root=model_root, providers=providers) app.prepare(ctx_id=0, det_size=(int(det_size), int(det_size))) _FACE_APP_CACHE[key] = app return app def _ensure_uint8_rgb(t): if isinstance(t, torch.Tensor): x = t.detach().cpu() if x.ndim == 3 and x.shape[-1] in (3, 4): rgb = x[..., :3].numpy() elif x.ndim == 3 and x.shape[0] in (3, 4): rgb = x[:3, ...].permute(1, 2, 0).numpy() else: raise ValueError("Unsupported tensor shape for image") if rgb.dtype != np.uint8: rgb = np.clip(rgb * 255.0, 0, 255).astype(np.uint8) return rgb if isinstance(t, np.ndarray): arr = t if arr.ndim != 3: raise ValueError("Unsupported numpy image shape") if arr.shape[-1] == 4: arr = arr[..., :3] if arr.dtype != np.uint8: arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8) return arr raise ValueError("Unsupported image type") def _prepare_for_detection(img_tensor_or_np, size, mode): rgb = _ensure_uint8_rgb(img_tensor_or_np) h, w, _ = rgb.shape scale = (float(size) / float(min(h, w))) if mode == "fit_min_side" else (float(size) / float(max(h, w))) if scale != 1.0: new_w = max(1, int(round(w * scale))) new_h = max(1, int(round(h * scale))) rgb = cv2.resize(rgb, (new_w, new_h), interpolation=cv2.INTER_LINEAR) bgr = cv2.cvtColor(np.ascontiguousarray(rgb), cv2.COLOR_RGB2BGR) return bgr, (h, w) def _iterate_batch(imgs): if isinstance(imgs, torch.Tensor): if imgs.ndim == 4: for i in range(imgs.shape[0]): yield i, imgs[i] elif imgs.ndim == 3: yield 0, imgs else: raise ValueError("Expected IMAGE tensor of ndim 3 or 4") elif isinstance(imgs, (list, tuple)): for i, im in enumerate(imgs): yield i, im else: yield 0, imgs def _as_batched_tensor(imgs): if isinstance(imgs, torch.Tensor): return imgs if imgs.ndim == 4 else imgs.unsqueeze(0) if isinstance(imgs, (list, tuple)): ts = [] for im in imgs: if not isinstance(im, torch.Tensor): raise ValueError("IMAGE list must contain torch tensors") ts.append(im if im.ndim == 3 else im.squeeze(0)) return torch.stack(ts, dim=0) if ts else torch.zeros((0, 64, 64, 3), dtype=torch.float32) raise ValueError("Unsupported IMAGE container") def _empty_like_batch(batched): return batched[0:0] def _pass_through_first(batched): return batched[:1] if batched.shape[0] else batched def _black_image_batch_like(batched, size): device = batched.device h = int(batched.shape[1]) if batched.shape[0] else size w = int(batched.shape[2]) if batched.shape[0] else size return torch.zeros((1, h, w, 3), dtype=torch.float32, device=device) def _build_batch_by_indices(batched, indices): if len(indices) == 0: return batched[0:0] return torch.stack([batched[i] for i in indices], dim=0) def _policy_apply_empty(batched, policy): if policy == "return_empty": return _empty_like_batch(batched) if policy == "black_512": return _black_image_batch_like(batched, 512) return _pass_through_first(batched) def _embed_faces(batch_images, app, det_side, resize_mode, info_lines, tag): embeddings = [] faces_per_image = [] shapes = [] for idx, img in _iterate_batch(batch_images): try: bgr, orig_hw = _prepare_for_detection(img, det_side, resize_mode) shapes.append((orig_hw[0], orig_hw[1], int(bgr.shape[0]), int(bgr.shape[1]))) faces = app.get(bgr) except Exception as e: info_lines.append(f"{tag}[{idx}] preprocess or detect error {repr(e)}") faces = [] faces_per_image.append(len(faces)) for face in faces: vec = face.embedding.astype(np.float32) n = np.linalg.norm(vec) if n > 0: embeddings.append(vec / n) if shapes: info_lines.append(f"{tag}_shapes={shapes}") return embeddings, faces_per_image class FaceFilterNode: @classmethod def INPUT_TYPES(cls): return { "required": { "ref_images": ("IMAGE",), "candidate_images": ("IMAGE",), "threshold": ("FLOAT", {"default": 0.30, "min": 0.0, "max": 1.0, "step": 0.01}), "on_empty_matching": (["black_512", "return_empty", "pass_through"],), "on_empty_rejected": (["black_512", "return_empty", "pass_through"],), "debug": ("BOOLEAN", {"default": False}), "model_name": (_PACK_CHOICES,), "providers": (["auto(cuda+cpu)", "cpu_only"],), "detector_size": ("INT", {"default": 256, "min": 32, "max": 2048, "step": 32}), "resize_mode": (["fit_min_side", "fit_longest_side"],), } } RETURN_TYPES = ("IMAGE", "IMAGE", "STRING") RETURN_NAMES = ("MATCHING", "REJECTED", "DEBUG") FUNCTION = "filter" CATEGORY = "image/face" OUTPUT_NODE = False def filter(self, ref_images, candidate_images, threshold, on_empty_matching, on_empty_rejected, debug, model_name, providers, detector_size, resize_mode): logs = [] app = _get_face_app(model_name, providers, detector_size) cand_batched = _as_batched_tensor(candidate_images) ref_embs, ref_faces = _embed_faces(ref_images, app, detector_size, resize_mode, logs, "refs") logs += [ f"refs_faces_per_image={ref_faces}", f"total_ref_embeddings={len(ref_embs)}", f"model_name={model_name} providers={providers} det_size={detector_size} resize_mode={resize_mode}", ] kept_indices = [] rejected_indices = [] cand_faces_counts = [] cand_best_sims = [] for idx, img in _iterate_batch(cand_batched): if not ref_embs: cand_faces_counts.append(0) cand_best_sims.append(float("-inf")) rejected_indices.append(idx) continue try: bgr, _ = _prepare_for_detection(img, detector_size, resize_mode) faces = app.get(bgr) except Exception: cand_faces_counts.append(0) cand_best_sims.append(float("-inf")) rejected_indices.append(idx) continue if not faces: cand_faces_counts.append(0) cand_best_sims.append(float("-inf")) rejected_indices.append(idx) continue cand_faces_counts.append(len(faces)) best = -1.0 for face in faces: vec = face.embedding.astype(np.float32) n = np.linalg.norm(vec) if n == 0: continue vec /= n sims = np.dot(np.stack(ref_embs, axis=0), vec) best = max(best, float(np.max(sims))) cand_best_sims.append(best) if best >= float(threshold): kept_indices.append(idx) else: rejected_indices.append(idx) logs.append(f"candidates_faces_per_image={cand_faces_counts}") if cand_best_sims: logs.append(f"candidates_best_similarity={[round(x,4) if x!=-float('inf') else -1 for x in cand_best_sims]}") logs.append(f"threshold={threshold}") matching = _build_batch_by_indices(cand_batched, kept_indices) rejected = _build_batch_by_indices(cand_batched, rejected_indices) if matching.shape[0] == 0: matching = _policy_apply_empty(cand_batched, on_empty_matching) if rejected.shape[0] == 0: rejected = _policy_apply_empty(cand_batched, on_empty_rejected) dbg = "\n".join(logs) if debug else "" return matching, rejected, dbg NODE_CLASS_MAPPINGS = {"FaceFilterNode": FaceFilterNode} NODE_DISPLAY_NAME_MAPPINGS = {"FaceFilterNode": "Face Filter"}