Merge pull request #13 from ComfyAssets/feature/image-to-multiple-of
Feature/image to multiple of
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Image to Multiple Of
|
||||
|
||||
## Overview
|
||||
|
||||
The **Image to Multiple Of** node adjusts image dimensions to be multiples of a specified value. This is particularly useful for models that require input dimensions to be multiples of certain values (e.g., 8, 16, 32, 64) for optimal performance or compatibility.
|
||||
|
||||
## Purpose
|
||||
|
||||
Many AI models, especially diffusion models and VAEs, require input dimensions to be multiples of specific values due to their architecture (e.g., downsampling layers). This node ensures your images meet these requirements without manual calculation.
|
||||
|
||||
## Inputs
|
||||
|
||||
- **image** (IMAGE, required): The input image to process
|
||||
- **multiple_of** (INT, required): The value that dimensions should be multiple of
|
||||
- Default: 64
|
||||
- Range: 1-256
|
||||
- Step: 16
|
||||
- **method** (COMBO, required): Processing method
|
||||
- Options: "center crop", "rescale"
|
||||
|
||||
## Outputs
|
||||
|
||||
- **image** (IMAGE): Processed image with dimensions adjusted to multiples of the specified value
|
||||
|
||||
## Processing Methods
|
||||
|
||||
### Center Crop
|
||||
- Crops the image from the center to achieve the target dimensions
|
||||
- Preserves image quality but may lose edge content
|
||||
- Best for images where the important content is centered
|
||||
|
||||
### Rescale
|
||||
- Resizes the image to the target dimensions using bilinear interpolation
|
||||
- Keeps all content but may slightly affect image quality
|
||||
- Best when you need to preserve all image content
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Example 1: Prepare for VAE Encoding
|
||||
```
|
||||
Load Image → Image to Multiple Of (multiple_of: 64) → VAE Encode
|
||||
```
|
||||
|
||||
### Example 2: Prepare for Specific Model Requirements
|
||||
```
|
||||
Load Image → Image to Multiple Of (multiple_of: 32) → Model Processing
|
||||
```
|
||||
|
||||
### Example 3: Batch Processing
|
||||
```
|
||||
Load Images → Image to Multiple Of (multiple_of: 16, method: rescale) → Batch Process
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Supports batch processing (processes all images in a batch)
|
||||
- Works with any number of channels (RGB, RGBA, grayscale, etc.)
|
||||
- Calculates the largest dimensions that are less than or equal to the original size
|
||||
- For center crop: crops equally from all sides to maintain centering
|
||||
- For rescale: uses bilinear interpolation with align_corners=False
|
||||
|
||||
## Common Use Cases
|
||||
|
||||
1. **VAE Preprocessing**: Ensure images are compatible with VAE encoders that require dimensions divisible by 64
|
||||
2. **Model Compatibility**: Adjust images for models with specific architectural requirements
|
||||
3. **Batch Uniformity**: Ensure all images in a batch have dimensions that meet model requirements
|
||||
4. **Performance Optimization**: Some models perform better with dimensions that are powers of 2
|
||||
|
||||
## Tips
|
||||
|
||||
- Use **center crop** when your subject is centered and you don't mind losing edge details
|
||||
- Use **rescale** when you need to preserve all image content
|
||||
- Common multiple_of values: 8, 16, 32, 64, 128
|
||||
- For Stable Diffusion models, 64 is typically recommended
|
||||
- For some upscaling models, 32 or 16 may be sufficient
|
||||
|
||||
## Error Handling
|
||||
|
||||
The node will raise an error if:
|
||||
- The image dimensions are smaller than the specified multiple_of value
|
||||
- Invalid input types are provided
|
||||
- The resulting dimensions would be 0 or negative
|
||||
@@ -0,0 +1,123 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "LoadImage",
|
||||
"pos": [100, 200],
|
||||
"size": [315, 314],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"shape": 3,
|
||||
"label": "IMAGE"
|
||||
},
|
||||
{
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "ImageToMultipleOf",
|
||||
"pos": [500, 200],
|
||||
"size": [315, 106],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"links": [2, 3],
|
||||
"shape": 3,
|
||||
"label": "image"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "ImageToMultipleOf"
|
||||
},
|
||||
"widgets_values": [64, "center crop"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PreviewImage",
|
||||
"pos": [900, 100],
|
||||
"size": [210, 246],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "VAEEncode",
|
||||
"pos": [900, 400],
|
||||
"size": [210, 46],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"],
|
||||
[2, 2, 0, 3, 0, "IMAGE"],
|
||||
[3, 2, 0, 4, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -9,6 +9,7 @@ from .tools.seed_history import SeedHistoryNode
|
||||
from .tools.sampler_combo import SamplerComboNode, SamplerComboCompactNode
|
||||
from .tools.empty_latent_batch import EmptyLatentBatchNode
|
||||
from .tools.kiko_save_image import KikoSaveImageNode
|
||||
from .tools.image_to_multiple_of import ImageToMultipleOfNode
|
||||
|
||||
# ComfyUI node registration mappings
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
@@ -19,6 +20,7 @@ NODE_CLASS_MAPPINGS = {
|
||||
"SamplerComboCompact": SamplerComboCompactNode,
|
||||
"EmptyLatentBatch": EmptyLatentBatchNode,
|
||||
"KikoSaveImage": KikoSaveImageNode,
|
||||
"ImageToMultipleOf": ImageToMultipleOfNode,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -29,6 +31,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SamplerComboCompact": "Sampler Combo (Compact)",
|
||||
"EmptyLatentBatch": "Empty Latent Batch",
|
||||
"KikoSaveImage": "Kiko Save Image",
|
||||
"ImageToMultipleOf": "Image to Multiple of",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""ImageToMultipleOf tool for ComfyUI-KikoTools."""
|
||||
|
||||
from .node import ImageToMultipleOfNode
|
||||
|
||||
__all__ = ["ImageToMultipleOfNode"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Core logic for ImageToMultipleOf tool."""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
import torch.nn.functional as F
|
||||
from torch import Tensor
|
||||
|
||||
|
||||
def calculate_dimensions_to_multiple(
|
||||
height: int, width: int, multiple_of: int
|
||||
) -> Tuple[int, int]:
|
||||
"""Calculate new dimensions that are multiples of the specified value.
|
||||
|
||||
Args:
|
||||
height: Original height
|
||||
width: Original width
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
|
||||
Returns:
|
||||
Tuple of (new_height, new_width)
|
||||
"""
|
||||
new_height = height - (height % multiple_of)
|
||||
new_width = width - (width % multiple_of)
|
||||
return new_height, new_width
|
||||
|
||||
|
||||
def process_image_to_multiple_of(
|
||||
image: Tensor, multiple_of: int, method: str
|
||||
) -> Tensor:
|
||||
"""Process image to ensure dimensions are multiples of specified value.
|
||||
|
||||
Args:
|
||||
image: Input image tensor of shape (batch, height, width, channels)
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
method: Processing method - "center crop" or "rescale"
|
||||
|
||||
Returns:
|
||||
Processed image tensor
|
||||
"""
|
||||
_, height, width, _ = image.shape
|
||||
new_height, new_width = calculate_dimensions_to_multiple(height, width, multiple_of)
|
||||
|
||||
if method == "rescale":
|
||||
# Rescale the image to the new dimensions
|
||||
# Convert from BHWC to BCHW for interpolation
|
||||
image_chw = image.permute(0, 3, 1, 2)
|
||||
rescaled = F.interpolate(
|
||||
image_chw,
|
||||
size=(new_height, new_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)
|
||||
# Convert back to BHWC
|
||||
return rescaled.permute(0, 2, 3, 1)
|
||||
else: # center crop
|
||||
# Calculate crop offsets to center the crop
|
||||
top = (height - new_height) // 2
|
||||
left = (width - new_width) // 2
|
||||
bottom = top + new_height
|
||||
right = left + new_width
|
||||
return image[:, top:bottom, left:right, :]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""ComfyUI node implementation for ImageToMultipleOf."""
|
||||
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
from torch import Tensor
|
||||
|
||||
from ...base import ComfyAssetsBaseNode
|
||||
from .logic import process_image_to_multiple_of
|
||||
|
||||
|
||||
class ImageToMultipleOfNode(ComfyAssetsBaseNode):
|
||||
"""
|
||||
Adjusts image dimensions to be multiples of a specified value.
|
||||
|
||||
Useful for models that require specific dimension constraints.
|
||||
Supports both center cropping and rescaling methods.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls) -> Dict[str, Any]:
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"multiple_of": (
|
||||
"INT",
|
||||
{
|
||||
"default": 64,
|
||||
"min": 1,
|
||||
"max": 256,
|
||||
"step": 16,
|
||||
"display": "number",
|
||||
},
|
||||
),
|
||||
"method": (["center crop", "rescale"],),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "process"
|
||||
|
||||
def process(self, image: Tensor, multiple_of: int, method: str) -> Tuple[Tensor]:
|
||||
"""
|
||||
Process image to ensure dimensions are multiples of specified value.
|
||||
|
||||
Args:
|
||||
image: Input image tensor
|
||||
multiple_of: Value that dimensions should be multiple of
|
||||
method: Processing method - "center crop" or "rescale"
|
||||
|
||||
Returns:
|
||||
Tuple containing processed image tensor
|
||||
"""
|
||||
try:
|
||||
self.validate_inputs(image=image, multiple_of=multiple_of, method=method)
|
||||
|
||||
# Process the image
|
||||
processed_image = process_image_to_multiple_of(image, multiple_of, method)
|
||||
|
||||
_, new_height, new_width, _ = processed_image.shape
|
||||
self.log_info(
|
||||
f"Processed image from {image.shape[1]}x{image.shape[2]} "
|
||||
f"to {new_height}x{new_width} (multiple of {multiple_of}) "
|
||||
f"using {method}"
|
||||
)
|
||||
|
||||
return (processed_image,)
|
||||
|
||||
except Exception as e:
|
||||
self.handle_error(f"Failed to process image: {str(e)}", e)
|
||||
|
||||
def validate_inputs(self, **kwargs) -> None:
|
||||
"""Validate inputs for ImageToMultipleOf node."""
|
||||
image = kwargs.get("image")
|
||||
multiple_of = kwargs.get("multiple_of")
|
||||
method = kwargs.get("method")
|
||||
|
||||
if image is None:
|
||||
raise ValueError("Image input is required")
|
||||
|
||||
if not isinstance(image, Tensor) or len(image.shape) != 4:
|
||||
raise ValueError(
|
||||
f"Expected image tensor with shape (batch, height, width, channels), "
|
||||
f"got shape {image.shape if isinstance(image, Tensor) else 'non-tensor'}"
|
||||
)
|
||||
|
||||
if multiple_of <= 0:
|
||||
raise ValueError(f"multiple_of must be positive, got {multiple_of}")
|
||||
|
||||
if method not in ["center crop", "rescale"]:
|
||||
raise ValueError(f"Invalid method: {method}")
|
||||
|
||||
# Check if resulting dimensions would be too small
|
||||
_, height, width, _ = image.shape
|
||||
new_height = height - (height % multiple_of)
|
||||
new_width = width - (width % multiple_of)
|
||||
|
||||
if new_height <= 0 or new_width <= 0:
|
||||
raise ValueError(
|
||||
f"Image dimensions ({height}x{width}) are too small "
|
||||
f"to be adjusted to multiple of {multiple_of}"
|
||||
)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Unit tests for ImageToMultipleOf tool."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the project root to the Python path for tests
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent))
|
||||
|
||||
from kikotools.tools.image_to_multiple_of.logic import (
|
||||
calculate_dimensions_to_multiple,
|
||||
process_image_to_multiple_of,
|
||||
)
|
||||
from kikotools.tools.image_to_multiple_of.node import ImageToMultipleOfNode
|
||||
|
||||
|
||||
class TestImageToMultipleOfLogic:
|
||||
"""Test core logic functions."""
|
||||
|
||||
def test_calculate_dimensions_to_multiple(self):
|
||||
"""Test dimension calculation for various inputs."""
|
||||
# Test exact multiples
|
||||
assert calculate_dimensions_to_multiple(256, 512, 64) == (256, 512)
|
||||
|
||||
# Test non-exact multiples
|
||||
assert calculate_dimensions_to_multiple(300, 400, 64) == (256, 384)
|
||||
assert calculate_dimensions_to_multiple(150, 200, 32) == (128, 192)
|
||||
|
||||
# Test small values
|
||||
assert calculate_dimensions_to_multiple(10, 20, 8) == (8, 16)
|
||||
|
||||
# Test with multiple_of = 1 (should return original)
|
||||
assert calculate_dimensions_to_multiple(123, 456, 1) == (123, 456)
|
||||
|
||||
def test_process_image_center_crop(self):
|
||||
"""Test center crop processing."""
|
||||
# Create test image (batch=1, height=300, width=400, channels=3)
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
# Process with center crop
|
||||
result = process_image_to_multiple_of(image, 64, "center crop")
|
||||
|
||||
# Check dimensions
|
||||
assert result.shape == (1, 256, 384, 3)
|
||||
|
||||
# Check that center portion is preserved
|
||||
# The crop should start at (22, 8) and end at (278, 392)
|
||||
# This is a rough check that values are from the center
|
||||
assert result.dtype == image.dtype
|
||||
|
||||
def test_process_image_rescale(self):
|
||||
"""Test rescale processing."""
|
||||
# Create test image
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
# Process with rescale
|
||||
result = process_image_to_multiple_of(image, 64, "rescale")
|
||||
|
||||
# Check dimensions
|
||||
assert result.shape == (1, 256, 384, 3)
|
||||
assert result.dtype == image.dtype
|
||||
|
||||
def test_process_image_batch(self):
|
||||
"""Test processing with batch of images."""
|
||||
# Create batch of images
|
||||
batch_size = 4
|
||||
image = torch.rand(batch_size, 300, 400, 3)
|
||||
|
||||
# Process with center crop
|
||||
result_crop = process_image_to_multiple_of(image, 32, "center crop")
|
||||
assert result_crop.shape == (batch_size, 288, 384, 3)
|
||||
|
||||
# Process with rescale
|
||||
result_rescale = process_image_to_multiple_of(image, 32, "rescale")
|
||||
assert result_rescale.shape == (batch_size, 288, 384, 3)
|
||||
|
||||
def test_process_image_different_channels(self):
|
||||
"""Test with different channel counts."""
|
||||
# Test with 1 channel (grayscale)
|
||||
image_gray = torch.rand(1, 256, 256, 1)
|
||||
result = process_image_to_multiple_of(image_gray, 64, "center crop")
|
||||
assert result.shape == (1, 256, 256, 1)
|
||||
|
||||
# Test with 4 channels (RGBA)
|
||||
image_rgba = torch.rand(1, 300, 400, 4)
|
||||
result = process_image_to_multiple_of(image_rgba, 64, "rescale")
|
||||
assert result.shape == (1, 256, 384, 4)
|
||||
|
||||
|
||||
class TestImageToMultipleOfNode:
|
||||
"""Test ComfyUI node implementation."""
|
||||
|
||||
def test_node_input_types(self):
|
||||
"""Test node input type definitions."""
|
||||
input_types = ImageToMultipleOfNode.INPUT_TYPES()
|
||||
|
||||
assert "required" in input_types
|
||||
assert "image" in input_types["required"]
|
||||
assert "multiple_of" in input_types["required"]
|
||||
assert "method" in input_types["required"]
|
||||
|
||||
# Check multiple_of configuration
|
||||
multiple_config = input_types["required"]["multiple_of"][1]
|
||||
assert multiple_config["default"] == 64
|
||||
assert multiple_config["min"] == 1
|
||||
assert multiple_config["max"] == 256
|
||||
assert multiple_config["step"] == 16
|
||||
|
||||
# Check method options
|
||||
methods = input_types["required"]["method"][0]
|
||||
assert "center crop" in methods
|
||||
assert "rescale" in methods
|
||||
|
||||
def test_node_metadata(self):
|
||||
"""Test node metadata."""
|
||||
assert ImageToMultipleOfNode.RETURN_TYPES == ("IMAGE",)
|
||||
assert ImageToMultipleOfNode.RETURN_NAMES == ("image",)
|
||||
assert ImageToMultipleOfNode.FUNCTION == "process"
|
||||
assert ImageToMultipleOfNode.CATEGORY == "ComfyAssets"
|
||||
|
||||
def test_node_process_center_crop(self):
|
||||
"""Test node processing with center crop."""
|
||||
node = ImageToMultipleOfNode()
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
result = node.process(image, 64, "center crop")
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 1
|
||||
assert result[0].shape == (1, 256, 384, 3)
|
||||
|
||||
def test_node_process_rescale(self):
|
||||
"""Test node processing with rescale."""
|
||||
node = ImageToMultipleOfNode()
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
|
||||
result = node.process(image, 32, "rescale")
|
||||
|
||||
assert isinstance(result, tuple)
|
||||
assert len(result) == 1
|
||||
assert result[0].shape == (1, 288, 384, 3)
|
||||
|
||||
def test_node_validation_errors(self):
|
||||
"""Test input validation error handling."""
|
||||
node = ImageToMultipleOfNode()
|
||||
|
||||
# Test with None image
|
||||
with pytest.raises(ValueError, match="Image input is required"):
|
||||
node.validate_inputs(image=None, multiple_of=64, method="center crop")
|
||||
|
||||
# Test with invalid image shape
|
||||
invalid_image = torch.rand(300, 400, 3) # Missing batch dimension
|
||||
with pytest.raises(ValueError, match="Expected image tensor with shape"):
|
||||
node.validate_inputs(
|
||||
image=invalid_image, multiple_of=64, method="center crop"
|
||||
)
|
||||
|
||||
# Test with negative multiple_of
|
||||
image = torch.rand(1, 300, 400, 3)
|
||||
with pytest.raises(ValueError, match="multiple_of must be positive"):
|
||||
node.validate_inputs(image=image, multiple_of=-64, method="center crop")
|
||||
|
||||
# Test with invalid method
|
||||
with pytest.raises(ValueError, match="Invalid method"):
|
||||
node.validate_inputs(image=image, multiple_of=64, method="invalid")
|
||||
|
||||
# Test with image too small
|
||||
small_image = torch.rand(1, 30, 40, 3)
|
||||
with pytest.raises(ValueError, match="too small to be adjusted"):
|
||||
node.validate_inputs(
|
||||
image=small_image, multiple_of=64, method="center crop"
|
||||
)
|
||||
|
||||
def test_node_edge_cases(self):
|
||||
"""Test edge cases."""
|
||||
node = ImageToMultipleOfNode()
|
||||
|
||||
# Test with already multiple dimensions
|
||||
image = torch.rand(1, 256, 512, 3)
|
||||
result = node.process(image, 64, "center crop")
|
||||
assert result[0].shape == image.shape
|
||||
|
||||
# Test with multiple_of = 1
|
||||
image = torch.rand(1, 123, 456, 3)
|
||||
result = node.process(image, 1, "center crop")
|
||||
assert result[0].shape == image.shape
|
||||
|
||||
# Test with very large multiple_of
|
||||
image = torch.rand(1, 1024, 1024, 3)
|
||||
result = node.process(image, 256, "rescale")
|
||||
assert result[0].shape == (1, 1024, 1024, 3)
|
||||
Reference in New Issue
Block a user