Professional before/after image comparison with 4 layout modes: - Side-by-side with configurable gap - Horizontal split (left/right) with divider + triangle indicators - Vertical split (top/bottom) with divider + triangle indicators - Opacity blend crossfade Features: auto-resize, divider styling, optional text labels, custom colors.
549 lines
21 KiB
Python
549 lines
21 KiB
Python
"""
|
|
ComfyUI node for professional before/after image comparison.
|
|
|
|
This module provides a specialized node that creates publication-ready
|
|
comparison images from two inputs (before and after). Supports multiple
|
|
layout modes including side-by-side, horizontal/vertical split with
|
|
adjustable divider, and opacity blend for detailed inspection.
|
|
"""
|
|
|
|
import torch
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
import numpy as np
|
|
|
|
|
|
def tensor2pil(image):
|
|
"""Convert tensor to PIL image."""
|
|
return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
|
|
|
|
|
def pil2tensor(image):
|
|
"""Convert PIL image to tensor."""
|
|
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
|
|
class Karma_Before_After:
|
|
"""
|
|
Professional before/after image comparison node.
|
|
|
|
Creates publication-ready comparison images from two inputs using
|
|
multiple layout modes. Ideal for showcasing post-processing effects,
|
|
model comparisons, upscaling results, or any A/B image comparison.
|
|
|
|
Layout Modes:
|
|
- side_by_side: Images placed next to each other with optional gap
|
|
- split_horizontal: Left/right split with movable vertical divider
|
|
- split_vertical: Top/bottom split with movable horizontal divider
|
|
- blend: Opacity crossfade between the two images
|
|
|
|
Features:
|
|
- Automatic image resizing when dimensions don't match
|
|
- Configurable divider line with color and width
|
|
- Optional text labels for before/after identification
|
|
- Adjustable split position (0.0 to 1.0)
|
|
- Side-by-side gap width control
|
|
- Background color for gap/padding areas
|
|
"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""
|
|
Define the input parameters for the before/after comparison node.
|
|
|
|
Returns:
|
|
Dictionary containing required and optional input specifications
|
|
"""
|
|
return {
|
|
"required": {
|
|
"before_image": ("IMAGE", {
|
|
"tooltip": "The 'before' image (left/top in comparisons)"
|
|
}),
|
|
"after_image": ("IMAGE", {
|
|
"tooltip": "The 'after' image (right/bottom in comparisons)"
|
|
}),
|
|
"mode": (["side_by_side", "split_horizontal", "split_vertical", "blend"], {
|
|
"default": "split_horizontal",
|
|
"tooltip": "Comparison layout mode"
|
|
}),
|
|
"split_position": ("FLOAT", {
|
|
"default": 0.5,
|
|
"min": 0.0,
|
|
"max": 1.0,
|
|
"step": 0.01,
|
|
"tooltip": "Position of the split/blend point (0.0 = all before, 1.0 = all after)"
|
|
}),
|
|
"divider_width": ("INT", {
|
|
"default": 3,
|
|
"min": 0,
|
|
"max": 20,
|
|
"step": 1,
|
|
"tooltip": "Width of the divider line in pixels (0 = no divider)"
|
|
}),
|
|
"divider_color": (["white", "black", "red", "gray"], {
|
|
"default": "white",
|
|
"tooltip": "Color of the split divider line"
|
|
}),
|
|
"show_labels": (["none", "simple", "outlined"], {
|
|
"default": "none",
|
|
"tooltip": "Label style: none, simple text, or outlined text for visibility"
|
|
}),
|
|
"label_size": (["small", "medium", "large"], {
|
|
"default": "medium",
|
|
"tooltip": "Size of the before/after labels"
|
|
}),
|
|
"gap_width": ("INT", {
|
|
"default": 4,
|
|
"min": 0,
|
|
"max": 100,
|
|
"step": 2,
|
|
"tooltip": "Gap between images in side-by-side mode (pixels)"
|
|
}),
|
|
"background_color": (["black", "white", "gray"], {
|
|
"default": "black",
|
|
"tooltip": "Background/gap color"
|
|
}),
|
|
"resize_mode": (["match_before", "match_after", "match_larger", "match_smaller"], {
|
|
"default": "match_before",
|
|
"tooltip": "How to handle mismatched image sizes"
|
|
}),
|
|
},
|
|
"optional": {
|
|
"before_label": ("STRING", {
|
|
"default": "Before",
|
|
"tooltip": "Custom label for the before image"
|
|
}),
|
|
"after_label": ("STRING", {
|
|
"default": "After",
|
|
"tooltip": "Custom label for the after image"
|
|
}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("comparison",)
|
|
FUNCTION = "create_comparison"
|
|
CATEGORY = "KarmaNodes/Post-Processing"
|
|
|
|
# Color lookup for divider and background
|
|
COLOR_MAP = {
|
|
"white": (255, 255, 255),
|
|
"black": (0, 0, 0),
|
|
"red": (220, 50, 50),
|
|
"gray": (128, 128, 128),
|
|
}
|
|
|
|
def create_comparison(self, before_image: torch.Tensor, after_image: torch.Tensor,
|
|
mode: str, split_position: float, divider_width: int,
|
|
divider_color: str, show_labels: str, label_size: str,
|
|
gap_width: int, background_color: str, resize_mode: str,
|
|
before_label: str = "Before",
|
|
after_label: str = "After") -> tuple:
|
|
"""
|
|
Create a before/after comparison image.
|
|
|
|
Processes both input images, resizes them to matching dimensions if
|
|
needed, then composites them according to the selected layout mode.
|
|
|
|
Args:
|
|
before_image: The 'before' image tensor
|
|
after_image: The 'after' image tensor
|
|
mode: Layout mode (side_by_side, split_horizontal, split_vertical, blend)
|
|
split_position: Position of split point (0.0 to 1.0)
|
|
divider_width: Width of divider line in pixels
|
|
divider_color: Color of the divider line
|
|
show_labels: Label style (none, simple, outlined)
|
|
label_size: Label size (small, medium, large)
|
|
gap_width: Gap between images in side-by-side mode
|
|
background_color: Background/gap fill color
|
|
resize_mode: How to handle size mismatches
|
|
before_label: Custom text for 'before' label
|
|
after_label: Custom text for 'after' label
|
|
|
|
Returns:
|
|
Tuple containing the comparison image tensor
|
|
"""
|
|
pil_before = tensor2pil(before_image)
|
|
pil_after = tensor2pil(after_image)
|
|
|
|
# Ensure both images are RGB
|
|
pil_before = pil_before.convert("RGB")
|
|
pil_after = pil_after.convert("RGB")
|
|
|
|
# Resize images to match if needed
|
|
pil_before, pil_after = self._resize_images(pil_before, pil_after, resize_mode)
|
|
|
|
bg_color = self.COLOR_MAP.get(background_color, (0, 0, 0))
|
|
div_color = self.COLOR_MAP.get(divider_color, (255, 255, 255))
|
|
|
|
# Create comparison based on mode
|
|
if mode == "side_by_side":
|
|
result = self._side_by_side(pil_before, pil_after, gap_width, bg_color)
|
|
elif mode == "split_horizontal":
|
|
result = self._split_horizontal(pil_before, pil_after, split_position,
|
|
divider_width, div_color)
|
|
elif mode == "split_vertical":
|
|
result = self._split_vertical(pil_before, pil_after, split_position,
|
|
divider_width, div_color)
|
|
elif mode == "blend":
|
|
result = self._blend(pil_before, pil_after, split_position)
|
|
else:
|
|
result = pil_before
|
|
|
|
# Add labels if requested
|
|
if show_labels != "none":
|
|
result = self._add_labels(result, mode, split_position, gap_width,
|
|
show_labels, label_size, before_label,
|
|
after_label, div_color)
|
|
|
|
result_tensor = pil2tensor(result)
|
|
return (result_tensor,)
|
|
|
|
@staticmethod
|
|
def _resize_images(before: Image.Image, after: Image.Image,
|
|
resize_mode: str) -> tuple:
|
|
"""
|
|
Resize images to matching dimensions based on the selected mode.
|
|
|
|
Args:
|
|
before: Before PIL Image
|
|
after: After PIL Image
|
|
resize_mode: Strategy for matching dimensions
|
|
|
|
Returns:
|
|
Tuple of (resized_before, resized_after) PIL Images
|
|
"""
|
|
bw, bh = before.size
|
|
aw, ah = after.size
|
|
|
|
if bw == aw and bh == ah:
|
|
return before, after
|
|
|
|
if resize_mode == "match_before":
|
|
target_w, target_h = bw, bh
|
|
elif resize_mode == "match_after":
|
|
target_w, target_h = aw, ah
|
|
elif resize_mode == "match_larger":
|
|
target_w = max(bw, aw)
|
|
target_h = max(bh, ah)
|
|
elif resize_mode == "match_smaller":
|
|
target_w = min(bw, aw)
|
|
target_h = min(bh, ah)
|
|
else:
|
|
target_w, target_h = bw, bh
|
|
|
|
if (bw, bh) != (target_w, target_h):
|
|
before = before.resize((target_w, target_h), Image.LANCZOS)
|
|
if (aw, ah) != (target_w, target_h):
|
|
after = after.resize((target_w, target_h), Image.LANCZOS)
|
|
|
|
return before, after
|
|
|
|
@staticmethod
|
|
def _side_by_side(before: Image.Image, after: Image.Image,
|
|
gap: int, bg_color: tuple) -> Image.Image:
|
|
"""
|
|
Create a side-by-side comparison with optional gap.
|
|
|
|
Places both images horizontally next to each other with a
|
|
configurable gap between them.
|
|
|
|
Args:
|
|
before: Before PIL Image
|
|
after: After PIL Image
|
|
gap: Gap width in pixels between the two images
|
|
bg_color: Background color for the gap area
|
|
|
|
Returns:
|
|
Combined side-by-side image
|
|
"""
|
|
w, h = before.size
|
|
total_w = w * 2 + gap
|
|
|
|
canvas = Image.new("RGB", (total_w, h), bg_color)
|
|
canvas.paste(before, (0, 0))
|
|
canvas.paste(after, (w + gap, 0))
|
|
|
|
return canvas
|
|
|
|
@staticmethod
|
|
def _split_horizontal(before: Image.Image, after: Image.Image,
|
|
position: float, divider_width: int,
|
|
divider_color: tuple) -> Image.Image:
|
|
"""
|
|
Create a horizontal (left/right) split comparison.
|
|
|
|
Shows the 'before' image on the left portion and the 'after' image
|
|
on the right, with a vertical divider line at the split position.
|
|
|
|
Args:
|
|
before: Before PIL Image
|
|
after: After PIL Image
|
|
position: Split position (0.0 = far left, 1.0 = far right)
|
|
divider_width: Width of the vertical divider line
|
|
divider_color: Color of the divider line
|
|
|
|
Returns:
|
|
Split comparison image
|
|
"""
|
|
w, h = before.size
|
|
split_x = int(w * position)
|
|
split_x = max(0, min(split_x, w))
|
|
|
|
# Build result: left from before, right from after
|
|
before_arr = np.array(before)
|
|
after_arr = np.array(after)
|
|
result_arr = np.copy(after_arr)
|
|
if split_x > 0:
|
|
result_arr[:, :split_x] = before_arr[:, :split_x]
|
|
|
|
result = Image.fromarray(result_arr)
|
|
|
|
# Draw divider line
|
|
if divider_width > 0 and 0 < split_x < w:
|
|
draw = ImageDraw.Draw(result)
|
|
half_w = divider_width // 2
|
|
x0 = max(0, split_x - half_w)
|
|
x1 = min(w - 1, split_x + half_w)
|
|
draw.rectangle([x0, 0, x1, h - 1], fill=divider_color)
|
|
|
|
# Draw small triangular indicators at top and bottom
|
|
indicator_size = max(6, divider_width * 3)
|
|
# Top indicator (downward triangle)
|
|
draw.polygon([
|
|
(split_x - indicator_size, 0),
|
|
(split_x + indicator_size, 0),
|
|
(split_x, indicator_size)
|
|
], fill=divider_color)
|
|
# Bottom indicator (upward triangle)
|
|
draw.polygon([
|
|
(split_x - indicator_size, h - 1),
|
|
(split_x + indicator_size, h - 1),
|
|
(split_x, h - 1 - indicator_size)
|
|
], fill=divider_color)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _split_vertical(before: Image.Image, after: Image.Image,
|
|
position: float, divider_width: int,
|
|
divider_color: tuple) -> Image.Image:
|
|
"""
|
|
Create a vertical (top/bottom) split comparison.
|
|
|
|
Shows the 'before' image on the top portion and the 'after' image
|
|
on the bottom, with a horizontal divider line at the split position.
|
|
|
|
Args:
|
|
before: Before PIL Image
|
|
after: After PIL Image
|
|
position: Split position (0.0 = top, 1.0 = bottom)
|
|
divider_width: Width of the horizontal divider line
|
|
divider_color: Color of the divider line
|
|
|
|
Returns:
|
|
Split comparison image
|
|
"""
|
|
w, h = before.size
|
|
split_y = int(h * position)
|
|
split_y = max(0, min(split_y, h))
|
|
|
|
# Build result: top from before, bottom from after
|
|
before_arr = np.array(before)
|
|
after_arr = np.array(after)
|
|
result_arr = np.copy(after_arr)
|
|
if split_y > 0:
|
|
result_arr[:split_y, :] = before_arr[:split_y, :]
|
|
|
|
result = Image.fromarray(result_arr)
|
|
|
|
# Draw divider line
|
|
if divider_width > 0 and 0 < split_y < h:
|
|
draw = ImageDraw.Draw(result)
|
|
half_w = divider_width // 2
|
|
y0 = max(0, split_y - half_w)
|
|
y1 = min(h - 1, split_y + half_w)
|
|
draw.rectangle([0, y0, w - 1, y1], fill=divider_color)
|
|
|
|
# Draw small triangular indicators at left and right
|
|
indicator_size = max(6, divider_width * 3)
|
|
# Left indicator (rightward triangle)
|
|
draw.polygon([
|
|
(0, split_y - indicator_size),
|
|
(0, split_y + indicator_size),
|
|
(indicator_size, split_y)
|
|
], fill=divider_color)
|
|
# Right indicator (leftward triangle)
|
|
draw.polygon([
|
|
(w - 1, split_y - indicator_size),
|
|
(w - 1, split_y + indicator_size),
|
|
(w - 1 - indicator_size, split_y)
|
|
], fill=divider_color)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _blend(before: Image.Image, after: Image.Image,
|
|
opacity: float) -> Image.Image:
|
|
"""
|
|
Create a blended comparison using opacity crossfade.
|
|
|
|
Blends both images together using the split_position as the
|
|
opacity/mix value. At 0.0 only the before image is shown,
|
|
at 1.0 only the after image, and values in between show a
|
|
transparent overlay of both.
|
|
|
|
Args:
|
|
before: Before PIL Image
|
|
after: After PIL Image
|
|
opacity: Blend amount (0.0 = all before, 1.0 = all after)
|
|
|
|
Returns:
|
|
Blended comparison image
|
|
"""
|
|
before_arr = np.array(before, dtype=np.float32)
|
|
after_arr = np.array(after, dtype=np.float32)
|
|
|
|
blended = before_arr * (1.0 - opacity) + after_arr * opacity
|
|
return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))
|
|
|
|
def _add_labels(self, image: Image.Image, mode: str, split_position: float,
|
|
gap_width: int, label_style: str, label_size: str,
|
|
before_text: str, after_text: str,
|
|
divider_color: tuple) -> Image.Image:
|
|
"""
|
|
Add text labels to the comparison image.
|
|
|
|
Places 'Before' and 'After' labels at appropriate positions based
|
|
on the layout mode. Supports simple text or outlined text for
|
|
better visibility over varied backgrounds.
|
|
|
|
Args:
|
|
image: The comparison image to label
|
|
mode: Current layout mode
|
|
split_position: Current split position
|
|
gap_width: Gap width (for side-by-side positioning)
|
|
label_style: Label rendering style (simple or outlined)
|
|
label_size: Label size (small, medium, large)
|
|
before_text: Text for the 'before' label
|
|
after_text: Text for the 'after' label
|
|
divider_color: Divider color (used for label background reference)
|
|
|
|
Returns:
|
|
Image with labels added
|
|
"""
|
|
result = image.copy()
|
|
draw = ImageDraw.Draw(result)
|
|
w, h = result.size
|
|
|
|
# Determine font size based on image dimensions and label_size setting
|
|
base_size = max(12, min(w, h) // 30)
|
|
size_multipliers = {"small": 0.7, "medium": 1.0, "large": 1.5}
|
|
font_size = int(base_size * size_multipliers.get(label_size, 1.0))
|
|
|
|
try:
|
|
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
|
|
except (OSError, IOError):
|
|
try:
|
|
font = ImageFont.truetype("/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", font_size)
|
|
except (OSError, IOError):
|
|
font = ImageFont.load_default()
|
|
|
|
# Determine label positions based on mode
|
|
margin = font_size // 2
|
|
label_y = h - margin - font_size # Bottom of image
|
|
|
|
if mode == "side_by_side":
|
|
single_w = (w - gap_width) // 2
|
|
before_x = margin
|
|
after_x = single_w + gap_width + margin
|
|
elif mode == "split_horizontal":
|
|
split_x = int((w - gap_width) / 2 * split_position) if mode == "side_by_side" else int(w * split_position)
|
|
# Place before label in left quarter, after in right quarter
|
|
before_x = margin
|
|
after_x = max(split_x + margin, w * 3 // 4 - margin)
|
|
elif mode == "split_vertical":
|
|
before_x = margin
|
|
after_x = margin
|
|
split_y = int(h * split_position)
|
|
label_y = split_y - margin - font_size # Before label above split
|
|
elif mode == "blend":
|
|
before_x = margin
|
|
after_x = w - margin # Will be adjusted by text width below
|
|
else:
|
|
before_x = margin
|
|
after_x = w // 2 + margin
|
|
|
|
# Get text bounding boxes for positioning
|
|
before_bbox = draw.textbbox((0, 0), before_text, font=font)
|
|
after_bbox = draw.textbbox((0, 0), after_text, font=font)
|
|
before_tw = before_bbox[2] - before_bbox[0]
|
|
after_tw = after_bbox[2] - after_bbox[0]
|
|
text_h = before_bbox[3] - before_bbox[1]
|
|
|
|
# Adjust after_x for right-aligned modes
|
|
if mode in ("blend",):
|
|
after_x = w - margin - after_tw
|
|
|
|
# For vertical split, position labels differently
|
|
if mode == "split_vertical":
|
|
split_y = int(h * split_position)
|
|
before_y = max(margin, split_y - margin - text_h - 8)
|
|
after_y = min(h - margin - text_h, split_y + margin)
|
|
else:
|
|
before_y = label_y
|
|
after_y = label_y
|
|
|
|
# Draw labels with padding background for readability
|
|
padding = 4
|
|
self._draw_label(draw, before_text, int(before_x), int(before_y),
|
|
font, label_style, padding)
|
|
self._draw_label(draw, after_text, int(after_x), int(after_y),
|
|
font, label_style, padding)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def _draw_label(draw: ImageDraw.Draw, text: str, x: int, y: int,
|
|
font, style: str, padding: int) -> None:
|
|
"""
|
|
Draw a single text label with the specified style.
|
|
|
|
For 'simple' style, draws white text on a semi-transparent dark
|
|
background. For 'outlined' style, draws white text with a dark
|
|
outline for maximum legibility over any background.
|
|
|
|
Args:
|
|
draw: PIL ImageDraw instance
|
|
text: Label text to render
|
|
x: X position for the label
|
|
y: Y position for the label
|
|
font: PIL font instance
|
|
style: Label style ('simple' or 'outlined')
|
|
padding: Padding around the text in pixels
|
|
"""
|
|
bbox = draw.textbbox((x, y), text, font=font)
|
|
tw = bbox[2] - bbox[0]
|
|
th = bbox[3] - bbox[1]
|
|
|
|
if style == "simple":
|
|
# Draw semi-transparent background rectangle
|
|
bg_rect = [
|
|
x - padding,
|
|
y - padding,
|
|
x + tw + padding,
|
|
y + th + padding
|
|
]
|
|
# Draw dark background (solid since PIL doesn't support alpha easily)
|
|
draw.rectangle(bg_rect, fill=(0, 0, 0, 180))
|
|
draw.text((x, y), text, fill=(255, 255, 255), font=font)
|
|
|
|
elif style == "outlined":
|
|
# Draw text outline (dark border around white text)
|
|
outline_range = max(1, padding // 2)
|
|
for ox in range(-outline_range, outline_range + 1):
|
|
for oy in range(-outline_range, outline_range + 1):
|
|
if ox != 0 or oy != 0:
|
|
draw.text((x + ox, y + oy), text,
|
|
fill=(0, 0, 0), font=font)
|
|
draw.text((x, y), text, fill=(255, 255, 255), font=font)
|