Files
2026-01-30 05:12:51 -05:00

890 lines
25 KiB
Python

"""ComfyUI custom nodes: Seamless texture tools.
Includes:
- Seamless Texture Radial Mask: Offset-and-blend with a radial mask
- Seamless Texture Mirrored Collage: Mirrored quadrant collage
- Seamless Texture Half Shift: Half shift with simple seam blending
- Seamless Texture Pre-Processor: Pre-crop and lighting correction
- Seamless Texture Tiled Preview: Tiled preview with seam markers
Dependencies: torch only (as provided by ComfyUI).
"""
from __future__ import annotations
import math
import re
from typing import Optional, Tuple
import torch
import torch.nn.functional as F
# -------------------------
# Basic tensor utilities
# -------------------------
def _clamp01(x: torch.Tensor) -> torch.Tensor:
return torch.clamp(x, 0.0, 1.0)
def _to_chw(img_hwc: torch.Tensor) -> torch.Tensor:
"""Convert HWC to CHW format."""
return img_hwc.permute(2, 0, 1)
def _to_hwc(img_chw: torch.Tensor) -> torch.Tensor:
"""Convert CHW to HWC format."""
return img_chw.permute(1, 2, 0)
def _roll(img: torch.Tensor, dx: int, dy: int) -> torch.Tensor:
"""Circular shift image by (dx, dy) pixels. img: CHW format."""
return torch.roll(img, shifts=(dy, dx), dims=(1, 2))
def _validate_bhwc_image(image: torch.Tensor) -> None:
if image.dim() != 4:
raise ValueError("Expected IMAGE tensor in BHWC format")
if image.shape[3] not in (3, 4):
raise ValueError(f"Expected 3 or 4 channels, got {image.shape[3]}")
# -------------------------
# Gaussian blur (periodic)
# -------------------------
def _gaussian_kernel_1d(sigma: float, device, dtype) -> torch.Tensor:
if sigma <= 0:
return torch.tensor([1.0], device=device, dtype=dtype)
radius = max(1, int(math.ceil(3.0 * sigma)))
x = torch.arange(-radius, radius + 1, device=device, dtype=dtype)
k = torch.exp(-0.5 * (x / sigma)**2)
k = k / torch.sum(k)
return k
def _gaussian_blur_periodic(img: torch.Tensor, sigma: float) -> torch.Tensor:
"""Separable Gaussian blur with periodic (wrap-around) padding.
img: CHW float [0..1]
"""
if sigma <= 0:
return img
k = _gaussian_kernel_1d(sigma, img.device, img.dtype)
radius = (k.numel() - 1) // 2
# Horizontal pass
w_h = k.view(1, 1, 1, -1).repeat(img.shape[0], 1, 1, 1)
x = img.unsqueeze(0)
x = F.pad(x, (radius, radius, 0, 0), mode="circular")
x = F.conv2d(x, w_h, groups=img.shape[0])
# Vertical pass
w_v = k.view(1, 1, -1, 1).repeat(img.shape[0], 1, 1, 1)
x = F.pad(x, (0, 0, radius, radius), mode="circular")
x = F.conv2d(x, w_v, groups=img.shape[0])
return x.squeeze(0)
# -------------------------
# Illumination pre-processing
# -------------------------
def _apply_pre_crop(
img: torch.Tensor,
pre_crop_left: int,
pre_crop_top: int,
pre_crop_bottom: int,
pre_crop_right: int,
) -> torch.Tensor:
h, w, _ = img.shape
l = int(max(0, pre_crop_left))
t = int(max(0, pre_crop_top))
r = int(max(0, pre_crop_right))
b = int(max(0, pre_crop_bottom))
x0 = min(w, l)
y0 = min(h, t)
x1 = max(x0 + 2, w - r)
y1 = max(y0 + 2, h - b)
x1 = min(w, x1)
y1 = min(h, y1)
return img[y0:y1, x0:x1, :]
def _pre_average_lighting(img: torch.Tensor, intensity: int, radius: int) -> torch.Tensor:
"""Reduce large-scale brightness gradients before making seamless."""
intensity = int(max(0, min(100, intensity)))
if intensity == 0:
return img
radius = int(max(1, min(20, radius)))
c, h, w = img.shape
if c < 3:
return img
rgb = img[:3]
# Compute luminance
y = (0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2]).unsqueeze(0)
# Map radius (1..20) to sigma in pixels
sigma = (min(h, w) / 256.0) * float(radius)
sigma = max(0.5, float(sigma))
y_blur = _gaussian_blur_periodic(y, sigma=sigma)
mean_y = torch.mean(y)
eps = 1e-6
scale = (mean_y / (y_blur + eps)).clamp(0.25, 4.0)
corrected = _clamp01(rgb * scale)
a = intensity / 100.0
out_rgb = rgb * (1.0 - a) + corrected * a
out = img.clone()
out[:3] = out_rgb
return out
# -------------------------
# Blend curve functions
# -------------------------
def _apply_blend_curve(t: torch.Tensor, curve: str) -> torch.Tensor:
"""Apply a blend curve to transition values t in [0, 1].
Args:
t: Tensor of values in [0, 1] representing blend position
curve: One of 'linear', 'cosine', 'smoothstep', 'quadratic', 'cubic'
Returns:
Transformed t values with the specified easing curve
"""
curve = curve.lower()
if curve == "linear":
return t
elif curve == "cosine":
# Smooth S-curve using cosine
return 0.5 - 0.5 * torch.cos(math.pi * t)
elif curve == "smoothstep":
# Hermite smoothstep: 3t² - 2t³
return t * t * (3.0 - 2.0 * t)
elif curve == "smootherstep":
# Ken Perlin's smootherstep: 6t⁵ - 15t⁴ + 10t³
return t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
elif curve == "quadratic":
# Quadratic ease-in-out
return torch.where(t < 0.5, 2.0 * t * t, 1.0 - 2.0 * (1.0 - t)**2)
elif curve == "cubic":
# Cubic ease-in-out
return torch.where(t < 0.5, 4.0 * t * t * t, 1.0 - 4.0 * (1.0 - t)**3)
else:
# Default to cosine
return 0.5 - 0.5 * torch.cos(math.pi * t)
# -------------------------
# Core seamless methods
# -------------------------
def _make_seamless_offset_blend(
img: torch.Tensor,
inner_radius: float = 0.85,
outer_radius: float = 1.0,
scatter_strength: float = 0.0,
blend_curve: str = "cosine",
) -> torch.Tensor:
"""Create seamless texture using offset-and-blend with radial mask.
This is the core algorithm:
1. Create a half-shifted version (seamless edges from original's center)
2. Blend original (center) with shifted (edges) using elliptical mask
Args:
img: Input image in CHW format
inner_radius: Normalized radius where blend starts (0-1, from center)
outer_radius: Normalized radius where blend ends (0-1+)
scatter_strength: Amount of wavy variation in blend boundary (0 = smooth circle)
blend_curve: Interpolation curve ('linear', 'cosine', 'smoothstep', etc.)
Returns:
Seamless texture in CHW format
"""
c, h, w = img.shape
# Create shifted version - edges become seamless because
# the original's continuous center is now at the edges
shifted = _roll(img, dx=w // 2, dy=h // 2)
# Create normalized coordinate grids
ys = torch.arange(h, device=img.device, dtype=img.dtype)
xs = torch.arange(w, device=img.device, dtype=img.dtype)
cy, cx = h / 2.0, w / 2.0
ny = (ys - cy) / cy # -1 to 1
nx = (xs - cx) / cx # -1 to 1
ny = ny.view(h, 1)
nx = nx.view(1, w)
# Compute elliptical distance from center
dist = torch.sqrt(ny**2 + nx**2)
# Add scattered/wavy variation to blend boundary if requested
if scatter_strength > 0:
angle = torch.atan2(ny, nx)
# Multiple frequencies for organic look
scatter = (0.08 * torch.sin(angle * 5) + 0.05 * torch.sin(angle * 11) + 0.03 * torch.sin(angle * 17)) * scatter_strength
dist = dist + scatter
# Compute blend factor: 0 at center (keep original), 1 at edges (use shifted)
t = (dist - inner_radius) / max(0.001, outer_radius - inner_radius)
t = torch.clamp(t, 0.0, 1.0)
# Apply blend curve
mask = _apply_blend_curve(t, blend_curve)
mask = mask.view(1, h, w) # [1, H, W] for broadcasting
# Blend: original where mask=0 (center), shifted where mask=1 (edges)
out = img * (1.0 - mask) + shifted * mask
return _clamp01(out)
def _make_seamless_half_shift(
img: torch.Tensor,
inner_radius: float = 0.7,
outer_radius: float = 1.0,
blend_curve: str = "linear",
orientation: str = "both",
) -> torch.Tensor:
"""Create seamless texture using a half shift and axis-aligned blend.
Args:
img: Input image in CHW format
inner_radius: Normalized distance where blending starts (0 = center, 1 = edge)
outer_radius: Normalized distance where blending ends
blend_curve: Interpolation curve ('linear', 'cosine', 'smoothstep', etc.)
orientation: "both", "horizontal", or "vertical"
Returns:
Seamless texture in CHW format
"""
c, h, w = img.shape
orientation = orientation.strip().lower()
if orientation not in ("both", "horizontal", "vertical"):
orientation = "both"
dx = w // 2 if orientation in ("both", "horizontal") else 0
dy = h // 2 if orientation in ("both", "vertical") else 0
shifted = _roll(img, dx=dx, dy=dy)
ys = torch.arange(h, device=img.device, dtype=img.dtype)
xs = torch.arange(w, device=img.device, dtype=img.dtype)
cy, cx = h / 2.0, w / 2.0
ny = torch.abs((ys - cy) / cy).view(h, 1)
nx = torch.abs((xs - cx) / cx).view(1, w)
if orientation == "horizontal":
dist = nx.expand(h, w)
elif orientation == "vertical":
dist = ny.expand(h, w)
else:
dist = torch.max(nx, ny)
t = (dist - inner_radius) / max(0.001, outer_radius - inner_radius)
t = torch.clamp(t, 0.0, 1.0)
mask = _apply_blend_curve(t, blend_curve)
mask = mask.view(1, h, w)
out = img * (1.0 - mask) + shifted * mask
return _clamp01(out)
def _make_seamless_collage(img: torch.Tensor, blend_curve: str = "cosine") -> torch.Tensor:
"""Create seamless texture using mirrored quadrant collage.
This fundamentally different method:
1. Creates a 2x2 grid of mirrored versions
2. Extracts the center region (which is seamless due to symmetry)
3. Applies local smoothing at the cross seam
Args:
img: Input image in CHW format
blend_curve: Interpolation curve for seam blending
Returns:
Seamless texture in CHW format
"""
c, h, w = img.shape
# Create mirrored quadrants
a = img # Original
b = torch.flip(img, dims=(2, )) # Horizontal flip
c1 = torch.flip(img, dims=(1, )) # Vertical flip
d = torch.flip(img, dims=(1, 2)) # Both flips
# Assemble 2x2 grid
top = torch.cat([a, b], dim=2)
bottom = torch.cat([c1, d], dim=2)
big = torch.cat([top, bottom], dim=1) # [C, 2H, 2W]
# Extract center region (where all quadrants meet)
y0 = h // 2
x0 = w // 2
out = big[:, y0:y0 + h, x0:x0 + w]
# Apply local smoothing on the cross seam at center
band = max(6, min(h, w) // 12)
# Create cross-shaped mask for seam blending
cx_img = w // 2
cy_img = h // 2
xs = torch.arange(w, device=img.device, dtype=img.dtype).view(1, 1, w)
ys = torch.arange(h, device=img.device, dtype=img.dtype).view(1, h, 1)
dx = torch.abs(xs - float(cx_img))
dy = torch.abs(ys - float(cy_img))
# Smooth cross mask
def band_mask(d: torch.Tensor) -> torch.Tensor:
t = torch.clamp(1.0 - d / band, 0.0, 1.0)
return _apply_blend_curve(t, blend_curve)
mv = band_mask(dx)
mh = band_mask(dy)
mask = torch.clamp(mv + mh, 0.0, 1.0)
# Blend with slightly blurred version at seams
blurred = _gaussian_blur_periodic(out, sigma=max(0.8, min(h, w) / 768.0))
out = out * (1.0 - mask) + blurred * mask
return _clamp01(out)
# -------------------------
# Color parsing utilities
# -------------------------
def _parse_color(color_str: str) -> Optional[Tuple[float, float, float, float]]:
"""Parse a color string into RGBA tuple (0-1 range).
Supports:
- Hex: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
- RGB: rgb(255, 0, 0) or rgb(255,0,0)
- RGBA: rgba(255, 0, 0, 1.0) or rgba(255,0,0,0.5)
- Named: red, green, blue, white, black, yellow, cyan, magenta
Returns None if parsing fails or string is empty/whitespace.
"""
if not color_str or not color_str.strip():
return None
color_str = color_str.strip().lower()
# Named colors
named_colors = {
"red": (1.0, 0.0, 0.0, 1.0),
"green": (0.0, 1.0, 0.0, 1.0),
"blue": (0.0, 0.0, 1.0, 1.0),
"white": (1.0, 1.0, 1.0, 1.0),
"black": (0.0, 0.0, 0.0, 1.0),
"yellow": (1.0, 1.0, 0.0, 1.0),
"cyan": (0.0, 1.0, 1.0, 1.0),
"magenta": (1.0, 0.0, 1.0, 1.0),
"orange": (1.0, 0.5, 0.0, 1.0),
}
if color_str in named_colors:
return named_colors[color_str]
# Hex format
if color_str.startswith("#"):
hex_str = color_str[1:]
try:
if len(hex_str) == 3: # #RGB
r = int(hex_str[0], 16) / 15.0
g = int(hex_str[1], 16) / 15.0
b = int(hex_str[2], 16) / 15.0
return (r, g, b, 1.0)
elif len(hex_str) == 4: # #RGBA
r = int(hex_str[0], 16) / 15.0
g = int(hex_str[1], 16) / 15.0
b = int(hex_str[2], 16) / 15.0
a = int(hex_str[3], 16) / 15.0
return (r, g, b, a)
elif len(hex_str) == 6: # #RRGGBB
r = int(hex_str[0:2], 16) / 255.0
g = int(hex_str[2:4], 16) / 255.0
b = int(hex_str[4:6], 16) / 255.0
return (r, g, b, 1.0)
elif len(hex_str) == 8: # #RRGGBBAA
r = int(hex_str[0:2], 16) / 255.0
g = int(hex_str[2:4], 16) / 255.0
b = int(hex_str[4:6], 16) / 255.0
a = int(hex_str[6:8], 16) / 255.0
return (r, g, b, a)
except ValueError:
return None
# RGB format: rgb(r, g, b) or rgb(r,g,b)
rgb_match = re.match(r'rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)', color_str)
if rgb_match:
r = min(255, int(rgb_match.group(1))) / 255.0
g = min(255, int(rgb_match.group(2))) / 255.0
b = min(255, int(rgb_match.group(3))) / 255.0
return (r, g, b, 1.0)
# RGBA format: rgba(r, g, b, a)
rgba_match = re.match(r'rgba\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)', color_str)
if rgba_match:
r = min(255, int(rgba_match.group(1))) / 255.0
g = min(255, int(rgba_match.group(2))) / 255.0
b = min(255, int(rgba_match.group(3))) / 255.0
a = min(1.0, float(rgba_match.group(4)))
return (r, g, b, a)
return None
# -------------------------
# Tile preview and seam markers
# -------------------------
def _make_tile_preview(img: torch.Tensor, tiles_x: int, tiles_y: int) -> torch.Tensor:
"""Create a tiled preview of the texture. img: CHW format."""
tiles_x = max(1, int(tiles_x))
tiles_y = max(1, int(tiles_y))
return torch.tile(img, (1, tiles_y, tiles_x))
def _mark_seams(
tile: torch.Tensor,
single_w: int,
single_h: int,
tiles_x: int,
tiles_y: int,
color_str: str,
) -> torch.Tensor:
"""Draw seam marker lines on tile preview.
Args:
tile: Tiled image in CHW format
single_w: Width of a single tile
single_h: Height of a single tile
tiles_x: Number of horizontal tiles
tiles_y: Number of vertical tiles
color_str: Color string (empty = disabled)
Returns:
Tile with seam markers drawn (or unchanged if disabled)
"""
rgba = _parse_color(color_str)
if rgba is None:
return tile
r, g, b, a = rgba
out = tile.clone()
c, h, w = out.shape
thickness = max(1, int(round(min(single_w, single_h) * 0.01)))
half_t = thickness // 2
def paint_v(x_center: int):
"""Paint vertical line centered at x_center."""
x0 = max(0, x_center - half_t)
x1 = min(w, x_center - half_t + thickness)
if x1 <= x0:
return
# Blend with alpha
out[0, :, x0:x1] = out[0, :, x0:x1] * (1 - a) + r * a
if c > 1:
out[1, :, x0:x1] = out[1, :, x0:x1] * (1 - a) + g * a
if c > 2:
out[2, :, x0:x1] = out[2, :, x0:x1] * (1 - a) + b * a
def paint_h(y_center: int):
"""Paint horizontal line centered at y_center."""
y0 = max(0, y_center - half_t)
y1 = min(h, y_center - half_t + thickness)
if y1 <= y0:
return
out[0, y0:y1, :] = out[0, y0:y1, :] * (1 - a) + r * a
if c > 1:
out[1, y0:y1, :] = out[1, y0:y1, :] * (1 - a) + g * a
if c > 2:
out[2, y0:y1, :] = out[2, y0:y1, :] * (1 - a) + b * a
# Mark tile boundaries (centered on seam positions)
for tx in range(tiles_x + 1):
x_pos = tx * single_w
# Clamp to valid range for centering
if x_pos >= w:
x_pos = w - 1
paint_v(x_pos)
for ty in range(tiles_y + 1):
y_pos = ty * single_h
if y_pos >= h:
y_pos = h - 1
paint_h(y_pos)
return out
# -------------------------
# ComfyUI nodes
# -------------------------
class SeamlessTextureRadialMask:
"""Create seamless/tileable textures using offset-and-blend with a radial mask."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "Image to make seamless using the radial mask method."
}),
"inner_radius": ("FLOAT", {
"default": 0.85,
"min": 0.0,
"max": 1.5,
"step": 0.01,
"tooltip": "Normalized radius where blending starts. 0 = center, 1 = edge. Higher values preserve more of the original center."
}),
"outer_radius": ("FLOAT", {
"default": 1.0,
"min": 0.1,
"max": 2.0,
"step": 0.01,
"tooltip": "Normalized radius where blending ends. Values > 1 extend blending into corners."
}),
"scatter_strength": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 2.0,
"step": 0.05,
"tooltip": "Amount of wavy variation in blend boundary. 0 = smooth ellipse, higher = more organic/scattered."
}),
"blend_curve": (["cosine", "linear", "smoothstep", "smootherstep", "quadratic", "cubic"], {
"default": "cubic",
"tooltip": "Blend curve for the transition from center to edges."
}),
}
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("texture", )
FUNCTION = "make_seamless"
CATEGORY = "image/texture"
def make_seamless(
self,
image: torch.Tensor,
inner_radius: float,
outer_radius: float,
scatter_strength: float,
blend_curve: str,
) -> Tuple[torch.Tensor]:
_validate_bhwc_image(image)
batch_out = []
for img in image:
chw = _to_chw(img)
out = _make_seamless_offset_blend(
chw,
inner_radius=inner_radius,
outer_radius=outer_radius,
scatter_strength=scatter_strength,
blend_curve=blend_curve,
)
batch_out.append(_to_hwc(out))
return (torch.stack(batch_out, dim=0), )
class SeamlessTextureHalfShift:
"""Create seamless/tileable textures using half shift with simple seam blending."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "Image to make seamless using a half shift."
}),
"inner_radius": ("FLOAT", {
"default": 0.7,
"min": 0.0,
"max": 1.5,
"step": 0.01,
"tooltip": "Normalized distance where blending starts. 0 = center, 1 = edge."
}),
"outer_radius": ("FLOAT", {
"default": 1.0,
"min": 0.1,
"max": 2.0,
"step": 0.01,
"tooltip": "Normalized distance where blending ends. Values > 1 extend blending beyond edges."
}),
"blend_curve": (["linear", "cosine", "smoothstep", "smootherstep", "quadratic", "cubic"], {
"default": "linear",
"tooltip": "Blend curve for the transition at the seams."
}),
"orientation": (["both", "horizontal", "vertical"], {
"default": "both",
"tooltip": "Shift direction: both axes, horizontal only, or vertical only."
}),
}
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("texture", )
FUNCTION = "make_seamless"
CATEGORY = "image/texture"
def make_seamless(
self,
image: torch.Tensor,
inner_radius: float,
outer_radius: float,
blend_curve: str,
orientation: str,
) -> Tuple[torch.Tensor]:
_validate_bhwc_image(image)
batch_out = []
for img in image:
chw = _to_chw(img)
out = _make_seamless_half_shift(
chw,
inner_radius=inner_radius,
outer_radius=outer_radius,
blend_curve=blend_curve,
orientation=orientation,
)
batch_out.append(_to_hwc(out))
return (torch.stack(batch_out, dim=0), )
class SeamlessTextureMirroredCollage:
"""Create seamless/tileable textures using mirrored quadrants."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "Image to make seamless using mirrored quadrants."
}),
"blend_curve": (["cosine", "linear", "smoothstep", "smootherstep", "quadratic", "cubic"], {
"default": "cosine",
"tooltip": "Blend curve for smoothing the central cross seam."
}),
}
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("texture", )
FUNCTION = "make_seamless"
CATEGORY = "image/texture"
def make_seamless(self, image: torch.Tensor, blend_curve: str) -> Tuple[torch.Tensor]:
_validate_bhwc_image(image)
batch_out = []
for img in image:
chw = _to_chw(img)
out = _make_seamless_collage(chw, blend_curve=blend_curve)
batch_out.append(_to_hwc(out))
return (torch.stack(batch_out, dim=0), )
class SeamlessTexturePreProcessor:
"""Pre-crop and lighting correction for texture inputs."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "Image to pre-process before seamless generation."
}),
"pre_crop_left": ("INT", {
"default": 0,
"min": 0,
"max": 8192,
"step": 1,
"tooltip": "Pixels to crop from the left edge before processing."
}),
"pre_crop_top": ("INT", {
"default": 0,
"min": 0,
"max": 8192,
"step": 1,
"tooltip": "Pixels to crop from the top edge before processing."
}),
"pre_crop_bottom": ("INT", {
"default": 0,
"min": 0,
"max": 8192,
"step": 1,
"tooltip": "Pixels to crop from the bottom edge before processing."
}),
"pre_crop_right": ("INT", {
"default": 0,
"min": 0,
"max": 8192,
"step": 1,
"tooltip": "Pixels to crop from the right edge before processing."
}),
"lighting_correction_intensity": ("INT", {
"default": 65,
"min": 0,
"max": 100,
"step": 1,
"tooltip": "Strength of lighting correction (0 disables)."
}),
"lighting_correction_radius": ("INT", {
"default": 5,
"min": 1,
"max": 20,
"step": 1,
"tooltip": "Blur radius for lighting correction."
}),
}
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("image", )
FUNCTION = "pre_process"
CATEGORY = "image/texture"
def pre_process(
self,
image: torch.Tensor,
pre_crop_left: int,
pre_crop_top: int,
pre_crop_bottom: int,
pre_crop_right: int,
lighting_correction_intensity: int,
lighting_correction_radius: int,
) -> Tuple[torch.Tensor]:
_validate_bhwc_image(image)
batch_out = []
for img in image:
cropped = _apply_pre_crop(
img,
pre_crop_left=pre_crop_left,
pre_crop_top=pre_crop_top,
pre_crop_bottom=pre_crop_bottom,
pre_crop_right=pre_crop_right,
)
chw = _to_chw(cropped)
if lighting_correction_intensity > 0:
chw = _pre_average_lighting(chw, intensity=lighting_correction_intensity, radius=lighting_correction_radius)
chw = _clamp01(chw)
batch_out.append(_to_hwc(chw))
return (torch.stack(batch_out, dim=0), )
class SeamlessTextureTiledPreview:
"""Generate a tiled preview with optional seam markers."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {
"tooltip": "Image to preview as a tiled pattern."
}),
"tile_preview_width": ("INT", {
"default": 3,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Number of tiles horizontally in the preview."
}),
"tile_preview_height": ("INT", {
"default": 3,
"min": 1,
"max": 10,
"step": 1,
"tooltip": "Number of tiles vertically in the preview."
}),
"seam_marker_color": ("STRING", {
"default": "",
"tooltip": "Color for seam markers. Supports hex (#FF0000), rgb(255,0,0), rgba(255,0,0,0.5), or names (red, blue, etc). Leave empty to disable."
}),
}
}
RETURN_TYPES = ("IMAGE", )
RETURN_NAMES = ("tiled_preview", )
FUNCTION = "make_preview"
CATEGORY = "image/texture"
def make_preview(
self,
image: torch.Tensor,
tile_preview_width: int,
tile_preview_height: int,
seam_marker_color: str,
) -> Tuple[torch.Tensor]:
_validate_bhwc_image(image)
batch_out = []
for img in image:
chw = _to_chw(img)
tile = _make_tile_preview(chw, tiles_x=tile_preview_width, tiles_y=tile_preview_height)
tile = _mark_seams(
tile,
single_w=chw.shape[2],
single_h=chw.shape[1],
tiles_x=tile_preview_width,
tiles_y=tile_preview_height,
color_str=seam_marker_color,
)
tile = _clamp01(tile)
batch_out.append(_to_hwc(tile))
return (torch.stack(batch_out, dim=0), )
NODE_CLASS_MAPPINGS = {
"SeamlessTextureRadialMask": SeamlessTextureRadialMask,
"SeamlessTextureHalfShift": SeamlessTextureHalfShift,
"SeamlessTextureMirroredCollage": SeamlessTextureMirroredCollage,
"SeamlessTexturePreProcessor": SeamlessTexturePreProcessor,
"SeamlessTextureTiledPreview": SeamlessTextureTiledPreview,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SeamlessTextureRadialMask": "Seamless Texture Radial Mask",
"SeamlessTextureHalfShift": "Seamless Texture Half Shift",
"SeamlessTextureMirroredCollage": "Seamless Texture Mirrored Collage",
"SeamlessTexturePreProcessor": "Seamless Texture Pre-Processor",
"SeamlessTextureTiledPreview": "Seamless Texture Tiled Preview",
}