Add files via upload

This commit is contained in:
AI Lab
2026-01-17 10:57:33 -08:00
committed by GitHub
parent d9c6f82a62
commit 07ecb7a06f
4 changed files with 894 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
import torch
from comfy.utils import ProgressBar
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}"
elif len(color) < 6:
raise ValueError(f"Invalid color format: {color}")
elif len(color) > 6:
color = color[:6]
return f"#{color}"
def hex_to_rgb(hex_color: str):
hex_color = hex_color.lstrip('#')
r = int(hex_color[0:2], 16) / 255.0
g = int(hex_color[2:4], 16) / 255.0
b = int(hex_color[4:6], 16) / 255.0
return r, g, b
def validate_image_data(images):
if not isinstance(images, torch.Tensor):
raise TypeError("Images must be a torch tensor.")
if images.min() < 0 or images.max() > 255:
raise ValueError("Images must have pixel values in the range [0, 255].")
def compute_color_distances(images, color):
return torch.norm(images - color, dim=-1)
class AILab_ColorToMask:
RETURN_TYPES = ("MASK",)
FUNCTION = "color_to_mask"
CATEGORY = "🧪AILab/🧽RMBG"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"invert": ("BOOLEAN", {"default": False}),
"threshold": ("INT", {"default": 10, "min": 0, "max": 255, "step": 1}),
"mask_color": ("COLORCODE", {"default": "#FFFFFF", "tooltip": "Mask color (hex)"}),
},
}
def color_to_mask(self, images, mask_color, threshold, invert):
validate_image_data(images)
mask_color = color_format(mask_color)
r, g, b = hex_to_rgb(mask_color)
color = torch.tensor([r, g, b], dtype=torch.float32)
black = torch.tensor([0.0, 0.0, 0.0], dtype=torch.float32)
white = torch.tensor([1.0, 1.0, 1.0], dtype=torch.float32)
if invert:
black, white = white, black
per_batch = 16
device = images.device
color = color.to(device)
black = black.to(device)
white = white.to(device)
if images.max() > 1.0:
images = images / 255.0
steps = images.shape[0]
pbar = ProgressBar(steps)
tensors_out = []
for start_idx in range(0, steps, per_batch):
end_idx = min(start_idx + per_batch, steps)
batch = images[start_idx:end_idx]
color_distances = compute_color_distances(batch, color)
mask = color_distances <= threshold / 255.0
mask_out = torch.where(mask.unsqueeze(-1), white, black).float()
mask_out = mask_out.mean(dim=-1)
tensors_out.append(mask_out.cpu())
pbar.update(end_idx - start_idx)
tensors_out = torch.cat(tensors_out, dim=0)
tensors_out = torch.clamp(tensors_out, min=0.0, max=1.0)
return tensors_out,
NODE_CLASS_MAPPINGS = {
"AILab_ColorToMask": AILab_ColorToMask
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AILab_ColorToMask": "Color to Mask (RMBG) 🎭"
}
+352
View File
@@ -0,0 +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)",
}
+163
View File
@@ -0,0 +1,163 @@
import json
from typing import Any, List, Tuple
import torch
class AILab_Florence2ToCoordinates:
CATEGORY = "🧪AILab/🧽RMBG"
RETURN_TYPES = ("STRING", "BBOX", "MASK")
RETURN_NAMES = ("CENTER_COORDINATES", "BBOXES", "MASK")
FUNCTION = "convert"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"data": ("JSON", {"tooltip": "Florence2 JSON output (list per image)."}),
"index": (
"STRING",
{
"default": "",
"tooltip": "Comma-separated indexes; blank = use all boxes from first item.",
},
),
"batch": ("BOOLEAN", {"default": False, "tooltip": "If true, gather boxes across the batch."}),
},
"optional": {
"image": ("IMAGE",)
},
}
@staticmethod
def _parse_payload(payload: Any) -> List[Any]:
if payload is None:
return []
if isinstance(payload, str):
cleaned = payload.strip()
if not cleaned:
return []
try:
return json.loads(cleaned)
except json.JSONDecodeError:
# Fallback: some upstream nodes stringify Python lists with single quotes
try:
normalized = cleaned.replace("'", '"')
return json.loads(normalized)
except json.JSONDecodeError as exc:
raise ValueError("Invalid JSON payload for Florence2 data") from exc
if isinstance(payload, list):
return payload
return []
@staticmethod
def _get_bboxes(entry: Any) -> List[List[float]]:
if isinstance(entry, dict):
if "bboxes" in entry:
return entry["bboxes"]
raise ValueError("Entry does not contain 'bboxes'.")
if isinstance(entry, list):
return entry
raise ValueError("Unsupported entry type; expected dict with 'bboxes' or list of boxes.")
@staticmethod
def _parse_indexes(index_str: str, default_count: int) -> List[int]:
text = index_str.strip()
if not text:
return list(range(default_count))
try:
return [int(part.strip()) for part in text.split(",") if part.strip()]
except ValueError as exc:
raise ValueError("Index must be comma-separated integers.") from exc
def convert(self, data, index: str, batch: bool = False, image=None):
records = self._parse_payload(data)
if not records:
empty = json.dumps([{"x": 0, "y": 0}])
mask = self._build_empty_mask(image)
return (empty, [], mask)
first_bboxes = self._get_bboxes(records[0])
if not first_bboxes:
empty = json.dumps([{"x": 0, "y": 0}])
mask = self._build_empty_mask(image)
return (empty, [], mask)
indexes = self._parse_indexes(index, len(first_bboxes))
centers = []
selected_boxes = []
selections: List[Tuple[int, List[float]]] = []
max_dims = {}
def append_box(batch_idx: int, box: List[float]):
min_x, min_y, max_x, max_y = box
center_x = int((min_x + max_x) / 2)
center_y = int((min_y + max_y) / 2)
centers.append({"x": center_x, "y": center_y})
selected_boxes.append(box)
selections.append((batch_idx, box))
dims = max_dims.setdefault(batch_idx, [1, 1])
dims[0] = max(dims[0], int(max_x) + 1)
dims[1] = max(dims[1], int(max_y) + 1)
if batch:
for batch_idx, record in enumerate(records):
boxes = self._get_bboxes(record)
for idx in indexes:
if 0 <= idx < len(boxes):
append_box(batch_idx, boxes[idx])
else:
boxes = first_bboxes
for idx in indexes:
if not 0 <= idx < len(boxes):
raise ValueError(f"Index {idx} is out of range for available boxes")
append_box(0, boxes[idx])
mask_tensor = self._build_mask_tensor(image, selections, max_dims, batch, len(records))
return (json.dumps(centers), selected_boxes, mask_tensor)
@staticmethod
def _build_empty_mask(image):
if image is not None:
tensor = image
if tensor.dim() == 3:
tensor = tensor.unsqueeze(0)
return torch.zeros((tensor.shape[0], tensor.shape[1], tensor.shape[2]), dtype=torch.float32, device=tensor.device)
return torch.zeros((1, 1, 1), dtype=torch.float32)
def _build_mask_tensor(self, image, selections, max_dims, batch_mode, record_count):
if not selections:
return self._build_empty_mask(image)
if image is not None:
tensor = image
if tensor.dim() == 3:
tensor = tensor.unsqueeze(0)
base = torch.zeros((tensor.shape[0], tensor.shape[1], tensor.shape[2]), dtype=torch.float32, device=tensor.device)
else:
batch_size = record_count if batch_mode else 1
max_width = max((dims[0] for dims in max_dims.values()), default=1)
max_height = max((dims[1] for dims in max_dims.values()), default=1)
base = torch.zeros((batch_size, max_height, max_width), dtype=torch.float32)
for batch_idx, box in selections:
min_x, min_y, max_x, max_y = box
width = base.shape[2]
height = base.shape[1]
x0 = max(0, min(int(min_x), width - 1))
y0 = max(0, min(int(min_y), height - 1))
x1 = max(x0 + 1, min(int(max_x), width))
y1 = max(y0 + 1, min(int(max_y), height))
base[batch_idx, y0:y1, x0:x1] = 1.0
return base
NODE_CLASS_MAPPINGS = {
"AILab_Florence2ToCoordinates": AILab_Florence2ToCoordinates,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"AILab_Florence2ToCoordinates": "Florence2 Box Coordinates (RMBG)",
}
+279
View File
@@ -0,0 +1,279 @@
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)",
}