RBG Pad Pro 1.2 — Changelog

feat(RBGPadPro): major update with RTX VSR, advanced resamplers, noise outpainting & auto-crop

- Integrated NVIDIA RTX Video Super Resolution (VSR) upscaling via nvvfx
- Added advanced resampling filters (magic_kernel_sharp, mitchell_netravali, PyTorch AA, dwt_haar)
- Added Auto-Crop to Aspect Ratio feature alongside existing aspect padding
- Added seeded outpainting noise generation with Color, Greyscale, and Greyscale+Mask modes
- Added canvas divisibility alignment options (2, 4, 8, 16, 32, 64, 128, 256)
- Added real-time node preview functionality using PromptServer
- Enhanced RGBA tensor handling, auto-border crop precision, and spatial position alignments
This commit is contained in:
Ramon Guthrie
2026-08-20 12:01:10 +01:00
committed by GitHub
parent 89eca5cd21
commit a1695fb0e0
4 changed files with 297 additions and 159 deletions
+70 -8
View File
@@ -65,21 +65,83 @@ def _nvidia_rtx_vsr_upscale(image_cf, target_w: int, target_h: int):
return None
import torch.nn.functional as F
from torchvision.transforms import InterpolationMode
from .rbg_resampling import advanced_resample as _advanced_resample
def _legacy_advanced_resample(samples, target_width, target_height, method):
"""
Advanced PyTorch Resampling Engine supporting Magic Kernel Sharp,
Mitchell-Netravali, Wavelet DWT, Anti-Aliased Filters, and standard methods.
Expects BCHW tensor in [0.0, 1.0].
"""
_, _, h, w = samples.shape
if h == target_height and w == target_width:
return samples
if method == "magic_kernel_sharp":
down = comfy.utils.common_upscale(samples, target_width, target_height, "bicubic", "disabled")
kernel = torch.tensor([[0, -0.125, 0], [-0.125, 1.5, -0.125], [0, -0.125, 0]], dtype=samples.dtype, device=samples.device).view(1, 1, 3, 3)
kernel = kernel.repeat(samples.shape[1], 1, 1, 1)
padded_down = F.pad(down, (1, 1, 1, 1), mode='replicate')
sharpened = F.conv2d(padded_down, kernel, padding=0, groups=samples.shape[1])
return torch.clamp(sharpened, 0.0, 1.0)
elif method == "mitchell_netravali":
bicubic_aa = comfy.utils.common_upscale(samples, target_width, target_height, "bicubic", "disabled")
area_aa = comfy.utils.common_upscale(samples, target_width, target_height, "area", "disabled")
return torch.clamp(0.66 * bicubic_aa + 0.34 * area_aa, 0.0, 1.0)
elif method == "anti_aliased_bicubic":
if HAS_TVF:
out = TVF.resize(samples, [target_height, target_width], interpolation=InterpolationMode.BICUBIC, antialias=True)
else:
out = comfy.utils.common_upscale(samples, target_width, target_height, "bicubic", "disabled")
return torch.clamp(out, 0.0, 1.0)
elif method == "anti_aliased_lanczos":
out = comfy.utils.common_upscale(samples, target_width, target_height, "lanczos", "disabled")
return torch.clamp(out, 0.0, 1.0)
elif method == "dwt_haar":
scaled = samples
while scaled.shape[2] >= target_height * 2 and scaled.shape[3] >= target_width * 2:
scaled = F.avg_pool2d(scaled, kernel_size=2, stride=2)
out = comfy.utils.common_upscale(scaled, target_width, target_height, "bicubic", "disabled")
return torch.clamp(out, 0.0, 1.0)
else:
out = comfy.utils.common_upscale(samples, target_width, target_height, method, "disabled")
return torch.clamp(out, 0.0, 1.0)
def _rtx_aware_upscale(image_cf, target_w: int, target_h: int, method: str):
"""
Drop-in replacement for comfy.utils.common_upscale that adds nvidia_rtx_vsr.
Drop-in replacement for comfy.utils.common_upscale that adds nvidia_rtx_vsr and advanced resample methods.
image_cf is (B, C, H, W). Returns (B, C, H, W).
"""
if method == "nvidia_rtx_vsr":
result = _nvidia_rtx_vsr_upscale(image_cf, target_w, target_h)
if result is not None:
return result
return comfy.utils.common_upscale(image_cf, target_w, target_h, "lanczos", "disabled")
return comfy.utils.common_upscale(image_cf, target_w, target_h, method, "disabled")
return torch.clamp(result, 0.0, 1.0)
res = comfy.utils.common_upscale(image_cf, target_w, target_h, "lanczos", "disabled")
return torch.clamp(res, 0.0, 1.0)
return _advanced_resample(image_cf, target_w, target_h, method)
class RBGImageStitchPlus:
upscale_methods = ["nvidia_rtx_vsr", "lanczos", "bicubic", "nearest-exact", "bilinear", "area"]
upscale_methods = [
"nvidia_rtx_vsr",
"lanczos",
"bicubic",
"nearest-exact",
"bilinear",
"area",
"magic_kernel_sharp",
"mitchell_netravali",
"anti_aliased_bicubic",
"anti_aliased_lanczos",
"dwt_haar",
]
@classmethod
def INPUT_TYPES(s):
@@ -98,11 +160,11 @@ class RBGImageStitchPlus:
# Final Resizing Options
"final_resize_mode": (["🚫 none", "↔️ resize_longer_side", "↕️ resize_shorter_side"], { "default": "🚫 none" }),
"final_target_size": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8}),
"resample_filter": (s.upscale_methods, {"default": "bicubic", "tooltip": "nvidia_rtx_vsr: NVIDIA RTX AI upscaler (pip install nvvfx, RTX GPU required, upscale only, auto-fallback) | Interpolation for general resizing and the upscaling part of supersampling."}),
"resample_filter": (s.upscale_methods, {"default": "magic_kernel_sharp", "tooltip": "magic_kernel_sharp: Magic Kernel 3 + Sharp-2013 | mitchell_netravali: B=C=1/3 spline | anti_aliased_bicubic: PyTorch AA | dwt_haar: Haar LL low-pass downscaling | nvidia_rtx_vsr: RTX AI upscaler"}),
# Supersampling for anti-aliasing
"supersample_factor": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 4.0, "step": 0.1, "tooltip": "Upscales then downscales the final image for anti-aliasing. Factor > 1 enables it."}),
"final_downsample_interpolation": (s.upscale_methods, {"default": "area", "tooltip": "Interpolation for the downsampling part of supersampling. 'area' is often best for this."}),
"final_downsample_interpolation": (s.upscale_methods, {"default": "magic_kernel_sharp", "tooltip": "Interpolation for downsampling part of supersampling."}),
# Clarity (Midtone Contrast)
"clarity_strength": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01, "tooltip": "Adjusts midtone contrast. Negative values for a dreamlike look, positive for punchy. -100=soft, +100=punchy."}),
@@ -537,4 +599,4 @@ class RBGImageStitchPlus:
return (out_image, new_width, new_height)
NODE_CLASS_MAPPINGS = { "RBGImageStitchPlus": RBGImageStitchPlus }
NODE_DISPLAY_NAME_MAPPINGS = { "RBGImageStitchPlus": "RBG Image Stitch Plus" }
NODE_DISPLAY_NAME_MAPPINGS = { "RBGImageStitchPlus": "RBG Image Stitch Plus" }
+199 -123
View File
@@ -1,20 +1,23 @@
import torch
import comfy.utils
from torchvision.transforms import functional as TF
from torchvision.transforms import InterpolationMode
import numpy as np
from PIL import Image
# License: GPL-3.0-or-later
# RBG Krea2 Smart FaceDetailer 🎨
import io
import base64
import numpy as np
from PIL import Image
import torch
import torch.nn.functional as F
from torchvision.transforms import functional as TF
from torchvision.transforms import InterpolationMode
import comfy.utils
from server import PromptServer
import cv2
from .rbg_resampling import advanced_resample as _advanced_resample
MAX_RESOLUTION = 8192
# --- NVIDIA RTX Video Super Resolution ---
# Requires: pip install nvvfx (RTX GPU + NVIDIA driver 570+ required)
# Always present in the upscale_methods list so saved workflows remain portable.
# Silently falls back to lanczos when nvvfx is not installed or on downscale requests.
try:
import nvvfx as _nvvfx
_NVVFX_AVAILABLE = True
@@ -24,47 +27,74 @@ except ImportError:
_rtx_unavail_warned = False
def _nvidia_rtx_vsr_upscale(image_cf, target_w: int, target_h: int):
def _nvidia_rtx_vsr_upscale(image_cf: torch.Tensor, target_w: int, target_h: int):
"""
Upscale a (B, C, H, W) float32 CUDA tensor via NVIDIA RTX Video Super Resolution.
Output dimensions are set directly — no integer-scale-factor constraint.
Returns a (B, C, H, W) float32 tensor on the original device, or None on failure.
Handles RGB (3 channels) or RGBA (4 channels) by isolating RGB and preserving Alpha.
"""
global _rtx_unavail_warned
src_h, src_w = image_cf.shape[2], image_cf.shape[3]
if target_w <= src_w and target_h <= src_h:
return None # RTX VSR is an upscaler only
# RTX VSR is strictly an upscaler; cannot downscale
return None
if not _NVVFX_AVAILABLE:
if not _rtx_unavail_warned:
print(
"RBG: nvidia_rtx_vsr selected but 'nvvfx' is not installed.\n"
" → pip install nvvfx (RTX GPU + NVIDIA driver 570+ required)\n"
" → pip install nvidia-vfx (RTX GPU + NVIDIA driver 570+ required)\n"
" Falling back to lanczos."
)
_rtx_unavail_warned = True
return None
if not torch.cuda.is_available():
print("RBG: CUDA not available for nvidia_rtx_vsr. Falling back to lanczos.")
return None
try:
# Separate RGB (3 channels) from Alpha (4th channel) if present
has_alpha = image_cf.shape[1] == 4
rgb_tensor = image_cf[:, :3, :, :]
alpha_tensor = image_cf[:, 3:4, :, :] if has_alpha else None
target_device = image_cf.device if image_cf.device.type == "cuda" else torch.device("cuda:0")
device_idx = target_device.index if target_device.index is not None else 0
# NVIDIA RTX VSR output dimensions must be divisible by 2 or 8
out_w = max(8, round(target_w / 8) * 8)
out_h = max(8, round(target_h / 8) * 8)
with _nvvfx.VideoSuperRes(_nvvfx.effects.QualityLevel.ULTRA) as nvvfx_sr:
try:
nvvfx_sr = _nvvfx.VideoSuperRes(_nvvfx.effects.QualityLevel.ULTRA, device=device_idx)
except TypeError:
nvvfx_sr = _nvvfx.VideoSuperRes(_nvvfx.effects.QualityLevel.ULTRA)
with nvvfx_sr:
nvvfx_sr.output_width = out_w
nvvfx_sr.output_height = out_h
nvvfx_sr.load()
frames_chw = image_cf.cuda().contiguous()
# Ensure (3, H, W) on CUDA float32
frames_chw = rgb_tensor.to(device=target_device, dtype=torch.float32).contiguous()
upscaled_frames = []
for j in range(frames_chw.shape[0]):
dlpack_out = nvvfx_sr.run(frames_chw[j]).image
frame_3ch = frames_chw[j] # Shape: (3, H, W)
dlpack_out = nvvfx_sr.run(frame_3ch).image
upscaled_frames.append(torch.from_dlpack(dlpack_out).clone())
result = torch.stack(upscaled_frames, dim=0) # (B, C, H, W)
result = torch.stack(upscaled_frames, dim=0)
# Exact pixel dimension alignment if target dimensions were not a multiple of 8
if result.shape[3] != target_w or result.shape[2] != target_h:
result = comfy.utils.common_upscale(result, target_w, target_h, "lanczos", "disabled")
# Upscale and recombine Alpha channel if present
if has_alpha and alpha_tensor is not None:
upscaled_alpha = comfy.utils.common_upscale(alpha_tensor, target_w, target_h, "bicubic", "disabled")
result = torch.cat([result, upscaled_alpha.to(result.device)], dim=1)
return result.to(image_cf.device)
except Exception as e:
@@ -72,17 +102,18 @@ def _nvidia_rtx_vsr_upscale(image_cf, target_w: int, target_h: int):
return None
def _rtx_aware_upscale(image_cf, target_w: int, target_h: int, method: str):
def _rtx_aware_upscale(image_cf: torch.Tensor, target_w: int, target_h: int, method: str):
"""
Drop-in replacement for comfy.utils.common_upscale that adds nvidia_rtx_vsr.
image_cf is (B, C, H, W). Returns (B, C, H, W).
Drop-in replacement for comfy.utils.common_upscale that integrates nvidia_rtx_vsr and advanced resamplers.
"""
if method == "nvidia_rtx_vsr":
result = _nvidia_rtx_vsr_upscale(image_cf, target_w, target_h)
if result is not None:
return result
return comfy.utils.common_upscale(image_cf, target_w, target_h, "lanczos", "disabled")
return comfy.utils.common_upscale(image_cf, target_w, target_h, method, "disabled")
return torch.clamp(result, 0.0, 1.0)
res = comfy.utils.common_upscale(image_cf, target_w, target_h, "lanczos", "disabled")
return torch.clamp(res, 0.0, 1.0)
return _advanced_resample(image_cf, target_w, target_h, method)
class RBGPadPro:
ASPECT_RATIOS = [
@@ -100,7 +131,21 @@ class RBGPadPro:
"🎞️ 21:9 Landscape (Cinematic Widescreen)",
]
upscale_methods = ["nvidia_rtx_vsr", "lanczos", "bicubic", "nearest-exact", "bilinear", "area"]
upscale_methods = [
"nvidia_rtx_vsr",
"lanczos",
"bicubic",
"nearest-exact",
"bilinear",
"area",
"magic_kernel_sharp",
"mitchell_netravali",
"anti_aliased_bicubic",
"anti_aliased_lanczos",
"dwt_haar",
]
DIVISIBILITY_OPTIONS = ["None", "2", "4", "8", "16", "32", "64", "128", "256"]
@classmethod
def INPUT_TYPES(s):
@@ -113,18 +158,18 @@ class RBGPadPro:
"pad_top": ("INT", {"default": 0, "min": 0, "max": MAX_RESOLUTION, "step": 1}),
"pad_bottom": ("INT", {"default": 0, "min": 0, "max": MAX_RESOLUTION, "step": 1}),
"mask_blur_sigma": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 300.0, "step": 0.1}),
"pad_color": ("STRING", {"default": "#FFFFFF"}),
"pad_color": ("STRING", {"default": "#00FF00"}),
"image_position": (["center", "left", "right", "top", "bottom"], {"default": "center"}),
"image_offset_x": ("INT", {"default": 0, "min": -MAX_RESOLUTION, "max": MAX_RESOLUTION, "step": 1}),
"image_offset_y": ("INT", {"default": 0, "min": -MAX_RESOLUTION, "max": MAX_RESOLUTION, "step": 1}),
"image_scale": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}),
"fill_transparent_background": ("BOOLEAN", {"default": False}),
"transparent_fill_color": ("STRING", {"default": "#000000"}),
"transparent_fill_color": ("STRING", {"default": "#7F7F7F"}),
"pad_aspect_ratio": (s.ASPECT_RATIOS, {"default": "custom"}),
"resize_mode": (["🚫 none", "↔️ resize_longer_side", "↕️ resize_shorter_side"], { "default": "🚫 none" }),
"resize_mode": (["🚫 none", "↔️ resize_longer_side", "↕️ resize_shorter_side"], {"default": "🚫 none"}),
"target_size": ("INT", {"default": 1024, "min": 64, "max": 8192, "step": 8}),
"resample_filter": (s.upscale_methods, {"default": "bicubic", "tooltip": "nvidia_rtx_vsr: NVIDIA RTX AI upscaler (pip install nvvfx, RTX GPU required, upscale only, auto-fallback) | Bicubic: Standard | Lanczos: Sharp | Area: Best for downscaling."}),
"auto_crop": ("BOOLEAN", {"default": False, "label_on": "🔳 Auto-Crop (Square)", "label_off": "✖️ Auto-Crop (Square)", "tooltip": "Automatically crops the image to a square based on the smaller dimension."}),
"resample_filter": (s.upscale_methods, {"default": "magic_kernel_sharp", "tooltip": "magic_kernel_sharp: Costella MK3 + Sharp-2013 | mitchell_netravali: B=C=1/3 Spline | anti_aliased_bicubic: PyTorch AA Bicubic | dwt_haar: Haar LL DWT Downscaling | nvidia_rtx_vsr: AI TensorRT Upscaling"}),
"auto_crop_aspect_ratio": ("BOOLEAN", {"default": False, "label_on": "✂️ Auto-Crop to Ratio", "label_off": "🚫 Auto-Crop to Ratio", "tooltip": "Crops the image to match the selected pad_aspect_ratio instead of expanding the canvas with padding."}),
"invert_mask": ("BOOLEAN", {"default": True}),
"flip_horizontal": ("BOOLEAN", {"default": False, "label_on": "↔️ Flip Horizontal", "label_off": "🚫 Flip Horizontal"}),
"image_rotation": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 0.01}),
@@ -133,6 +178,7 @@ class RBGPadPro:
"pad_noise_strength": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.001, "display": "slider", "tooltip": "Strength of noise added to the padded area. Useful for outpainting."}),
"noise_seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "tooltip": "Seed for the random noise generation."}),
"noise_mode": (["Color (Default)", "Greyscale", "Greyscale + Mask"], {"default": "Color (Default)"}),
"divisibility": (s.DIVISIBILITY_OPTIONS, {"default": "None", "tooltip": "Enforce output dimensions to be divisible by this amount (e.g., 8, 16, 32 for model compatibility)"}),
"show_preview": ("BOOLEAN", {"default": False, "label_on": "Preview Enabled", "label_off": "Preview Disabled"}),
},
"optional": {
@@ -144,47 +190,41 @@ class RBGPadPro:
FUNCTION = "pad_image"
CATEGORY = "RBG-Suite-Pack"
def hex_to_rgb(self, hex_color):
def hex_to_rgb(self, hex_color: str):
hex_color = hex_color.lstrip('#')
if len(hex_color) == 3:
hex_color = ''.join([c * 2 for c in hex_color])
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
def get_image_info(self, image_tensor):
def get_image_info(self, image_tensor: torch.Tensor):
if image_tensor is None:
return "Output: No image"
batch_size, height, width, channels = image_tensor.shape
batch_size, height, width, _ = image_tensor.shape
num_elements = image_tensor.numel()
element_size = image_tensor.element_size()
total_size_bytes = num_elements * element_size
total_size_mb = total_size_bytes / (1024 * 1024)
total_size_mb = (num_elements * element_size) / (1024 * 1024)
return f"Output: {batch_size} x {width} x {height} | {total_size_mb:.2f}MB"
def prepare_image_for_display(self, image_tensor: torch.Tensor):
"""Convert tensor image to base64 for frontend display"""
if image_tensor.dim() == 4 and image_tensor.shape[0] == 1:
img_np = image_tensor.squeeze(0).cpu().numpy()
img_np = image_tensor.squeeze(0).detach().cpu().numpy()
else:
img_np = image_tensor.cpu().numpy()
img_np = image_tensor.detach().cpu().numpy()
img_pil = Image.fromarray((img_np * 255).astype(np.uint8))
max_size = (512, 512)
img_pil.thumbnail(max_size, Image.Resampling.LANCZOS)
img_pil = Image.fromarray((img_np.clip(0, 1) * 255).astype(np.uint8))
img_pil.thumbnail((512, 512), Image.Resampling.LANCZOS)
buffered = io.BytesIO()
img_pil.save(buffered, format="PNG")
img_str = base64.b64encode(buffered.getvalue()).decode()
return f"data:image/png;base64,{img_str}"
return f"data:image/png;base64,{base64.b64encode(buffered.getvalue()).decode()}"
def _ensure_rgba(self, image_tensor):
def _ensure_rgba(self, image_tensor: torch.Tensor):
if image_tensor.shape[-1] == 3:
alpha_channel = torch.ones(*image_tensor.shape[:-1], 1, device=image_tensor.device, dtype=image_tensor.dtype)
return torch.cat([image_tensor, alpha_channel], dim=-1)
return image_tensor
def get_edge_color(self, image_tensor):
def get_edge_color(self, image_tensor: torch.Tensor):
edges = torch.cat([
image_tensor[:, 0, :, :3].reshape(-1, 3), image_tensor[:, -1, :, :3].reshape(-1, 3),
image_tensor[:, :, 0, :3].reshape(-1, 3), image_tensor[:, :, -1, :3].reshape(-1, 3)
@@ -194,25 +234,26 @@ class RBGPadPro:
def _calculate_gaussian_kernel_size(self, sigma: float) -> int:
return max(1, 2 * int(round(3 * sigma)) + 1)
def pad_image(self, image, pad_mode, pad_left, pad_right, pad_top, pad_bottom, mask_blur_sigma, pad_color, pad_noise_strength, noise_seed, noise_mode, image_position, image_offset_x, image_offset_y, image_scale, fill_transparent_background, transparent_fill_color, pad_aspect_ratio, resize_mode, target_size, resample_filter, auto_crop, invert_mask, flip_horizontal, image_rotation, BorderCrop_threshold=0.0, BorderCrop_color="#000000", show_preview=False, mask=None):
def pad_image(self, image, pad_mode, pad_left, pad_right, pad_top, pad_bottom, mask_blur_sigma, pad_color, pad_noise_strength, noise_seed, noise_mode, image_position, image_offset_x, image_offset_y, image_scale, fill_transparent_background, transparent_fill_color, pad_aspect_ratio, resize_mode, target_size, resample_filter, auto_crop_aspect_ratio, invert_mask, flip_horizontal, image_rotation, BorderCrop_threshold=0.0, BorderCrop_color="#000000", divisibility="None", show_preview=False, mask=None):
pad_mode = pad_mode.split(" ", 1)[-1] if " " in pad_mode else pad_mode
resize_mode = resize_mode.split(" ", 1)[-1] if " " in resize_mode else resize_mode # Updated to handle emoji
resize_mode = resize_mode.split(" ", 1)[-1] if " " in resize_mode else resize_mode
image = self._ensure_rgba(image)
div_factor = int(divisibility) if divisibility != "None" else 1
# Border cropping
if BorderCrop_threshold > 0:
check_channels = 3 if image.shape[-1] > 3 else image.shape[-1]
try:
border_rgb = self.hex_to_rgb(BorderCrop_color)
except:
except Exception:
border_rgb = (0, 0, 0)
border_tensor = torch.tensor(border_rgb, device=image.device, dtype=image.dtype).div(255.0).view(1, 1, 1, 3)
img_check = image[..., :check_channels]
if img_check.shape[-1] == 1:
border_tensor = (border_tensor[..., 0] * 0.299 + border_tensor[..., 1] * 0.587 + border_tensor[..., 2] * 0.114).unsqueeze(-1)
mask_threshold = (torch.abs(img_check - border_tensor) > BorderCrop_threshold).any(dim=-1).any(dim=0)
coords = torch.nonzero(mask_threshold)
if len(coords) > 0:
@@ -237,49 +278,76 @@ class RBGPadPro:
mask = mask.squeeze(1)
B, orig_H, orig_W, C = image.shape
crop_rect = (0, 0, orig_W, orig_H)
if auto_crop:
if orig_W > orig_H:
crop_rect = ((orig_W - orig_H) // 2, 0, orig_H, orig_H)
elif orig_H > orig_W:
crop_rect = (0, (orig_H - orig_W) // 2, orig_W, orig_W)
if image_position == 'left':
crop_rect = (0, 0, orig_H, orig_H)
elif image_position == 'right':
crop_rect = (orig_W - orig_H, 0, orig_H, orig_H)
elif image_position == 'top':
crop_rect = (0, 0, orig_W, orig_W)
elif image_position == 'bottom':
crop_rect = (0, orig_H - orig_W, orig_W, orig_W)
crop_W, crop_H = crop_rect[2], crop_rect[3]
aspect_pad_h, aspect_pad_w = 0, 0
# Parse Aspect Ratio
target_ratio = None
if pad_aspect_ratio != "custom":
ratio_part = next((part for part in pad_aspect_ratio.split(' ') if ':' in part), None)
if ratio_part is None: raise ValueError(f"Invalid ratio: '{pad_aspect_ratio}'")
if ratio_part is None:
raise ValueError(f"Invalid ratio: '{pad_aspect_ratio}'")
w_ratio, h_ratio = map(int, ratio_part.split(':'))
target_ratio, current_ratio = w_ratio / h_ratio, crop_W / crop_H
target_ratio = w_ratio / h_ratio
# Auto-Crop to Aspect Ratio Logic
crop_rect = (0, 0, orig_W, orig_H)
if auto_crop_aspect_ratio and target_ratio is not None:
current_ratio = orig_W / orig_H
if abs(current_ratio - target_ratio) > 1e-6:
if current_ratio < target_ratio: aspect_pad_w = round(crop_H * target_ratio) - crop_W
else: aspect_pad_h = round(crop_W / target_ratio) - crop_H
if current_ratio > target_ratio:
crop_H = orig_H
crop_W = int(round(orig_H * target_ratio))
if image_position == 'left':
crop_x = 0
elif image_position == 'right':
crop_x = orig_W - crop_W
else:
crop_x = (orig_W - crop_W) // 2
crop_y = 0
else:
crop_W = orig_W
crop_H = int(round(orig_W / target_ratio))
if image_position == 'top':
crop_y = 0
elif image_position == 'bottom':
crop_y = orig_H - crop_H
else:
crop_y = (orig_H - crop_H) // 2
crop_x = 0
crop_rect = (crop_x, crop_y, crop_W, crop_H)
crop_W, crop_H = crop_rect[2], crop_rect[3]
# Pad to Aspect Ratio Logic (Only when auto-crop is OFF)
aspect_pad_h, aspect_pad_w = 0, 0
if pad_aspect_ratio != "custom" and not auto_crop_aspect_ratio and target_ratio is not None:
current_ratio = crop_W / crop_H
if abs(current_ratio - target_ratio) > 1e-6:
if current_ratio < target_ratio:
aspect_pad_w = round(crop_H * target_ratio) - crop_W
else:
aspect_pad_h = round(crop_W / target_ratio) - crop_H
final_width = crop_W + pad_left + pad_right + aspect_pad_w
final_height = crop_H + pad_top + pad_bottom + aspect_pad_h
# Enforce divisibility upfront if not resizing afterwards
if resize_mode == "none" and div_factor > 1:
final_width = ((final_width + div_factor - 1) // div_factor) * div_factor
final_height = ((final_height + div_factor - 1) // div_factor) * div_factor
scaled_image = image
scaled_crop_rect = crop_rect
if image_scale != 1.0:
scaled_image = comfy.utils.common_upscale(image.movedim(-1,1), int(orig_W * image_scale), int(orig_H * image_scale), "lanczos", "disabled").movedim(1,-1)
scaled_crop_rect = tuple(int(x * image_scale) for x in crop_rect)
if mask is not None:
mask = comfy.utils.common_upscale(mask.unsqueeze(1), int(orig_W * image_scale), int(orig_H * image_scale), "bilinear", "disabled").squeeze(1)
target_scale_w = int(round(orig_W * image_scale))
target_scale_h = int(round(orig_H * image_scale))
scaled_image = _rtx_aware_upscale(image.movedim(-1, 1), target_scale_w, target_scale_h, resample_filter).movedim(1, -1)
scaled_crop_rect = tuple(int(round(x * image_scale)) for x in crop_rect)
if mask is not None:
scaled_mask = comfy.utils.common_upscale(mask.unsqueeze(1), target_scale_w, target_scale_h, "bilinear", "disabled").squeeze(1)
B, scaled_H_full, scaled_W_full, C = scaled_image.shape
scaled_crop_W, scaled_crop_H = scaled_crop_rect[2], scaled_crop_rect[3]
scaled_mask = mask if mask is not None else torch.ones((B, scaled_H_full, scaled_W_full), device=image.device)
if mask is not None and (scaled_mask.shape[1] != scaled_H_full or scaled_mask.shape[2] != scaled_W_full):
scaled_mask = comfy.utils.common_upscale(scaled_mask.unsqueeze(1), scaled_W_full, scaled_H_full, "bilinear", "disabled").squeeze(1)
@@ -289,9 +357,11 @@ class RBGPadPro:
region_mask = torch.zeros((B, final_height, final_width), device=image.device, dtype=torch.float32)
position_map = {
'center': ((final_width - scaled_crop_W)//2, (final_height-scaled_crop_H)//2), 'left': (0, (final_height-scaled_crop_H)//2),
'right': (final_width-scaled_crop_W, (final_height-scaled_crop_H)//2), 'top': ((final_width-scaled_crop_W)//2, 0),
'bottom': ((final_width-scaled_crop_W)//2, final_height-scaled_crop_H)
'center': ((final_width - scaled_crop_W) // 2, (final_height - scaled_crop_H) // 2),
'left': (0, (final_height - scaled_crop_H) // 2),
'right': (final_width - scaled_crop_W, (final_height - scaled_crop_H) // 2),
'top': ((final_width - scaled_crop_W) // 2, 0),
'bottom': ((final_width - scaled_crop_W) // 2, final_height - scaled_crop_H)
}
viewport_pos_x, viewport_pos_y = position_map.get(image_position, position_map['center'])
@@ -300,7 +370,7 @@ class RBGPadPro:
dst_start_x = viewport_pos_x
dst_start_y = viewport_pos_y
copy_width = scaled_crop_W
copy_height = scaled_crop_H
@@ -308,7 +378,7 @@ class RBGPadPro:
dst_start_x += -src_start_x
copy_width += src_start_x
src_start_x = 0
if src_start_y < 0:
dst_start_y += -src_start_y
copy_height += src_start_y
@@ -324,11 +394,8 @@ class RBGPadPro:
copy_height += dst_start_y
dst_start_y = 0
copy_width = int(min(copy_width, scaled_W_full - src_start_x))
copy_height = int(min(copy_height, scaled_H_full - src_start_y))
copy_width = int(min(copy_width, final_width - dst_start_x))
copy_height = int(min(copy_height, final_height - dst_start_y))
copy_width = int(min(copy_width, scaled_W_full - src_start_x, final_width - dst_start_x))
copy_height = int(min(copy_height, scaled_H_full - src_start_y, final_height - dst_start_y))
copy_width = max(0, copy_width)
copy_height = max(0, copy_height)
@@ -347,10 +414,14 @@ class RBGPadPro:
canvas[:, can_slice[0], can_slice[1], :] = scaled_image[:, img_slice[0], img_slice[1], :]
mask_canvas[:, can_slice[0], can_slice[1]] = scaled_mask[:, img_slice[0], img_slice[1]]
img_end_y, img_end_x = dst_start_y + copy_height, dst_start_x + copy_width
if dst_start_y > 0: canvas[:, 0:dst_start_y, dst_start_x:img_end_x, :] = canvas[:, dst_start_y:dst_start_y+1, dst_start_x:img_end_x, :].repeat(1, dst_start_y, 1, 1)
if img_end_y < final_height: canvas[:, img_end_y:final_height, dst_start_x:img_end_x, :] = canvas[:, img_end_y-1:img_end_y, dst_start_x:img_end_x, :].repeat(1, final_height-img_end_y, 1, 1)
if dst_start_x > 0: canvas[:, :, 0:dst_start_x, :] = canvas[:, :, dst_start_x:dst_start_x+1, :].repeat(1, 1, dst_start_x, 1)
if img_end_x < final_width: canvas[:, :, img_end_x:final_width, :] = canvas[:, :, img_end_x-1:img_end_x, :].repeat(1, 1, final_width-img_end_x, 1)
if dst_start_y > 0:
canvas[:, 0:dst_start_y, dst_start_x:img_end_x, :] = canvas[:, dst_start_y:dst_start_y+1, dst_start_x:img_end_x, :].repeat(1, dst_start_y, 1, 1)
if img_end_y < final_height:
canvas[:, img_end_y:final_height, dst_start_x:img_end_x, :] = canvas[:, img_end_y-1:img_end_y, dst_start_x:img_end_x, :].repeat(1, final_height-img_end_y, 1, 1)
if dst_start_x > 0:
canvas[:, :, 0:dst_start_x, :] = canvas[:, :, dst_start_x:dst_start_x+1, :].repeat(1, 1, dst_start_x, 1)
if img_end_x < final_width:
canvas[:, :, img_end_x:final_width, :] = canvas[:, :, img_end_x-1:img_end_x, :].repeat(1, 1, final_width-img_end_x, 1)
elif pad_mode == 'transparent_fill':
canvas[:, can_slice[0], can_slice[1], :] = scaled_image[:, img_slice[0], img_slice[1], :]
mask_canvas[:, can_slice[0], can_slice[1]] = scaled_image[:, img_slice[0], img_slice[1], 3]
@@ -359,63 +430,67 @@ class RBGPadPro:
mask_part = scaled_mask[:, img_slice[0], img_slice[1]].unsqueeze(-1)
canvas[:, can_slice[0], can_slice[1], :] = img_part * mask_part + canvas[:, can_slice[0], can_slice[1], :] * (1 - mask_part)
mask_canvas[:, can_slice[0], can_slice[1]] = scaled_mask[:, img_slice[0], img_slice[1]]
if mask_blur_sigma > 0:
kernel_size = self._calculate_gaussian_kernel_size(mask_blur_sigma)
if kernel_size > 1: mask_canvas = TF.gaussian_blur(mask_canvas.unsqueeze(1), kernel_size=[kernel_size, kernel_size], sigma=[mask_blur_sigma, mask_blur_sigma]).squeeze(1)
if kernel_size > 1:
mask_canvas = TF.gaussian_blur(mask_canvas.unsqueeze(1), kernel_size=[kernel_size, kernel_size], sigma=[mask_blur_sigma, mask_blur_sigma]).squeeze(1)
if fill_transparent_background and C == 4:
fill_color_tensor = torch.tensor(self.hex_to_rgb(transparent_fill_color), device=image.device, dtype=image.dtype).div(255.0)
alpha = canvas[..., 3:]; canvas[..., :3] = canvas[..., :3] * alpha + fill_color_tensor.view(1, 1, 1, 3) * (1 - alpha); canvas[..., 3] = 1.0
alpha = canvas[..., 3:]
canvas[..., :3] = canvas[..., :3] * alpha + fill_color_tensor.view(1, 1, 1, 3) * (1 - alpha)
canvas[..., 3] = 1.0
# Resizing mode with divisibility alignment (defaulting to 8 if None)
if resize_mode != "none":
B, H, W, C = canvas.shape
if W > 0 and H > 0:
align_factor = max(8, div_factor) if div_factor > 1 else 8
ratio = target_size / max(W, H) if resize_mode == "resize_longer_side" else target_size / min(W, H)
target_width, target_height = (round(W * ratio) // 8) * 8, (round(H * ratio) // 8) * 8
target_width = max(align_factor, (round(W * ratio) // align_factor) * align_factor)
target_height = max(align_factor, (round(H * ratio) // align_factor) * align_factor)
if target_width > 0 and target_height > 0:
# Masks are single-channel; nvidia_rtx_vsr (and lanczos) need special handling
if resample_filter in ("nvidia_rtx_vsr", "lanczos"):
mask_resample_filter = "bicubic"
else:
mask_resample_filter = resample_filter
canvas = _rtx_aware_upscale(canvas.movedim(-1,1), target_width, target_height, resample_filter).movedim(1,-1)
COMFY_NATIVE_MODES = {"nearest-exact", "bilinear", "bicubic", "area", "lanczos"}
mask_resample_filter = resample_filter if resample_filter in COMFY_NATIVE_MODES else "bicubic"
canvas = _rtx_aware_upscale(canvas.movedim(-1, 1), target_width, target_height, resample_filter).movedim(1, -1)
mask_canvas = comfy.utils.common_upscale(mask_canvas.unsqueeze(1), target_width, target_height, mask_resample_filter, "disabled").squeeze(1)
region_mask = comfy.utils.common_upscale(region_mask.unsqueeze(1), target_width, target_height, "nearest-exact", "disabled").squeeze(1)
# Pad Noise with local Generator to preserve global random states
if pad_noise_strength > 0:
B, H, W, C = canvas.shape
torch.manual_seed(noise_seed)
gen_device = canvas.device if canvas.device.type == "cuda" else torch.device("cpu")
generator = torch.Generator(device=gen_device)
generator.manual_seed(noise_seed)
# Generate Noise
if "Greyscale" in noise_mode:
greyscale_noise = torch.randn((B, H, W, 1), device=canvas.device, dtype=canvas.dtype) * pad_noise_strength
greyscale_noise = torch.randn((B, H, W, 1), generator=generator, device=gen_device, dtype=canvas.dtype).to(canvas.device) * pad_noise_strength
noise_for_image = greyscale_noise.repeat(1, 1, 1, 3)
else:
noise_for_image = torch.randn((B, H, W, 3), device=canvas.device, dtype=canvas.dtype) * pad_noise_strength
greyscale_noise = None # Not available for color mode
noise_for_image = torch.randn((B, H, W, 3), generator=generator, device=gen_device, dtype=canvas.dtype).to(canvas.device) * pad_noise_strength
greyscale_noise = None
# Determine Application Mask for the IMAGE
if noise_mode == "Greyscale + Mask":
padding_mask_for_image = (1.0 - (region_mask * mask_canvas)).clamp(0.0, 1.0).unsqueeze(-1)
else:
padding_mask_for_image = (1.0 - mask_canvas).unsqueeze(-1)
# Apply noise to IMAGE
canvas[..., :3] = torch.clamp(canvas[..., :3] + noise_for_image * padding_mask_for_image, 0.0, 1.0)
# Apply noise to MASK if in the correct mode
if noise_mode == "Greyscale + Mask" and greyscale_noise is not None:
noise_application_mask_for_mask = padding_mask_for_image.squeeze(-1)
mask_canvas = torch.clamp(mask_canvas + greyscale_noise.squeeze(-1) * noise_application_mask_for_mask, 0.0, 1.0)
if invert_mask: mask_canvas = 1.0 - mask_canvas
if invert_mask:
mask_canvas = 1.0 - mask_canvas
canvas = canvas[..., :3]
if show_preview:
try:
# We only need to preview the first image of the batch
preview_image_tensor = canvas[0:1]
preview_image = self.prepare_image_for_display(preview_image_tensor)
PromptServer.instance.send_sync("rbg_pad_pro_preview", {"image": preview_image})
@@ -425,5 +500,6 @@ class RBGPadPro:
image_info = self.get_image_info(canvas)
return {"ui": {"text": [image_info]}, "result": (canvas, mask_canvas)}
NODE_CLASS_MAPPINGS = { "RBGPadPro": RBGPadPro }
NODE_DISPLAY_NAME_MAPPINGS = { "RBGPadPro": "RBG Pad Pro" }
NODE_CLASS_MAPPINGS = {"RBGPadPro": RBGPadPro}
NODE_DISPLAY_NAME_MAPPINGS = {"RBGPadPro": "RBG Pad Pro"}
+1
View File
@@ -1,2 +1,3 @@
numpy
Pillow
nvidia-vfx
+27 -28
View File
@@ -2,10 +2,9 @@ import { app } from "/scripts/app.js";
import { api } from "/scripts/api.js";
app.registerExtension({
name: "RBGSuitePack.PadPro.Complete", // Updated name to ensure cache is cleared
name: "RBGSuitePack.PadPro.Complete",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name === "RBGPadPro") {
// This function runs once when the node is created.
const onNodeCreated = nodeType.prototype.onNodeCreated;
const MIN_NODE_HEIGHT_WITH_PREVIEW = 400;
const MIN_NODE_HEIGHT_WITHOUT_PREVIEW = 250;
@@ -16,27 +15,34 @@ app.registerExtension({
nodeType.prototype.onNodeCreated = function () {
onNodeCreated?.apply(this, arguments);
// --- START: RE-ADD TOOLTIPS ---
// Tooltips configuration
const tooltips = {
pad_mode: "Choose how to handle the padding. 'Pad' adds space, 'pad_edge' uses edge colors, 'pad_edge_pixel' extends edge pixels, 'pad_mirror' reflects the image edges, 'transparent_fill' fills transparent areas, and 'crop' cuts the image.",
pad_mode: "Choose how to handle the padding. 'pad' adds solid color, 'pad_edge' averages border colors, 'pad_edge_pixel' extends border pixels, and 'transparent_fill' fills alpha channels.",
pad_left: "Amount of padding to add to the left side.",
pad_right: "Amount of padding to add to the right side.",
pad_top: "Amount of padding to add to the top.",
pad_bottom: "Amount of padding to add to the bottom.",
mask_blur_sigma: "Applies a Gaussian blur to the generated mask, blending the padded area more smoothly.",
pad_color: "The color of the padded area.",
image_position: "The base position of the image on the canvas.",
image_offset_x: "Fine-tune the image's horizontal position.",
image_offset_y: "Fine-tune the image's vertical position.",
image_scale: "Scale the image before padding. The canvas size is not affected.",
mask_blur_sigma: "Applies a Gaussian blur to the generated mask for smoother inpainting seams.",
pad_color: "The solid color of the padded area.",
image_position: "The base alignment/anchor of the image on the canvas.",
image_offset_x: "Fine-tune the horizontal position in pixels.",
image_offset_y: "Fine-tune the vertical position in pixels.",
image_scale: "Scale the image before padding without altering the canvas bounds.",
fill_transparent_background: "Fill transparent areas with a solid color.",
transparent_fill_color: "The color to use for transparent areas.",
pad_aspect_ratio: "Automatically adjust padding to match a specific aspect ratio.",
resize_mode: "Choose how to resize the final image. 'resize_longer_side' and 'resize_shorter_side' maintain the aspect ratio.",
transparent_fill_color: "The color to use for transparent background fill.",
pad_aspect_ratio: "Target aspect ratio preset.",
resize_mode: "Choose how to resize the final image ('resize_longer_side' or 'resize_shorter_side').",
target_size: "The target size in pixels for the selected side.",
resample_filter: "The interpolation method to use for resizing.",
auto_crop: "Automatically crop the image to a 1:1 square aspect ratio before any other processing.",
invert_mask: "Invert the generated mask. Useful for outpainting workflows where you want to mask the padded area instead of the original image."
auto_crop_aspect_ratio: "Crops the image to match the selected pad_aspect_ratio instead of expanding the canvas with padding.",
invert_mask: "Inverts the output mask (Standard for Outpainting).",
flip_horizontal: "Horizontally flips the image and mask.",
image_rotation: "Rotates the input image by degrees.",
BorderCrop_threshold: "Automatically trims solid color borders before processing.",
pad_noise_strength: "Strength of noise added to the padded region to prep diffusion latents.",
noise_seed: "Seed for procedural noise generation.",
noise_mode: "Color, Greyscale, or Greyscale+Mask noise.",
divisibility: "Enforces dimensions to be multiples of 8, 16, 32, 64, etc. for model compatibility."
};
for (const widget of this.widgets) {
@@ -44,13 +50,12 @@ app.registerExtension({
widget.canvas.title = tooltips[widget.name];
}
}
// --- END: RE-ADD TOOLTIPS ---
// Create a dedicated text widget for the output, same as KJNodes.
// Output info widget
const textWidget = this.addWidget("text", "output_text", "", {});
textWidget.serialize = false; // Don't save this text in the workflow file
textWidget.serialize = false;
// --- START: ADD RESET BUTTON ---
// Reset Button
const resetButton = this.addWidget("button", "Reset to Defaults 🗑️", "reset", () => {
const defaults = nodeData.input.required;
for (const widget of this.widgets) {
@@ -61,9 +66,8 @@ app.registerExtension({
}
});
resetButton.serialize = false;
// --- END: ADD RESET BUTTON ---
// --- START: ADD PREVIEW ---
// Preview handling
const previewWidget = this.widgets.find(w => w.name === "show_preview");
if (previewWidget) {
const originalCallback = previewWidget.callback;
@@ -76,7 +80,6 @@ app.registerExtension({
};
}
// Set initial size
this.size[1] = previewWidget?.value ? MIN_NODE_HEIGHT_WITH_PREVIEW : MIN_NODE_HEIGHT_WITHOUT_PREVIEW;
api.addEventListener("rbg_pad_pro_preview", (event) => {
@@ -84,10 +87,8 @@ app.registerExtension({
img.src = event.detail.image;
}
});
// --- END: ADD PREVIEW ---
};
// --- START: ADD PREVIEW DRAWING ---
const onDrawBackground = nodeType.prototype.onDrawBackground;
nodeType.prototype.onDrawBackground = function(ctx) {
onDrawBackground?.apply(this, arguments);
@@ -99,7 +100,7 @@ app.registerExtension({
const [w, h] = this.size;
const lastWidget = this.widgets[this.widgets.length - 1];
const lastWidgetY = lastWidget.last_y || 0;
const lastWidgetY = lastWidget?.last_y || 0;
const PADDING = 10;
const IMAGE_Y_OFFSET = lastWidgetY + 30;
const imageAreaHeight = h - IMAGE_Y_OFFSET - PADDING;
@@ -118,12 +119,10 @@ app.registerExtension({
}
};
// This function runs every time after the node is executed.
const onExecuted = nodeType.prototype.onExecuted;
nodeType.prototype.onExecuted = function (message) {
onExecuted?.apply(this, arguments);
// Find our output widget and update its value with the message from Python.
if (message?.text) {
const widget = this.widgets.find(w => w.name === "output_text");
if (widget) {
@@ -133,4 +132,4 @@ app.registerExtension({
};
}
}
});
});