Add files via upload
This commit is contained in:
@@ -21,9 +21,8 @@ from huggingface_hub import hf_hub_download
|
||||
import sys
|
||||
import importlib.util
|
||||
from safetensors.torch import load_file
|
||||
import cv2
|
||||
|
||||
from AILab_utils import get_device
|
||||
from AILab_utils import get_device, clean_vram, unload_model
|
||||
device = get_device()
|
||||
|
||||
# Add model path
|
||||
@@ -214,6 +213,7 @@ def handle_model_error(message):
|
||||
raise RuntimeError(message)
|
||||
|
||||
def refine_foreground(image_bchw, masks_b1hw):
|
||||
import cv2
|
||||
b, c, h, w = image_bchw.shape
|
||||
if b != masks_b1hw.shape[0]:
|
||||
raise ValueError("images and masks must have the same batch size")
|
||||
@@ -406,6 +406,7 @@ class BiRefNetRMBG:
|
||||
"mask_offset": ("INT", {"default": 0, "min": -20, "max": 20, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"refine_foreground": ("BOOLEAN", {"default": False, "tooltip": tooltips["refine_foreground"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
}
|
||||
@@ -497,6 +498,11 @@ class BiRefNetRMBG:
|
||||
return (torch.cat(processed_images, dim=0), torch.cat(processed_masks, dim=0), mask_image_output)
|
||||
except Exception as e:
|
||||
handle_model_error(f"Error in image processing: {str(e)}")
|
||||
finally:
|
||||
if params.get("unload_model", False):
|
||||
unload_model(self.model)
|
||||
else:
|
||||
clean_vram()
|
||||
|
||||
# Node Mapping
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
|
||||
+7
-28
@@ -16,36 +16,11 @@ import torch.nn as nn
|
||||
import numpy as np
|
||||
from typing import Tuple, Union
|
||||
from PIL import Image, ImageFilter
|
||||
import onnxruntime
|
||||
import folder_paths
|
||||
from huggingface_hub import hf_hub_download
|
||||
import shutil
|
||||
from torchvision import transforms
|
||||
|
||||
def pil2tensor(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)[None,]
|
||||
|
||||
def tensor2pil(image: torch.Tensor) -> Image.Image:
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
|
||||
|
||||
def image2mask(image: Image.Image) -> torch.Tensor:
|
||||
if isinstance(image, Image.Image):
|
||||
image = pil2tensor(image)
|
||||
return image.squeeze()[..., 0]
|
||||
|
||||
def mask2image(mask: torch.Tensor) -> Image.Image:
|
||||
if len(mask.shape) == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
return tensor2pil(mask)
|
||||
|
||||
def RGB2RGBA(image: Image.Image, mask: Union[Image.Image, torch.Tensor]) -> Image.Image:
|
||||
if isinstance(mask, torch.Tensor):
|
||||
mask = mask2image(mask)
|
||||
if mask.size != image.size:
|
||||
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return Image.merge('RGBA', (*image.convert('RGB').split(), mask.convert('L')))
|
||||
|
||||
from AILab_utils import get_device
|
||||
from AILab_utils import get_device, clean_vram, unload_model, pil2tensor, tensor2pil, RGB2RGBA, mask2image, image2mask
|
||||
device = get_device()
|
||||
|
||||
folder_paths.add_model_folder_path("rmbg", os.path.join(folder_paths.models_dir, "RMBG"))
|
||||
@@ -83,6 +58,7 @@ class BodySegment:
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
},
|
||||
@@ -124,7 +100,7 @@ class BodySegment:
|
||||
except Exception as e:
|
||||
return False, f"Error downloading model file: {str(e)}"
|
||||
|
||||
def segment_body(self, images, mask_blur=0, mask_offset=0, background="Alpha", background_color="#222222", invert_output=False, **class_selections):
|
||||
def segment_body(self, images, mask_blur=0, mask_offset=0, invert_output=False, unload_model=False, background="Alpha", background_color="#222222", **class_selections):
|
||||
try:
|
||||
# Check and download model if needed
|
||||
cache_status, message = self.check_model_cache()
|
||||
@@ -136,6 +112,7 @@ class BodySegment:
|
||||
|
||||
# Load model if needed
|
||||
if self.model is None:
|
||||
import onnxruntime
|
||||
self.model = onnxruntime.InferenceSession(
|
||||
os.path.join(self.cache_dir, self.model_file)
|
||||
)
|
||||
@@ -241,7 +218,9 @@ class BodySegment:
|
||||
self.clear_model()
|
||||
raise RuntimeError(f"Error in Body Segmentation processing: {str(e)}")
|
||||
finally:
|
||||
self.clear_model()
|
||||
if unload_model:
|
||||
self.clear_model()
|
||||
clean_vram()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"BodySegment": BodySegment
|
||||
|
||||
@@ -15,36 +15,11 @@ import torch.nn as nn
|
||||
import numpy as np
|
||||
from typing import Tuple, Union
|
||||
from PIL import Image, ImageFilter
|
||||
from transformers import SegformerImageProcessor, AutoModelForSemanticSegmentation
|
||||
import folder_paths
|
||||
from huggingface_hub import hf_hub_download
|
||||
import shutil
|
||||
from torchvision import transforms
|
||||
|
||||
def pil2tensor(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)[None,]
|
||||
|
||||
def tensor2pil(image: torch.Tensor) -> Image.Image:
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
|
||||
|
||||
def image2mask(image: Image.Image) -> torch.Tensor:
|
||||
if isinstance(image, Image.Image):
|
||||
image = pil2tensor(image)
|
||||
return image.squeeze()[..., 0]
|
||||
|
||||
def mask2image(mask: torch.Tensor) -> Image.Image:
|
||||
if len(mask.shape) == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
return tensor2pil(mask)
|
||||
|
||||
def RGB2RGBA(image: Image.Image, mask: Union[Image.Image, torch.Tensor]) -> Image.Image:
|
||||
if isinstance(mask, torch.Tensor):
|
||||
mask = mask2image(mask)
|
||||
if mask.size != image.size:
|
||||
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return Image.merge('RGBA', (*image.convert('RGB').split(), mask.convert('L')))
|
||||
|
||||
from AILab_utils import get_device
|
||||
from AILab_utils import get_device, clean_vram, unload_model, pil2tensor, tensor2pil, RGB2RGBA, mask2image, image2mask
|
||||
device = get_device()
|
||||
|
||||
folder_paths.add_model_folder_path("rmbg", os.path.join(folder_paths.models_dir, "RMBG"))
|
||||
@@ -83,6 +58,7 @@ class ClothesSegment:
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
},
|
||||
@@ -144,7 +120,7 @@ class ClothesSegment:
|
||||
except Exception as e:
|
||||
return False, f"Error downloading model files: {str(e)}"
|
||||
|
||||
def segment_clothes(self, images, process_res=1024, mask_blur=0, mask_offset=0, background="Alpha", background_color="#222222", invert_output=False, **class_selections):
|
||||
def segment_clothes(self, images, process_res=1024, mask_blur=0, mask_offset=0, invert_output=False, unload_model=False, background="Alpha", background_color="#222222", **class_selections):
|
||||
try:
|
||||
# Check and download model if needed
|
||||
cache_status, message = self.check_model_cache()
|
||||
@@ -156,6 +132,7 @@ class ClothesSegment:
|
||||
|
||||
# Load model if needed
|
||||
if self.processor is None:
|
||||
from transformers import SegformerImageProcessor, AutoModelForSemanticSegmentation
|
||||
self.processor = SegformerImageProcessor.from_pretrained(self.cache_dir)
|
||||
self.model = AutoModelForSemanticSegmentation.from_pretrained(self.cache_dir)
|
||||
self.model.eval()
|
||||
@@ -277,8 +254,9 @@ class ClothesSegment:
|
||||
self.clear_model()
|
||||
raise RuntimeError(f"Error in Clothes Segformer processing: {str(e)}")
|
||||
finally:
|
||||
if not getattr(self.model, "training", False):
|
||||
if unload_model:
|
||||
self.clear_model()
|
||||
clean_vram()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ClothesSegment": ClothesSegment
|
||||
|
||||
+274
-294
@@ -1,294 +1,274 @@
|
||||
# ComfyUI-RMBG
|
||||
# This custom node for ComfyUI provides functionality for face parsing using Segformer model.
|
||||
#
|
||||
# This integration script follows GPL-3.0 License.
|
||||
# When using or modifying this code, please respect both the original model licenses
|
||||
# and this integration's license terms.
|
||||
#
|
||||
# Source: https://github.com/AILab-AI/ComfyUI-RMBG
|
||||
|
||||
import os
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import numpy as np
|
||||
from typing import Tuple, Union
|
||||
from PIL import Image, ImageFilter
|
||||
from transformers import SegformerImageProcessor, AutoModelForSemanticSegmentation
|
||||
import folder_paths
|
||||
from huggingface_hub import hf_hub_download
|
||||
import shutil
|
||||
from torchvision import transforms
|
||||
|
||||
def pil2tensor(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)[None,]
|
||||
|
||||
def tensor2pil(image: torch.Tensor) -> Image.Image:
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
|
||||
|
||||
def image2mask(image: Image.Image) -> torch.Tensor:
|
||||
if isinstance(image, Image.Image):
|
||||
image = pil2tensor(image)
|
||||
return image.squeeze()[..., 0]
|
||||
|
||||
def mask2image(mask: torch.Tensor) -> Image.Image:
|
||||
if len(mask.shape) == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
return tensor2pil(mask)
|
||||
|
||||
def RGB2RGBA(image: Image.Image, mask: Union[Image.Image, torch.Tensor]) -> Image.Image:
|
||||
if isinstance(mask, torch.Tensor):
|
||||
mask = mask2image(mask)
|
||||
if mask.size != image.size:
|
||||
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return Image.merge('RGBA', (*image.convert('RGB').split(), mask.convert('L')))
|
||||
|
||||
from AILab_utils import get_device
|
||||
device = get_device()
|
||||
|
||||
folder_paths.add_model_folder_path("rmbg", os.path.join(folder_paths.models_dir, "RMBG"))
|
||||
|
||||
AVAILABLE_MODELS = {
|
||||
"face_parsing": "1038lab/segformer_face"
|
||||
}
|
||||
|
||||
class FaceSegment:
|
||||
def __init__(self):
|
||||
self.processor = None
|
||||
self.model = None
|
||||
self.cache_dir = os.path.join(folder_paths.models_dir, "RMBG", "segformer_face")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
available_classes = [
|
||||
"Skin", "Nose", "Eyeglasses", "Left-eye", "Right-eye",
|
||||
"Left-eyebrow", "Right-eyebrow", "Left-ear", "Right-ear", "Mouth",
|
||||
"Upper-lip", "Lower-lip", "Hair", "Earring", "Neck",
|
||||
]
|
||||
tooltips = {
|
||||
"process_res": "Processing resolution (higher = more VRAM)",
|
||||
"mask_blur": "Blur amount for mask edges",
|
||||
"mask_offset": "Expand/Shrink mask boundary",
|
||||
"invert_output": "Invert both image and mask output",
|
||||
"background": "Choose background type: Alpha (transparent) or Color (custom background color).",
|
||||
"background_color": "Choose background color (Alpha = transparent)"
|
||||
}
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
},
|
||||
"optional": {
|
||||
**{cls_name: ("BOOLEAN", {"default": False})
|
||||
for cls_name in available_classes},
|
||||
"process_res": ("INT", {"default": 512, "min": 128, "max": 2048, "step": 32, "tooltip": tooltips["process_res"]}),
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
|
||||
RETURN_NAMES = ("IMAGE", "MASK", "MASK_IMAGE")
|
||||
FUNCTION = "segment_face"
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
|
||||
def check_model_cache(self):
|
||||
if not os.path.exists(self.cache_dir):
|
||||
return False, "Model directory not found"
|
||||
|
||||
required_files = [
|
||||
'config.json',
|
||||
'model.safetensors',
|
||||
'preprocessor_config.json'
|
||||
]
|
||||
|
||||
missing_files = [f for f in required_files if not os.path.exists(os.path.join(self.cache_dir, f))]
|
||||
if missing_files:
|
||||
return False, f"Required model files missing: {', '.join(missing_files)}"
|
||||
return True, "Model cache verified"
|
||||
|
||||
def clear_model(self):
|
||||
if self.model is not None:
|
||||
self.model.cpu()
|
||||
del self.model
|
||||
self.model = None
|
||||
self.processor = None
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
def download_model_files(self):
|
||||
model_id = AVAILABLE_MODELS["face_parsing"]
|
||||
model_files = {
|
||||
'config.json': 'config.json',
|
||||
'model.safetensors': 'model.safetensors',
|
||||
'preprocessor_config.json': 'preprocessor_config.json'
|
||||
}
|
||||
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
print(f"Downloading face parsing model files...")
|
||||
|
||||
try:
|
||||
for save_name, repo_path in model_files.items():
|
||||
print(f"Downloading {save_name}...")
|
||||
downloaded_path = hf_hub_download(
|
||||
repo_id=model_id,
|
||||
filename=repo_path,
|
||||
local_dir=self.cache_dir,
|
||||
local_dir_use_symlinks=False
|
||||
)
|
||||
|
||||
if os.path.dirname(downloaded_path) != self.cache_dir:
|
||||
target_path = os.path.join(self.cache_dir, save_name)
|
||||
shutil.move(downloaded_path, target_path)
|
||||
return True, "Model files downloaded successfully"
|
||||
except Exception as e:
|
||||
return False, f"Error downloading model files: {str(e)}"
|
||||
|
||||
def segment_face(self, images, process_res=512, mask_blur=0, mask_offset=0, background="Alpha", background_color="#222222", invert_output=False, **class_selections):
|
||||
try:
|
||||
# Check and download model if needed
|
||||
cache_status, message = self.check_model_cache()
|
||||
if not cache_status:
|
||||
print(f"Cache check: {message}")
|
||||
download_status, download_message = self.download_model_files()
|
||||
if not download_status:
|
||||
raise RuntimeError(download_message)
|
||||
|
||||
# Load model if needed
|
||||
if self.processor is None:
|
||||
self.processor = SegformerImageProcessor.from_pretrained(self.cache_dir)
|
||||
self.model = AutoModelForSemanticSegmentation.from_pretrained(self.cache_dir)
|
||||
self.model.eval()
|
||||
for param in self.model.parameters():
|
||||
param.requires_grad = False
|
||||
self.model.to(device)
|
||||
|
||||
# Class mapping for segmentation
|
||||
class_map = {
|
||||
"Background": 0, "Skin": 1, "Nose": 2, "Eyeglasses": 3,
|
||||
"Left-eye": 4, "Right-eye": 5, "Left-eyebrow": 6, "Right-eyebrow": 7,
|
||||
"Left-ear": 8, "Right-ear": 9, "Mouth": 10, "Upper-lip": 11,
|
||||
"Lower-lip": 12, "Hair": 13, "Hat": 14, "Earring": 15,
|
||||
"Necklace": 16, "Neck": 17, "Clothing": 18
|
||||
}
|
||||
|
||||
# Get selected classes
|
||||
selected_classes = [name for name, selected in class_selections.items() if selected]
|
||||
if not selected_classes:
|
||||
selected_classes = ["Skin", "Nose", "Left-eye", "Right-eye", "Mouth"]
|
||||
|
||||
# Validate selected classes
|
||||
invalid_classes = [cls for cls in selected_classes if cls not in class_map]
|
||||
if invalid_classes:
|
||||
raise ValueError(f"Invalid class selections: {', '.join(invalid_classes)}. Valid classes are: {', '.join(class_map.keys())}")
|
||||
|
||||
# Image preprocessing
|
||||
transform_image = transforms.Compose([
|
||||
transforms.Resize((process_res, process_res)),
|
||||
transforms.ToTensor(),
|
||||
])
|
||||
|
||||
batch_tensor = []
|
||||
batch_masks = []
|
||||
|
||||
for image in images:
|
||||
orig_image = tensor2pil(image)
|
||||
w, h = orig_image.size
|
||||
|
||||
input_tensor = transform_image(orig_image)
|
||||
|
||||
if input_tensor.shape[0] == 4:
|
||||
input_tensor = input_tensor[:3]
|
||||
|
||||
input_tensor = transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])(input_tensor)
|
||||
|
||||
input_tensor = input_tensor.unsqueeze(0).to(device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = self.model(input_tensor)
|
||||
logits = outputs.logits.cpu()
|
||||
upsampled_logits = nn.functional.interpolate(
|
||||
logits,
|
||||
size=(h, w),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)
|
||||
pred_seg = upsampled_logits.argmax(dim=1)[0]
|
||||
|
||||
# Combine selected class masks
|
||||
combined_mask = None
|
||||
for class_name in selected_classes:
|
||||
mask = (pred_seg == class_map[class_name]).float()
|
||||
if combined_mask is None:
|
||||
combined_mask = mask
|
||||
else:
|
||||
combined_mask = torch.clamp(combined_mask + mask, 0, 1)
|
||||
|
||||
# Convert mask to PIL for processing
|
||||
mask_image = Image.fromarray((combined_mask.numpy() * 255).astype(np.uint8))
|
||||
|
||||
if mask_blur > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=mask_blur))
|
||||
|
||||
if mask_offset != 0:
|
||||
if mask_offset > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.MaxFilter(size=mask_offset * 2 + 1))
|
||||
else:
|
||||
mask_image = mask_image.filter(ImageFilter.MinFilter(size=-mask_offset * 2 + 1))
|
||||
|
||||
if invert_output:
|
||||
mask_image = Image.fromarray(255 - np.array(mask_image))
|
||||
|
||||
# Handle background color
|
||||
if background == "Alpha":
|
||||
rgba_image = RGB2RGBA(orig_image, mask_image)
|
||||
result_image = pil2tensor(rgba_image)
|
||||
else:
|
||||
def hex_to_rgba(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
if len(hex_color) == 6:
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
a = 255
|
||||
elif len(hex_color) == 8:
|
||||
r, g, b, a = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16), int(hex_color[6:8], 16)
|
||||
else:
|
||||
raise ValueError("Invalid color format")
|
||||
return (r, g, b, a)
|
||||
rgba_image = RGB2RGBA(orig_image, mask_image)
|
||||
rgba = hex_to_rgba(background_color)
|
||||
bg_image = Image.new('RGBA', orig_image.size, rgba)
|
||||
composite_image = Image.alpha_composite(bg_image, rgba_image)
|
||||
result_image = pil2tensor(composite_image.convert('RGB'))
|
||||
|
||||
batch_tensor.append(result_image)
|
||||
batch_masks.append(pil2tensor(mask_image))
|
||||
|
||||
# Create mask image for visualization
|
||||
mask_images = []
|
||||
for mask_tensor in batch_masks:
|
||||
# Convert mask to RGB image format for visualization
|
||||
mask_image = mask_tensor.reshape((-1, 1, mask_tensor.shape[-2], mask_tensor.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
mask_images.append(mask_image)
|
||||
|
||||
mask_image_output = torch.cat(mask_images, dim=0)
|
||||
|
||||
# Prepare final output
|
||||
batch_tensor = torch.cat(batch_tensor, dim=0)
|
||||
batch_masks = torch.cat(batch_masks, dim=0)
|
||||
|
||||
return (batch_tensor, batch_masks, mask_image_output)
|
||||
|
||||
except Exception as e:
|
||||
self.clear_model()
|
||||
raise RuntimeError(f"Error in Face Parsing processing: {str(e)}")
|
||||
finally:
|
||||
if self.model is not None and not self.model.training:
|
||||
self.clear_model()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FaceSegment": FaceSegment
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FaceSegment": "Face Segment (RMBG)"
|
||||
}
|
||||
# ComfyUI-RMBG
|
||||
# This custom node for ComfyUI provides functionality for face parsing using Segformer model.
|
||||
#
|
||||
# This integration script follows GPL-3.0 License.
|
||||
# When using or modifying this code, please respect both the original model licenses
|
||||
# and this integration's license terms.
|
||||
#
|
||||
# Source: https://github.com/AILab-AI/ComfyUI-RMBG
|
||||
|
||||
import os
|
||||
import torch
|
||||
import torch.nn as nn
|
||||
import numpy as np
|
||||
from typing import Tuple, Union
|
||||
from PIL import Image, ImageFilter
|
||||
import folder_paths
|
||||
from huggingface_hub import hf_hub_download
|
||||
import shutil
|
||||
from torchvision import transforms
|
||||
|
||||
from torchvision import transforms
|
||||
from AILab_utils import get_device, clean_vram, unload_model, pil2tensor, tensor2pil, RGB2RGBA, mask2image, image2mask
|
||||
device = get_device()
|
||||
|
||||
folder_paths.add_model_folder_path("rmbg", os.path.join(folder_paths.models_dir, "RMBG"))
|
||||
|
||||
AVAILABLE_MODELS = {
|
||||
"face_parsing": "1038lab/segformer_face"
|
||||
}
|
||||
|
||||
class FaceSegment:
|
||||
def __init__(self):
|
||||
self.processor = None
|
||||
self.model = None
|
||||
self.cache_dir = os.path.join(folder_paths.models_dir, "RMBG", "segformer_face")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
available_classes = [
|
||||
"Skin", "Nose", "Eyeglasses", "Left-eye", "Right-eye",
|
||||
"Left-eyebrow", "Right-eyebrow", "Left-ear", "Right-ear", "Mouth",
|
||||
"Upper-lip", "Lower-lip", "Hair", "Earring", "Neck",
|
||||
]
|
||||
tooltips = {
|
||||
"process_res": "Processing resolution (higher = more VRAM)",
|
||||
"mask_blur": "Blur amount for mask edges",
|
||||
"mask_offset": "Expand/Shrink mask boundary",
|
||||
"invert_output": "Invert both image and mask output",
|
||||
"background": "Choose background type: Alpha (transparent) or Color (custom background color).",
|
||||
"background_color": "Choose background color (Alpha = transparent)"
|
||||
}
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
},
|
||||
"optional": {
|
||||
**{cls_name: ("BOOLEAN", {"default": False})
|
||||
for cls_name in available_classes},
|
||||
"process_res": ("INT", {"default": 512, "min": 128, "max": 2048, "step": 32, "tooltip": tooltips["process_res"]}),
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
|
||||
RETURN_NAMES = ("IMAGE", "MASK", "MASK_IMAGE")
|
||||
FUNCTION = "segment_face"
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
|
||||
def check_model_cache(self):
|
||||
if not os.path.exists(self.cache_dir):
|
||||
return False, "Model directory not found"
|
||||
|
||||
required_files = [
|
||||
'config.json',
|
||||
'model.safetensors',
|
||||
'preprocessor_config.json'
|
||||
]
|
||||
|
||||
missing_files = [f for f in required_files if not os.path.exists(os.path.join(self.cache_dir, f))]
|
||||
if missing_files:
|
||||
return False, f"Required model files missing: {', '.join(missing_files)}"
|
||||
return True, "Model cache verified"
|
||||
|
||||
def clear_model(self):
|
||||
if self.model is not None:
|
||||
self.model.cpu()
|
||||
del self.model
|
||||
self.model = None
|
||||
self.processor = None
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
def download_model_files(self):
|
||||
model_id = AVAILABLE_MODELS["face_parsing"]
|
||||
model_files = {
|
||||
'config.json': 'config.json',
|
||||
'model.safetensors': 'model.safetensors',
|
||||
'preprocessor_config.json': 'preprocessor_config.json'
|
||||
}
|
||||
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
print(f"Downloading face parsing model files...")
|
||||
|
||||
try:
|
||||
for save_name, repo_path in model_files.items():
|
||||
print(f"Downloading {save_name}...")
|
||||
downloaded_path = hf_hub_download(
|
||||
repo_id=model_id,
|
||||
filename=repo_path,
|
||||
local_dir=self.cache_dir,
|
||||
local_dir_use_symlinks=False
|
||||
)
|
||||
|
||||
if os.path.dirname(downloaded_path) != self.cache_dir:
|
||||
target_path = os.path.join(self.cache_dir, save_name)
|
||||
shutil.move(downloaded_path, target_path)
|
||||
return True, "Model files downloaded successfully"
|
||||
except Exception as e:
|
||||
return False, f"Error downloading model files: {str(e)}"
|
||||
|
||||
def segment_face(self, images, process_res=512, mask_blur=0, mask_offset=0, invert_output=False, unload_model=False, background="Alpha", background_color="#222222", **class_selections):
|
||||
try:
|
||||
# Check and download model if needed
|
||||
cache_status, message = self.check_model_cache()
|
||||
if not cache_status:
|
||||
print(f"Cache check: {message}")
|
||||
download_status, download_message = self.download_model_files()
|
||||
if not download_status:
|
||||
raise RuntimeError(download_message)
|
||||
|
||||
# Load model if needed
|
||||
if self.processor is None:
|
||||
from transformers import SegformerImageProcessor, AutoModelForSemanticSegmentation
|
||||
self.processor = SegformerImageProcessor.from_pretrained(self.cache_dir)
|
||||
self.model = AutoModelForSemanticSegmentation.from_pretrained(self.cache_dir)
|
||||
self.model.eval()
|
||||
for param in self.model.parameters():
|
||||
param.requires_grad = False
|
||||
self.model.to(device)
|
||||
|
||||
# Class mapping for segmentation
|
||||
class_map = {
|
||||
"Background": 0, "Skin": 1, "Nose": 2, "Eyeglasses": 3,
|
||||
"Left-eye": 4, "Right-eye": 5, "Left-eyebrow": 6, "Right-eyebrow": 7,
|
||||
"Left-ear": 8, "Right-ear": 9, "Mouth": 10, "Upper-lip": 11,
|
||||
"Lower-lip": 12, "Hair": 13, "Hat": 14, "Earring": 15,
|
||||
"Necklace": 16, "Neck": 17, "Clothing": 18
|
||||
}
|
||||
|
||||
# Get selected classes
|
||||
selected_classes = [name for name, selected in class_selections.items() if selected]
|
||||
if not selected_classes:
|
||||
selected_classes = ["Skin", "Nose", "Left-eye", "Right-eye", "Mouth"]
|
||||
|
||||
# Validate selected classes
|
||||
invalid_classes = [cls for cls in selected_classes if cls not in class_map]
|
||||
if invalid_classes:
|
||||
raise ValueError(f"Invalid class selections: {', '.join(invalid_classes)}. Valid classes are: {', '.join(class_map.keys())}")
|
||||
|
||||
# Image preprocessing
|
||||
transform_image = transforms.Compose([
|
||||
transforms.Resize((process_res, process_res)),
|
||||
transforms.ToTensor(),
|
||||
])
|
||||
|
||||
batch_tensor = []
|
||||
batch_masks = []
|
||||
|
||||
for image in images:
|
||||
orig_image = tensor2pil(image)
|
||||
w, h = orig_image.size
|
||||
|
||||
input_tensor = transform_image(orig_image)
|
||||
|
||||
if input_tensor.shape[0] == 4:
|
||||
input_tensor = input_tensor[:3]
|
||||
|
||||
input_tensor = transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])(input_tensor)
|
||||
|
||||
input_tensor = input_tensor.unsqueeze(0).to(device)
|
||||
|
||||
with torch.no_grad():
|
||||
outputs = self.model(input_tensor)
|
||||
logits = outputs.logits.cpu()
|
||||
upsampled_logits = nn.functional.interpolate(
|
||||
logits,
|
||||
size=(h, w),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)
|
||||
pred_seg = upsampled_logits.argmax(dim=1)[0]
|
||||
|
||||
# Combine selected class masks
|
||||
combined_mask = None
|
||||
for class_name in selected_classes:
|
||||
mask = (pred_seg == class_map[class_name]).float()
|
||||
if combined_mask is None:
|
||||
combined_mask = mask
|
||||
else:
|
||||
combined_mask = torch.clamp(combined_mask + mask, 0, 1)
|
||||
|
||||
# Convert mask to PIL for processing
|
||||
mask_image = Image.fromarray((combined_mask.numpy() * 255).astype(np.uint8))
|
||||
|
||||
if mask_blur > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=mask_blur))
|
||||
|
||||
if mask_offset != 0:
|
||||
if mask_offset > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.MaxFilter(size=mask_offset * 2 + 1))
|
||||
else:
|
||||
mask_image = mask_image.filter(ImageFilter.MinFilter(size=-mask_offset * 2 + 1))
|
||||
|
||||
if invert_output:
|
||||
mask_image = Image.fromarray(255 - np.array(mask_image))
|
||||
|
||||
# Handle background color
|
||||
if background == "Alpha":
|
||||
rgba_image = RGB2RGBA(orig_image, mask_image)
|
||||
result_image = pil2tensor(rgba_image)
|
||||
else:
|
||||
def hex_to_rgba(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
if len(hex_color) == 6:
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
a = 255
|
||||
elif len(hex_color) == 8:
|
||||
r, g, b, a = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16), int(hex_color[6:8], 16)
|
||||
else:
|
||||
raise ValueError("Invalid color format")
|
||||
return (r, g, b, a)
|
||||
rgba_image = RGB2RGBA(orig_image, mask_image)
|
||||
rgba = hex_to_rgba(background_color)
|
||||
bg_image = Image.new('RGBA', orig_image.size, rgba)
|
||||
composite_image = Image.alpha_composite(bg_image, rgba_image)
|
||||
result_image = pil2tensor(composite_image.convert('RGB'))
|
||||
|
||||
batch_tensor.append(result_image)
|
||||
batch_masks.append(pil2tensor(mask_image))
|
||||
|
||||
# Create mask image for visualization
|
||||
mask_images = []
|
||||
for mask_tensor in batch_masks:
|
||||
# Convert mask to RGB image format for visualization
|
||||
mask_image = mask_tensor.reshape((-1, 1, mask_tensor.shape[-2], mask_tensor.shape[-1])).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
mask_images.append(mask_image)
|
||||
|
||||
mask_image_output = torch.cat(mask_images, dim=0)
|
||||
|
||||
# Prepare final output
|
||||
batch_tensor = torch.cat(batch_tensor, dim=0)
|
||||
batch_masks = torch.cat(batch_masks, dim=0)
|
||||
|
||||
return (batch_tensor, batch_masks, mask_image_output)
|
||||
|
||||
except Exception as e:
|
||||
self.clear_model()
|
||||
raise RuntimeError(f"Error in Face Parsing processing: {str(e)}")
|
||||
finally:
|
||||
if unload_model:
|
||||
self.clear_model()
|
||||
clean_vram()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FaceSegment": FaceSegment
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FaceSegment": "Face Segment (RMBG)"
|
||||
}
|
||||
|
||||
@@ -14,31 +14,11 @@ import torch.nn as nn
|
||||
import numpy as np
|
||||
from typing import Tuple, Union
|
||||
from PIL import Image, ImageFilter
|
||||
from transformers import SegformerImageProcessor, AutoModelForSemanticSegmentation
|
||||
import folder_paths
|
||||
from huggingface_hub import hf_hub_download
|
||||
import shutil
|
||||
from torchvision import transforms
|
||||
|
||||
def pil2tensor(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)[None,]
|
||||
|
||||
def tensor2pil(image: torch.Tensor) -> Image.Image:
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
|
||||
|
||||
def RGB2RGBA(image: Image.Image, mask: Union[Image.Image, torch.Tensor]) -> Image.Image:
|
||||
if isinstance(mask, torch.Tensor):
|
||||
mask = mask2image(mask)
|
||||
if mask.size != image.size:
|
||||
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return Image.merge('RGBA', (*image.convert('RGB').split(), mask.convert('L')))
|
||||
|
||||
def mask2image(mask: torch.Tensor) -> Image.Image:
|
||||
if len(mask.shape) == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
return tensor2pil(mask)
|
||||
|
||||
from AILab_utils import get_device
|
||||
from AILab_utils import get_device, clean_vram, unload_model, pil2tensor, tensor2pil, RGB2RGBA, mask2image
|
||||
device = get_device()
|
||||
|
||||
folder_paths.add_model_folder_path("rmbg", os.path.join(folder_paths.models_dir, "RMBG"))
|
||||
@@ -176,6 +156,7 @@ class FashionSegmentClothing:
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
},
|
||||
@@ -238,7 +219,7 @@ class FashionSegmentClothing:
|
||||
return False, f"Error downloading model files: {str(e)}"
|
||||
|
||||
def segment_fashion(self, images, accessories_options=None, process_res=512, mask_blur=0, mask_offset=0,
|
||||
background="Alpha", background_color="#222222", invert_output=False, **class_selections):
|
||||
invert_output=False, unload_model=False, background="Alpha", background_color="#222222", **class_selections):
|
||||
if accessories_options is None:
|
||||
accessories_options = []
|
||||
try:
|
||||
@@ -252,6 +233,7 @@ class FashionSegmentClothing:
|
||||
|
||||
# Load model if needed
|
||||
if self.processor is None:
|
||||
from transformers import SegformerImageProcessor, AutoModelForSemanticSegmentation
|
||||
self.processor = SegformerImageProcessor.from_pretrained(self.cache_dir)
|
||||
self.model = AutoModelForSemanticSegmentation.from_pretrained(self.cache_dir)
|
||||
self.model.eval()
|
||||
@@ -362,8 +344,9 @@ class FashionSegmentClothing:
|
||||
self.clear_model()
|
||||
raise RuntimeError(f"Error in fashion segmentation: {str(e)}")
|
||||
finally:
|
||||
if self.model is not None and not self.model.training:
|
||||
if unload_model:
|
||||
self.clear_model()
|
||||
clean_vram()
|
||||
|
||||
def __del__(self):
|
||||
self.clear_model()
|
||||
|
||||
+352
-352
@@ -1,352 +1,352 @@
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from unittest.mock import patch
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.patches as patches
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import torch
|
||||
import torchvision.transforms.functional as TF
|
||||
from PIL import Image, ImageColor, ImageDraw
|
||||
|
||||
import comfy.model_management as mm
|
||||
from comfy.utils import ProgressBar
|
||||
import folder_paths
|
||||
import transformers
|
||||
from transformers import AutoModelForCausalLM, AutoProcessor
|
||||
from transformers.dynamic_module_utils import get_imports
|
||||
|
||||
MODEL_DIR = os.path.join(folder_paths.models_dir, "LLM")
|
||||
os.makedirs(MODEL_DIR, exist_ok=True)
|
||||
folder_paths.add_model_folder_path("LLM", MODEL_DIR)
|
||||
|
||||
TASK_CONFIGS = {
|
||||
"Polygon Mask (text prompt)": {
|
||||
"token": "<REFERRING_EXPRESSION_SEGMENTATION>",
|
||||
"mode": "polygon",
|
||||
"allows_text": True,
|
||||
},
|
||||
"Phrase Grounding (text boxes)": {
|
||||
"token": "<CAPTION_TO_PHRASE_GROUNDING>",
|
||||
"mode": "bbox",
|
||||
"allows_text": True,
|
||||
},
|
||||
"Region Proposals (boxes only)": {
|
||||
"token": "<REGION_PROPOSAL>",
|
||||
"mode": "bbox",
|
||||
"allows_text": False,
|
||||
},
|
||||
}
|
||||
|
||||
TASK_CHOICES = tuple(TASK_CONFIGS.keys())
|
||||
|
||||
GENERATION_CONFIG = {
|
||||
"max_new_tokens": 512,
|
||||
"num_beams": 3,
|
||||
"do_sample": True,
|
||||
}
|
||||
|
||||
MODEL_CHOICES = (
|
||||
"microsoft/Florence-2-base",
|
||||
"microsoft/Florence-2-base-ft",
|
||||
"microsoft/Florence-2-large",
|
||||
"microsoft/Florence-2-large-ft",
|
||||
"thwri/CogFlorence-2.1-Large",
|
||||
"thwri/CogFlorence-2.2-Large",
|
||||
)
|
||||
|
||||
COLOR_BANK = ["blue", "orange", "green", "purple", "pink", "cyan"]
|
||||
|
||||
|
||||
def _fixed_get_imports(filename):
|
||||
try:
|
||||
if not str(filename).endswith("modeling_florence2.py"):
|
||||
return get_imports(filename)
|
||||
imports = get_imports(filename)
|
||||
if "flash_attn" in imports:
|
||||
imports.remove("flash_attn")
|
||||
return imports
|
||||
except Exception:
|
||||
return get_imports(filename)
|
||||
|
||||
|
||||
class AILab_Florence2:
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "JSON")
|
||||
RETURN_NAMES = ("IMAGE", "MASK", "DATA")
|
||||
FUNCTION = "analyze"
|
||||
MODEL_CACHE: Dict[Tuple[str, str, str], Dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"model_name": (
|
||||
MODEL_CHOICES,
|
||||
{
|
||||
"default": MODEL_CHOICES[0],
|
||||
"tooltip": "Base = stable, +ft = fine-tuned captions, CogFlorence = sharper phrase alignment.",
|
||||
},
|
||||
),
|
||||
"task": (
|
||||
TASK_CHOICES,
|
||||
{
|
||||
"default": TASK_CHOICES[0],
|
||||
"tooltip": "Polygon masks use prompts; phrase grounding/region proposals return boxes.",
|
||||
},
|
||||
),
|
||||
"precision": (
|
||||
("fp16", "bf16", "fp32"),
|
||||
{"default": "fp16", "tooltip": "Lower precision saves VRAM; fp32 is safest if you hit NaNs."},
|
||||
),
|
||||
"attention": (
|
||||
("flash_attention_2", "sdpa", "eager"),
|
||||
{"default": "sdpa", "tooltip": "flash_attn2 needs PyTorch 2.1+; use eager if kernels fail."},
|
||||
),
|
||||
"fill_mask": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "tooltip": "When true, bbox tasks also output filled mask tensors."},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"output_mask_select": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Comma-separated indices or labels (e.g. 0,2,person) to limit masks.",
|
||||
},
|
||||
),
|
||||
"keep_model_loaded": (
|
||||
"BOOLEAN",
|
||||
{"default": False, "tooltip": "Keep weights on the current device after execution."},
|
||||
),
|
||||
"text_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Prompt: e.g. a person wearing red coat",
|
||||
"tooltip": "Used for polygon masks or phrase grounding; ignored for region proposals.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def _ensure_weights(self, model_name: str) -> str:
|
||||
target = os.path.join(MODEL_DIR, model_name.split("/")[-1])
|
||||
if os.path.exists(target):
|
||||
return target
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
snapshot_download(model_name, local_dir=target, local_dir_use_symlinks=False)
|
||||
return target
|
||||
|
||||
def _get_model(self, model_name: str, precision: str, attention: str) -> Dict[str, Any]:
|
||||
key = (model_name, precision, attention)
|
||||
if key in self.MODEL_CACHE:
|
||||
return self.MODEL_CACHE[key]
|
||||
|
||||
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[precision]
|
||||
weights = self._ensure_weights(model_name)
|
||||
offload = mm.unet_offload_device()
|
||||
|
||||
if transformers.__version__ < "4.51.0":
|
||||
with patch("transformers.dynamic_module_utils.get_imports", _fixed_get_imports):
|
||||
model = AutoModelForCausalLM.from_pretrained(
|
||||
weights,
|
||||
attn_implementation=attention,
|
||||
torch_dtype=dtype,
|
||||
trust_remote_code=True,
|
||||
).to(offload)
|
||||
else:
|
||||
from models.modeling_florence2 import Florence2ForConditionalGeneration
|
||||
|
||||
model = Florence2ForConditionalGeneration.from_pretrained(
|
||||
weights,
|
||||
attn_implementation=attention,
|
||||
torch_dtype=dtype,
|
||||
).to(offload)
|
||||
|
||||
processor = AutoProcessor.from_pretrained(weights, trust_remote_code=True)
|
||||
bundle = {"model": model, "processor": processor, "dtype": dtype}
|
||||
self.MODEL_CACHE[key] = bundle
|
||||
return bundle
|
||||
|
||||
@staticmethod
|
||||
def _prepare_prompt(task: str, text_prompt: str) -> str:
|
||||
config = TASK_CONFIGS[task]
|
||||
text = text_prompt.strip()
|
||||
if text and not config["allows_text"]:
|
||||
text = ""
|
||||
base = config["token"]
|
||||
return f"{base} {text}" if text else base
|
||||
|
||||
def _draw_regions(
|
||||
self,
|
||||
image_pil: Image.Image,
|
||||
predictions: Dict[str, Any],
|
||||
fill_mask: bool,
|
||||
select_filter: List[str],
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
width, height = image_pil.size
|
||||
fig, ax = plt.subplots(figsize=(width / 100, height / 100), dpi=100)
|
||||
fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
||||
ax.imshow(image_pil)
|
||||
|
||||
mask_layer = Image.new("RGB", image_pil.size, (0, 0, 0)) if fill_mask else None
|
||||
mask_draw = ImageDraw.Draw(mask_layer) if mask_layer else None
|
||||
|
||||
for index, (bbox, label) in enumerate(zip(predictions["bboxes"], predictions["labels"])):
|
||||
x0, y0, x1, y1 = bbox
|
||||
if y1 < y0:
|
||||
y0, y1 = y1, y0
|
||||
if x1 < x0:
|
||||
x0, x1 = x1, x0
|
||||
filter_hit = not select_filter or str(index) in select_filter or label in select_filter
|
||||
if fill_mask and filter_hit:
|
||||
mask_draw.rectangle([x0, y0, x1, y1], fill=(255, 255, 255))
|
||||
rect = patches.Rectangle((x0, y0), x1 - x0, y1 - y0, linewidth=1, edgecolor="red", facecolor="none")
|
||||
ax.add_patch(rect)
|
||||
ax.text(
|
||||
x0,
|
||||
max(0, y0 - 12),
|
||||
f"{index}.{label}",
|
||||
color="white",
|
||||
fontsize=12,
|
||||
bbox=dict(facecolor=random.choice(COLOR_BANK), alpha=0.5),
|
||||
)
|
||||
|
||||
ax.axis("off")
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format="png", pad_inches=0)
|
||||
buf.seek(0)
|
||||
annotated = Image.open(buf)
|
||||
plt.close(fig)
|
||||
|
||||
annotated_tensor = TF.to_tensor(annotated)[:3, :, :].unsqueeze(0).permute(0, 2, 3, 1).cpu().float()
|
||||
if mask_layer is not None:
|
||||
mask_tensor = TF.to_tensor(mask_layer).unsqueeze(0).permute(0, 2, 3, 1).cpu().float()
|
||||
mask_tensor = mask_tensor.mean(dim=0, keepdim=True)[:, :, :, 0]
|
||||
else:
|
||||
mask_tensor = torch.zeros((1, annotated_tensor.shape[1], annotated_tensor.shape[2]), dtype=torch.float32)
|
||||
|
||||
return annotated_tensor, mask_tensor
|
||||
|
||||
def _segment_polygons(
|
||||
self,
|
||||
image_pil: Image.Image,
|
||||
predictions: Dict[str, Any],
|
||||
fill_mask: bool,
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
width, height = image_pil.size
|
||||
mask_image = Image.new("RGB", (width, height), "black")
|
||||
mask_draw = ImageDraw.Draw(mask_image)
|
||||
|
||||
for polygons, label in zip(predictions["polygons"], predictions["labels"]):
|
||||
color = random.choice(COLOR_BANK)
|
||||
for polygon in polygons:
|
||||
polygon = np.array(polygon).reshape(-1, 2)
|
||||
polygon = np.clip(polygon, [0, 0], [width - 1, height - 1])
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
pts = polygon.reshape(-1).tolist()
|
||||
if fill_mask:
|
||||
overlay = Image.new("RGBA", image_pil.size, (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
rgba = ImageColor.getrgb(color) + (180,)
|
||||
draw.polygon(pts, outline=color, fill=rgba, width=3)
|
||||
image_pil = Image.alpha_composite(image_pil.convert("RGBA"), overlay).convert("RGB")
|
||||
else:
|
||||
draw = ImageDraw.Draw(image_pil)
|
||||
draw.polygon(pts, outline=color, width=3)
|
||||
mask_draw.polygon(pts, outline="white", fill="white")
|
||||
|
||||
image_tensor = TF.to_tensor(image_pil)[:3, :, :].unsqueeze(0).permute(0, 2, 3, 1).cpu().float()
|
||||
mask_tensor = TF.to_tensor(mask_image).unsqueeze(0).permute(0, 2, 3, 1).cpu().float()
|
||||
mask_tensor = mask_tensor.mean(dim=0, keepdim=True)[:, :, :, 0]
|
||||
return image_tensor, mask_tensor
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
model_name: str,
|
||||
task: str,
|
||||
precision: str,
|
||||
attention: str,
|
||||
fill_mask: bool,
|
||||
output_mask_select: str = "",
|
||||
keep_model_loaded: bool = False,
|
||||
text_prompt: str = "",
|
||||
):
|
||||
bundle = self._get_model(model_name, precision, attention)
|
||||
model = bundle["model"]
|
||||
processor = bundle["processor"]
|
||||
dtype = bundle["dtype"]
|
||||
|
||||
device = mm.get_torch_device()
|
||||
offload = mm.unet_offload_device()
|
||||
model.to(device)
|
||||
images = image.permute(0, 3, 1, 2)
|
||||
|
||||
task_config = TASK_CONFIGS[task]
|
||||
prompt = self._prepare_prompt(task, text_prompt)
|
||||
out_images, out_masks, out_data = [], [], []
|
||||
pbar = ProgressBar(len(images))
|
||||
select_filter = [s.strip() for s in output_mask_select.split(",") if s.strip()]
|
||||
|
||||
for tensor in images:
|
||||
image_pil = TF.to_pil_image(tensor)
|
||||
inputs = processor(
|
||||
text=prompt,
|
||||
images=image_pil,
|
||||
return_tensors="pt",
|
||||
do_rescale=False,
|
||||
).to(dtype).to(device)
|
||||
|
||||
generated_ids = model.generate(
|
||||
input_ids=inputs["input_ids"],
|
||||
pixel_values=inputs["pixel_values"],
|
||||
max_new_tokens=GENERATION_CONFIG["max_new_tokens"],
|
||||
do_sample=GENERATION_CONFIG["do_sample"],
|
||||
num_beams=GENERATION_CONFIG["num_beams"],
|
||||
use_cache=False,
|
||||
)
|
||||
raw = processor.batch_decode(generated_ids, skip_special_tokens=False)[0]
|
||||
parsed = processor.post_process_generation(raw, task=task_config["token"], image_size=image_pil.size)
|
||||
predictions = parsed[task_config["token"]]
|
||||
|
||||
if task_config["mode"] == "bbox":
|
||||
annotated, mask = self._draw_regions(image_pil, predictions, fill_mask, select_filter)
|
||||
out_images.append(annotated)
|
||||
out_masks.append(mask)
|
||||
else:
|
||||
image_tensor, mask_tensor = self._segment_polygons(image_pil, predictions, fill_mask)
|
||||
out_images.append(image_tensor)
|
||||
out_masks.append(mask_tensor)
|
||||
|
||||
out_data.append(predictions)
|
||||
pbar.update(1)
|
||||
|
||||
image_out = torch.cat(out_images, dim=0)
|
||||
mask_out = torch.cat(out_masks, dim=0)
|
||||
|
||||
if not keep_model_loaded:
|
||||
model.to(offload)
|
||||
mm.soft_empty_cache()
|
||||
|
||||
return image_out, mask_out, out_data
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AILab_Florence2": AILab_Florence2,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AILab_Florence2": "Florence2 (RMBG)",
|
||||
}
|
||||
import io
|
||||
import os
|
||||
import random
|
||||
from typing import Any, Dict, List, Tuple
|
||||
from unittest.mock import patch
|
||||
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.patches as patches
|
||||
import matplotlib.pyplot as plt
|
||||
import numpy as np
|
||||
import torch
|
||||
import torchvision.transforms.functional as TF
|
||||
from PIL import Image, ImageColor, ImageDraw
|
||||
|
||||
import comfy.model_management as mm
|
||||
from comfy.utils import ProgressBar
|
||||
import folder_paths
|
||||
import transformers
|
||||
from transformers import AutoModelForCausalLM, AutoProcessor
|
||||
from transformers.dynamic_module_utils import get_imports
|
||||
|
||||
MODEL_DIR = os.path.join(folder_paths.models_dir, "LLM")
|
||||
os.makedirs(MODEL_DIR, exist_ok=True)
|
||||
folder_paths.add_model_folder_path("LLM", MODEL_DIR)
|
||||
|
||||
TASK_CONFIGS = {
|
||||
"Polygon Mask (text prompt)": {
|
||||
"token": "<REFERRING_EXPRESSION_SEGMENTATION>",
|
||||
"mode": "polygon",
|
||||
"allows_text": True,
|
||||
},
|
||||
"Phrase Grounding (text boxes)": {
|
||||
"token": "<CAPTION_TO_PHRASE_GROUNDING>",
|
||||
"mode": "bbox",
|
||||
"allows_text": True,
|
||||
},
|
||||
"Region Proposals (boxes only)": {
|
||||
"token": "<REGION_PROPOSAL>",
|
||||
"mode": "bbox",
|
||||
"allows_text": False,
|
||||
},
|
||||
}
|
||||
|
||||
TASK_CHOICES = tuple(TASK_CONFIGS.keys())
|
||||
|
||||
GENERATION_CONFIG = {
|
||||
"max_new_tokens": 512,
|
||||
"num_beams": 3,
|
||||
"do_sample": True,
|
||||
}
|
||||
|
||||
MODEL_CHOICES = (
|
||||
"microsoft/Florence-2-base",
|
||||
"microsoft/Florence-2-base-ft",
|
||||
"microsoft/Florence-2-large",
|
||||
"microsoft/Florence-2-large-ft",
|
||||
"thwri/CogFlorence-2.1-Large",
|
||||
"thwri/CogFlorence-2.2-Large",
|
||||
)
|
||||
|
||||
COLOR_BANK = ["blue", "orange", "green", "purple", "pink", "cyan"]
|
||||
|
||||
|
||||
def _fixed_get_imports(filename):
|
||||
try:
|
||||
if not str(filename).endswith("modeling_florence2.py"):
|
||||
return get_imports(filename)
|
||||
imports = get_imports(filename)
|
||||
if "flash_attn" in imports:
|
||||
imports.remove("flash_attn")
|
||||
return imports
|
||||
except Exception:
|
||||
return get_imports(filename)
|
||||
|
||||
|
||||
class AILab_Florence2:
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "JSON")
|
||||
RETURN_NAMES = ("IMAGE", "MASK", "DATA")
|
||||
FUNCTION = "analyze"
|
||||
MODEL_CACHE: Dict[Tuple[str, str, str], Dict[str, Any]] = {}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"model_name": (
|
||||
MODEL_CHOICES,
|
||||
{
|
||||
"default": MODEL_CHOICES[0],
|
||||
"tooltip": "Base = stable, +ft = fine-tuned captions, CogFlorence = sharper phrase alignment.",
|
||||
},
|
||||
),
|
||||
"task": (
|
||||
TASK_CHOICES,
|
||||
{
|
||||
"default": TASK_CHOICES[0],
|
||||
"tooltip": "Polygon masks use prompts; phrase grounding/region proposals return boxes.",
|
||||
},
|
||||
),
|
||||
"precision": (
|
||||
("fp16", "bf16", "fp32"),
|
||||
{"default": "fp16", "tooltip": "Lower precision saves VRAM; fp32 is safest if you hit NaNs."},
|
||||
),
|
||||
"attention": (
|
||||
("flash_attention_2", "sdpa", "eager"),
|
||||
{"default": "sdpa", "tooltip": "flash_attn2 needs PyTorch 2.1+; use eager if kernels fail."},
|
||||
),
|
||||
"fill_mask": (
|
||||
"BOOLEAN",
|
||||
{"default": True, "tooltip": "When true, bbox tasks also output filled mask tensors."},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"output_mask_select": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Comma-separated indices or labels (e.g. 0,2,person) to limit masks.",
|
||||
},
|
||||
),
|
||||
"keep_model_loaded": (
|
||||
"BOOLEAN",
|
||||
{"default": False, "tooltip": "Keep weights on the current device after execution."},
|
||||
),
|
||||
"text_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"multiline": True,
|
||||
"placeholder": "Prompt: e.g. a person wearing red coat",
|
||||
"tooltip": "Used for polygon masks or phrase grounding; ignored for region proposals.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def _ensure_weights(self, model_name: str) -> str:
|
||||
target = os.path.join(MODEL_DIR, model_name.split("/")[-1])
|
||||
if os.path.exists(target):
|
||||
return target
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
snapshot_download(model_name, local_dir=target, local_dir_use_symlinks=False)
|
||||
return target
|
||||
|
||||
def _get_model(self, model_name: str, precision: str, attention: str) -> Dict[str, Any]:
|
||||
key = (model_name, precision, attention)
|
||||
if key in self.MODEL_CACHE:
|
||||
return self.MODEL_CACHE[key]
|
||||
|
||||
dtype = {"fp16": torch.float16, "bf16": torch.bfloat16, "fp32": torch.float32}[precision]
|
||||
weights = self._ensure_weights(model_name)
|
||||
offload = mm.unet_offload_device()
|
||||
|
||||
if transformers.__version__ < "4.51.0":
|
||||
with patch("transformers.dynamic_module_utils.get_imports", _fixed_get_imports):
|
||||
model = AutoModelForCausalLM.from_pretrained(
|
||||
weights,
|
||||
attn_implementation=attention,
|
||||
torch_dtype=dtype,
|
||||
trust_remote_code=True,
|
||||
).to(offload)
|
||||
else:
|
||||
from models.modeling_florence2 import Florence2ForConditionalGeneration
|
||||
|
||||
model = Florence2ForConditionalGeneration.from_pretrained(
|
||||
weights,
|
||||
attn_implementation=attention,
|
||||
torch_dtype=dtype,
|
||||
).to(offload)
|
||||
|
||||
processor = AutoProcessor.from_pretrained(weights, trust_remote_code=True)
|
||||
bundle = {"model": model, "processor": processor, "dtype": dtype}
|
||||
self.MODEL_CACHE[key] = bundle
|
||||
return bundle
|
||||
|
||||
@staticmethod
|
||||
def _prepare_prompt(task: str, text_prompt: str) -> str:
|
||||
config = TASK_CONFIGS[task]
|
||||
text = text_prompt.strip()
|
||||
if text and not config["allows_text"]:
|
||||
text = ""
|
||||
base = config["token"]
|
||||
return f"{base} {text}" if text else base
|
||||
|
||||
def _draw_regions(
|
||||
self,
|
||||
image_pil: Image.Image,
|
||||
predictions: Dict[str, Any],
|
||||
fill_mask: bool,
|
||||
select_filter: List[str],
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
width, height = image_pil.size
|
||||
fig, ax = plt.subplots(figsize=(width / 100, height / 100), dpi=100)
|
||||
fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
||||
ax.imshow(image_pil)
|
||||
|
||||
mask_layer = Image.new("RGB", image_pil.size, (0, 0, 0)) if fill_mask else None
|
||||
mask_draw = ImageDraw.Draw(mask_layer) if mask_layer else None
|
||||
|
||||
for index, (bbox, label) in enumerate(zip(predictions["bboxes"], predictions["labels"])):
|
||||
x0, y0, x1, y1 = bbox
|
||||
if y1 < y0:
|
||||
y0, y1 = y1, y0
|
||||
if x1 < x0:
|
||||
x0, x1 = x1, x0
|
||||
filter_hit = not select_filter or str(index) in select_filter or label in select_filter
|
||||
if fill_mask and filter_hit:
|
||||
mask_draw.rectangle([x0, y0, x1, y1], fill=(255, 255, 255))
|
||||
rect = patches.Rectangle((x0, y0), x1 - x0, y1 - y0, linewidth=1, edgecolor="red", facecolor="none")
|
||||
ax.add_patch(rect)
|
||||
ax.text(
|
||||
x0,
|
||||
max(0, y0 - 12),
|
||||
f"{index}.{label}",
|
||||
color="white",
|
||||
fontsize=12,
|
||||
bbox=dict(facecolor=random.choice(COLOR_BANK), alpha=0.5),
|
||||
)
|
||||
|
||||
ax.axis("off")
|
||||
buf = io.BytesIO()
|
||||
plt.savefig(buf, format="png", pad_inches=0)
|
||||
buf.seek(0)
|
||||
annotated = Image.open(buf)
|
||||
plt.close(fig)
|
||||
|
||||
annotated_tensor = TF.to_tensor(annotated)[:3, :, :].unsqueeze(0).permute(0, 2, 3, 1).cpu().float()
|
||||
if mask_layer is not None:
|
||||
mask_tensor = TF.to_tensor(mask_layer).unsqueeze(0).permute(0, 2, 3, 1).cpu().float()
|
||||
mask_tensor = mask_tensor.mean(dim=0, keepdim=True)[:, :, :, 0]
|
||||
else:
|
||||
mask_tensor = torch.zeros((1, annotated_tensor.shape[1], annotated_tensor.shape[2]), dtype=torch.float32)
|
||||
|
||||
return annotated_tensor, mask_tensor
|
||||
|
||||
def _segment_polygons(
|
||||
self,
|
||||
image_pil: Image.Image,
|
||||
predictions: Dict[str, Any],
|
||||
fill_mask: bool,
|
||||
) -> Tuple[torch.Tensor, torch.Tensor]:
|
||||
width, height = image_pil.size
|
||||
mask_image = Image.new("RGB", (width, height), "black")
|
||||
mask_draw = ImageDraw.Draw(mask_image)
|
||||
|
||||
for polygons, label in zip(predictions["polygons"], predictions["labels"]):
|
||||
color = random.choice(COLOR_BANK)
|
||||
for polygon in polygons:
|
||||
polygon = np.array(polygon).reshape(-1, 2)
|
||||
polygon = np.clip(polygon, [0, 0], [width - 1, height - 1])
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
pts = polygon.reshape(-1).tolist()
|
||||
if fill_mask:
|
||||
overlay = Image.new("RGBA", image_pil.size, (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
rgba = ImageColor.getrgb(color) + (180,)
|
||||
draw.polygon(pts, outline=color, fill=rgba, width=3)
|
||||
image_pil = Image.alpha_composite(image_pil.convert("RGBA"), overlay).convert("RGB")
|
||||
else:
|
||||
draw = ImageDraw.Draw(image_pil)
|
||||
draw.polygon(pts, outline=color, width=3)
|
||||
mask_draw.polygon(pts, outline="white", fill="white")
|
||||
|
||||
image_tensor = TF.to_tensor(image_pil)[:3, :, :].unsqueeze(0).permute(0, 2, 3, 1).cpu().float()
|
||||
mask_tensor = TF.to_tensor(mask_image).unsqueeze(0).permute(0, 2, 3, 1).cpu().float()
|
||||
mask_tensor = mask_tensor.mean(dim=0, keepdim=True)[:, :, :, 0]
|
||||
return image_tensor, mask_tensor
|
||||
|
||||
def analyze(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
model_name: str,
|
||||
task: str,
|
||||
precision: str,
|
||||
attention: str,
|
||||
fill_mask: bool,
|
||||
output_mask_select: str = "",
|
||||
keep_model_loaded: bool = False,
|
||||
text_prompt: str = "",
|
||||
):
|
||||
bundle = self._get_model(model_name, precision, attention)
|
||||
model = bundle["model"]
|
||||
processor = bundle["processor"]
|
||||
dtype = bundle["dtype"]
|
||||
|
||||
device = mm.get_torch_device()
|
||||
offload = mm.unet_offload_device()
|
||||
model.to(device)
|
||||
images = image.permute(0, 3, 1, 2)
|
||||
|
||||
task_config = TASK_CONFIGS[task]
|
||||
prompt = self._prepare_prompt(task, text_prompt)
|
||||
out_images, out_masks, out_data = [], [], []
|
||||
pbar = ProgressBar(len(images))
|
||||
select_filter = [s.strip() for s in output_mask_select.split(",") if s.strip()]
|
||||
|
||||
for tensor in images:
|
||||
image_pil = TF.to_pil_image(tensor)
|
||||
inputs = processor(
|
||||
text=prompt,
|
||||
images=image_pil,
|
||||
return_tensors="pt",
|
||||
do_rescale=False,
|
||||
).to(dtype).to(device)
|
||||
|
||||
generated_ids = model.generate(
|
||||
input_ids=inputs["input_ids"],
|
||||
pixel_values=inputs["pixel_values"],
|
||||
max_new_tokens=GENERATION_CONFIG["max_new_tokens"],
|
||||
do_sample=GENERATION_CONFIG["do_sample"],
|
||||
num_beams=GENERATION_CONFIG["num_beams"],
|
||||
use_cache=False,
|
||||
)
|
||||
raw = processor.batch_decode(generated_ids, skip_special_tokens=False)[0]
|
||||
parsed = processor.post_process_generation(raw, task=task_config["token"], image_size=image_pil.size)
|
||||
predictions = parsed[task_config["token"]]
|
||||
|
||||
if task_config["mode"] == "bbox":
|
||||
annotated, mask = self._draw_regions(image_pil, predictions, fill_mask, select_filter)
|
||||
out_images.append(annotated)
|
||||
out_masks.append(mask)
|
||||
else:
|
||||
image_tensor, mask_tensor = self._segment_polygons(image_pil, predictions, fill_mask)
|
||||
out_images.append(image_tensor)
|
||||
out_masks.append(mask_tensor)
|
||||
|
||||
out_data.append(predictions)
|
||||
pbar.update(1)
|
||||
|
||||
image_out = torch.cat(out_images, dim=0)
|
||||
mask_out = torch.cat(out_masks, dim=0)
|
||||
|
||||
if not keep_model_loaded:
|
||||
model.to(offload)
|
||||
mm.soft_empty_cache()
|
||||
|
||||
return image_out, mask_out, out_data
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AILab_Florence2": AILab_Florence2,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AILab_Florence2": "Florence2 (RMBG)",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
# ComfyUI-RMBG
|
||||
# Interactive Image Compare Node (ComfyUI Standard)
|
||||
# License: GPL-3.0
|
||||
|
||||
import os
|
||||
import random
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from PIL import Image
|
||||
import folder_paths
|
||||
|
||||
|
||||
class AILab_ImageCompareView:
|
||||
"""Real-time Interactive Image Compare node.
|
||||
|
||||
Renders two images side-by-side or with various comparison modes directly
|
||||
on the node canvas. All UI controls (mode dropdown, match_size checkbox)
|
||||
are native ComfyUI widgets — the frontend JS handles canvas rendering and
|
||||
mouse interaction only.
|
||||
|
||||
Outputs ``IMAGE`` (the composed result for the current mode) and
|
||||
``DIFF_MASK`` (the absolute-difference mask between the two inputs).
|
||||
"""
|
||||
|
||||
DESCRIPTION = (
|
||||
"Real-time Interactive Image Compare (RMBG) - Compare two images on-node. "
|
||||
"Supports wipe (L/R & T/B), overlay opacity blend, pixel difference, "
|
||||
"side-by-side, and highlight-diff (red on grey). Uses native ComfyUI "
|
||||
"widgets for mode selection; the frontend handles interactive canvas "
|
||||
"rendering."
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image1": ("IMAGE", {"tooltip": "First image to compare (e.g. before / original)."}),
|
||||
"image2": ("IMAGE", {"tooltip": "Second image to compare (e.g. after / processed)."}),
|
||||
"mode": (
|
||||
[
|
||||
"left_right",
|
||||
"up_down",
|
||||
"overlay",
|
||||
"difference",
|
||||
"side_by_side",
|
||||
"highlight_diff",
|
||||
],
|
||||
{"default": "left_right", "tooltip": "Comparison mode."},
|
||||
),
|
||||
"match_size": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "If True, resize image2 to match image1 dimensions when they differ.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {
|
||||
"prompt": "PROMPT",
|
||||
"extra_pnginfo": "EXTRA_PNGINFO",
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("IMAGE", "DIFF_MASK")
|
||||
FUNCTION = "compare_images"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "🧪AILab/🖼️IMAGE"
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_temp_directory()
|
||||
self.type = "temp"
|
||||
self.prefix_append = "_cmp_" + "".join(
|
||||
random.choice("abcdefghijklmnopqrstuvwxyz") for _ in range(5)
|
||||
)
|
||||
self.compress_level = 4
|
||||
|
||||
# ── Core entry point ───────────────────────────────────────
|
||||
def compare_images(self, image1, image2, mode="left_right",
|
||||
match_size=True, prompt=None, extra_pnginfo=None):
|
||||
# ── Both images provided (required inputs) ──────────────
|
||||
# Ensure RGB (strip alpha if present)
|
||||
t1 = image1[..., :3] if image1.shape[-1] == 4 else image1
|
||||
t2 = image2[..., :3] if image2.shape[-1] == 4 else image2
|
||||
|
||||
# Align batch size
|
||||
b1, b2 = t1.shape[0], t2.shape[0]
|
||||
if b1 != b2:
|
||||
if b1 == 1:
|
||||
t1 = t1.repeat(b2, 1, 1, 1)
|
||||
elif b2 == 1:
|
||||
t2 = t2.repeat(b1, 1, 1, 1)
|
||||
else:
|
||||
min_b = min(b1, b2)
|
||||
t1 = t1[:min_b]
|
||||
t2 = t2[:min_b]
|
||||
|
||||
# Match spatial size if requested (resize image2 → image1)
|
||||
if match_size and t1.shape[1:3] != t2.shape[1:3]:
|
||||
nchw2 = t2.permute(0, 3, 1, 2)
|
||||
resized2 = F.interpolate(
|
||||
nchw2, size=(t1.shape[1], t1.shape[2]),
|
||||
mode="bicubic", align_corners=False,
|
||||
)
|
||||
t2 = torch.clamp(resized2.permute(0, 2, 3, 1), 0.0, 1.0)
|
||||
|
||||
# Safety: if sizes still differ (match_size=False), crop both to the
|
||||
# common minimum so _compose and diff_mask never crash on shape mismatch.
|
||||
if t1.shape[1:3] != t2.shape[1:3]:
|
||||
min_h = min(t1.shape[1], t2.shape[1])
|
||||
min_w = min(t1.shape[2], t2.shape[2])
|
||||
t1 = t1[:, :min_h, :min_w, :]
|
||||
t2 = t2[:, :min_h, :min_w, :]
|
||||
|
||||
# Save both images to temp directory for frontend interaction
|
||||
results = self._save_images([(1, t1), (2, t2)])
|
||||
|
||||
# Difference mask (used by all modes)
|
||||
diff_mask = torch.clamp(torch.abs(t1 - t2).mean(dim=-1), 0.0, 1.0)
|
||||
|
||||
# Compose output based on mode
|
||||
out_img = self._compose(mode, t1, t2)
|
||||
|
||||
return {"ui": {"images": results}, "result": (out_img, diff_mask)}
|
||||
|
||||
# ── Mode composition ──────────────────────────────────────
|
||||
@staticmethod
|
||||
def _compose(mode, t1, t2):
|
||||
"""Return the composed output tensor for *mode*."""
|
||||
if mode == "left_right":
|
||||
mid = t1.shape[2] // 2
|
||||
out = t2.clone()
|
||||
out[:, :, :mid, :] = t1[:, :, :mid, :]
|
||||
elif mode == "up_down":
|
||||
mid = t1.shape[1] // 2
|
||||
out = t2.clone()
|
||||
out[:, :mid, :, :] = t1[:, :mid, :, :]
|
||||
elif mode == "overlay":
|
||||
out = torch.clamp(0.5 * t1 + 0.5 * t2, 0.0, 1.0)
|
||||
elif mode == "difference":
|
||||
out = torch.clamp(torch.abs(t1 - t2), 0.0, 1.0)
|
||||
elif mode == "side_by_side":
|
||||
out = torch.cat([t1, t2], dim=2)
|
||||
elif mode == "highlight_diff":
|
||||
# grey base (luminance of image1), red where pixels differ
|
||||
grey = t1.mean(dim=-1, keepdim=True).repeat(1, 1, 1, 3)
|
||||
diff = torch.abs(t1 - t2).mean(dim=-1, keepdim=True)
|
||||
red = torch.zeros_like(t1)
|
||||
red[..., 0] = 1.0 # pure red channel
|
||||
threshold = 0.15
|
||||
mask = (diff > threshold).expand_as(t1).float()
|
||||
out = grey * (1 - mask) + red * mask
|
||||
else:
|
||||
out = t1
|
||||
return out
|
||||
|
||||
# ── Image saving helper ────────────────────────────────────
|
||||
def _save_images(self, slot_tensors):
|
||||
"""Save tensors to temp dir, tagged with *slot* (1 or 2).
|
||||
|
||||
The frontend uses ``slot`` to distinguish image1 from image2.
|
||||
"""
|
||||
results = []
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
first_tensor = slot_tensors[0][1]
|
||||
prefix = "ailab_cmp" + self.prefix_append
|
||||
w = first_tensor.shape[2]
|
||||
h = first_tensor.shape[1]
|
||||
|
||||
full_output_folder, filename, counter, subfolder, _ = (
|
||||
folder_paths.get_save_image_path(prefix, self.output_dir, w, h)
|
||||
)
|
||||
|
||||
for slot, tensor in slot_tensors:
|
||||
arr = np.clip(255.0 * tensor[0].cpu().numpy(), 0, 255).astype(np.uint8)
|
||||
img = Image.fromarray(arr)
|
||||
file = f"{filename}_{counter:05}_{slot}.png"
|
||||
img.save(
|
||||
os.path.join(full_output_folder, file),
|
||||
compress_level=self.compress_level,
|
||||
)
|
||||
results.append({
|
||||
"filename": file,
|
||||
"subfolder": subfolder,
|
||||
"type": self.type,
|
||||
"slot": slot,
|
||||
})
|
||||
counter += 1
|
||||
|
||||
return results
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AILab_ImageCompareView": AILab_ImageCompareView,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AILab_ImageCompareView": "Image Compare (RMBG) 🔀🖼️",
|
||||
}
|
||||
+17
-21
@@ -20,12 +20,7 @@ from torchvision import transforms
|
||||
from huggingface_hub import hf_hub_download
|
||||
import shutil
|
||||
import gc
|
||||
|
||||
def tensor2pil(image):
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||||
|
||||
def pil2tensor(image):
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
from AILab_utils import clean_vram, unload_model, tensor2pil, pil2tensor
|
||||
|
||||
def pil2comfy(image):
|
||||
img_tensor = torch.from_numpy(np.array(image).astype(np.float32) / 255.0)
|
||||
@@ -68,6 +63,9 @@ class AILab_LamaRemover:
|
||||
"removal_strength": ("INT", {"default": 230, "min": 0, "max": 255, "step": 1, "display": "slider", "tooltip": tooltips["removal_strength"]}),
|
||||
"edge_smoothness": ("INT", {"default": 8, "min": 0, "max": 20, "step": 1, "display": "slider", "tooltip": tooltips["edge_smoothness"]}),
|
||||
},
|
||||
"optional": {
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
@@ -100,9 +98,8 @@ class AILab_LamaRemover:
|
||||
self.model.to(self.device)
|
||||
|
||||
def download_model(self):
|
||||
print("Downloading Big-Lama model...")
|
||||
os.makedirs(self.cache_dir, exist_ok=True)
|
||||
|
||||
print("Downloading Big-Lama model...")
|
||||
try:
|
||||
downloaded_path = hf_hub_download(
|
||||
repo_id="1038lab/Lama",
|
||||
@@ -110,22 +107,20 @@ class AILab_LamaRemover:
|
||||
local_dir=self.cache_dir,
|
||||
local_dir_use_symlinks=False
|
||||
)
|
||||
|
||||
if os.path.dirname(downloaded_path) != self.cache_dir:
|
||||
shutil.move(downloaded_path, self.model_path)
|
||||
|
||||
print("Big-Lama model downloaded successfully")
|
||||
target_path = os.path.join(self.cache_dir, "big-lama.pt")
|
||||
shutil.move(downloaded_path, target_path)
|
||||
print("Model downloaded successfully")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Error downloading Big-Lama model: {str(e)}")
|
||||
|
||||
def process_with_model(self, img_tensor, mask_tensor):
|
||||
with torch.inference_mode():
|
||||
img_tensor = img_tensor.to(self.device)
|
||||
mask_tensor = mask_tensor.to(self.device)
|
||||
|
||||
img_tensor = img_tensor.to(self.device)
|
||||
mask_tensor = mask_tensor.to(self.device)
|
||||
|
||||
with torch.no_grad():
|
||||
result = self.model(img_tensor, mask_tensor)
|
||||
result_cpu = result[0].cpu()
|
||||
|
||||
result_cpu = result.cpu()
|
||||
del img_tensor
|
||||
del mask_tensor
|
||||
if torch.cuda.is_available():
|
||||
@@ -133,7 +128,7 @@ class AILab_LamaRemover:
|
||||
|
||||
return result_cpu
|
||||
|
||||
def remove_object(self, images, masks, removal_strength, edge_smoothness):
|
||||
def remove_object(self, images, masks, removal_strength, edge_smoothness, unload_model=False):
|
||||
try:
|
||||
self.load_model()
|
||||
results = []
|
||||
@@ -179,8 +174,9 @@ class AILab_LamaRemover:
|
||||
print(traceback.format_exc())
|
||||
raise RuntimeError(f"Error in object removal: {str(e)}")
|
||||
finally:
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
if unload_model:
|
||||
self.model = None
|
||||
clean_vram()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AILab_LamaRemover": AILab_LamaRemover,
|
||||
|
||||
+705
-706
File diff suppressed because it is too large
Load Diff
+41
-67
@@ -13,17 +13,10 @@ import comfy.model_management
|
||||
|
||||
from hydra import initialize_config_dir
|
||||
from hydra.core.global_hydra import GlobalHydra
|
||||
from AILab_utils import patch_transformers_bert, clean_vram, unload_model, get_or_download_model_file, process_mask, apply_background_color
|
||||
|
||||
try:
|
||||
from groundingdino.util.slconfig import SLConfig
|
||||
from groundingdino.models import build_model
|
||||
from groundingdino.util.utils import clean_state_dict
|
||||
from groundingdino.util import box_ops
|
||||
from groundingdino.datasets.transforms import Compose, RandomResize, ToTensor, Normalize
|
||||
GROUNDINGDINO_AVAILABLE = True
|
||||
except ImportError:
|
||||
GROUNDINGDINO_AVAILABLE = False
|
||||
print("Warning: GroundingDINO not available. Text prompts will use fallback method.")
|
||||
# GroundingDINO imports are deferred to first use (segment_v2) to avoid ~12.5s import at startup.
|
||||
GROUNDINGDINO_AVAILABLE = None # Tri-state: None = not yet checked, True/False = checked
|
||||
|
||||
current_dir = Path(__file__).resolve().parent
|
||||
repo_root = current_dir.parent
|
||||
@@ -105,50 +98,6 @@ DINO_MODELS = {
|
||||
}
|
||||
}
|
||||
|
||||
def get_or_download_model_file(filename, url, dirname):
|
||||
local_path = folder_paths.get_full_path(dirname, filename)
|
||||
if local_path:
|
||||
return local_path
|
||||
folder = os.path.join(folder_paths.models_dir, dirname)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
local_path = os.path.join(folder, filename)
|
||||
if not os.path.exists(local_path):
|
||||
print(f"Downloading {filename} from {url} ...")
|
||||
download_url_to_file(url, local_path)
|
||||
return local_path
|
||||
|
||||
def process_mask(mask_image: Image.Image, invert_output: bool = False,
|
||||
mask_blur: int = 0, mask_offset: int = 0) -> Image.Image:
|
||||
if invert_output:
|
||||
mask_np = np.array(mask_image)
|
||||
mask_image = Image.fromarray(255 - mask_np)
|
||||
if mask_blur > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=mask_blur))
|
||||
if mask_offset != 0:
|
||||
filter_type = ImageFilter.MaxFilter if mask_offset > 0 else ImageFilter.MinFilter
|
||||
size = abs(mask_offset) * 2 + 1
|
||||
for _ in range(abs(mask_offset)):
|
||||
mask_image = mask_image.filter(filter_type(size))
|
||||
return mask_image
|
||||
|
||||
def apply_background_color(image: Image.Image, mask_image: Image.Image,
|
||||
background: str = "Alpha",
|
||||
background_color: str = "#222222") -> Image.Image:
|
||||
rgba_image = image.copy().convert('RGBA')
|
||||
rgba_image.putalpha(mask_image.convert('L'))
|
||||
|
||||
if background == "Color":
|
||||
def hex_to_rgba(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
return (r, g, b, 255)
|
||||
rgba = hex_to_rgba(background_color)
|
||||
bg_image = Image.new('RGBA', image.size, rgba)
|
||||
composite_image = Image.alpha_composite(bg_image, rgba_image)
|
||||
return composite_image.convert('RGB')
|
||||
return rgba_image
|
||||
|
||||
|
||||
class SAM2Segment:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -177,6 +126,7 @@ class SAM2Segment:
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
}
|
||||
@@ -249,8 +199,25 @@ class SAM2Segment:
|
||||
return self.sam2_model_cache[cache_key]
|
||||
|
||||
def segment_v2(self, image, prompt, sam2_model, dino_model, device, threshold=0.35,
|
||||
mask_blur=0, mask_offset=0, background="Alpha",
|
||||
background_color="#222222", invert_output=False):
|
||||
mask_blur=0, mask_offset=0, invert_output=False, unload_model=False,
|
||||
background="Alpha", background_color="#222222"):
|
||||
# Lazy import groundingdino (~12.5s) -- only when the node is actually executed
|
||||
global GROUNDINGDINO_AVAILABLE
|
||||
if GROUNDINGDINO_AVAILABLE is None:
|
||||
try:
|
||||
from groundingdino.util.slconfig import SLConfig # noqa: F401
|
||||
GROUNDINGDINO_AVAILABLE = True
|
||||
except ImportError:
|
||||
GROUNDINGDINO_AVAILABLE = False
|
||||
print("Warning: GroundingDINO not available. Text prompts will use fallback method.")
|
||||
if not GROUNDINGDINO_AVAILABLE:
|
||||
raise RuntimeError("GroundingDINO is required for SAM2Segment but is not installed.")
|
||||
from groundingdino.util.slconfig import SLConfig
|
||||
from groundingdino.models import build_model
|
||||
from groundingdino.util.utils import clean_state_dict
|
||||
from groundingdino.util import box_ops
|
||||
from groundingdino.datasets.transforms import Compose, RandomResize, ToTensor, Normalize
|
||||
|
||||
device_obj = comfy.model_management.get_torch_device()
|
||||
|
||||
# Process batch images
|
||||
@@ -274,6 +241,7 @@ class SAM2Segment:
|
||||
# Load and cache GroundingDINO model
|
||||
dino_key = (config_path, weights_path, device_obj)
|
||||
if dino_key not in self.dino_model_cache:
|
||||
patch_transformers_bert()
|
||||
args = SLConfig.fromfile(config_path)
|
||||
model = build_model(args)
|
||||
checkpoint = load_file(weights_path)
|
||||
@@ -385,17 +353,23 @@ class SAM2Segment:
|
||||
result_masks.append(mask_tensor)
|
||||
result_mask_images.append(mask_image_vis)
|
||||
|
||||
# If no images were successfully processed, return empty results
|
||||
if len(result_images) == 0:
|
||||
width, height = tensor2pil(image[0]).size
|
||||
empty_mask = torch.zeros((batch_size, 1, height, width), dtype=torch.float32, device="cpu")
|
||||
empty_mask_rgb = empty_mask.reshape((-1, 1, height, width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
return (image, empty_mask, empty_mask_rgb)
|
||||
|
||||
# Combine all batch results
|
||||
return (torch.cat(result_images, dim=0),
|
||||
torch.cat(result_masks, dim=0),
|
||||
torch.cat(result_mask_images, dim=0))
|
||||
try:
|
||||
# If no images were successfully processed, return empty results
|
||||
if len(result_images) == 0:
|
||||
width, height = tensor2pil(image[0]).size
|
||||
empty_mask = torch.zeros((batch_size, 1, height, width), dtype=torch.float32, device="cpu")
|
||||
empty_mask_rgb = empty_mask.reshape((-1, 1, height, width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
return (image, empty_mask, empty_mask_rgb)
|
||||
|
||||
# Combine all batch results
|
||||
return (torch.cat(result_images, dim=0),
|
||||
torch.cat(result_masks, dim=0),
|
||||
torch.cat(result_mask_images, dim=0))
|
||||
finally:
|
||||
if unload_model:
|
||||
self.dino_model_cache.clear()
|
||||
self.sam2_model_cache.clear()
|
||||
clean_vram()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SAM2Segment": SAM2Segment,
|
||||
|
||||
+100
-121
@@ -12,86 +12,42 @@ import folder_paths
|
||||
import comfy.model_management
|
||||
|
||||
from AILab_ImageMaskTools import pil2tensor, tensor2pil
|
||||
from AILab_utils import clean_vram, process_mask, apply_background_color, get_or_download_model_file
|
||||
|
||||
# ── paths ──────────────────────────────────────────────────────────────
|
||||
CURRENT_DIR = Path(__file__).resolve().parent
|
||||
REPO_ROOT = CURRENT_DIR.parent
|
||||
SAM3_LOCAL_DIR = REPO_ROOT / "models" / "sam3"
|
||||
if str(SAM3_LOCAL_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SAM3_LOCAL_DIR))
|
||||
MODELS_ROOT = REPO_ROOT / "models"
|
||||
if str(MODELS_ROOT) not in sys.path:
|
||||
|
||||
if "sam3" not in sys.modules:
|
||||
sys.path.insert(0, str(SAM3_LOCAL_DIR))
|
||||
sys.path.insert(0, str(MODELS_ROOT))
|
||||
|
||||
SAM3_BPE_PATH = SAM3_LOCAL_DIR / "assets" / "bpe_simple_vocab_16e6.txt.gz"
|
||||
if not os.path.isfile(SAM3_BPE_PATH):
|
||||
raise RuntimeError("SAM3 assets missing; ensure sam3/assets/bpe_simple_vocab_16e6.txt.gz exists.")
|
||||
|
||||
_DEFAULT_PT_ENTRY = {
|
||||
"model_url": "https://huggingface.co/1038lab/sam3/resolve/main/sam3.pt",
|
||||
"filename": "sam3.pt",
|
||||
# ── safetensors import ─────────────────────────────────────────────────
|
||||
try:
|
||||
from safetensors.torch import load_file as safetensors_load_file
|
||||
except ImportError:
|
||||
safetensors_load_file = None
|
||||
|
||||
# ── model registry ─────────────────────────────────────────────────────
|
||||
SAM3_SAFETENSOR_MODELS = {
|
||||
"sam3.1_multiplex_fp16": {
|
||||
"model_url": "https://huggingface.co/1038lab/sam3/resolve/main/sam3.1_multiplex_fp16.safetensors",
|
||||
"filename": "sam3.1_multiplex_fp16.safetensors",
|
||||
"dtype": "float16",
|
||||
},
|
||||
"sam3": {
|
||||
"model_url": "https://huggingface.co/1038lab/sam3/resolve/main/sam3.safetensors",
|
||||
"filename": "sam3.safetensors",
|
||||
"dtype": "float32",
|
||||
},
|
||||
}
|
||||
|
||||
SAM3_MODELS = {
|
||||
"sam3": _DEFAULT_PT_ENTRY.copy(),
|
||||
}
|
||||
|
||||
|
||||
def get_sam3_pt_models():
|
||||
entry = SAM3_MODELS.get("sam3")
|
||||
if entry and entry.get("filename", "").endswith(".pt"):
|
||||
return {"sam3": entry}
|
||||
for key, value in SAM3_MODELS.items():
|
||||
if value.get("filename", "").endswith(".pt"):
|
||||
return {"sam3": value}
|
||||
if "sam3" in key and value:
|
||||
candidate = value.copy()
|
||||
candidate["model_url"] = _DEFAULT_PT_ENTRY["model_url"]
|
||||
candidate["filename"] = _DEFAULT_PT_ENTRY["filename"]
|
||||
return {"sam3": candidate}
|
||||
return {"sam3": _DEFAULT_PT_ENTRY.copy()}
|
||||
|
||||
|
||||
def process_mask(mask_image, invert_output=False, mask_blur=0, mask_offset=0):
|
||||
if invert_output:
|
||||
mask_np = np.array(mask_image)
|
||||
mask_image = Image.fromarray(255 - mask_np)
|
||||
if mask_blur > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=mask_blur))
|
||||
if mask_offset != 0:
|
||||
filt = ImageFilter.MaxFilter if mask_offset > 0 else ImageFilter.MinFilter
|
||||
size = abs(mask_offset) * 2 + 1
|
||||
for _ in range(abs(mask_offset)):
|
||||
mask_image = mask_image.filter(filt(size))
|
||||
return mask_image
|
||||
|
||||
|
||||
def apply_background_color(image, mask_image, background="Alpha", background_color="#222222"):
|
||||
rgba_image = image.copy().convert("RGBA")
|
||||
rgba_image.putalpha(mask_image.convert("L"))
|
||||
if background == "Color":
|
||||
hex_color = background_color.lstrip("#")
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
bg_image = Image.new("RGBA", image.size, (r, g, b, 255))
|
||||
composite = Image.alpha_composite(bg_image, rgba_image)
|
||||
return composite.convert("RGB")
|
||||
return rgba_image
|
||||
|
||||
|
||||
def get_or_download_model_file(filename, url):
|
||||
local_path = None
|
||||
if hasattr(folder_paths, "get_full_path"):
|
||||
local_path = folder_paths.get_full_path("sam3", filename)
|
||||
if local_path and os.path.isfile(local_path):
|
||||
return local_path
|
||||
base_models_dir = getattr(folder_paths, "models_dir", os.path.join(CURRENT_DIR, "models"))
|
||||
models_dir = os.path.join(base_models_dir, "sam3")
|
||||
os.makedirs(models_dir, exist_ok=True)
|
||||
local_path = os.path.join(models_dir, filename)
|
||||
if not os.path.exists(local_path):
|
||||
print(f"Downloading {filename} from {url} ...")
|
||||
download_url_to_file(url, local_path)
|
||||
return local_path
|
||||
|
||||
|
||||
def _resolve_device(user_choice):
|
||||
auto_device = comfy.model_management.get_torch_device()
|
||||
@@ -104,6 +60,43 @@ def _resolve_device(user_choice):
|
||||
return auto_device
|
||||
|
||||
|
||||
# ── safetensors-aware checkpoint loader ────────────────────────────────
|
||||
def _load_safetensors_checkpoint(model, checkpoint_path):
|
||||
"""Load safetensors checkpoint with smart key-mapping."""
|
||||
if safetensors_load_file is None:
|
||||
raise ImportError(
|
||||
"safetensors is required for SAM3 Multiplex. "
|
||||
"Install with: pip install safetensors"
|
||||
)
|
||||
state_dict = safetensors_load_file(checkpoint_path, device="cpu")
|
||||
|
||||
# Sniff key structure: if keys have 'detector.' prefix, strip it
|
||||
has_detector_prefix = any(k.startswith("detector.") for k in state_dict)
|
||||
if has_detector_prefix:
|
||||
sam3_image_ckpt = {
|
||||
k.replace("detector.", ""): v
|
||||
for k, v in state_dict.items()
|
||||
if "detector" in k
|
||||
}
|
||||
if model.inst_interactive_predictor is not None:
|
||||
sam3_image_ckpt.update({
|
||||
k.replace("tracker.", "inst_interactive_predictor.model."): v
|
||||
for k, v in state_dict.items()
|
||||
if "tracker" in k
|
||||
})
|
||||
else:
|
||||
# Flat keys — load as-is
|
||||
sam3_image_ckpt = state_dict
|
||||
|
||||
missing_keys, unexpected_keys = model.load_state_dict(sam3_image_ckpt, strict=False)
|
||||
if missing_keys:
|
||||
print(f"[SAM3 Multiplex] loaded {checkpoint_path}")
|
||||
print(f" missing keys ({len(missing_keys)}): {missing_keys[:10]}{'...' if len(missing_keys) > 10 else ''}")
|
||||
if unexpected_keys:
|
||||
print(f" unexpected keys ({len(unexpected_keys)}): {unexpected_keys[:10]}{'...' if len(unexpected_keys) > 10 else ''}")
|
||||
|
||||
|
||||
# ── model builder import ───────────────────────────────────────────────
|
||||
from sam3.model_builder import build_sam3_image_model # noqa: E402
|
||||
from sam3.model.sam3_image_processor import Sam3Processor # noqa: E402
|
||||
|
||||
@@ -111,9 +104,11 @@ from sam3.model.sam3_image_processor import Sam3Processor # noqa: E402
|
||||
class SAM3Segment:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
model_names = list(SAM3_SAFETENSOR_MODELS.keys())
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"model_name": (model_names, {"default": model_names[0]}),
|
||||
"prompt": ("STRING", {"default": "", "multiline": True, "placeholder": "Describe the concept"}),
|
||||
"output_mode": (["Merged", "Separate"], {"default": "Merged"}),
|
||||
"confidence_threshold": ("FLOAT", {"default": 0.5, "min": 0.05, "max": 0.95, "step": 0.01}),
|
||||
@@ -139,24 +134,36 @@ class SAM3Segment:
|
||||
def __init__(self):
|
||||
self.processor_cache = {}
|
||||
|
||||
def _load_processor(self, device_choice):
|
||||
def _load_processor(self, model_name, device_choice):
|
||||
torch_device = _resolve_device(device_choice)
|
||||
device_str = "cuda" if torch_device.type == "cuda" else "cpu"
|
||||
cache_key = ("sam3", device_str)
|
||||
cache_key = (model_name, device_str)
|
||||
if cache_key not in self.processor_cache:
|
||||
model_info = SAM3_MODELS["sam3"]
|
||||
ckpt_path = get_or_download_model_file(model_info["filename"], model_info["model_url"])
|
||||
model_info = SAM3_SAFETENSOR_MODELS[model_name]
|
||||
ckpt_path = get_or_download_model_file(model_info["filename"], model_info["model_url"], "sam3")
|
||||
|
||||
# Build the model architecture (without loading weights)
|
||||
model = build_sam3_image_model(
|
||||
bpe_path=SAM3_BPE_PATH,
|
||||
device=device_str,
|
||||
device="cpu", # load on CPU first, move later
|
||||
eval_mode=True,
|
||||
checkpoint_path=ckpt_path,
|
||||
checkpoint_path=None, # we load manually below
|
||||
load_from_HF=False,
|
||||
enable_segmentation=True,
|
||||
enable_inst_interactivity=False,
|
||||
)
|
||||
|
||||
# Load safetensors checkpoint
|
||||
_load_safetensors_checkpoint(model, ckpt_path)
|
||||
|
||||
# Move to target device (keep FP32 — autocast handles precision at inference)
|
||||
if device_str == "cuda":
|
||||
model = model.cuda()
|
||||
model.eval()
|
||||
|
||||
processor = Sam3Processor(model, device=device_str)
|
||||
self.processor_cache[cache_key] = processor
|
||||
print(f"[SAM3 Multiplex] Loaded {model_name} on {device_str} ({model_info['dtype']})")
|
||||
return self.processor_cache[cache_key], torch_device
|
||||
|
||||
def _empty_result(self, img_pil, background, background_color):
|
||||
@@ -230,18 +237,8 @@ class SAM3Segment:
|
||||
def _run_single_merged(self, processor, img_tensor, prompt, confidence, max_segments, segment_pick, mask_blur, mask_offset, invert, unload_model, background, background_color):
|
||||
img_pil = tensor2pil(img_tensor)
|
||||
imgs, masks, _ = self._run_single_per_instance(
|
||||
processor,
|
||||
img_tensor,
|
||||
prompt,
|
||||
confidence,
|
||||
max_segments,
|
||||
segment_pick,
|
||||
mask_blur,
|
||||
mask_offset,
|
||||
invert,
|
||||
unload_model,
|
||||
background,
|
||||
background_color,
|
||||
processor, img_tensor, prompt, confidence, max_segments, segment_pick,
|
||||
mask_blur, mask_offset, invert, unload_model, background, background_color,
|
||||
)
|
||||
if masks.shape[0] == 0:
|
||||
return self._empty_result(img_pil, background, background_color)
|
||||
@@ -255,48 +252,35 @@ class SAM3Segment:
|
||||
mask_rgb = mask_tensor.reshape((1, mask_image.height, mask_image.width, 1)).expand(-1, -1, -1, 3)
|
||||
return result_image, mask_tensor, mask_rgb
|
||||
|
||||
def segment(self, image, prompt, device, confidence_threshold=0.5, max_segments=0, segment_pick=0, mask_blur=0, mask_offset=0, invert_output=False, unload_model=False, background="Alpha", background_color="#222222", output_mode="Merged"):
|
||||
def segment(self, image, model_name, prompt, device, confidence_threshold=0.5, max_segments=0, segment_pick=0, mask_blur=0, mask_offset=0, invert_output=False, unload_model=False, background="Alpha", background_color="#222222", output_mode="Merged"):
|
||||
if image.ndim == 3:
|
||||
image = image.unsqueeze(0)
|
||||
processor, torch_device = self._load_processor(device)
|
||||
processor, torch_device = self._load_processor(model_name, device)
|
||||
autocast_device = comfy.model_management.get_autocast_device(torch_device)
|
||||
autocast_enabled = torch_device.type == "cuda" and not comfy.model_management.is_device_mps(torch_device)
|
||||
ctx = torch.autocast(autocast_device, dtype=torch.bfloat16) if autocast_enabled else nullcontext()
|
||||
|
||||
# Use bfloat16 for original sam3, float16 for multiplex fp16
|
||||
model_info = SAM3_SAFETENSOR_MODELS[model_name]
|
||||
autocast_dtype = torch.float16 if model_info["dtype"] == "float16" else torch.bfloat16
|
||||
ctx = torch.autocast(autocast_device, dtype=autocast_dtype) if autocast_enabled else nullcontext()
|
||||
|
||||
result_images, result_masks, result_mask_images = [], [], []
|
||||
with ctx:
|
||||
for tensor_img in image:
|
||||
if output_mode == "Separate":
|
||||
imgs_batch, masks_batch, mask_imgs_batch = self._run_single_per_instance(
|
||||
processor,
|
||||
tensor_img,
|
||||
prompt,
|
||||
confidence_threshold,
|
||||
max_segments,
|
||||
segment_pick,
|
||||
mask_blur,
|
||||
mask_offset,
|
||||
invert_output,
|
||||
unload_model,
|
||||
background,
|
||||
background_color,
|
||||
processor, tensor_img, prompt, confidence_threshold,
|
||||
max_segments, segment_pick, mask_blur, mask_offset,
|
||||
invert_output, unload_model, background, background_color,
|
||||
)
|
||||
result_images.append(imgs_batch)
|
||||
result_masks.append(masks_batch)
|
||||
result_mask_images.append(mask_imgs_batch)
|
||||
else:
|
||||
img_pil, mask_tensor, mask_rgb = self._run_single_merged(
|
||||
processor,
|
||||
tensor_img,
|
||||
prompt,
|
||||
confidence_threshold,
|
||||
max_segments,
|
||||
segment_pick,
|
||||
mask_blur,
|
||||
mask_offset,
|
||||
invert_output,
|
||||
unload_model,
|
||||
background,
|
||||
background_color,
|
||||
processor, tensor_img, prompt, confidence_threshold,
|
||||
max_segments, segment_pick, mask_blur, mask_offset,
|
||||
invert_output, unload_model, background, background_color,
|
||||
)
|
||||
result_images.append(pil2tensor(img_pil))
|
||||
result_masks.append(mask_tensor)
|
||||
@@ -304,30 +288,25 @@ class SAM3Segment:
|
||||
|
||||
if unload_model:
|
||||
device_str = "cuda" if torch_device.type == "cuda" else "cpu"
|
||||
cache_key = ("sam3", device_str)
|
||||
cache_key = (model_name, device_str)
|
||||
if cache_key in self.processor_cache:
|
||||
del self.processor_cache[cache_key]
|
||||
if torch_device.type == "cuda":
|
||||
torch.cuda.empty_cache()
|
||||
clean_vram()
|
||||
|
||||
# return torch.cat(result_images, dim=0), torch.cat(result_masks, dim=0), torch.cat(result_mask_images, dim=0)
|
||||
# Handle empty results
|
||||
final_images = torch.cat(result_images, dim=0)
|
||||
final_masks = torch.cat(result_masks, dim=0)
|
||||
final_mask_images = torch.cat(result_mask_images, dim=0)
|
||||
|
||||
|
||||
# If no segments found in Separate mode, return at least one empty result
|
||||
if final_images.shape[0] == 0:
|
||||
# Use the first input image to get dimensions
|
||||
img_pil = tensor2pil(image[0])
|
||||
empty_img, empty_mask, empty_mask_img = self._empty_result(img_pil, background, background_color)
|
||||
final_images = pil2tensor(empty_img)
|
||||
final_masks = empty_mask
|
||||
final_mask_images = empty_mask_img
|
||||
|
||||
|
||||
return final_images, final_masks, final_mask_images
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SAM3Segment": SAM3Segment}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"SAM3Segment": "SAM3 Segmentation (RMBG)"}
|
||||
|
||||
|
||||
+404
-435
@@ -1,435 +1,404 @@
|
||||
# ComfyUI-RMBG
|
||||
# This custom node for ComfyUI provides functionality for Object removal using SDMatte model.
|
||||
#
|
||||
# reference from https://github.com/vivoCameraResearch/SDMatte
|
||||
# model: https://huggingface.co/1038lab/SDMatte
|
||||
#
|
||||
# This integration script follows GPL-3.0 License.
|
||||
# When using or modifying this code, please respect both the original model licenses
|
||||
# and this integration's license terms.
|
||||
#
|
||||
# Source: https://github.com/AILab-AI/ComfyUI-RMBG
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
if os.environ.get('SDMATTE_CPU_ONLY', '').lower() in ('1', 'true', 'yes'):
|
||||
os.environ['CUDA_VISIBLE_DEVICES'] = ''
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
from torch.hub import download_url_to_file
|
||||
from torchvision import transforms
|
||||
from torchvision.transforms import InterpolationMode
|
||||
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
COMFY_AVAILABLE = True
|
||||
except Exception as e:
|
||||
print(f"Warning: ComfyUI model management not available: {e}")
|
||||
COMFY_AVAILABLE = False
|
||||
class MockModelManagement:
|
||||
@staticmethod
|
||||
def get_torch_device():
|
||||
return torch.device('cpu')
|
||||
|
||||
class MockComfy:
|
||||
model_management = MockModelManagement()
|
||||
|
||||
comfy = MockComfy()
|
||||
|
||||
try:
|
||||
from safetensors.torch import load_file
|
||||
SAFETENSORS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SAFETENSORS_AVAILABLE = False
|
||||
print("Warning: safetensors not available. Will use torch.load for model loading.")
|
||||
|
||||
try:
|
||||
import diffusers
|
||||
import transformers
|
||||
DIFFUSERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
DIFFUSERS_AVAILABLE = False
|
||||
print("Warning: diffusers/transformers not available. SDMatte functionality will be limited.")
|
||||
|
||||
current_dir = Path(__file__).resolve().parent
|
||||
repo_root = current_dir.parent
|
||||
sdmatte_path = repo_root / "models" / "SDMatte"
|
||||
sys.path.insert(0, str(sdmatte_path))
|
||||
|
||||
SDMATTE_MODELS = {
|
||||
"SDMatte": {
|
||||
"model_url": "https://huggingface.co/1038lab/SDMatte/resolve/main/SDMatte.safetensors",
|
||||
"filename": "SDMatte.safetensors",
|
||||
"repo_id": "1038lab/SDMatte"
|
||||
},
|
||||
"SDMatte_plus": {
|
||||
"model_url": "https://huggingface.co/1038lab/SDMatte/resolve/main/SDMatte_plus.safetensors",
|
||||
"filename": "SDMatte_plus.safetensors",
|
||||
"repo_id": "1038lab/SDMatte"
|
||||
}
|
||||
}
|
||||
|
||||
REQUIRED_COMPONENTS = ["scheduler", "text_encoder", "tokenizer", "unet", "vae"]
|
||||
|
||||
def get_or_download_model_file(filename, url, dirname):
|
||||
local_path = folder_paths.get_full_path(dirname, filename)
|
||||
if local_path:
|
||||
return local_path
|
||||
folder = os.path.join(folder_paths.models_dir, dirname)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
local_path = os.path.join(folder, filename)
|
||||
if not os.path.exists(local_path):
|
||||
print(f"Downloading {filename} from {url} ...")
|
||||
try:
|
||||
download_url_to_file(url, local_path)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to download {filename} from {url}: {e}")
|
||||
return local_path
|
||||
|
||||
def ensure_model_components(model_name):
|
||||
model_info = SDMATTE_MODELS[model_name]
|
||||
repo_id = model_info["repo_id"]
|
||||
|
||||
components_dir = os.path.join(folder_paths.models_dir, "RMBG", "SDMatte")
|
||||
|
||||
missing_components = []
|
||||
for component in REQUIRED_COMPONENTS:
|
||||
component_path = os.path.join(components_dir, component)
|
||||
if not os.path.exists(component_path) or not os.listdir(component_path):
|
||||
missing_components.append(component)
|
||||
|
||||
if missing_components:
|
||||
print(f"Downloading missing SDMatte components: {missing_components}")
|
||||
base_url = f"https://huggingface.co/{repo_id}/resolve/main"
|
||||
|
||||
for component in missing_components:
|
||||
component_dir = os.path.join(components_dir, component)
|
||||
os.makedirs(component_dir, exist_ok=True)
|
||||
|
||||
if component == "scheduler":
|
||||
files = ["scheduler_config.json"]
|
||||
elif component == "text_encoder":
|
||||
files = ["config.json"]
|
||||
elif component == "tokenizer":
|
||||
files = ["merges.txt", "special_tokens_map.json", "tokenizer_config.json", "vocab.json"]
|
||||
elif component == "unet":
|
||||
files = ["config.json"]
|
||||
elif component == "vae":
|
||||
files = ["config.json"]
|
||||
|
||||
for file in files:
|
||||
file_url = f"{base_url}/{component}/{file}"
|
||||
file_path = os.path.join(component_dir, file)
|
||||
if not os.path.exists(file_path):
|
||||
try:
|
||||
print(f" Downloading {component}/{file}...")
|
||||
download_url_to_file(file_url, file_path)
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to download {file}: {e}")
|
||||
|
||||
return components_dir
|
||||
|
||||
def process_mask(mask_image: Image.Image, invert_output: bool = False,
|
||||
mask_blur: int = 0, mask_offset: int = 0) -> Image.Image:
|
||||
if invert_output:
|
||||
mask_np = np.array(mask_image)
|
||||
mask_image = Image.fromarray(255 - mask_np)
|
||||
if mask_blur > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=mask_blur))
|
||||
if mask_offset != 0:
|
||||
filter_type = ImageFilter.MaxFilter if mask_offset > 0 else ImageFilter.MinFilter
|
||||
size = abs(mask_offset) * 2 + 1
|
||||
for _ in range(abs(mask_offset)):
|
||||
mask_image = mask_image.filter(filter_type(size))
|
||||
return mask_image
|
||||
|
||||
def apply_background_color(image: Image.Image, mask_image: Image.Image,
|
||||
background: str = "Alpha",
|
||||
background_color: str = "#222222") -> Image.Image:
|
||||
rgba_image = image.copy().convert('RGBA')
|
||||
rgba_image.putalpha(mask_image.convert('L'))
|
||||
|
||||
if background == "Color":
|
||||
def hex_to_rgba(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
return (r, g, b, 255)
|
||||
rgba = hex_to_rgba(background_color)
|
||||
bg_image = Image.new('RGBA', image.size, rgba)
|
||||
composite_image = Image.alpha_composite(bg_image, rgba_image)
|
||||
return composite_image.convert('RGB')
|
||||
return rgba_image
|
||||
|
||||
def pil2tensor(image):
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
def tensor2pil(image):
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||||
|
||||
SDMatteCore = None
|
||||
|
||||
def _resize_norm_image_bchw(image_bchw: torch.Tensor, size_hw=(1024, 1024)) -> torch.Tensor:
|
||||
if image_bchw.shape[1] == 4:
|
||||
image_bchw = image_bchw[:, :3, :, :]
|
||||
|
||||
resize = transforms.Resize(size_hw, interpolation=InterpolationMode.BILINEAR, antialias=True)
|
||||
norm = transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5])
|
||||
x = resize(image_bchw)
|
||||
x = norm(x)
|
||||
return x
|
||||
|
||||
def _resize_mask_b1hw(mask_b1hw: torch.Tensor, size_hw=(1024, 1024)) -> torch.Tensor:
|
||||
resize = transforms.Resize(size_hw, interpolation=InterpolationMode.BILINEAR, antialias=True)
|
||||
return resize(mask_b1hw)
|
||||
|
||||
class AILab_SDMatte:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
tooltips = {
|
||||
"model": "SDMatte model variant: Standard or Plus version",
|
||||
"image": "Input image for matting extraction",
|
||||
"mask": "Mask: White=foreground, Black=background. If omitted and image has alpha, alpha will be used.",
|
||||
"process_res": "Processing resolution: higher = better quality but slower",
|
||||
"device": "Auto: smart detection, CPU: force CPU, GPU: force GPU",
|
||||
"transparent_object": "Whether input image contains transparent objects",
|
||||
"mask_refine": "Enable mask refinement using mask constraints",
|
||||
"sensitivity": "Sensitivity for mask constraint (0.1-1.0): higher = more strict",
|
||||
"mask_blur": "Blur mask edges (0 = disabled)",
|
||||
"mask_offset": "Expand/shrink mask (positive = expand)",
|
||||
"invert_output": "Invert the mask output",
|
||||
"background": "Background type for output",
|
||||
"background_color": "Background color (when not Alpha)",
|
||||
}
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"model": (list(SDMATTE_MODELS.keys()), {"default": "SDMatte", "tooltip": tooltips["model"]}),
|
||||
"device": (["Auto", "CPU", "GPU"], {"default": "Auto", "tooltip": tooltips["device"]}),
|
||||
"process_res": ("INT", {"default": 1024, "min": 256, "max": 2048, "step": 8, "tooltip": tooltips["process_res"]}),
|
||||
},
|
||||
"optional": {
|
||||
"mask": ("MASK", {"tooltip": tooltips["mask"]}),
|
||||
"transparent_object": ("BOOLEAN", {"default": True, "tooltip": tooltips["transparent_object"]}),
|
||||
"mask_refine": ("BOOLEAN", {"default": True, "tooltip": tooltips["mask_refine"]}),
|
||||
"sensitivity": ("FLOAT", {"default": 0.9, "min": 0.1, "max": 1.0, "step": 0.1, "tooltip": tooltips["sensitivity"]}),
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
|
||||
RETURN_NAMES = ("IMAGE", "MASK", "MASK_IMAGE")
|
||||
FUNCTION = "matting_inference"
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
|
||||
def __init__(self):
|
||||
self.model_cache = {}
|
||||
|
||||
def load_sdmatte_model(self, model_name, device="Auto"):
|
||||
cache_key = f"{model_name}_{device}"
|
||||
|
||||
current_model_keys = [k for k in self.model_cache.keys() if k.startswith(model_name)]
|
||||
if cache_key not in self.model_cache and len(self.model_cache) > 0:
|
||||
for key in list(self.model_cache.keys()):
|
||||
if key not in current_model_keys:
|
||||
del self.model_cache[key]
|
||||
import gc
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
if cache_key not in self.model_cache:
|
||||
if not DIFFUSERS_AVAILABLE:
|
||||
raise ImportError("diffusers and transformers are required for SDMatte functionality")
|
||||
|
||||
global SDMatteCore
|
||||
if SDMatteCore is None:
|
||||
import sys
|
||||
import os
|
||||
current_dir = os.path.dirname(__file__)
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
from SDMatte.modeling.SDMatte.meta_arch import SDMatte as SDMatteCore
|
||||
|
||||
model_info = SDMATTE_MODELS[model_name]
|
||||
|
||||
model_path = get_or_download_model_file(
|
||||
model_info["filename"],
|
||||
model_info["model_url"],
|
||||
"RMBG/SDMatte"
|
||||
)
|
||||
|
||||
pretrained_repo = ensure_model_components(model_name)
|
||||
|
||||
sdmatte_model = SDMatteCore(
|
||||
pretrained_model_name_or_path=pretrained_repo,
|
||||
load_weight=False,
|
||||
use_aux_input=True,
|
||||
aux_input="trimap",
|
||||
use_encoder_hidden_states=True,
|
||||
use_attention_mask=True,
|
||||
add_noise=False,
|
||||
)
|
||||
|
||||
self._load_model_weights(sdmatte_model, model_path)
|
||||
|
||||
device_obj = comfy.model_management.get_torch_device()
|
||||
if device == "CPU":
|
||||
device_obj = torch.device('cpu')
|
||||
elif device == "GPU":
|
||||
if not torch.cuda.is_available():
|
||||
print("SDMatte: GPU requested but CUDA not available, falling back to CPU")
|
||||
device_obj = torch.device('cpu')
|
||||
else:
|
||||
device_obj = comfy.model_management.get_torch_device()
|
||||
|
||||
sdmatte_model.eval()
|
||||
sdmatte_model.to(device_obj)
|
||||
|
||||
if device_obj.type == 'cuda':
|
||||
self._apply_memory_optimizations(sdmatte_model)
|
||||
|
||||
self.model_cache[cache_key] = sdmatte_model
|
||||
|
||||
return self.model_cache[cache_key]
|
||||
|
||||
def _load_model_weights(self, model, model_path):
|
||||
if not SAFETENSORS_AVAILABLE:
|
||||
raise ImportError("safetensors is required for SDMatte functionality")
|
||||
|
||||
try:
|
||||
state_dict = load_file(model_path)
|
||||
model.load_state_dict(state_dict, strict=False)
|
||||
except Exception as e:
|
||||
if os.path.exists(model_path):
|
||||
print(f"[SDMatte] Model file appears corrupted, deleting: {model_path}")
|
||||
os.remove(model_path)
|
||||
raise RuntimeError(f"Failed to load model weights. File may be corrupted. Please try again to re-download. Error: {e}")
|
||||
|
||||
def _apply_memory_optimizations(self, model):
|
||||
try:
|
||||
torch.cuda.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
unet = getattr(model, 'unet', None)
|
||||
if unet is not None and hasattr(unet, 'set_attn_processor'):
|
||||
from diffusers.models.attention_processor import SlicedAttnProcessor
|
||||
unet.set_attn_processor(SlicedAttnProcessor(slice_size=1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def matting_inference(self, image, model, process_res, device="Auto",
|
||||
mask=None, transparent_object=True, mask_refine=True,
|
||||
sensitivity=0.8, mask_blur=0, mask_offset=0,
|
||||
invert_output=False, background="Alpha", background_color="#222222"):
|
||||
sdmatte_model = self.load_sdmatte_model(model, device)
|
||||
device_obj = comfy.model_management.get_torch_device()
|
||||
if device == "CPU":
|
||||
device_obj = torch.device('cpu')
|
||||
|
||||
batch_size = image.shape[0]
|
||||
|
||||
result_masks = []
|
||||
result_images = []
|
||||
result_mask_images = []
|
||||
|
||||
for b in range(batch_size):
|
||||
img_pil = tensor2pil(image[b])
|
||||
B, H, W = 1, img_pil.height, img_pil.width
|
||||
orig_h, orig_w = H, W
|
||||
|
||||
img_bchw = image[b:b+1].permute(0, 3, 1, 2).contiguous().to(device_obj)
|
||||
img_in = _resize_norm_image_bchw(img_bchw, (int(process_res), int(process_res)))
|
||||
|
||||
if mask is not None:
|
||||
mask_b1hw = mask[b:b+1].unsqueeze(1).contiguous().to(device_obj)
|
||||
mask_for_refine = mask[b:b+1]
|
||||
else:
|
||||
if image.shape[-1] == 4:
|
||||
alpha = image[b, :, :, 3]
|
||||
mask_b1hw = alpha.unsqueeze(0).unsqueeze(0).contiguous().to(device_obj)
|
||||
mask_for_refine = alpha.unsqueeze(0)
|
||||
else:
|
||||
raise ValueError("Mask required: provide a mask or use an image with alpha.")
|
||||
|
||||
tri = _resize_mask_b1hw(mask_b1hw, (int(process_res), int(process_res))) * 2 - 1
|
||||
data = {"image": img_in,
|
||||
"is_trans": torch.tensor([1 if transparent_object else 0], device=device_obj),
|
||||
"caption": [""],
|
||||
"trimap": tri,
|
||||
"trimap_coords": torch.tensor([[0,0,1,1]], dtype=tri.dtype, device=device_obj)}
|
||||
|
||||
with torch.inference_mode():
|
||||
if device_obj.type == 'cuda':
|
||||
with torch.autocast(device_type='cuda', dtype=torch.float16):
|
||||
pred_alpha = sdmatte_model(data)
|
||||
else:
|
||||
pred_alpha = sdmatte_model(data)
|
||||
|
||||
out = transforms.Resize((orig_h, orig_w), interpolation=InterpolationMode.BILINEAR, antialias=True)(pred_alpha)
|
||||
out = out.squeeze(1).clamp(0, 1).detach().cpu()
|
||||
|
||||
if mask_refine:
|
||||
out = self._refine_mask(out, mask_for_refine, sensitivity)
|
||||
|
||||
mask_pil = Image.fromarray((out[0].numpy() * 255).astype(np.uint8), mode="L")
|
||||
|
||||
mask_image = process_mask(mask_pil, invert_output, mask_blur, mask_offset)
|
||||
|
||||
result_image = apply_background_color(img_pil, mask_image, background, background_color)
|
||||
if background == "Color":
|
||||
result_image = result_image.convert("RGB")
|
||||
else:
|
||||
result_image = result_image.convert("RGBA")
|
||||
|
||||
mask_tensor = torch.from_numpy(np.array(mask_image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
mask_image_vis = mask_tensor.reshape((-1, 1, mask_image.height, mask_image.width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
|
||||
result_masks.append(mask_tensor)
|
||||
result_images.append(pil2tensor(result_image))
|
||||
result_mask_images.append(mask_image_vis)
|
||||
|
||||
if device_obj.type == 'cuda':
|
||||
torch.cuda.empty_cache()
|
||||
import gc
|
||||
gc.collect()
|
||||
|
||||
return (torch.cat(result_images, dim=0), torch.cat(result_masks, dim=0), torch.cat(result_mask_images, dim=0))
|
||||
|
||||
def _refine_mask(self, mask, trimap, constraint):
|
||||
trimap_cpu = trimap.cpu()
|
||||
foreground_regions = trimap_cpu > constraint
|
||||
background_regions = trimap_cpu < (1.0 - constraint)
|
||||
unknown_regions = ~(foreground_regions | background_regions)
|
||||
|
||||
refined_mask = mask.clone()
|
||||
refined_mask[background_regions] = 0.0
|
||||
refined_mask[foreground_regions] = torch.clamp(refined_mask[foreground_regions] * 1.2, 0, 1)
|
||||
|
||||
alpha_threshold = 0.3
|
||||
low_confidence = (refined_mask < alpha_threshold) & unknown_regions
|
||||
refined_mask[low_confidence] = 0.0
|
||||
|
||||
return refined_mask
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AILab_SDMatte": AILab_SDMatte,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AILab_SDMatte": "SDMatte Matting (RMBG)",
|
||||
}
|
||||
# ComfyUI-RMBG
|
||||
# This custom node for ComfyUI provides functionality for Object removal using SDMatte model.
|
||||
#
|
||||
# reference from https://github.com/vivoCameraResearch/SDMatte
|
||||
# model: https://huggingface.co/1038lab/SDMatte
|
||||
#
|
||||
# This integration script follows GPL-3.0 License.
|
||||
# When using or modifying this code, please respect both the original model licenses
|
||||
# and this integration's license terms.
|
||||
#
|
||||
# Source: https://github.com/AILab-AI/ComfyUI-RMBG
|
||||
|
||||
|
||||
import os
|
||||
import sys
|
||||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
if os.environ.get('SDMATTE_CPU_ONLY', '').lower() in ('1', 'true', 'yes'):
|
||||
os.environ['CUDA_VISIBLE_DEVICES'] = ''
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
import numpy as np
|
||||
from PIL import Image, ImageFilter
|
||||
from torch.hub import download_url_to_file
|
||||
from torchvision import transforms
|
||||
from torchvision.transforms import InterpolationMode
|
||||
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
import comfy.model_management
|
||||
COMFY_AVAILABLE = True
|
||||
except Exception as e:
|
||||
print(f"Warning: ComfyUI model management not available: {e}")
|
||||
COMFY_AVAILABLE = False
|
||||
class MockModelManagement:
|
||||
@staticmethod
|
||||
def get_torch_device():
|
||||
return torch.device('cpu')
|
||||
|
||||
class MockComfy:
|
||||
model_management = MockModelManagement()
|
||||
|
||||
comfy = MockComfy()
|
||||
|
||||
try:
|
||||
from safetensors.torch import load_file
|
||||
SAFETENSORS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SAFETENSORS_AVAILABLE = False
|
||||
print("Warning: safetensors not available. Will use torch.load for model loading.")
|
||||
|
||||
# Lazy: diffusers/transformers availability is checked on first use to avoid ~3.7s import at startup
|
||||
DIFFUSERS_AVAILABLE = None # Tri-state: None = not yet checked
|
||||
|
||||
from AILab_utils import get_device, clean_vram, unload_model, get_or_download_model_file, process_mask, apply_background_color, pil2tensor, tensor2pil
|
||||
|
||||
current_dir = Path(__file__).resolve().parent
|
||||
repo_root = current_dir.parent
|
||||
sdmatte_path = repo_root / "models" / "SDMatte"
|
||||
sys.path.insert(0, str(sdmatte_path))
|
||||
|
||||
SDMATTE_MODELS = {
|
||||
"SDMatte": {
|
||||
"model_url": "https://huggingface.co/1038lab/SDMatte/resolve/main/SDMatte.safetensors",
|
||||
"filename": "SDMatte.safetensors",
|
||||
"repo_id": "1038lab/SDMatte"
|
||||
},
|
||||
"SDMatte_plus": {
|
||||
"model_url": "https://huggingface.co/1038lab/SDMatte/resolve/main/SDMatte_plus.safetensors",
|
||||
"filename": "SDMatte_plus.safetensors",
|
||||
"repo_id": "1038lab/SDMatte"
|
||||
}
|
||||
}
|
||||
|
||||
REQUIRED_COMPONENTS = ["scheduler", "text_encoder", "tokenizer", "unet", "vae"]
|
||||
|
||||
def ensure_model_components(model_name):
|
||||
model_info = SDMATTE_MODELS[model_name]
|
||||
repo_id = model_info["repo_id"]
|
||||
|
||||
components_dir = os.path.join(folder_paths.models_dir, "RMBG", "SDMatte")
|
||||
|
||||
missing_components = []
|
||||
for component in REQUIRED_COMPONENTS:
|
||||
component_path = os.path.join(components_dir, component)
|
||||
if not os.path.exists(component_path) or not os.listdir(component_path):
|
||||
missing_components.append(component)
|
||||
|
||||
if missing_components:
|
||||
print(f"Downloading missing SDMatte components: {missing_components}")
|
||||
base_url = f"https://huggingface.co/{repo_id}/resolve/main"
|
||||
|
||||
for component in missing_components:
|
||||
component_dir = os.path.join(components_dir, component)
|
||||
os.makedirs(component_dir, exist_ok=True)
|
||||
|
||||
if component == "scheduler":
|
||||
files = ["scheduler_config.json"]
|
||||
elif component == "text_encoder":
|
||||
files = ["config.json"]
|
||||
elif component == "tokenizer":
|
||||
files = ["merges.txt", "special_tokens_map.json", "tokenizer_config.json", "vocab.json"]
|
||||
elif component == "unet":
|
||||
files = ["config.json"]
|
||||
elif component == "vae":
|
||||
files = ["config.json"]
|
||||
|
||||
for file in files:
|
||||
file_url = f"{base_url}/{component}/{file}"
|
||||
file_path = os.path.join(component_dir, file)
|
||||
if not os.path.exists(file_path):
|
||||
try:
|
||||
print(f" Downloading {component}/{file}...")
|
||||
download_url_to_file(file_url, file_path)
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to download {file}: {e}")
|
||||
|
||||
return components_dir
|
||||
|
||||
SDMatteCore = None
|
||||
|
||||
def _resize_norm_image_bchw(image_bchw: torch.Tensor, size_hw=(1024, 1024)) -> torch.Tensor:
|
||||
if image_bchw.shape[1] == 4:
|
||||
image_bchw = image_bchw[:, :3, :, :]
|
||||
|
||||
resize = transforms.Resize(size_hw, interpolation=InterpolationMode.BILINEAR, antialias=True)
|
||||
norm = transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5])
|
||||
x = resize(image_bchw)
|
||||
x = norm(x)
|
||||
return x
|
||||
|
||||
def _resize_mask_b1hw(mask_b1hw: torch.Tensor, size_hw=(1024, 1024)) -> torch.Tensor:
|
||||
resize = transforms.Resize(size_hw, interpolation=InterpolationMode.BILINEAR, antialias=True)
|
||||
return resize(mask_b1hw)
|
||||
|
||||
class AILab_SDMatte:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
tooltips = {
|
||||
"model": "SDMatte model variant: Standard or Plus version",
|
||||
"image": "Input image for matting extraction",
|
||||
"mask": "Mask: White=foreground, Black=background. If omitted and image has alpha, alpha will be used.",
|
||||
"process_res": "Processing resolution: higher = better quality but slower",
|
||||
"device": "Auto: smart detection, CPU: force CPU, GPU: force GPU",
|
||||
"transparent_object": "Whether input image contains transparent objects",
|
||||
"mask_refine": "Enable mask refinement using mask constraints",
|
||||
"sensitivity": "Sensitivity for mask constraint (0.1-1.0): higher = more strict",
|
||||
"mask_blur": "Blur mask edges (0 = disabled)",
|
||||
"mask_offset": "Expand/shrink mask (positive = expand)",
|
||||
"invert_output": "Invert the mask output",
|
||||
"background": "Background type for output",
|
||||
"background_color": "Background color (when not Alpha)",
|
||||
}
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"model": (list(SDMATTE_MODELS.keys()), {"default": "SDMatte", "tooltip": tooltips["model"]}),
|
||||
"device": (["Auto", "CPU", "GPU"], {"default": "Auto", "tooltip": tooltips["device"]}),
|
||||
"process_res": ("INT", {"default": 1024, "min": 256, "max": 2048, "step": 8, "tooltip": tooltips["process_res"]}),
|
||||
},
|
||||
"optional": {
|
||||
"mask": ("MASK", {"tooltip": tooltips["mask"]}),
|
||||
"transparent_object": ("BOOLEAN", {"default": True, "tooltip": tooltips["transparent_object"]}),
|
||||
"mask_refine": ("BOOLEAN", {"default": True, "tooltip": tooltips["mask_refine"]}),
|
||||
"sensitivity": ("FLOAT", {"default": 0.9, "min": 0.1, "max": 1.0, "step": 0.1, "tooltip": tooltips["sensitivity"]}),
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE")
|
||||
RETURN_NAMES = ("IMAGE", "MASK", "MASK_IMAGE")
|
||||
FUNCTION = "matting_inference"
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
|
||||
def __init__(self):
|
||||
self.model_cache = {}
|
||||
|
||||
def load_sdmatte_model(self, model_name, device="Auto"):
|
||||
cache_key = f"{model_name}_{device}"
|
||||
|
||||
current_model_keys = [k for k in self.model_cache.keys() if k.startswith(model_name)]
|
||||
if cache_key not in self.model_cache and len(self.model_cache) > 0:
|
||||
for key in list(self.model_cache.keys()):
|
||||
if key not in current_model_keys:
|
||||
del self.model_cache[key]
|
||||
import gc
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
if cache_key not in self.model_cache:
|
||||
global DIFFUSERS_AVAILABLE
|
||||
if DIFFUSERS_AVAILABLE is None:
|
||||
try:
|
||||
import diffusers # noqa: F401
|
||||
import transformers # noqa: F401
|
||||
DIFFUSERS_AVAILABLE = True
|
||||
except ImportError:
|
||||
DIFFUSERS_AVAILABLE = False
|
||||
if not DIFFUSERS_AVAILABLE:
|
||||
raise ImportError("diffusers and transformers are required for SDMatte functionality")
|
||||
|
||||
global SDMatteCore
|
||||
if SDMatteCore is None:
|
||||
import sys
|
||||
import os
|
||||
current_dir = os.path.dirname(__file__)
|
||||
if current_dir not in sys.path:
|
||||
sys.path.insert(0, current_dir)
|
||||
from SDMatte.modeling.SDMatte.meta_arch import SDMatte as SDMatteCore
|
||||
|
||||
model_info = SDMATTE_MODELS[model_name]
|
||||
|
||||
model_path = get_or_download_model_file(
|
||||
model_info["filename"],
|
||||
model_info["model_url"],
|
||||
"RMBG/SDMatte"
|
||||
)
|
||||
|
||||
pretrained_repo = ensure_model_components(model_name)
|
||||
|
||||
sdmatte_model = SDMatteCore(
|
||||
pretrained_model_name_or_path=pretrained_repo,
|
||||
load_weight=False,
|
||||
use_aux_input=True,
|
||||
aux_input="trimap",
|
||||
use_encoder_hidden_states=True,
|
||||
use_attention_mask=True,
|
||||
add_noise=False,
|
||||
)
|
||||
|
||||
self._load_model_weights(sdmatte_model, model_path)
|
||||
|
||||
device_obj = comfy.model_management.get_torch_device()
|
||||
if device == "CPU":
|
||||
device_obj = torch.device('cpu')
|
||||
elif device == "GPU":
|
||||
if not torch.cuda.is_available():
|
||||
print("SDMatte: GPU requested but CUDA not available, falling back to CPU")
|
||||
device_obj = torch.device('cpu')
|
||||
else:
|
||||
device_obj = comfy.model_management.get_torch_device()
|
||||
|
||||
sdmatte_model.eval()
|
||||
sdmatte_model.to(device_obj)
|
||||
|
||||
if device_obj.type == 'cuda':
|
||||
self._apply_memory_optimizations(sdmatte_model)
|
||||
|
||||
self.model_cache[cache_key] = sdmatte_model
|
||||
|
||||
return self.model_cache[cache_key]
|
||||
|
||||
def _load_model_weights(self, model, model_path):
|
||||
if not SAFETENSORS_AVAILABLE:
|
||||
raise ImportError("safetensors is required for SDMatte functionality")
|
||||
|
||||
try:
|
||||
state_dict = load_file(model_path)
|
||||
model.load_state_dict(state_dict, strict=False)
|
||||
except Exception as e:
|
||||
if os.path.exists(model_path):
|
||||
print(f"[SDMatte] Model file appears corrupted, deleting: {model_path}")
|
||||
os.remove(model_path)
|
||||
raise RuntimeError(f"Failed to load model weights. File may be corrupted. Please try again to re-download. Error: {e}")
|
||||
|
||||
def _apply_memory_optimizations(self, model):
|
||||
try:
|
||||
torch.cuda.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
unet = getattr(model, 'unet', None)
|
||||
if unet is not None and hasattr(unet, 'set_attn_processor'):
|
||||
from diffusers.models.attention_processor import SlicedAttnProcessor
|
||||
unet.set_attn_processor(SlicedAttnProcessor(slice_size=1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def matting_inference(self, image, model, process_res, device="Auto",
|
||||
mask=None, transparent_object=True, mask_refine=True,
|
||||
sensitivity=0.8, mask_blur=0, mask_offset=0,
|
||||
invert_output=False, unload_model=False, background="Alpha", background_color="#222222"):
|
||||
if device == "CPU":
|
||||
device_obj = torch.device('cpu')
|
||||
elif device == "GPU":
|
||||
device_obj = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
||||
else:
|
||||
device_obj = get_device()
|
||||
|
||||
sdmatte_model = self.load_sdmatte_model(model, device)
|
||||
|
||||
batch_size = image.shape[0]
|
||||
|
||||
result_masks = []
|
||||
result_images = []
|
||||
result_mask_images = []
|
||||
|
||||
try:
|
||||
for b in range(batch_size):
|
||||
img_pil = tensor2pil(image[b])
|
||||
B, H, W = 1, img_pil.height, img_pil.width
|
||||
orig_h, orig_w = H, W
|
||||
|
||||
img_bchw = image[b:b+1].permute(0, 3, 1, 2).contiguous().to(device_obj)
|
||||
img_in = _resize_norm_image_bchw(img_bchw, (int(process_res), int(process_res)))
|
||||
|
||||
if mask is not None:
|
||||
if mask.ndim == 2:
|
||||
mask_single = mask.unsqueeze(0).unsqueeze(0)
|
||||
elif mask.ndim == 3:
|
||||
idx = min(b, mask.shape[0] - 1)
|
||||
mask_single = mask[idx:idx+1].unsqueeze(1)
|
||||
elif mask.ndim == 4:
|
||||
idx = min(b, mask.shape[0] - 1)
|
||||
mask_single = mask[idx:idx+1]
|
||||
else:
|
||||
mask_single = mask.view(1, 1, H, W)
|
||||
mask_b1hw = mask_single.contiguous().to(device_obj)
|
||||
mask_for_refine = mask_b1hw.squeeze(1).cpu()
|
||||
elif image.shape[-1] == 4:
|
||||
alpha = image[b, :, :, 3]
|
||||
mask_b1hw = alpha.unsqueeze(0).unsqueeze(0).contiguous().to(device_obj)
|
||||
mask_for_refine = alpha.unsqueeze(0).cpu()
|
||||
else:
|
||||
print("[SDMatte INFO] No input mask provided; using full foreground mask as fallback. For best results, connect a MASK from RMBG/BiRefNet/SAM node.")
|
||||
mask_b1hw = torch.ones((1, 1, H, W), dtype=torch.float32, device=device_obj)
|
||||
mask_for_refine = torch.ones((1, H, W), dtype=torch.float32)
|
||||
|
||||
tri = _resize_mask_b1hw(mask_b1hw, (int(process_res), int(process_res))) * 2 - 1
|
||||
data = {"image": img_in,
|
||||
"is_trans": torch.tensor([1 if transparent_object else 0], device=device_obj),
|
||||
"caption": [""],
|
||||
"trimap": tri,
|
||||
"trimap_coords": torch.tensor([[0,0,1,1]], dtype=tri.dtype, device=device_obj)}
|
||||
|
||||
with torch.inference_mode():
|
||||
if device_obj.type == 'cuda':
|
||||
with torch.autocast(device_type='cuda', dtype=torch.float16):
|
||||
pred_alpha = sdmatte_model(data)
|
||||
else:
|
||||
pred_alpha = sdmatte_model(data)
|
||||
|
||||
out = transforms.Resize((orig_h, orig_w), interpolation=InterpolationMode.BILINEAR, antialias=True)(pred_alpha)
|
||||
out = out.squeeze(1).clamp(0, 1).detach().cpu()
|
||||
|
||||
if mask_refine:
|
||||
out = self._refine_mask(out, mask_for_refine, sensitivity)
|
||||
|
||||
mask_pil = Image.fromarray((out[0].numpy() * 255).astype(np.uint8), mode="L")
|
||||
|
||||
mask_image = process_mask(mask_pil, invert_output, mask_blur, mask_offset)
|
||||
|
||||
result_image = apply_background_color(img_pil, mask_image, background, background_color)
|
||||
if background == "Color":
|
||||
result_image = result_image.convert("RGB")
|
||||
else:
|
||||
result_image = result_image.convert("RGBA")
|
||||
|
||||
mask_tensor = torch.from_numpy(np.array(mask_image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
mask_image_vis = mask_tensor.reshape((-1, 1, mask_image.height, mask_image.width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
|
||||
result_masks.append(mask_tensor)
|
||||
result_images.append(pil2tensor(result_image))
|
||||
result_mask_images.append(mask_image_vis)
|
||||
|
||||
return (torch.cat(result_images, dim=0), torch.cat(result_masks, dim=0), torch.cat(result_mask_images, dim=0))
|
||||
finally:
|
||||
if unload_model:
|
||||
self.model_cache.clear()
|
||||
clean_vram()
|
||||
|
||||
def _refine_mask(self, mask, trimap, constraint):
|
||||
trimap_cpu = trimap.cpu()
|
||||
foreground_regions = trimap_cpu > constraint
|
||||
background_regions = trimap_cpu < (1.0 - constraint)
|
||||
unknown_regions = ~(foreground_regions | background_regions)
|
||||
|
||||
refined_mask = mask.clone()
|
||||
refined_mask[background_regions] = 0.0
|
||||
refined_mask[foreground_regions] = torch.clamp(refined_mask[foreground_regions] * 1.2, 0, 1)
|
||||
|
||||
alpha_threshold = 0.3
|
||||
low_confidence = (refined_mask < alpha_threshold) & unknown_regions
|
||||
refined_mask[low_confidence] = 0.0
|
||||
|
||||
return refined_mask
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AILab_SDMatte": AILab_SDMatte,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AILab_SDMatte": "SDMatte Matting (RMBG)",
|
||||
}
|
||||
|
||||
+56
-96
@@ -28,6 +28,7 @@ from torch.hub import download_url_to_file
|
||||
import folder_paths
|
||||
import comfy.model_management
|
||||
from segment_anything import sam_model_registry, SamPredictor
|
||||
from AILab_utils import get_device, patch_transformers_bert, clean_vram, unload_model, process_mask, apply_background_color, image2mask, pil2tensor, tensor2pil
|
||||
|
||||
SAM_MODELS = {
|
||||
"sam_vit_h (2.56GB)": {
|
||||
@@ -95,55 +96,6 @@ def split_image_mask(image):
|
||||
mask = torch.zeros((image.height, image.width), dtype=torch.float32, device="cpu")[None,]
|
||||
return (image_rgb, mask)
|
||||
|
||||
def process_mask(mask_image: Image.Image, invert_output: bool = False,
|
||||
mask_blur: int = 0, mask_offset: int = 0) -> Image.Image:
|
||||
if invert_output:
|
||||
mask_np = np.array(mask_image)
|
||||
mask_image = Image.fromarray(255 - mask_np)
|
||||
|
||||
if mask_blur > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=mask_blur))
|
||||
|
||||
if mask_offset != 0:
|
||||
filter_type = ImageFilter.MaxFilter if mask_offset > 0 else ImageFilter.MinFilter
|
||||
size = abs(mask_offset) * 2 + 1
|
||||
for _ in range(abs(mask_offset)):
|
||||
mask_image = mask_image.filter(filter_type(size))
|
||||
|
||||
return mask_image
|
||||
|
||||
def pil2tensor(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)[None,]
|
||||
|
||||
def tensor2pil(image: torch.Tensor) -> Image.Image:
|
||||
return Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8))
|
||||
|
||||
def image2mask(image: Image.Image) -> torch.Tensor:
|
||||
if isinstance(image, Image.Image):
|
||||
if image.mode != 'L':
|
||||
image = image.convert('L')
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0)
|
||||
return image.squeeze()
|
||||
|
||||
def apply_background_color(image: Image.Image, mask_image: Image.Image,
|
||||
background: str = "Alpha",
|
||||
background_color: str = "#222222") -> Image.Image:
|
||||
rgba_image = image.copy().convert('RGBA')
|
||||
rgba_image.putalpha(mask_image.convert('L'))
|
||||
|
||||
if background == "Color":
|
||||
def hex_to_rgba(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
return (r, g, b, 255)
|
||||
|
||||
rgba = hex_to_rgba(background_color)
|
||||
bg_image = Image.new('RGBA', image.size, rgba)
|
||||
composite_image = Image.alpha_composite(bg_image, rgba_image)
|
||||
return composite_image.convert('RGB')
|
||||
|
||||
return rgba_image
|
||||
|
||||
class Segment:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -168,6 +120,7 @@ class Segment:
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
}
|
||||
@@ -192,55 +145,61 @@ class Segment:
|
||||
self._dino_model_cache = {}
|
||||
|
||||
def segment(self, image, prompt, sam_model, dino_model, threshold=0.35,
|
||||
mask_blur=0, mask_offset=0, background="Alpha",
|
||||
background_color="#222222", invert_output=False):
|
||||
print(f'Processing create segment for: "{prompt}"...')
|
||||
|
||||
image = Image.fromarray(np.clip(255. * image[0].cpu().numpy(), 0, 255).astype(np.uint8)).convert('RGBA')
|
||||
dino_model = self.load_groundingdino(dino_model)
|
||||
sam_model = self.load_sam(sam_model)
|
||||
boxes = self.predict_boxes(dino_model, image, prompt, threshold)
|
||||
|
||||
if boxes is None or boxes.shape[0] == 0:
|
||||
print(f'No objects found for: "{prompt}"')
|
||||
width, height = image.size
|
||||
empty_mask = torch.zeros((1, height, width), dtype=torch.uint8, device="cpu")
|
||||
# Create empty RGB mask for visualization
|
||||
empty_mask_rgb = empty_mask.reshape((-1, 1, height, width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
return (pil2tensor(image), empty_mask, empty_mask_rgb)
|
||||
|
||||
masks = self.generate_masks(sam_model, image, boxes)
|
||||
if masks is None:
|
||||
print(f'Failed to generate mask for: "{prompt}"')
|
||||
width, height = image.size
|
||||
empty_mask = torch.zeros((1, height, width), dtype=torch.uint8, device="cpu")
|
||||
# Create empty RGB mask for visualization
|
||||
empty_mask_rgb = empty_mask.reshape((-1, 1, height, width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
return (pil2tensor(image), empty_mask, empty_mask_rgb)
|
||||
|
||||
mask_image = Image.fromarray((masks[1][0].numpy() * 255).astype(np.uint8))
|
||||
mask_image = process_mask(mask_image, invert_output, mask_blur, mask_offset)
|
||||
|
||||
result_image = apply_background_color(image, mask_image, background, background_color)
|
||||
|
||||
if background == "Color":
|
||||
result_image = result_image.convert("RGB")
|
||||
else:
|
||||
result_image = result_image.convert("RGBA")
|
||||
mask_blur=0, mask_offset=0, invert_output=False, unload_model=False,
|
||||
background="Alpha", background_color="#222222"):
|
||||
try:
|
||||
print(f'Processing create segment for: "{prompt}"...')
|
||||
|
||||
mask_tensor = image2mask(mask_image).unsqueeze(0)
|
||||
image = Image.fromarray(np.clip(255. * image[0].cpu().numpy(), 0, 255).astype(np.uint8)).convert('RGBA')
|
||||
dino_model = self.load_groundingdino(dino_model)
|
||||
sam_model = self.load_sam(sam_model)
|
||||
boxes = self.predict_boxes(dino_model, image, prompt, threshold)
|
||||
|
||||
if boxes is None or boxes.shape[0] == 0:
|
||||
print(f'No objects found for: "{prompt}"')
|
||||
width, height = image.size
|
||||
empty_mask = torch.zeros((1, height, width), dtype=torch.uint8, device="cpu")
|
||||
# Create empty RGB mask for visualization
|
||||
empty_mask_rgb = empty_mask.reshape((-1, 1, height, width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
return (pil2tensor(image), empty_mask, empty_mask_rgb)
|
||||
|
||||
masks = self.generate_masks(sam_model, image, boxes)
|
||||
if masks is None:
|
||||
print(f'Failed to generate mask for: "{prompt}"')
|
||||
width, height = image.size
|
||||
empty_mask = torch.zeros((1, height, width), dtype=torch.uint8, device="cpu")
|
||||
# Create empty RGB mask for visualization
|
||||
empty_mask_rgb = empty_mask.reshape((-1, 1, height, width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
return (pil2tensor(image), empty_mask, empty_mask_rgb)
|
||||
|
||||
print(f'Successfully created segment for: "{prompt}"')
|
||||
|
||||
# Create mask image for visualization (similar to other nodes)
|
||||
mask_images = []
|
||||
# Convert mask to RGB image format for visualization
|
||||
mask_image_vis = mask_tensor.reshape((-1, 1, mask_image.height, mask_image.width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
mask_images.append(mask_image_vis)
|
||||
|
||||
mask_image_output = torch.cat(mask_images, dim=0)
|
||||
|
||||
return (pil2tensor(result_image), mask_tensor, mask_image_output)
|
||||
mask_image = Image.fromarray((masks[1][0].numpy() * 255).astype(np.uint8))
|
||||
mask_image = process_mask(mask_image, invert_output, mask_blur, mask_offset)
|
||||
|
||||
result_image = apply_background_color(image, mask_image, background, background_color)
|
||||
|
||||
if background == "Color":
|
||||
result_image = result_image.convert("RGB")
|
||||
else:
|
||||
result_image = result_image.convert("RGBA")
|
||||
|
||||
mask_tensor = image2mask(mask_image).unsqueeze(0)
|
||||
|
||||
print(f'Successfully created segment for: "{prompt}"')
|
||||
|
||||
# Create mask image for visualization (similar to other nodes)
|
||||
mask_images = []
|
||||
# Convert mask to RGB image format for visualization
|
||||
mask_image_vis = mask_tensor.reshape((-1, 1, mask_image.height, mask_image.width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
mask_images.append(mask_image_vis)
|
||||
|
||||
mask_image_output = torch.cat(mask_images, dim=0)
|
||||
|
||||
return (pil2tensor(result_image), mask_tensor, mask_image_output)
|
||||
finally:
|
||||
if unload_model:
|
||||
self._sam_model_cache.clear()
|
||||
self._dino_model_cache.clear()
|
||||
clean_vram()
|
||||
|
||||
def load_sam(self, model_name):
|
||||
if model_name in self._sam_model_cache:
|
||||
@@ -275,6 +234,7 @@ class Segment:
|
||||
"grounding-dino"
|
||||
)
|
||||
)
|
||||
patch_transformers_bert()
|
||||
dino = self.build_model(dino_model_args)
|
||||
checkpoint = torch.load(
|
||||
self.get_local_filepath(
|
||||
|
||||
+26
-58
@@ -8,14 +8,9 @@ from torch.hub import download_url_to_file
|
||||
|
||||
import folder_paths
|
||||
from segment_anything import sam_model_registry, SamPredictor
|
||||
from groundingdino.util.slconfig import SLConfig
|
||||
from groundingdino.models import build_model
|
||||
from groundingdino.util.utils import clean_state_dict
|
||||
from groundingdino.util import box_ops
|
||||
from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection
|
||||
|
||||
from AILab_ImageMaskTools import pil2tensor, tensor2pil
|
||||
from AILab_utils import get_device
|
||||
from AILab_utils import get_device, patch_transformers_bert, clean_vram, unload_model, get_or_download_model_file, process_mask, apply_background_color
|
||||
|
||||
SAM_MODELS = {
|
||||
"sam_vit_h (2.56GB)": {
|
||||
@@ -65,49 +60,8 @@ DINO_MODELS = {
|
||||
}
|
||||
}
|
||||
|
||||
def get_or_download_model_file(filename, url, dirname):
|
||||
local_path = folder_paths.get_full_path(dirname, filename)
|
||||
if local_path:
|
||||
return local_path
|
||||
folder = os.path.join(folder_paths.models_dir, dirname)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
local_path = os.path.join(folder, filename)
|
||||
if not os.path.exists(local_path):
|
||||
print(f"Downloading {filename} from {url} ...")
|
||||
download_url_to_file(url, local_path)
|
||||
return local_path
|
||||
|
||||
def process_mask(mask_image: Image.Image, invert_output: bool = False,
|
||||
mask_blur: int = 0, mask_offset: int = 0) -> Image.Image:
|
||||
if invert_output:
|
||||
mask_np = np.array(mask_image)
|
||||
mask_image = Image.fromarray(255 - mask_np)
|
||||
if mask_blur > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=mask_blur))
|
||||
if mask_offset != 0:
|
||||
filter_type = ImageFilter.MaxFilter if mask_offset > 0 else ImageFilter.MinFilter
|
||||
size = abs(mask_offset) * 2 + 1
|
||||
for _ in range(abs(mask_offset)):
|
||||
mask_image = mask_image.filter(filter_type(size))
|
||||
return mask_image
|
||||
|
||||
def apply_background_color(image: Image.Image, mask_image: Image.Image,
|
||||
background: str = "Alpha",
|
||||
background_color: str = "#222222") -> Image.Image:
|
||||
rgba_image = image.copy().convert('RGBA')
|
||||
rgba_image.putalpha(mask_image.convert('L'))
|
||||
if background == "Color":
|
||||
def hex_to_rgba(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
return (r, g, b, 255)
|
||||
rgba = hex_to_rgba(background_color)
|
||||
bg_image = Image.new('RGBA', image.size, rgba)
|
||||
composite_image = Image.alpha_composite(bg_image, rgba_image)
|
||||
return composite_image.convert('RGB')
|
||||
return rgba_image
|
||||
|
||||
def get_groundingdino_model(device):
|
||||
from transformers import AutoProcessor, AutoModelForZeroShotObjectDetection
|
||||
processor = AutoProcessor.from_pretrained("IDEA-Research/grounding-dino-tiny")
|
||||
model = AutoModelForZeroShotObjectDetection.from_pretrained("IDEA-Research/grounding-dino-tiny").to(device)
|
||||
return processor, model
|
||||
@@ -149,6 +103,7 @@ class SegmentV2:
|
||||
"mask_blur": ("INT", {"default": 0, "min": 0, "max": 64, "step": 1, "tooltip": tooltips["mask_blur"]}),
|
||||
"mask_offset": ("INT", {"default": 0, "min": -64, "max": 64, "step": 1, "tooltip": tooltips["mask_offset"]}),
|
||||
"invert_output": ("BOOLEAN", {"default": False, "tooltip": tooltips["invert_output"]}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
"background": (["Alpha", "Color"], {"default": "Alpha", "tooltip": tooltips["background"]}),
|
||||
"background_color": ("COLORCODE", {"default": "#222222", "tooltip": tooltips["background_color"]}),
|
||||
}
|
||||
@@ -164,8 +119,14 @@ class SegmentV2:
|
||||
self.sam_model_cache = {}
|
||||
|
||||
def segment_v2(self, image, prompt, sam_model, dino_model, threshold=0.30,
|
||||
mask_blur=0, mask_offset=0, background="Alpha",
|
||||
background_color="#222222", invert_output=False):
|
||||
mask_blur=0, mask_offset=0, invert_output=False, unload_model=False,
|
||||
background="Alpha", background_color="#222222"):
|
||||
# Lazy import groundingdino (~12.5s) -- only when the node is actually executed
|
||||
from groundingdino.util.slconfig import SLConfig
|
||||
from groundingdino.models import build_model
|
||||
from groundingdino.util.utils import clean_state_dict
|
||||
from groundingdino.util import box_ops
|
||||
|
||||
device = get_device()
|
||||
|
||||
batch_size = image.shape[0] if len(image.shape) == 4 else 1
|
||||
@@ -182,6 +143,7 @@ class SegmentV2:
|
||||
weights_path = get_or_download_model_file(dino_info["model_filename"], dino_info["model_url"], "grounding-dino")
|
||||
dino_key = (config_path, weights_path, device)
|
||||
if dino_key not in self.dino_model_cache:
|
||||
patch_transformers_bert()
|
||||
args = SLConfig.fromfile(config_path)
|
||||
model = build_model(args)
|
||||
checkpoint = torch.load(weights_path, map_location="cpu")
|
||||
@@ -268,14 +230,20 @@ class SegmentV2:
|
||||
result_images.append(pil2tensor(result_image))
|
||||
result_masks.append(mask_tensor)
|
||||
result_mask_images.append(mask_image_vis)
|
||||
if len(result_images) == 0:
|
||||
width, height = tensor2pil(image[0]).size
|
||||
empty_mask = torch.zeros((batch_size, 1, height, width), dtype=torch.float32, device="cpu")
|
||||
empty_mask_rgb = empty_mask.reshape((-1, 1, height, width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
return (image, empty_mask, empty_mask_rgb)
|
||||
return (torch.cat(result_images, dim=0),
|
||||
torch.cat(result_masks, dim=0),
|
||||
torch.cat(result_mask_images, dim=0))
|
||||
try:
|
||||
if len(result_images) == 0:
|
||||
width, height = tensor2pil(image[0]).size
|
||||
empty_mask = torch.zeros((batch_size, 1, height, width), dtype=torch.float32, device="cpu")
|
||||
empty_mask_rgb = empty_mask.reshape((-1, 1, height, width)).movedim(1, -1).expand(-1, -1, -1, 3)
|
||||
return (image, empty_mask, empty_mask_rgb)
|
||||
return (torch.cat(result_images, dim=0),
|
||||
torch.cat(result_masks, dim=0),
|
||||
torch.cat(result_mask_images, dim=0))
|
||||
finally:
|
||||
if unload_model:
|
||||
self.dino_model_cache.clear()
|
||||
self.sam_model_cache.clear()
|
||||
clean_vram()
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SegmentV2": SegmentV2,
|
||||
|
||||
+289
-279
@@ -1,279 +1,289 @@
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
import folder_paths
|
||||
from AILab_ImageMaskTools import pil2tensor, tensor2pil
|
||||
|
||||
ULTRALYTICS_DIR = os.path.join(folder_paths.models_dir, "ultralytics")
|
||||
YOLO_LEGACY_DIR = os.path.join(folder_paths.models_dir, "yolo")
|
||||
os.makedirs(ULTRALYTICS_DIR, exist_ok=True)
|
||||
os.makedirs(YOLO_LEGACY_DIR, exist_ok=True)
|
||||
folder_paths.add_model_folder_path("ultralytics", ULTRALYTICS_DIR, is_default=True)
|
||||
folder_paths.add_model_folder_path("ultralytics", YOLO_LEGACY_DIR)
|
||||
|
||||
DEVICE_CHOICES = ("auto", "cuda", "cpu", "mps")
|
||||
MASK_COUNT_CHOICES = ("all", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
|
||||
MASK_INDEX_CHOICES = ("none", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
|
||||
|
||||
|
||||
class AILab_YoloV8Adv:
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "MASK")
|
||||
RETURN_NAMES = ("ANNOTATED_IMAGE", "MASK", "MASK_LIST")
|
||||
FUNCTION = "yolo_detect"
|
||||
|
||||
_MODEL_CACHE: Dict[str, "YOLO"] = {}
|
||||
|
||||
@classmethod
|
||||
def _list_models(cls) -> List[str]:
|
||||
files = folder_paths.get_filename_list("ultralytics")
|
||||
return sorted(f for f in files if f.lower().endswith(".pt"))
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
models = cls._list_models()
|
||||
if not models:
|
||||
models = [f"Put .pt models into {ULTRALYTICS_DIR}"]
|
||||
default_model = models[0]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"yolo_model": (tuple(models), {"default": default_model, "tooltip": f"YOLOv8 weights stored under {ULTRALYTICS_DIR} (subfolders allowed)."}),
|
||||
"mask_count": (MASK_COUNT_CHOICES, {"default": "all", "tooltip": "Merge this many detections. 'all' merges everything (or just the selected index when specified)."}),
|
||||
},
|
||||
"optional": {
|
||||
"select_mask_index": (MASK_INDEX_CHOICES, {"default": "none", "tooltip": "1-based index of the first mask to keep. Use 'none' to start from the first detection."}),
|
||||
"conf": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Confidence threshold forwarded to Ultralytics."}),
|
||||
"iou": ("FLOAT", {"default": 0.45, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "IOU used during NMS."}),
|
||||
"classes": ("STRING", {"default": "", "placeholder": "e.g. 0,2,5-7", "tooltip": "Comma list or ranges of class IDs; empty keeps every class."}),
|
||||
"device": (DEVICE_CHOICES, {"default": "auto", "tooltip": "Force a device or auto-detect CUDA → MPS → CPU."}),
|
||||
"max_det": ("INT", {"default": 300, "min": 1, "max": 1000, "step": 1, "tooltip": "Maximum detections per image."}),
|
||||
"retina_masks": ("BOOLEAN", {"default": True, "tooltip": "Use high-resolution masks (Ultralytics retina_masks flag)."}),
|
||||
"agnostic_nms": ("BOOLEAN", {"default": False, "tooltip": "Enable class-agnostic NMS."}),
|
||||
},
|
||||
}
|
||||
|
||||
def _resolve_device(self, requested: str) -> str:
|
||||
if requested != "auto":
|
||||
return requested
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
return "cpu"
|
||||
|
||||
def _parse_classes(self, value: str) -> Optional[List[int]]:
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
classes: List[int] = []
|
||||
try:
|
||||
for chunk in value.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
if "-" in chunk:
|
||||
start, end = [int(x) for x in chunk.split("-", 1)]
|
||||
if start > end:
|
||||
start, end = end, start
|
||||
classes.extend(range(start, end + 1))
|
||||
else:
|
||||
classes.append(int(chunk))
|
||||
return sorted(set(classes))
|
||||
except ValueError:
|
||||
print(f"[AILab_YoloV8] Invalid classes string: {value}. Ignoring filter.")
|
||||
return None
|
||||
|
||||
def _resolve_model_path(self, name: str) -> str:
|
||||
return folder_paths.get_full_path_or_raise("ultralytics", name)
|
||||
|
||||
def _get_model(self, model_path: str):
|
||||
model = self._MODEL_CACHE.get(model_path)
|
||||
if model is None:
|
||||
from ultralytics import YOLO
|
||||
model = YOLO(model_path)
|
||||
self._MODEL_CACHE[model_path] = model
|
||||
return model
|
||||
|
||||
def _result_to_tensor(self, result) -> torch.Tensor:
|
||||
plotted = result.plot()
|
||||
rgb = plotted[..., ::-1]
|
||||
return pil2tensor(Image.fromarray(rgb))
|
||||
|
||||
def _mask_from_tensor(self, mask_tensor: torch.Tensor, size: Image.Image.size):
|
||||
mask_np = mask_tensor.detach().cpu().numpy()
|
||||
mask_img = Image.fromarray((mask_np * 255).astype(np.uint8))
|
||||
if mask_img.size != size:
|
||||
mask_img = mask_img.resize(size, Image.Resampling.NEAREST)
|
||||
return torch.from_numpy(np.array(mask_img).astype(np.float32) / 255.0)
|
||||
|
||||
def _collect_masks(self, result, size) -> List[torch.Tensor]:
|
||||
width, height = size
|
||||
masks: List[torch.Tensor] = []
|
||||
|
||||
if getattr(result, "masks", None) is not None and result.masks.data is not None:
|
||||
for mask_tensor in result.masks.data:
|
||||
masks.append(self._mask_from_tensor(mask_tensor, size))
|
||||
|
||||
elif getattr(result, "boxes", None) is not None and len(result.boxes.xyxy) > 0:
|
||||
for box in result.boxes:
|
||||
x1, y1, x2, y2 = [int(v) for v in box.xyxy[0].tolist()]
|
||||
mask_img = Image.new("L", size, 0)
|
||||
draw = ImageDraw.Draw(mask_img)
|
||||
draw.rectangle([x1, y1, x2, y2], fill=255)
|
||||
masks.append(torch.from_numpy(np.array(mask_img).astype(np.float32) / 255.0))
|
||||
|
||||
if not masks:
|
||||
masks.append(torch.zeros((height, width), dtype=torch.float32))
|
||||
|
||||
return masks
|
||||
|
||||
def _merge_masks(self, masks: List[torch.Tensor]) -> torch.Tensor:
|
||||
if not masks:
|
||||
raise ValueError("Cannot merge empty mask list.")
|
||||
merged = torch.zeros_like(masks[0])
|
||||
|
||||
for mask in masks:
|
||||
merged = torch.maximum(merged, mask)
|
||||
|
||||
return merged
|
||||
|
||||
def yolo_detect(
|
||||
self,
|
||||
images,
|
||||
yolo_model,
|
||||
mask_count="all",
|
||||
conf=0.25,
|
||||
iou=0.45,
|
||||
classes="",
|
||||
device="auto",
|
||||
max_det=300,
|
||||
retina_masks=True,
|
||||
agnostic_nms=False,
|
||||
select_mask_index: str = "none",
|
||||
):
|
||||
model_path = self._resolve_model_path(yolo_model)
|
||||
model = self._get_model(model_path)
|
||||
device_target = self._resolve_device(device)
|
||||
class_filter = self._parse_classes(classes)
|
||||
|
||||
merged_masks: List[torch.Tensor] = []
|
||||
annotated_images: List[torch.Tensor] = []
|
||||
mask_list: List[torch.Tensor] = []
|
||||
|
||||
count_limit = 0 if mask_count == "all" else max(0, int(mask_count))
|
||||
chosen_index: Optional[int] = None
|
||||
if select_mask_index != "none":
|
||||
chosen_index = int(select_mask_index) - 1
|
||||
|
||||
for idx in range(images.shape[0]):
|
||||
image_pil = tensor2pil(images[idx])
|
||||
|
||||
results = model(
|
||||
image_pil,
|
||||
conf=conf,
|
||||
iou=iou,
|
||||
classes=class_filter,
|
||||
device=device_target,
|
||||
max_det=max_det,
|
||||
retina_masks=retina_masks,
|
||||
agnostic_nms=agnostic_nms,
|
||||
)
|
||||
|
||||
if not results:
|
||||
continue
|
||||
|
||||
result = results[0]
|
||||
annotated_images.append(self._result_to_tensor(result))
|
||||
|
||||
frame_masks = self._collect_masks(result, image_pil.size)
|
||||
|
||||
selected_masks: List[torch.Tensor]
|
||||
if chosen_index is None:
|
||||
if count_limit <= 0 or count_limit >= len(frame_masks):
|
||||
selected_masks = frame_masks
|
||||
else:
|
||||
selected_masks = frame_masks[:count_limit]
|
||||
else:
|
||||
if chosen_index >= len(frame_masks):
|
||||
selected_masks = []
|
||||
else:
|
||||
span = count_limit if count_limit > 0 else 1
|
||||
selected_masks = frame_masks[chosen_index : chosen_index + span]
|
||||
|
||||
if selected_masks:
|
||||
merged_masks.append(self._merge_masks(selected_masks))
|
||||
mask_list.extend(selected_masks)
|
||||
else:
|
||||
fallback = torch.zeros_like(frame_masks[0])
|
||||
merged_masks.append(fallback)
|
||||
mask_list.append(fallback)
|
||||
|
||||
if not merged_masks:
|
||||
width, height = tensor2pil(images[0]).size
|
||||
merged_masks = [torch.zeros((height, width), dtype=torch.float32)]
|
||||
|
||||
if not mask_list:
|
||||
width, height = merged_masks[0].shape[1], merged_masks[0].shape[0]
|
||||
mask_list = [torch.zeros((height, width), dtype=torch.float32)]
|
||||
|
||||
if not annotated_images:
|
||||
annotated_images = [images]
|
||||
|
||||
merged_tensor = torch.stack(merged_masks, dim=0)
|
||||
annotated_tensor = torch.cat(annotated_images, dim=0)
|
||||
mask_tensor = torch.stack(mask_list, dim=0)
|
||||
|
||||
return annotated_tensor, merged_tensor, mask_tensor
|
||||
|
||||
|
||||
class AILab_YoloV8(AILab_YoloV8Adv):
|
||||
FUNCTION = "yolo_detect_simple"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
models = cls._list_models()
|
||||
if not models:
|
||||
models = [f"Put .pt models into {ULTRALYTICS_DIR}"]
|
||||
default_model = models[0]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"yolo_model": (tuple(models), {"default": default_model, "tooltip": f"YOLOv8 weights stored under {ULTRALYTICS_DIR}. Advanced controls available on YOLOv8 Adv."}),
|
||||
"mask_count": (MASK_COUNT_CHOICES, {"default": "all", "tooltip": "Merge this many detections. 'all' merges everything (or just the selected index when specified)."}),
|
||||
},
|
||||
"optional": {
|
||||
"select_mask_index": (MASK_INDEX_CHOICES, {"default": "none", "tooltip": "1-based index of the first mask to keep. Use 'none' to start from the first detection."}),
|
||||
},
|
||||
}
|
||||
|
||||
def yolo_detect_simple(self, images, yolo_model, mask_count="all", select_mask_index="none"):
|
||||
return super().yolo_detect(
|
||||
images=images,
|
||||
yolo_model=yolo_model,
|
||||
mask_count=mask_count,
|
||||
conf=0.25,
|
||||
iou=0.45,
|
||||
classes="",
|
||||
device="auto",
|
||||
max_det=300,
|
||||
retina_masks=True,
|
||||
agnostic_nms=False,
|
||||
select_mask_index=select_mask_index,
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AILab_YoloV8": AILab_YoloV8,
|
||||
"AILab_YoloV8Adv": AILab_YoloV8Adv,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AILab_YoloV8": "YOLOv8 (RMBG)",
|
||||
"AILab_YoloV8Adv": "YOLOv8 Adv (RMBG)",
|
||||
}
|
||||
import os
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
import folder_paths
|
||||
from AILab_ImageMaskTools import pil2tensor, tensor2pil
|
||||
from AILab_utils import clean_vram
|
||||
|
||||
ULTRALYTICS_DIR = os.path.join(folder_paths.models_dir, "ultralytics")
|
||||
YOLO_LEGACY_DIR = os.path.join(folder_paths.models_dir, "yolo")
|
||||
os.makedirs(ULTRALYTICS_DIR, exist_ok=True)
|
||||
os.makedirs(YOLO_LEGACY_DIR, exist_ok=True)
|
||||
folder_paths.add_model_folder_path("ultralytics", ULTRALYTICS_DIR, is_default=True)
|
||||
folder_paths.add_model_folder_path("ultralytics", YOLO_LEGACY_DIR)
|
||||
|
||||
DEVICE_CHOICES = ("auto", "cuda", "cpu", "mps")
|
||||
MASK_COUNT_CHOICES = ("all", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
|
||||
MASK_INDEX_CHOICES = ("none", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10")
|
||||
|
||||
|
||||
class AILab_YoloV8Adv:
|
||||
CATEGORY = "🧪AILab/🧽RMBG"
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "MASK")
|
||||
RETURN_NAMES = ("ANNOTATED_IMAGE", "MASK", "MASK_LIST")
|
||||
FUNCTION = "yolo_detect"
|
||||
|
||||
_MODEL_CACHE: Dict[str, "YOLO"] = {}
|
||||
|
||||
@classmethod
|
||||
def _list_models(cls) -> List[str]:
|
||||
files = folder_paths.get_filename_list("ultralytics")
|
||||
return sorted(f for f in files if f.lower().endswith(".pt"))
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
models = cls._list_models()
|
||||
if not models:
|
||||
models = [f"Put .pt models into {ULTRALYTICS_DIR}"]
|
||||
default_model = models[0]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"yolo_model": (tuple(models), {"default": default_model, "tooltip": f"YOLOv8 weights stored under {ULTRALYTICS_DIR} (subfolders allowed)."}),
|
||||
"mask_count": (MASK_COUNT_CHOICES, {"default": "all", "tooltip": "Merge this many detections. 'all' merges everything (or just the selected index when specified)."}),
|
||||
},
|
||||
"optional": {
|
||||
"select_mask_index": (MASK_INDEX_CHOICES, {"default": "none", "tooltip": "1-based index of the first mask to keep. Use 'none' to start from the first detection."}),
|
||||
"conf": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "Confidence threshold forwarded to Ultralytics."}),
|
||||
"iou": ("FLOAT", {"default": 0.45, "min": 0.0, "max": 1.0, "step": 0.01, "tooltip": "IOU used during NMS."}),
|
||||
"classes": ("STRING", {"default": "", "placeholder": "e.g. 0,2,5-7", "tooltip": "Comma list or ranges of class IDs; empty keeps every class."}),
|
||||
"device": (DEVICE_CHOICES, {"default": "auto", "tooltip": "Force a device or auto-detect CUDA → MPS → CPU."}),
|
||||
"max_det": ("INT", {"default": 300, "min": 1, "max": 1000, "step": 1, "tooltip": "Maximum detections per image."}),
|
||||
"retina_masks": ("BOOLEAN", {"default": True, "tooltip": "Use high-resolution masks (Ultralytics retina_masks flag)."}),
|
||||
"agnostic_nms": ("BOOLEAN", {"default": False, "tooltip": "Enable class-agnostic NMS."}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
},
|
||||
}
|
||||
|
||||
def _resolve_device(self, requested: str) -> str:
|
||||
if requested != "auto":
|
||||
return requested
|
||||
if torch.cuda.is_available():
|
||||
return "cuda"
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
return "mps"
|
||||
return "cpu"
|
||||
|
||||
def _parse_classes(self, value: str) -> Optional[List[int]]:
|
||||
if not value or not value.strip():
|
||||
return None
|
||||
classes: List[int] = []
|
||||
try:
|
||||
for chunk in value.split(","):
|
||||
chunk = chunk.strip()
|
||||
if not chunk:
|
||||
continue
|
||||
if "-" in chunk:
|
||||
start, end = [int(x) for x in chunk.split("-", 1)]
|
||||
if start > end:
|
||||
start, end = end, start
|
||||
classes.extend(range(start, end + 1))
|
||||
else:
|
||||
classes.append(int(chunk))
|
||||
return sorted(set(classes))
|
||||
except ValueError:
|
||||
print(f"[AILab_YoloV8] Invalid classes string: {value}. Ignoring filter.")
|
||||
return None
|
||||
|
||||
def _resolve_model_path(self, name: str) -> str:
|
||||
return folder_paths.get_full_path_or_raise("ultralytics", name)
|
||||
|
||||
def _get_model(self, model_path: str):
|
||||
model = self._MODEL_CACHE.get(model_path)
|
||||
if model is None:
|
||||
from ultralytics import YOLO
|
||||
model = YOLO(model_path)
|
||||
self._MODEL_CACHE[model_path] = model
|
||||
return model
|
||||
|
||||
def _result_to_tensor(self, result) -> torch.Tensor:
|
||||
plotted = result.plot()
|
||||
rgb = plotted[..., ::-1]
|
||||
return pil2tensor(Image.fromarray(rgb))
|
||||
|
||||
def _mask_from_tensor(self, mask_tensor: torch.Tensor, size: Image.Image.size):
|
||||
mask_np = mask_tensor.detach().cpu().numpy()
|
||||
mask_img = Image.fromarray((mask_np * 255).astype(np.uint8))
|
||||
if mask_img.size != size:
|
||||
mask_img = mask_img.resize(size, Image.Resampling.NEAREST)
|
||||
return torch.from_numpy(np.array(mask_img).astype(np.float32) / 255.0)
|
||||
|
||||
def _collect_masks(self, result, size) -> List[torch.Tensor]:
|
||||
width, height = size
|
||||
masks: List[torch.Tensor] = []
|
||||
|
||||
if getattr(result, "masks", None) is not None and result.masks.data is not None:
|
||||
for mask_tensor in result.masks.data:
|
||||
masks.append(self._mask_from_tensor(mask_tensor, size))
|
||||
|
||||
elif getattr(result, "boxes", None) is not None and len(result.boxes.xyxy) > 0:
|
||||
for box in result.boxes:
|
||||
x1, y1, x2, y2 = [int(v) for v in box.xyxy[0].tolist()]
|
||||
mask_img = Image.new("L", size, 0)
|
||||
draw = ImageDraw.Draw(mask_img)
|
||||
draw.rectangle([x1, y1, x2, y2], fill=255)
|
||||
masks.append(torch.from_numpy(np.array(mask_img).astype(np.float32) / 255.0))
|
||||
|
||||
if not masks:
|
||||
masks.append(torch.zeros((height, width), dtype=torch.float32))
|
||||
|
||||
return masks
|
||||
|
||||
def _merge_masks(self, masks: List[torch.Tensor]) -> torch.Tensor:
|
||||
if not masks:
|
||||
raise ValueError("Cannot merge empty mask list.")
|
||||
merged = torch.zeros_like(masks[0])
|
||||
|
||||
for mask in masks:
|
||||
merged = torch.maximum(merged, mask)
|
||||
|
||||
return merged
|
||||
|
||||
def yolo_detect(
|
||||
self,
|
||||
images,
|
||||
yolo_model,
|
||||
mask_count="all",
|
||||
conf=0.25,
|
||||
iou=0.45,
|
||||
classes="",
|
||||
device="auto",
|
||||
max_det=300,
|
||||
retina_masks=True,
|
||||
agnostic_nms=False,
|
||||
select_mask_index: str = "none",
|
||||
unload_model: bool = False,
|
||||
):
|
||||
try:
|
||||
model_path = self._resolve_model_path(yolo_model)
|
||||
model = self._get_model(model_path)
|
||||
device_target = self._resolve_device(device)
|
||||
class_filter = self._parse_classes(classes)
|
||||
|
||||
merged_masks: List[torch.Tensor] = []
|
||||
annotated_images: List[torch.Tensor] = []
|
||||
mask_list: List[torch.Tensor] = []
|
||||
|
||||
count_limit = 0 if mask_count == "all" else max(0, int(mask_count))
|
||||
chosen_index: Optional[int] = None
|
||||
if select_mask_index != "none":
|
||||
chosen_index = int(select_mask_index) - 1
|
||||
|
||||
for idx in range(images.shape[0]):
|
||||
image_pil = tensor2pil(images[idx])
|
||||
|
||||
results = model(
|
||||
image_pil,
|
||||
conf=conf,
|
||||
iou=iou,
|
||||
classes=class_filter,
|
||||
device=device_target,
|
||||
max_det=max_det,
|
||||
retina_masks=retina_masks,
|
||||
agnostic_nms=agnostic_nms,
|
||||
)
|
||||
|
||||
if not results:
|
||||
continue
|
||||
|
||||
result = results[0]
|
||||
annotated_images.append(self._result_to_tensor(result))
|
||||
|
||||
frame_masks = self._collect_masks(result, image_pil.size)
|
||||
|
||||
selected_masks: List[torch.Tensor]
|
||||
if chosen_index is None:
|
||||
if count_limit <= 0 or count_limit >= len(frame_masks):
|
||||
selected_masks = frame_masks
|
||||
else:
|
||||
selected_masks = frame_masks[:count_limit]
|
||||
else:
|
||||
if chosen_index >= len(frame_masks):
|
||||
selected_masks = []
|
||||
else:
|
||||
span = count_limit if count_limit > 0 else 1
|
||||
selected_masks = frame_masks[chosen_index : chosen_index + span]
|
||||
|
||||
if selected_masks:
|
||||
merged_masks.append(self._merge_masks(selected_masks))
|
||||
mask_list.extend(selected_masks)
|
||||
else:
|
||||
fallback = torch.zeros_like(frame_masks[0])
|
||||
merged_masks.append(fallback)
|
||||
mask_list.append(fallback)
|
||||
|
||||
if not merged_masks:
|
||||
width, height = tensor2pil(images[0]).size
|
||||
merged_masks = [torch.zeros((height, width), dtype=torch.float32)]
|
||||
|
||||
if not mask_list:
|
||||
width, height = merged_masks[0].shape[1], merged_masks[0].shape[0]
|
||||
mask_list = [torch.zeros((height, width), dtype=torch.float32)]
|
||||
|
||||
if not annotated_images:
|
||||
annotated_images = [images]
|
||||
|
||||
merged_tensor = torch.stack(merged_masks, dim=0)
|
||||
annotated_tensor = torch.cat(annotated_images, dim=0)
|
||||
mask_tensor = torch.stack(mask_list, dim=0)
|
||||
|
||||
return annotated_tensor, merged_tensor, mask_tensor
|
||||
finally:
|
||||
if unload_model:
|
||||
self._MODEL_CACHE.clear()
|
||||
clean_vram()
|
||||
|
||||
|
||||
class AILab_YoloV8(AILab_YoloV8Adv):
|
||||
FUNCTION = "yolo_detect_simple"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
models = cls._list_models()
|
||||
if not models:
|
||||
models = [f"Put .pt models into {ULTRALYTICS_DIR}"]
|
||||
default_model = models[0]
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"yolo_model": (tuple(models), {"default": default_model, "tooltip": f"YOLOv8 weights stored under {ULTRALYTICS_DIR}. Advanced controls available on YOLOv8 Adv."}),
|
||||
"mask_count": (MASK_COUNT_CHOICES, {"default": "all", "tooltip": "Merge this many detections. 'all' merges everything (or just the selected index when specified)."}),
|
||||
},
|
||||
"optional": {
|
||||
"select_mask_index": (MASK_INDEX_CHOICES, {"default": "none", "tooltip": "1-based index of the first mask to keep. Use 'none' to start from the first detection."}),
|
||||
"unload_model": ("BOOLEAN", {"default": False, "tooltip": "Unload model from VRAM after execution"}),
|
||||
},
|
||||
}
|
||||
|
||||
def yolo_detect_simple(self, images, yolo_model, mask_count="all", select_mask_index="none", unload_model=False):
|
||||
return super().yolo_detect(
|
||||
images=images,
|
||||
yolo_model=yolo_model,
|
||||
mask_count=mask_count,
|
||||
conf=0.25,
|
||||
iou=0.45,
|
||||
classes="",
|
||||
device="auto",
|
||||
max_det=300,
|
||||
retina_masks=True,
|
||||
agnostic_nms=False,
|
||||
select_mask_index=select_mask_index,
|
||||
unload_model=unload_model,
|
||||
)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AILab_YoloV8": AILab_YoloV8,
|
||||
"AILab_YoloV8Adv": AILab_YoloV8Adv,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AILab_YoloV8": "YOLOv8 (RMBG)",
|
||||
"AILab_YoloV8Adv": "YOLOv8 Adv (RMBG)",
|
||||
}
|
||||
|
||||
+349
-129
@@ -1,129 +1,349 @@
|
||||
# Shared utility helpers
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
from comfy.utils import common_upscale
|
||||
import comfy.model_management as mm
|
||||
|
||||
|
||||
def get_device():
|
||||
"""Return the torch device ComfyUI selected (CUDA, MPS, or CPU).
|
||||
|
||||
Defers to ComfyUI's device management instead of assuming CUDA-or-CPU, so nodes
|
||||
follow whatever backend ComfyUI chose. Fixes models silently running on CPU on
|
||||
Apple Silicon (MPS). See issues #200 and #135.
|
||||
"""
|
||||
return mm.get_torch_device()
|
||||
|
||||
|
||||
def tensor2pil(image: torch.Tensor) -> Image.Image:
|
||||
return Image.fromarray(np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||||
|
||||
|
||||
def pil2tensor(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
def pil2mask(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image.convert("L")).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
def resize_image(img: Image.Image, width: int, height: int) -> Image.Image:
|
||||
return img.resize((width, height), resample=Image.LANCZOS)
|
||||
|
||||
|
||||
def blend_overlay(img_1: Image.Image, img_2: Image.Image) -> Image.Image:
|
||||
arr1 = np.array(img_1).astype(float) / 255.0
|
||||
arr2 = np.array(img_2).astype(float) / 255.0
|
||||
mask = arr2 < 0.5
|
||||
result = np.zeros_like(arr1)
|
||||
result[mask] = 2 * arr1[mask] * arr2[mask]
|
||||
result[~mask] = 1 - 2 * (1 - arr1[~mask]) * (1 - arr2[~mask])
|
||||
return Image.fromarray(np.clip(result * 255, 0, 255).astype(np.uint8))
|
||||
|
||||
|
||||
def fill_mask(width: int, height: int, mask: Image.Image, box=(0, 0), color=0) -> Image.Image:
|
||||
bg = Image.new("L", (width, height), color)
|
||||
bg.paste(mask, box, mask)
|
||||
return bg
|
||||
|
||||
|
||||
def empty_image(width: int, height: int, batch_size: int = 1) -> torch.Tensor:
|
||||
return torch.zeros([batch_size, height, width, 3])
|
||||
|
||||
|
||||
def upscale_mask(mask: torch.Tensor, width: int, height: int) -> torch.Tensor:
|
||||
if mask.ndim == 3:
|
||||
mask = mask.unsqueeze(1)
|
||||
mask = common_upscale(mask, width, height, "bicubic", "disabled")
|
||||
mask = mask.squeeze(1)
|
||||
return mask
|
||||
|
||||
|
||||
def extract_alpha_mask(image: torch.Tensor) -> torch.Tensor:
|
||||
alpha = image[..., 3]
|
||||
if alpha.max() > 1.0:
|
||||
alpha = alpha / 255.0
|
||||
if len(alpha.shape) == 4:
|
||||
alpha = alpha[:, :, :, 0]
|
||||
return alpha.unsqueeze(1) if alpha.ndim == 3 else alpha
|
||||
|
||||
|
||||
def ensure_mask_shape(mask: torch.Tensor | None) -> torch.Tensor | None:
|
||||
if mask is None:
|
||||
return None
|
||||
if mask.ndim == 2:
|
||||
return mask.unsqueeze(0)
|
||||
if mask.ndim == 4 and mask.shape[1] == 1:
|
||||
return mask.squeeze(1)
|
||||
return mask
|
||||
|
||||
|
||||
COLOR_PRESETS = {
|
||||
"black": "#000000",
|
||||
"white": "#FFFFFF",
|
||||
"red": "#FF0000",
|
||||
"green": "#00FF00",
|
||||
"blue": "#0000FF",
|
||||
"yellow": "#FFFF00",
|
||||
"cyan": "#00FFFF",
|
||||
"magenta": "#FF00FF",
|
||||
"gray": "#808080",
|
||||
"silver": "#C0C0C0",
|
||||
"maroon": "#800000",
|
||||
"olive": "#808000",
|
||||
"purple": "#800080",
|
||||
"teal": "#008080",
|
||||
"navy": "#000080",
|
||||
"orange": "#FFA500",
|
||||
"pink": "#FFC0CB",
|
||||
"brown": "#A52A2A",
|
||||
"violet": "#EE82EE",
|
||||
"indigo": "#4B0082",
|
||||
"light_gray": "#D3D3D3",
|
||||
"dark_gray": "#A9A9A9",
|
||||
"light_blue": "#ADD8E6",
|
||||
"dark_blue": "#00008B",
|
||||
"light_green": "#90EE90",
|
||||
"dark_green": "#006400",
|
||||
}
|
||||
|
||||
|
||||
def color_format(color: str) -> str:
|
||||
if not color:
|
||||
return ""
|
||||
|
||||
color = color.strip().upper()
|
||||
if not color.startswith("#"):
|
||||
color = f"#{color}"
|
||||
|
||||
color = color[1:]
|
||||
if len(color) == 3:
|
||||
r, g, b = color[0], color[1], color[2]
|
||||
return f"#{r}{r}{g}{g}{b}{b}"
|
||||
if len(color) < 6:
|
||||
raise ValueError(f"Invalid color format: {color}")
|
||||
if len(color) > 6:
|
||||
color = color[:6]
|
||||
|
||||
return f"#{color}"
|
||||
# Shared utility helpers
|
||||
import os
|
||||
import gc
|
||||
from typing import Union, Optional
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageFilter
|
||||
from torch.hub import download_url_to_file
|
||||
from comfy.utils import common_upscale
|
||||
import comfy.model_management as mm
|
||||
import folder_paths
|
||||
|
||||
|
||||
def get_device():
|
||||
"""Return the torch device ComfyUI selected (CUDA, MPS, or CPU)."""
|
||||
return mm.get_torch_device()
|
||||
|
||||
|
||||
def clean_vram():
|
||||
"""Safely clear cached VRAM across CUDA, MPS, and CPU backends."""
|
||||
gc.collect()
|
||||
mm.soft_empty_cache()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
if hasattr(torch.cuda, "ipc_collect"):
|
||||
torch.cuda.ipc_collect()
|
||||
elif hasattr(torch, "mps") and hasattr(torch.mps, "empty_cache"):
|
||||
try:
|
||||
torch.mps.empty_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def unload_model(loader_or_model, attr_name="model"):
|
||||
"""Unload a model from memory and release VRAM.
|
||||
|
||||
Accepts either:
|
||||
1. A loader object that owns a model attribute (e.g. loader, attr_name='model')
|
||||
2. A raw torch.nn.Module instance directly.
|
||||
"""
|
||||
if hasattr(loader_or_model, attr_name):
|
||||
model = getattr(loader_or_model, attr_name)
|
||||
if model is not None:
|
||||
if hasattr(model, "to"):
|
||||
try:
|
||||
model.to("cpu")
|
||||
except Exception:
|
||||
pass
|
||||
setattr(loader_or_model, attr_name, None)
|
||||
if hasattr(loader_or_model, "current_model_version"):
|
||||
loader_or_model.current_model_version = None
|
||||
elif isinstance(loader_or_model, torch.nn.Module):
|
||||
try:
|
||||
loader_or_model.to("cpu")
|
||||
except Exception:
|
||||
pass
|
||||
del loader_or_model
|
||||
|
||||
clean_vram()
|
||||
|
||||
|
||||
def patch_transformers_bert():
|
||||
"""Ensure BertModel and BertModelWarper compatibility for GroundingDINO on transformers >= 5.0."""
|
||||
try:
|
||||
def _get_head_mask(self, head_mask=None, num_hidden_layers=12, is_attention_chunked=False):
|
||||
if head_mask is not None:
|
||||
if hasattr(head_mask, "dim"):
|
||||
if head_mask.dim() == 1:
|
||||
head_mask = head_mask.unsqueeze(0).unsqueeze(0).unsqueeze(-1).unsqueeze(-1)
|
||||
head_mask = head_mask.expand(num_hidden_layers, -1, -1, -1, -1)
|
||||
elif head_mask.dim() == 2:
|
||||
head_mask = head_mask.unsqueeze(1).unsqueeze(-1).unsqueeze(-1)
|
||||
if is_attention_chunked and hasattr(head_mask, "unsqueeze"):
|
||||
head_mask = head_mask.unsqueeze(-1)
|
||||
return head_mask
|
||||
return [None] * num_hidden_layers
|
||||
|
||||
def _adapted_get_extended_attention_mask(self, attention_mask, input_shape=None, *args, **kwargs):
|
||||
device = kwargs.pop("device", None)
|
||||
dtype = kwargs.pop("dtype", None)
|
||||
if len(args) >= 1:
|
||||
arg = args[0]
|
||||
if isinstance(arg, torch.device) or (isinstance(arg, str) and str(arg).startswith(("cuda", "cpu", "mps"))):
|
||||
device = arg
|
||||
elif isinstance(arg, torch.dtype):
|
||||
dtype = arg
|
||||
if len(args) >= 2:
|
||||
arg2 = args[1]
|
||||
if isinstance(arg2, torch.dtype):
|
||||
dtype = arg2
|
||||
elif isinstance(arg2, (torch.device, str)):
|
||||
device = arg2
|
||||
|
||||
if dtype is None:
|
||||
dtype = getattr(self, "dtype", torch.float32)
|
||||
|
||||
if attention_mask.dim() == 3:
|
||||
extended_attention_mask = attention_mask[:, None, :, :]
|
||||
elif attention_mask.dim() == 2:
|
||||
extended_attention_mask = attention_mask[:, None, None, :]
|
||||
else:
|
||||
raise ValueError(f"Wrong shape for attention_mask (shape {attention_mask.shape})")
|
||||
|
||||
if device is not None:
|
||||
extended_attention_mask = extended_attention_mask.to(device=device)
|
||||
if dtype is not None:
|
||||
extended_attention_mask = extended_attention_mask.to(dtype=dtype)
|
||||
|
||||
extended_attention_mask = (1.0 - extended_attention_mask) * torch.finfo(dtype).min
|
||||
return extended_attention_mask
|
||||
|
||||
# 1. Patch BertModelWarper directly in GroundingDINO
|
||||
try:
|
||||
import groundingdino.models.GroundingDINO.bertwarper as bw
|
||||
if not getattr(bw, "_rmbg_patched", False):
|
||||
orig_init = bw.BertModelWarper.__init__
|
||||
def new_init(self, bert_model):
|
||||
if not hasattr(bert_model, "get_head_mask"):
|
||||
bert_model.get_head_mask = lambda *a, **k: _get_head_mask(bert_model, *a, **k)
|
||||
bert_model.get_extended_attention_mask = lambda *a, **k: _adapted_get_extended_attention_mask(bert_model, *a, **k)
|
||||
orig_init(self, bert_model)
|
||||
if not hasattr(self, "get_head_mask") or self.get_head_mask is None:
|
||||
self.get_head_mask = lambda *a, **k: _get_head_mask(self, *a, **k)
|
||||
self.get_extended_attention_mask = lambda *a, **k: _adapted_get_extended_attention_mask(self, *a, **k)
|
||||
bw.BertModelWarper.__init__ = new_init
|
||||
bw._rmbg_patched = True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2. Patch BertModel in transformers
|
||||
try:
|
||||
import transformers
|
||||
if hasattr(transformers, "BertModel"):
|
||||
if not hasattr(transformers.BertModel, "get_head_mask"):
|
||||
transformers.BertModel.get_head_mask = _get_head_mask
|
||||
transformers.BertModel.get_extended_attention_mask = _adapted_get_extended_attention_mask
|
||||
if hasattr(transformers, "PreTrainedModel"):
|
||||
if not hasattr(transformers.PreTrainedModel, "get_head_mask"):
|
||||
transformers.PreTrainedModel.get_head_mask = _get_head_mask
|
||||
transformers.PreTrainedModel.get_extended_attention_mask = _adapted_get_extended_attention_mask
|
||||
from transformers.models.bert.modeling_bert import BertModel
|
||||
if not hasattr(BertModel, "get_head_mask"):
|
||||
BertModel.get_head_mask = _get_head_mask
|
||||
BertModel.get_extended_attention_mask = _adapted_get_extended_attention_mask
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def tensor2pil(image: torch.Tensor) -> Image.Image:
|
||||
return Image.fromarray(np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
||||
|
||||
|
||||
def pil2tensor(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
def pil2mask(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image.convert("L")).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
def mask2image(mask: torch.Tensor) -> Image.Image:
|
||||
if len(mask.shape) == 2:
|
||||
mask = mask.unsqueeze(0)
|
||||
return tensor2pil(mask)
|
||||
|
||||
|
||||
def image2mask(image: Image.Image) -> torch.Tensor:
|
||||
return torch.from_numpy(np.array(image.convert("L")).astype(np.float32) / 255.0)
|
||||
|
||||
|
||||
def RGB2RGBA(image: Image.Image, mask: Union[Image.Image, torch.Tensor]) -> Image.Image:
|
||||
if isinstance(mask, torch.Tensor):
|
||||
mask = mask2image(mask)
|
||||
if mask.size != image.size:
|
||||
mask = mask.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return Image.merge('RGBA', (*image.convert('RGB').split(), mask.convert('L')))
|
||||
|
||||
|
||||
def process_mask(mask_image: Image.Image, invert_output: bool = False, mask_blur: int = 0, mask_offset: int = 0) -> Image.Image:
|
||||
if invert_output:
|
||||
mask_np = np.array(mask_image)
|
||||
mask_image = Image.fromarray(255 - mask_np)
|
||||
if mask_blur > 0:
|
||||
mask_image = mask_image.filter(ImageFilter.GaussianBlur(radius=mask_blur))
|
||||
if mask_offset != 0:
|
||||
filter_type = ImageFilter.MaxFilter if mask_offset > 0 else ImageFilter.MinFilter
|
||||
size = abs(mask_offset) * 2 + 1
|
||||
for _ in range(abs(mask_offset)):
|
||||
mask_image = mask_image.filter(filter_type(size))
|
||||
return mask_image
|
||||
|
||||
|
||||
def apply_background_color(image: Image.Image, mask_image: Image.Image, background: str = "Alpha", background_color: str = "#222222") -> Image.Image:
|
||||
rgba_image = image.copy().convert('RGBA')
|
||||
rgba_image.putalpha(mask_image.convert('L'))
|
||||
if background == "Color":
|
||||
def hex_to_rgba(hex_color):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
|
||||
return (r, g, b, 255)
|
||||
rgba = hex_to_rgba(background_color)
|
||||
bg_image = Image.new('RGBA', image.size, rgba)
|
||||
composite_image = Image.alpha_composite(bg_image, rgba_image)
|
||||
return composite_image.convert('RGB')
|
||||
return rgba_image
|
||||
|
||||
|
||||
def get_or_download_model_file(filename: str, url: str, dirname: str = "rmbg") -> str:
|
||||
local_path = folder_paths.get_full_path(dirname, filename)
|
||||
if local_path:
|
||||
return local_path
|
||||
folder = os.path.join(folder_paths.models_dir, dirname)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
local_path = os.path.join(folder, filename)
|
||||
if not os.path.exists(local_path):
|
||||
print(f"Downloading {filename} from {url} ...")
|
||||
download_url_to_file(url, local_path)
|
||||
return local_path
|
||||
|
||||
|
||||
def resize_image(img: Image.Image, width: int, height: int) -> Image.Image:
|
||||
return img.resize((width, height), resample=Image.LANCZOS)
|
||||
|
||||
|
||||
def blend_overlay(img_1: Image.Image, img_2: Image.Image) -> Image.Image:
|
||||
arr1 = np.array(img_1).astype(float) / 255.0
|
||||
arr2 = np.array(img_2).astype(float) / 255.0
|
||||
mask = arr2 < 0.5
|
||||
result = np.zeros_like(arr1)
|
||||
result[mask] = 2 * arr1[mask] * arr2[mask]
|
||||
result[~mask] = 1 - 2 * (1 - arr1[~mask]) * (1 - arr2[~mask])
|
||||
return Image.fromarray(np.clip(result * 255, 0, 255).astype(np.uint8))
|
||||
|
||||
|
||||
def fill_mask(width: int, height: int, mask: Image.Image, box=(0, 0), color=0) -> Image.Image:
|
||||
bg = Image.new("L", (width, height), color)
|
||||
bg.paste(mask, box, mask)
|
||||
return bg
|
||||
|
||||
|
||||
def empty_image(width: int, height: int, batch_size: int = 1) -> torch.Tensor:
|
||||
return torch.zeros([batch_size, height, width, 3])
|
||||
|
||||
|
||||
def upscale_mask(mask: torch.Tensor, width: int, height: int) -> torch.Tensor:
|
||||
if mask.ndim == 3:
|
||||
mask = mask.unsqueeze(1)
|
||||
mask = common_upscale(mask, width, height, "bicubic", "disabled")
|
||||
mask = mask.squeeze(1)
|
||||
return mask
|
||||
|
||||
|
||||
def extract_alpha_mask(image: torch.Tensor) -> torch.Tensor:
|
||||
alpha = image[..., 3]
|
||||
if alpha.max() > 1.0:
|
||||
alpha = alpha / 255.0
|
||||
if len(alpha.shape) == 4:
|
||||
alpha = alpha[:, :, :, 0]
|
||||
return alpha.unsqueeze(1) if alpha.ndim == 3 else alpha
|
||||
|
||||
|
||||
def ensure_mask_shape(mask: torch.Tensor | None) -> torch.Tensor | None:
|
||||
if mask is None:
|
||||
return None
|
||||
if mask.ndim == 2:
|
||||
return mask.unsqueeze(0)
|
||||
if mask.ndim == 4 and mask.shape[1] == 1:
|
||||
return mask.squeeze(1)
|
||||
return mask
|
||||
|
||||
|
||||
COLOR_PRESETS = {
|
||||
"black": "#000000",
|
||||
"white": "#FFFFFF",
|
||||
"red": "#FF0000",
|
||||
"green": "#00FF00",
|
||||
"blue": "#0000FF",
|
||||
"yellow": "#FFFF00",
|
||||
"cyan": "#00FFFF",
|
||||
"magenta": "#FF00FF",
|
||||
"gray": "#808080",
|
||||
"silver": "#C0C0C0",
|
||||
"maroon": "#800000",
|
||||
"olive": "#808000",
|
||||
"purple": "#800080",
|
||||
"teal": "#008080",
|
||||
"navy": "#000080",
|
||||
"orange": "#FFA500",
|
||||
"pink": "#FFC0CB",
|
||||
"brown": "#A52A2A",
|
||||
"violet": "#EE82EE",
|
||||
"indigo": "#4B0082",
|
||||
"light_gray": "#D3D3D3",
|
||||
"dark_gray": "#A9A9A9",
|
||||
"light_blue": "#ADD8E6",
|
||||
"dark_blue": "#00008B",
|
||||
"light_green": "#90EE90",
|
||||
"dark_green": "#006400",
|
||||
}
|
||||
|
||||
ASPECT_RATIOS = {
|
||||
# Square
|
||||
"1:1 (Square)": (1, 1),
|
||||
|
||||
# Landscape
|
||||
"5:4 (Landscape)": (5, 4),
|
||||
"4:3 (Standard)": (4, 3),
|
||||
"3:2 (Photo)": (3, 2),
|
||||
"16:10 (Landscape)": (16, 10),
|
||||
"16:9 (Widescreen)": (16, 9),
|
||||
"1.85:1 (Film)": (185, 100),
|
||||
"2:1 (Univisium)": (2, 1),
|
||||
"21:9 (Ultrawide)": (21, 9),
|
||||
"2.35:1 (Anamorphic)": (235, 100),
|
||||
"2.39:1 (Panavision)": (239, 100),
|
||||
"32:9 (Super Ultrawide)": (32, 9),
|
||||
"3:1 (Panorama)": (3, 1),
|
||||
|
||||
# Portrait (mirrors of the landscape ratios)
|
||||
"4:5 (Portrait)": (4, 5),
|
||||
"3:4 (Portrait Standard)": (3, 4),
|
||||
"2:3 (Portrait Photo)": (2, 3),
|
||||
"10:16 (Portrait)": (10, 16),
|
||||
"9:16 (Portrait Widescreen)": (9, 16),
|
||||
"9:21 (Portrait Ultrawide)": (9, 21),
|
||||
"1:3 (Portrait Panorama)": (1, 3),
|
||||
}
|
||||
|
||||
def color_format(color: str) -> str:
|
||||
if not color:
|
||||
return ""
|
||||
|
||||
color = color.strip().upper()
|
||||
if not color.startswith("#"):
|
||||
color = f"#{color}"
|
||||
|
||||
color = color[1:]
|
||||
if len(color) == 3:
|
||||
r, g, b = color[0], color[1], color[2]
|
||||
return f"#{r}{r}{g}{g}{b}{b}"
|
||||
if len(color) < 6:
|
||||
raise ValueError(f"Invalid color format: {color}")
|
||||
if len(color) > 6:
|
||||
color = color[:6]
|
||||
|
||||
return f"#{color}"
|
||||
|
||||
Reference in New Issue
Block a user