Files
2026-05-06 18:05:27 -07:00

601 lines
22 KiB
Python

# ComfyUI-VS
# VS Image Compare Deluxe
# A retro transition/image comparison node for ComfyUI.
# Made for before/after comparisons, video previews
import math
import torch
import torch.nn.functional as F
def _single_frame(img: torch.Tensor) -> torch.Tensor:
# ComfyUI IMAGE tensors are usually [B, H, W, C]
if img.ndim == 4:
return img[0]
return img
def _resize_image(img: torch.Tensor, target_h: int, target_w: int) -> torch.Tensor:
if img.shape[0] == target_h and img.shape[1] == target_w:
return img
x = img.permute(2, 0, 1).unsqueeze(0) # HWC -> NCHW
x = F.interpolate(x, size=(target_h, target_w), mode="bilinear", align_corners=False)
return x.squeeze(0).permute(1, 2, 0) # NCHW -> HWC
def _smoothstep01(x: torch.Tensor) -> torch.Tensor:
x = torch.clamp(x, 0.0, 1.0)
return x * x * (3.0 - 2.0 * x)
def _soft_mask(score: torch.Tensor, t: float, feather: float = 0.03) -> torch.Tensor:
"""
score: H x W tensor in [0,1], lower values reveal earlier.
returns: H x W x 1 mask in [0,1]
"""
feather = max(float(feather), 1e-6)
x = (t - score + feather) / (2.0 * feather)
return _smoothstep01(x).unsqueeze(-1)
def _normalize_score(score: torch.Tensor, eps: float = 1e-4) -> torch.Tensor:
"""
Normalize score map to [0, 1-eps] so feathered masks finish cleanly.
This prevents tiny seams on the final frame.
"""
score = score - torch.min(score)
score = score / torch.clamp(torch.max(score), min=1e-6)
return torch.clamp(score, 0.0, 1.0 - eps)
def _seeded_rand(shape, seed: int, device):
# Generate on CPU for compatibility, then move to target device
g = torch.Generator(device="cpu")
g.manual_seed(int(seed) & 0x7FFFFFFFFFFFFFFF)
return torch.rand(shape, generator=g, device="cpu").to(device)
def _normalized_grid(h: int, w: int, device):
ys = torch.linspace(-1.0, 1.0, h, device=device).view(h, 1).expand(h, w)
xs = torch.linspace(-1.0, 1.0, w, device=device).view(1, w).expand(h, w)
return xs, ys
def _ease_value(t: float, easing: str) -> float:
t = max(0.0, min(1.0, float(t)))
if easing == "linear":
return t
if easing == "ease_in":
return t * t
if easing == "ease_out":
return 1.0 - ((1.0 - t) * (1.0 - t))
if easing == "ease_in_out":
if t < 0.5:
return 2.0 * t * t
return 1.0 - ((-2.0 * t + 2.0) ** 2) / 2.0
if easing == "smoothstep":
return t * t * (3.0 - 2.0 * t)
return t
class VSImageCompareDeluxe:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"source_image": ("IMAGE",),
"generated_image": ("IMAGE",),
"effect_type": (
[
"Fade",
"Dissolve",
"Circle Wipe",
"Diamond Wipe",
"Star Wipe",
"Clock Wipe",
"Door Wipe Open",
"Door Wipe Close",
"Iris Open",
"Iris Close",
"Heart Wipe",
"Spiral Wipe",
"Venetian Blinds",
"Checkerboard",
"Falling Blocks",
"Random Blocks",
"Pixelate Reveal",
"Luma Wipe",
"Push Left",
"Push Right",
"Toaster Color Shift",
],
{"default": "Fade"},
),
"frames": ("INT", {"default": 48, "min": 2, "max": 600, "step": 1}),
"fps": ("INT", {"default": 12, "min": 1, "max": 120, "step": 1}),
"loop_count": ("INT", {"default": 1, "min": 1, "max": 20, "step": 1}),
"loop_style": (["restart", "ping_pong"], {"default": "restart"}),
"hold_frames": ("INT", {"default": 0, "min": 0, "max": 120, "step": 1}),
"easing": (
["linear", "ease_in", "ease_out", "ease_in_out", "smoothstep"],
{"default": "smoothstep"},
),
"feather": ("FLOAT", {"default": 0.025, "min": 0.001, "max": 0.25, "step": 0.001}),
"block_size": ("INT", {"default": 48, "min": 8, "max": 256, "step": 8}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF, "step": 1}),
"invert_pattern": ("BOOLEAN", {"default": False}),
"toaster_tint_strength": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}),
"video_flavor": (
["none", "scanlines", "vhs_wobble", "crt_soft", "full_cheese"],
{"default": "none"},
),
"video_flavor_strength": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}),
}
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("frames_out", "frame_count", "fps")
FUNCTION = "build_compare"
CATEGORY = "ComfyUI-VS"
def _build_transition_state(self, effect_type: str, h: int, w: int, device, seed: int, block_size: int):
state = {"effect_type": effect_type}
xs, ys = _normalized_grid(h, w, device)
radius = torch.sqrt(xs * xs + ys * ys)
radius = _normalize_score(radius)
theta = torch.atan2(ys, xs)
if effect_type == "Dissolve":
state["noise"] = _seeded_rand((h, w), seed, device)
elif effect_type in ("Circle Wipe", "Iris Open"):
state["score"] = radius
elif effect_type == "Iris Close":
state["score"] = 1.0 - radius
elif effect_type == "Diamond Wipe":
score = torch.abs(xs) + torch.abs(ys)
state["score"] = _normalize_score(score)
elif effect_type == "Star Wipe":
# 5-point-ish star burst
star_shape = 0.5 + 0.5 * torch.pow(torch.abs(torch.cos(theta * 5.0)), 0.65)
score = radius / torch.clamp(star_shape, min=1e-6)
state["score"] = _normalize_score(score)
elif effect_type == "Clock Wipe":
# Starts at top and moves clockwise
angle = torch.atan2(xs, -ys)
angle = torch.remainder(angle + (2.0 * math.pi), 2.0 * math.pi) / (2.0 * math.pi)
state["score"] = _normalize_score(angle)
elif effect_type == "Door Wipe Open":
# Center reveals first, moving outward.
score = torch.abs(xs)
state["score"] = _normalize_score(score)
elif effect_type == "Door Wipe Close":
# Edges reveal first, finishing with both sides meeting at the center seam.
score = 1.0 - torch.abs(xs)
state["score"] = _normalize_score(score)
elif effect_type == "Heart Wipe":
# Heart-shaped implicit curve. Negative/low values reveal first from the heart center outward.
# Coordinates adjusted so the heart fits the image.
x = xs * 1.25
y = -ys * 1.25 + 0.25
heart = (x * x + y * y - 1.0) ** 3 - (x * x) * (y ** 3)
# Inside heart is <= 0. Convert to a reveal score where heart appears first.
score = heart
state["score"] = _normalize_score(score)
elif effect_type == "Spiral Wipe":
# Spiraling score field.
angle = torch.remainder(theta + (2.0 * math.pi), 2.0 * math.pi) / (2.0 * math.pi)
spiral = radius + angle * 0.35
state["score"] = _normalize_score(spiral)
elif effect_type == "Venetian Blinds":
stripe_h = max(4, block_size // 2)
row_idx = torch.arange(h, device=device)
stripe_idx = row_idx // stripe_h
stripe_dir = (stripe_idx % 2).float().view(h, 1).expand(h, w)
x01 = (torch.arange(w, device=device).float() / max(w - 1, 1)).view(1, w).expand(h, w)
score = torch.where(stripe_dir < 0.5, x01, 1.0 - x01)
state["score"] = _normalize_score(score)
elif effect_type == "Checkerboard":
tile = max(8, block_size)
gy = (torch.arange(h, device=device) // tile).view(h, 1).expand(h, w)
gx = (torch.arange(w, device=device) // tile).view(1, w).expand(h, w)
parity = ((gx + gy) % 2).float()
local_x = ((torch.arange(w, device=device) % tile).float() / max(tile - 1, 1)).view(1, w).expand(h, w)
local_y = ((torch.arange(h, device=device) % tile).float() / max(tile - 1, 1)).view(h, 1).expand(h, w)
local = (local_x + local_y) * 0.5
# Two-phase reveal
score = parity * 0.5 + local * 0.5
state["score"] = _normalize_score(score)
elif effect_type in ("Falling Blocks", "Random Blocks", "Pixelate Reveal"):
grid_h = math.ceil(h / block_size)
grid_w = math.ceil(w / block_size)
delays = _seeded_rand((grid_h, grid_w), seed, device) * 0.75
if effect_type == "Falling Blocks":
row_bias = torch.linspace(0.0, 0.25, grid_h, device=device).view(grid_h, 1)
delays = torch.clamp(delays * 0.65 + row_bias, 0.0, 0.95)
by = torch.arange(h, device=device) // block_size
bx = torch.arange(w, device=device) // block_size
delay_map = delays[by[:, None], bx[None, :]]
local_y = ((torch.arange(h, device=device) % block_size).float() / max(block_size - 1, 1)).view(h, 1).expand(h, w)
local_x = ((torch.arange(w, device=device) % block_size).float() / max(block_size - 1, 1)).view(1, w).expand(h, w)
detail_map = (local_y * 0.14) + (local_x * 0.06)
state["delay_map"] = delay_map
state["detail_map"] = detail_map
elif effect_type == "Luma Wipe":
# Luma based on generated image is built later because it needs dst.
pass
return state
def _blend_from_mask(self, src: torch.Tensor, dst: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
return torch.clamp(src * (1.0 - mask) + dst * mask, 0.0, 1.0)
def _push_frame(self, src: torch.Tensor, dst: torch.Tensor, t: float, direction: str) -> torch.Tensor:
h, w, c = src.shape
out = torch.zeros_like(src)
offset = int(round(w * t))
offset = max(0, min(w, offset))
if direction == "left":
# src moves left out, dst comes in from right
if offset < w:
out[:, : w - offset, :] = src[:, offset:, :]
if offset > 0:
out[:, w - offset :, :] = dst[:, :offset, :]
elif direction == "right":
# src moves right out, dst comes in from left
if offset < w:
out[:, offset:, :] = src[:, : w - offset, :]
if offset > 0:
out[:, :offset, :] = dst[:, w - offset :, :]
return out.clamp(0.0, 1.0)
def _toaster_color_shift(self, src: torch.Tensor, dst: torch.Tensor, t: float, tint_strength: float) -> torch.Tensor:
# Slight retro-blue/purple-ish broadcast cheese.
s = max(0.0, min(1.0, float(tint_strength)))
tint = torch.tensor(
[1.0 + 0.15 * s, 1.0 + 0.05 * s, 1.0 + 0.45 * s],
device=src.device,
dtype=src.dtype,
).view(1, 1, 3)
tinted_dst = torch.clamp(dst * tint, 0.0, 1.0)
# Subtle scanline-style modulation.
h, w, _ = src.shape
y = torch.arange(h, device=src.device, dtype=src.dtype).view(h, 1, 1)
scan = 1.0 - 0.03 * s * ((y % 2.0) == 0).to(src.dtype)
tinted_dst = torch.clamp(tinted_dst * scan, 0.0, 1.0)
return torch.clamp(src * (1.0 - t) + tinted_dst * t, 0.0, 1.0)
def _pixelate_reveal(
self,
src: torch.Tensor,
dst: torch.Tensor,
t: float,
state: dict,
block_size: int,
feather: float,
invert_pattern: bool,
) -> torch.Tensor:
h, w, c = src.shape
device = src.device
delay_map = state["delay_map"]
detail_map = state["detail_map"]
score = torch.clamp(delay_map + detail_map, 0.0, 0.999)
if invert_pattern:
score = 1.0 - score
mask = _soft_mask(score, t, feather=max(feather, 0.01))
# Pixelated generated image. As t increases, pixel blocks get smaller.
max_px = max(2, int(block_size))
min_px = 1
px = int(round(max_px * (1.0 - t) + min_px * t))
px = max(1, px)
small_h = max(1, h // px)
small_w = max(1, w // px)
x = dst.permute(2, 0, 1).unsqueeze(0)
small = F.interpolate(x, size=(small_h, small_w), mode="nearest")
pix = F.interpolate(small, size=(h, w), mode="nearest")
pix = pix.squeeze(0).permute(1, 2, 0)
# Blend from source into pixelated destination, which sharpens by the end.
return self._blend_from_mask(src, pix, mask)
def _video_flavor(
self,
frame: torch.Tensor,
frame_index: int,
total_frames: int,
flavor: str,
strength: float,
seed: int,
) -> torch.Tensor:
if flavor == "none" or strength <= 0.0:
return frame
s = max(0.0, min(1.0, float(strength)))
h, w, c = frame.shape
device = frame.device
out = frame
# Common scanline modulation.
if flavor in ("scanlines", "crt_soft", "full_cheese"):
y = torch.arange(h, device=device, dtype=frame.dtype).view(h, 1, 1)
scan = 1.0 - (0.04 * s) * ((y % 2.0) == 0).to(frame.dtype)
out = torch.clamp(out * scan, 0.0, 1.0)
# VHS-ish horizontal wobble.
if flavor in ("vhs_wobble", "full_cheese"):
rows = torch.arange(h, device=device, dtype=frame.dtype)
phase = (frame_index / max(total_frames - 1, 1)) * 2.0 * math.pi
wobble = torch.sin(rows * 0.075 + phase * 3.0) * (2.5 * s)
wobble = wobble.round().to(torch.int64)
shifted = torch.empty_like(out)
for yy in range(h):
shifted[yy] = torch.roll(out[yy], shifts=int(wobble[yy].item()), dims=0)
out = shifted
# CRT softening / mild bloom-ish blur.
if flavor in ("crt_soft", "full_cheese"):
x = out.permute(2, 0, 1).unsqueeze(0)
blurred = F.avg_pool2d(x, kernel_size=3, stride=1, padding=1)
blurred = blurred.squeeze(0).permute(1, 2, 0)
out = torch.clamp(out * (1.0 - 0.25 * s) + blurred * (0.25 * s), 0.0, 1.0)
# Tiny noise and chroma-ish shift for full cheese.
if flavor == "full_cheese":
noise = _seeded_rand((h, w, 1), seed + frame_index * 7919, device) - 0.5
out = torch.clamp(out + noise * (0.035 * s), 0.0, 1.0)
# Slight red/blue channel horizontal split.
shift = max(1, int(round(2 * s)))
r = torch.roll(out[:, :, 0], shifts=shift, dims=1)
g = out[:, :, 1]
b = torch.roll(out[:, :, 2], shifts=-shift, dims=1)
out = torch.stack([r, g, b], dim=2).clamp(0.0, 1.0)
return out.clamp(0.0, 1.0)
def _mask_for_effect(
self,
t: float,
effect_type: str,
state: dict,
src: torch.Tensor,
dst: torch.Tensor,
h: int,
w: int,
device,
feather: float,
invert_pattern: bool,
) -> torch.Tensor:
if effect_type == "Fade":
return torch.full((h, w, 1), float(t), device=device)
if effect_type == "Dissolve":
score = state["noise"]
if invert_pattern:
score = 1.0 - score
return _soft_mask(score, t, feather=max(feather, 0.01))
if effect_type == "Luma Wipe":
# Use generated image brightness as the transition map.
# Dark areas reveal earlier; invert_pattern flips that.
luma = dst[:, :, 0] * 0.2126 + dst[:, :, 1] * 0.7152 + dst[:, :, 2] * 0.0722
score = _normalize_score(luma)
if invert_pattern:
score = 1.0 - score
return _soft_mask(score, t, feather=max(feather, 0.01))
if effect_type in (
"Circle Wipe",
"Diamond Wipe",
"Star Wipe",
"Clock Wipe",
"Door Wipe Open",
"Door Wipe Close",
"Iris Open",
"Iris Close",
"Heart Wipe",
"Spiral Wipe",
"Venetian Blinds",
"Checkerboard",
):
score = state["score"]
if invert_pattern:
score = 1.0 - score
return _soft_mask(score, t, feather=feather)
if effect_type in ("Falling Blocks", "Random Blocks"):
delay_map = state["delay_map"]
detail_map = state["detail_map"]
score = torch.clamp(delay_map + detail_map, 0.0, 0.999)
if invert_pattern:
score = 1.0 - score
return _soft_mask(score, t, feather=max(feather, 0.01))
return torch.full((h, w, 1), float(t), device=device)
def _render_frame(
self,
src: torch.Tensor,
dst: torch.Tensor,
effect_type: str,
t: float,
state: dict,
feather: float,
block_size: int,
invert_pattern: bool,
toaster_tint_strength: float,
) -> torch.Tensor:
h, w, _ = src.shape
device = src.device
if effect_type == "Push Left":
return self._push_frame(src, dst, t, direction="left")
if effect_type == "Push Right":
return self._push_frame(src, dst, t, direction="right")
if effect_type == "Toaster Color Shift":
return self._toaster_color_shift(src, dst, t, toaster_tint_strength)
if effect_type == "Pixelate Reveal":
return self._pixelate_reveal(
src=src,
dst=dst,
t=t,
state=state,
block_size=block_size,
feather=feather,
invert_pattern=invert_pattern,
)
mask = self._mask_for_effect(
t=t,
effect_type=effect_type,
state=state,
src=src,
dst=dst,
h=h,
w=w,
device=device,
feather=feather,
invert_pattern=invert_pattern,
)
return self._blend_from_mask(src, dst, mask)
def build_compare(
self,
source_image,
generated_image,
effect_type,
frames,
fps,
loop_count,
loop_style,
hold_frames,
easing,
feather,
block_size,
seed,
invert_pattern,
toaster_tint_strength,
video_flavor,
video_flavor_strength,
):
src = _single_frame(source_image).to(torch.float32).clamp(0.0, 1.0)
dst = _single_frame(generated_image).to(torch.float32).clamp(0.0, 1.0)
# Match generated image to source image size.
target_h, target_w = src.shape[0], src.shape[1]
dst = _resize_image(dst, target_h, target_w).to(src.device)
h, w, _ = src.shape
device = src.device
state = self._build_transition_state(
effect_type=effect_type,
h=h,
w=w,
device=device,
seed=seed,
block_size=block_size,
)
cycle_frames = []
for i in range(frames):
raw_t = i / max(frames - 1, 1)
t = _ease_value(raw_t, easing)
frame = self._render_frame(
src=src,
dst=dst,
effect_type=effect_type,
t=t,
state=state,
feather=feather,
block_size=block_size,
invert_pattern=bool(invert_pattern),
toaster_tint_strength=toaster_tint_strength,
)
cycle_frames.append(frame)
output_frames = []
for _ in range(loop_count):
output_frames.extend(cycle_frames)
for _hf in range(hold_frames):
output_frames.append(dst.clone())
if loop_style == "ping_pong" and len(cycle_frames) > 2:
reverse_frames = list(reversed(cycle_frames[1:-1]))
output_frames.extend(reverse_frames)
for _hf in range(hold_frames):
output_frames.append(src.clone())
flavored_frames = []
total = len(output_frames)
for idx, frame in enumerate(output_frames):
flavored = self._video_flavor(
frame=frame,
frame_index=idx,
total_frames=total,
flavor=video_flavor,
strength=video_flavor_strength,
seed=seed,
)
flavored_frames.append(flavored)
batch = torch.stack(flavored_frames, dim=0)
return (batch, int(batch.shape[0]), int(fps))
NODE_CLASS_MAPPINGS = {
"VS Image Compare Deluxe": VSImageCompareDeluxe,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"VS Image Compare Deluxe": "VS Image Compare Deluxe",
}