890 lines
25 KiB
Python
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",
|
|
}
|