import torch import numpy as np from PIL import Image import cv2 from imgutils.detect import ( detect_person, detect_faces, detect_heads, detect_halfbody, detect_hands, detect_eyes, detect_with_booru_yolo, detect_with_nudenet ) from imgutils.detect.censor import detect_censors from imgutils.detect.visual import detection_visualize BBOX = "BBOX" class BBOX_imagutils: """ A class to represent bounding boxes in imgutils detection. This is a placeholder for future use if needed. """ def __init__(self, x0, y0, x1, y1,img_w,img_h, label="", confidence=0.0): self.x0 = x0 self.y0 = y0 self.x1 = x1 self.y1 = y1 self.img_w = img_w self.img_h = img_h self.label = label self.confidence = confidence def __repr__(self): return f"BBOX({self.x0}, {self.y0}, {self.x1}, {self.y1}, {self.img_w}, {self.img_h}, '{self.label}', {self.confidence})" def get_bbox(self): return (self.x0, self.y0, self.x1, self.y1) def to_json(self): """Convert BBOX_imagutils to JSON serializable format""" return { "type": "BBOX_imagutils", "x0": self.x0, "y0": self.y0, "x1": self.x1, "y1": self.y1, "img_w": self.img_w, "img_h": self.img_h, "label": self.label, "confidence": self.confidence } @classmethod def from_json(cls, data): """Create BBOX_imagutils from JSON data""" if isinstance(data, dict) and data.get("type") == "BBOX_imagutils": return cls( x0=data["x0"], y0=data["y0"], x1=data["x1"], y1=data["y1"], img_w=data.get("img_w", 0), img_h=data.get("img_h", 0), label=data.get("label", ""), confidence=data.get("confidence", 0.0) ) return data def __dict__(self): """Support for dict() conversion""" return self.to_json() DETECTION_FUNCTIONS = { "Person Detection": detect_person, "Face Detection": detect_faces, "Head Detection": detect_heads, "Halfbody Detection": detect_halfbody, "Hand Detection": detect_hands, "Eye Detection": detect_eyes, "Censor Detection": detect_censors, # "Booru YOLO Detection": detect_with_booru_yolo, # "Nude Detection": detect_with_nudenet, } MODEL_VERSIONS = { "Person Detection": ["v0", "v1", "v1.1"], "Face Detection": ["v0", "v1", "v1.3", "v1.4"], "Head Detection": [], "Halfbody Detection": ["v1"], "Hand Detection": ["v1"], "Eye Detection": ["v1"], "Censor Detection": ["v1"], } class ImgutilsGenericDetector: """ A generic imgutils detector node that unifies various detection functions. """ CATEGORY = "imgutils/detection" @classmethod def INPUT_TYPES(cls): return { "required": { "image": ("IMAGE",), "detection_type": (list(DETECTION_FUNCTIONS.keys()),), "conf_threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.05}), "iou_threshold": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.05}), "draw_boxes": ("BOOLEAN", {"default": True}), # Model specific parameters, always present for simplicity in this generic node "level": ("STRING", {"default": "s", "options": ["n", "s"]}), "version": ("STRING", {"default": "", "options": ["v0", "v1", "v1.1"]}), # "model_name": ("STRING", {"default": ""}), }, } RETURN_TYPES = ("IMAGE", "MASK",BBOX, ) RETURN_NAMES = ("image_with_boxes", "detection_mask","detection_bbox",) FUNCTION = "detect" def detect(self, image, detection_type, conf_threshold, iou_threshold, draw_boxes, level, version, model_name=None): # Convert ComfyUI image tensor to PIL Image # image is in format [B, H, W, C] with values 0-1 img_tensor = image[0] # Get first image from batch img_np = (img_tensor.cpu().numpy() * 255).astype(np.uint8) # Convert to 0-255 range img_pil = Image.fromarray(img_np) # For visualization and mask generation, we still need numpy array img_np_rgb = np.array(img_pil.convert("RGB")) selected_detect_func = DETECTION_FUNCTIONS.get(detection_type) detection_results_formatted = [] output_image_tensor = image # Default to original image tensor output_mask_np = np.zeros((img_np_rgb.shape[0], img_np_rgb.shape[1]), dtype=np.float32) output_string = "" if not selected_detect_func: output_string = f"Error: Detection type '{detection_type}' not supported." output_mask_tensor = torch.from_numpy(output_mask_np).unsqueeze(0) # Add batch dimension return (output_image_tensor, output_string, output_mask_tensor) func_params = { "image": img_pil, # Use PIL Image directly for imgutils functions "conf_threshold": conf_threshold, "iou_threshold": iou_threshold, } func_params["level"] = level if version in MODEL_VERSIONS[detection_type]: func_params["version"] = version if model_name is None or model_name == "": pass else: func_params["model_name"] = model_name # if 1 + 1 == 2: try: # print(f"Running {detection_type} with parameters:") # print(f" Original image type: {type(img_pil)}") # print(f" Original image mode: {img_pil.mode}") # print(f" Original image size: {img_pil.size}") # Ensure image is in RGB mode if img_pil.mode != 'RGB': img_pil = img_pil.convert('RGB') print(f"Using detection function: {selected_detect_func.__name__}") # # Try different calling patterns based on the function # if detection_type in ["Face Detection", "Person Detection"]: # # These functions might have different parameter requirements # if detection_type == "Face Detection": # # Try with minimal parameters first # raw_detections = selected_detect_func( # image=img_pil, # conf_threshold=conf_threshold # ) # else: # Person Detection # raw_detections = selected_detect_func( # image=img_pil, # level=level, # version=version if version in MODEL_VERSIONS[detection_type] else "v1.1", # conf_threshold=conf_threshold # ) # else: # # For other detection types, use the standard parameters # raw_detections = selected_detect_func( # image=img_pil, # level=level, # conf_threshold=conf_threshold # ) if "version" in func_params: raw_detections = selected_detect_func( image=img_pil, level=level, version=func_params["version"], conf_threshold=conf_threshold, iou_threshold=iou_threshold ) else: raw_detections = selected_detect_func( image=img_pil, level=level, conf_threshold=conf_threshold, iou_threshold=iou_threshold ) img_w, img_h = img_pil.size for item in raw_detections: if isinstance(item, tuple) and len(item) == 3: box, label, confidence = item if isinstance(box, tuple) and len(box) == 4: detection_results_formatted.append({ 'box': [int(b) for b in box], 'label': str(label), 'confidence': float(confidence) }) bbox_list = [] if detection_results_formatted: # output_string = f"{detection_type} Results:\n" # output_string += "\n".join([ # f"- {res['label']}: Confidence={res['confidence']:.2f}, Box={res['box']}" # for res in detection_results_formatted if res['confidence'] >= conf_threshold # Apply conf_threshold for string output too # ]) # print(f"Detection results: {output_string}") for res in detection_results_formatted: if res['confidence'] >= conf_threshold: bbox = BBOX_imagutils( x0=res['box'][0], y0=res['box'][1], x1=res['box'][2], y1=res['box'][3], img_w=img_w, img_h=img_h, label=res['label'], confidence=res['confidence'] ) bbox_list.append(bbox) else: print(f"No {detection_type} detected with confidence >= {conf_threshold}.") # output_string = f"No {detection_type} detected with confidence >= {conf_threshold}." if draw_boxes and detection_results_formatted: vis_input_for_detection_visualize = [] for res in detection_results_formatted: if res['confidence'] >= conf_threshold: vis_input_for_detection_visualize.append( (tuple(res['box']), res['label'], res['confidence']) ) if vis_input_for_detection_visualize: # Only visualize if there are results after filtering output_image_pil = detection_visualize( image=img_pil, detection=vis_input_for_detection_visualize, fontsize=12, no_label=False ) # Convert PIL back to ComfyUI tensor format output_image_np = np.array(output_image_pil.convert("RGB")) output_image_tensor = torch.from_numpy(output_image_np.astype(np.float32) / 255.0).unsqueeze(0) else: output_image_tensor = image # Use original image tensor else: # If not drawing or no results, use original image tensor output_image_tensor = image # Generate mask for res in detection_results_formatted: if res['confidence'] >= conf_threshold: box = res['box'] cv2.rectangle(output_mask_np, (box[0], box[1]), (box[2], box[3]), 1.0, -1) # Convert mask to ComfyUI tensor format [B, H, W] output_mask_tensor = torch.from_numpy(output_mask_np).unsqueeze(0) return ( output_image_tensor, output_mask_tensor, bbox_list if bbox_list else [], ) except Exception as e: print(f"Error during {detection_type} detection: {e}") # Error handling - return original image, error message, and empty mask # output_string = f"Error during '{detection_type}' detection: {e}" empty_mask = torch.zeros((1, img_np_rgb.shape[0], img_np_rgb.shape[1]), dtype=torch.float32) empty_bbox_list = [] return (image, empty_mask, empty_bbox_list) class MaskToBBoxNode: @classmethod def INPUT_TYPES(cls): return { "required": { "mask": ("MASK",), "threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Threshold to binarize the mask before finding bbox"}), "combine": ("BOOLEAN", {"default": False, "tooltip": "If True, combine all mask regions into one bbox. If False, output separate bboxes for each connected component."}), "nms_threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "IoU threshold for Non-Maximum Suppression. Higher values merge more overlapping boxes."}) } } RETURN_TYPES = ("BBOX",) FUNCTION = "mask_to_bbox" CATEGORY = "imgutils/bbox" DESCRIPTION = "Calculates the bounding box(es) of a mask. Can combine all regions or separate them with NMS." def calculate_iou(self, box1, box2): """ Calculate Intersection over Union (IoU) of two bounding boxes. Args: box1, box2: [x_min, y_min, x_max, y_max] Returns: IoU value between 0 and 1 """ x1_min, y1_min, x1_max, y1_max = box1 x2_min, y2_min, x2_max, y2_max = box2 # Calculate intersection area inter_x_min = max(x1_min, x2_min) inter_y_min = max(y1_min, y2_min) inter_x_max = min(x1_max, x2_max) inter_y_max = min(y1_max, y2_max) if inter_x_max <= inter_x_min or inter_y_max <= inter_y_min: return 0.0 inter_area = (inter_x_max - inter_x_min) * (inter_y_max - inter_y_min) # Calculate union area box1_area = (x1_max - x1_min) * (y1_max - y1_min) box2_area = (x2_max - x2_min) * (y2_max - y2_min) union_area = box1_area + box2_area - inter_area if union_area == 0: return 0.0 return inter_area / union_area def merge_boxes(self, box1, box2): """ Merge two bounding boxes by taking the outer bounds. Args: box1, box2: [x_min, y_min, x_max, y_max] Returns: Merged bounding box [x_min, y_min, x_max, y_max] """ x1_min, y1_min, x1_max, y1_max = box1 x2_min, y2_min, x2_max, y2_max = box2 return [ min(x1_min, x2_min), min(y1_min, y2_min), max(x1_max, x2_max), max(y1_max, y2_max) ] def apply_nms(self, bbox_list, nms_threshold): """ Apply Non-Maximum Suppression to merge overlapping bounding boxes. Args: bbox_list: List of bounding boxes [[x_min, y_min, x_max, y_max], ...] nms_threshold: IoU threshold for merging Returns: List of merged bounding boxes """ if len(bbox_list) <= 1: return bbox_list merged_boxes = [] used = [False] * len(bbox_list) img_w = bbox_list[0].img_w if isinstance(bbox_list[0], BBOX_imagutils) else 1 img_h = bbox_list[0].img_h if isinstance(bbox_list[0], BBOX_imagutils) else 1 for i in range(len(bbox_list)): if used[i]: continue current_box = bbox_list[i] boxes_to_merge = [current_box] used[i] = True # Find all boxes that overlap with current box above threshold # for j in range(i + 1, len(bbox_list)): # if used[j]: # continue # iou = self.calculate_iou(current_box, bbox_list[j]) # if iou >= nms_threshold: # boxes_to_merge.append(bbox_list[j]) # used[j] = True # # Merge all overlapping boxes # if len(boxes_to_merge) == 1: # merged_boxes.append(current_box) # else: # merged_box = boxes_to_merge[0] # for box in boxes_to_merge[1:]: # merged_box = self.merge_boxes(merged_box, box) # merged_boxes.append(merged_box) for j in range(i + 1, len(bbox_list)): if used[j]: continue # 取出坐标用于IoU计算 iou = self.calculate_iou(current_box.get_bbox(), bbox_list[j].get_bbox()) if iou >= nms_threshold: boxes_to_merge.append(bbox_list[j]) used[j] = True # Merge all overlapping boxes if len(boxes_to_merge) == 1: merged_boxes.append(current_box) else: # 合并所有box,取最大外接框,label和confidence可自定义合并策略 merged_box_coords = boxes_to_merge[0].get_bbox() label = boxes_to_merge[0].label confidence = max(box.confidence for box in boxes_to_merge) for box in boxes_to_merge[1:]: merged_box_coords = self.merge_boxes(merged_box_coords, box.get_bbox()) merged_box = BBOX_imagutils( x0=merged_box_coords[0], y0=merged_box_coords[1], x1=merged_box_coords[2], y1=merged_box_coords[3], img_w=img_w, img_h=img_h, label=label, confidence=confidence ) merged_boxes.append(merged_box) return merged_boxes def mask_to_bbox(self, mask, threshold=0.5, combine=True, nms_threshold=0.5): if mask.shape[0] > 1: print(f"Warning: MaskToBBoxNode received a batch of masks ({mask.shape[0]}). Calculating bbox for the first mask only.") # Convert to boolean numpy array (H, W) mask_np = mask[0].squeeze(0).cpu().numpy() > threshold mask_w, mask_h = mask_np.shape if combine: # Original behavior - combine all mask regions into one bbox rows = np.any(mask_np, axis=1) cols = np.any(mask_np, axis=0) if not np.any(rows) or not np.any(cols): print("Warning: Mask is empty or fully transparent, cannot calculate bbox.") bbox = [0, 0, 0, 0] else: y_min, y_max = np.where(rows)[0][[0, -1]] x_min, x_max = np.where(cols)[0][[0, -1]] bbox = [int(x_min), int(y_min), int(x_max), int(y_max)] print(f"Calculated combined BBox: {bbox}") return (bbox,) else: from scipy import ndimage # Label connected components labeled_mask, num_components = ndimage.label(mask_np) if num_components == 0: print("Warning: Mask is empty or fully transparent, cannot calculate bbox.") bbox_list = [] else: bbox_list = [] for component_id in range(1, num_components + 1): # Get mask for this component component_mask = (labeled_mask == component_id) # Find bounding box for this component rows = np.any(component_mask, axis=1) cols = np.any(component_mask, axis=0) if np.any(rows) and np.any(cols): y_min, y_max = np.where(rows)[0][[0, -1]] x_min, x_max = np.where(cols)[0][[0, -1]] # bbox = [int(x_min), int(y_min), int(x_max), int(y_max)] # bbox = [int(x_min), int(y_min), int(x_max), int(y_max),""] bbox = BBOX_imagutils( x0=int(x_min), y0=int(y_min), x1=int(x_max), y1=int(y_max), img_w=mask_w, img_h=mask_h, label="", # No label for generic bbox confidence=1.0 # Confidence is 1.0 for mask-based bboxes ) bbox_list.append(bbox) # Apply NMS to merge overlapping boxes if bbox_list and nms_threshold < 1.0: bbox_list = self.apply_nms(bbox_list, nms_threshold) print(f"After NMS (threshold={nms_threshold}): {len(bbox_list)} BBoxes remain") print(f"Final BBoxes: {bbox_list}") return (bbox_list,) class BBoxToMaskNode: @classmethod def INPUT_TYPES(cls): return { "required": { # "image_shape": ("INT", {"default": 512, "min": 1, "max": 4096, "step": 1, "tooltip": "Height and width of the output mask"}), "merge_options": ("BOOLEAN", {"default": False, "tooltip": "If True, merge all bounding boxes into one mask. If False, create separate masks for each bbox."}), } } RETURN_TYPES = ("MASK",) FUNCTION = "bbox_to_mask" CATEGORY = "imgutils/bbox" DESCRIPTION = "Converts bounding boxes to a binary mask." def bbox_to_mask(self, bbox, merge_options=False, image_shape=512): mask_list = [] bbox_input_list = [] if bbox is not None: if isinstance(bbox, list) and len(bbox) > 0: if isinstance(bbox[0], BBOX_imagutils): bbox_input_list = [b.get_bbox() for b in bbox] elif isinstance(bbox, BBOX_imagutils): bbox_input_list = [bbox.get_bbox()] if len(bbox_input_list) == 0: print("Warning: No bounding boxes provided, returning empty mask.") return (torch.zeros((1, image_shape, image_shape), dtype=torch.float32),) mask_w, mask_h = image_shape, image_shape if merge_options: # make sure all boxes are in same image shape img_w, img_h = bbox_input_list[0].img_w, bbox_input_list[0].img_h for box in bbox_input_list: if box.img_w != img_w or box.img_h != img_h: print(f"Warning: Bounding boxes have different image shapes ({box.img_w}x{box.img_h}), using first box shape ({img_w}x{img_h}) for mask.") break mask_w, mask_h = img_w, img_h for box in bbox_input_list: x0, y0, x1, y1 = box.get_bbox() # Ensure coordinates are within bounds x0 = max(0, min(x0, mask_w - 1)) y0 = max(0, min(y0, mask_h - 1)) x1 = max(0, min(x1, mask_w - 1)) y1 = max(0, min(y1, mask_h - 1)) mask = torch.zeros((1, mask_h, mask_w), dtype=torch.float32) mask[0, y0:y1, x0:x1] = 1. mask_list.append(mask) if merge_options: # Merge all masks into one merged_mask = torch.zeros((1, mask_h, mask_w), dtype=torch.float32) for mask in mask_list: merged_mask += mask merged_mask = (merged_mask > 0).float() return (merged_mask,) else: return (mask_list,) class BBoxFilter: @classmethod def INPUT_TYPES(cls): return { "required": { "bboxes": (BBOX,), "labels": ("STRING", {"default": "", "tooltip": "Comma-separated list of labels to filter by. Leave empty to keep all labels."}), "include_labels": ("BOOLEAN", {"default": True, "tooltip": "If True, only keep bounding boxes with specified labels. If False, exclude these labels."}), "min_area": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1000000.0, "step": 1.0, "tooltip": "Minimum area of bounding boxes to keep"}), "min_confidence": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Minimum confidence of bounding boxes to keep"}), } } RETURN_TYPES = (BBOX,) RETURN_NAMES = ("filtered_bboxes",) FUNCTION = "filter_bboxes" CATEGORY = "imgutils/bbox" DESCRIPTION = "Filters bounding boxes based on area, confidence, and labels." def filter_bboxes(self, bboxes,include_labels, min_area=0.0, min_confidence=0.0, labels=""): filtered_bboxes = [] # Labels 应该是一个逗号分隔的字符串,如果 labels 为空或者仅有空格,则表示不过滤标签 if labels: labels = [label.strip() for label in labels.split(",") if label.strip()] else: labels = None for bbox in bboxes: if isinstance(bbox, BBOX_imagutils): area = (bbox.x1 - bbox.x0) * (bbox.y1 - bbox.y0) if area < min_area or bbox.confidence < min_confidence: continue if include_labels: if labels and bbox.label not in labels: continue else: # Exclude labels if labels and bbox.label in labels: continue filtered_bboxes.append(bbox) else: print(f"Warning: Skipping non-BBOX_imagutils object: {bbox}") return (filtered_bboxes,) # ComfyUI Node Registration NODE_CLASS_MAPPINGS = { "ImgutilsGenericDetector": ImgutilsGenericDetector, "MaskToBBoxNode": MaskToBBoxNode, "BBoxToMaskNode": BBoxToMaskNode, "BBoxFilter": BBoxFilter, } NODE_DISPLAY_NAME_MAPPINGS = { "ImgutilsGenericDetector": "Imgutils Generic Detector", "MaskToBBoxNode": "Mask to BBox", "BBoxToMaskNode": "BBox to Mask", "BBoxFilter": "BBox Filter", }