feat(kikotools): add Resolution Calculator tool

This commit is contained in:
Vito Sansevero
2025-06-14 07:59:34 -07:00
parent 5031793b04
commit b24e7cb2a2
7 changed files with 459 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
"""
KikoTools package initialization and node registry
Handles automatic discovery and registration of all ComfyAssets tools
"""
from .tools.resolution_calculator import ResolutionCalculatorNode
# ComfyUI node registration mappings
NODE_CLASS_MAPPINGS = {
"ResolutionCalculator": ResolutionCalculatorNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ResolutionCalculator": "Resolution Calculator",
}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
+7
View File
@@ -0,0 +1,7 @@
"""
Base classes and utilities for all ComfyAssets tools
"""
from .base_node import ComfyAssetsBaseNode
__all__ = ["ComfyAssetsBaseNode"]
+76
View File
@@ -0,0 +1,76 @@
"""
Base node class for all ComfyAssets tools
Provides consistent categorization and shared functionality
"""
from typing import Dict, Any, Optional
import logging
logger = logging.getLogger(__name__)
class ComfyAssetsBaseNode:
"""
Base class for all ComfyAssets nodes
Provides:
- Consistent "ComfyAssets" categorization
- Standardized error handling and logging
- Common validation patterns
- Consistent return type handling
"""
CATEGORY = "ComfyAssets"
def validate_inputs(self, **kwargs) -> None:
"""
Common input validation logic
Override in subclasses for specific validation needs
Args:
**kwargs: Input parameters to validate
Raises:
ValueError: If validation fails
"""
pass
def handle_error(
self, error_msg: str, exception: Optional[Exception] = None
) -> None:
"""
Standardized error handling with logging
Args:
error_msg: Human-readable error message
exception: Optional original exception for logging
"""
logger.error(f"{self.__class__.__name__}: {error_msg}")
if exception:
logger.exception(f"Original exception: {exception}")
raise ValueError(error_msg)
def log_info(self, message: str) -> None:
"""
Standardized info logging
Args:
message: Information message to log
"""
logger.info(f"{self.__class__.__name__}: {message}")
@classmethod
def get_node_info(cls) -> Dict[str, Any]:
"""
Get standardized node information for debugging/introspection
Returns:
Dict containing node metadata
"""
return {
"class_name": cls.__name__,
"category": getattr(cls, "CATEGORY", "Unknown"),
"function": getattr(cls, "FUNCTION", "Unknown"),
"return_types": getattr(cls, "RETURN_TYPES", ()),
"return_names": getattr(cls, "RETURN_NAMES", ()),
}
+6
View File
@@ -0,0 +1,6 @@
"""
Individual tool implementations
Each tool is a self-contained module with node and logic components
"""
# Import statements will be added as tools are implemented
@@ -0,0 +1,8 @@
"""
Resolution Calculator tool
Calculates upscaled dimensions from image or latent inputs with proper scaling factors
"""
from .node import ResolutionCalculatorNode
__all__ = ["ResolutionCalculatorNode"]
@@ -0,0 +1,166 @@
"""
Core calculation logic for Resolution Calculator
Pure functions for dimension extraction and scaling calculations
"""
import torch
from typing import Tuple, Optional, Union, Dict, Any
def extract_dimensions(
image: Optional[torch.Tensor] = None,
latent: Optional[Dict[str, torch.Tensor]] = None,
) -> Tuple[int, int]:
"""
Extract width and height from IMAGE or LATENT tensor
Args:
image: Optional IMAGE tensor in ComfyUI format [batch, height, width, channels]
latent: Optional LATENT dict with 'samples' tensor [batch, channels, height/8, width/8]
Returns:
Tuple of (width, height) as integers
Raises:
ValueError: If neither image nor latent is provided
"""
if image is not None:
# IMAGE tensor format: [batch, height, width, channels]
if len(image.shape) != 4:
raise ValueError(
f"Expected IMAGE tensor with 4 dimensions, got {len(image.shape)}"
)
_, height, width, _ = image.shape
return int(width), int(height)
elif latent is not None:
# LATENT format: {"samples": [batch, channels, height/8, width/8]}
if "samples" not in latent:
raise ValueError("LATENT dict must contain 'samples' key")
samples = latent["samples"]
if len(samples.shape) != 4:
raise ValueError(
f"Expected LATENT samples tensor with 4 dimensions, got {len(samples.shape)}"
)
_, _, latent_height, latent_width = samples.shape
# Latent dimensions are 1/8 of actual image dimensions
width = int(latent_width * 8)
height = int(latent_height * 8)
return width, height
else:
raise ValueError("Either image or latent must be provided")
def ensure_divisible_by_8(width: int, height: int) -> Tuple[int, int]:
"""
Ensure dimensions are divisible by 8 (ComfyUI requirement)
Rounds to the nearest multiple of 8
Args:
width: Input width
height: Input height
Returns:
Tuple of (width, height) both divisible by 8
"""
# Round to nearest multiple of 8
# Formula: ((value + 4) // 8) * 8
# This rounds 0-3 down, 4-7 up, ensuring nearest multiple
new_width = ((width + 4) // 8) * 8
new_height = ((height + 4) // 8) * 8
return int(new_width), int(new_height)
def calculate_scaled_dimensions(
width: int, height: int, scale_factor: float
) -> Tuple[int, int]:
"""
Calculate new dimensions with scale factor and ensure divisible by 8
Args:
width: Original width
height: Original height
scale_factor: Scaling factor (e.g., 1.5, 2.0, 3.0)
Returns:
Tuple of (new_width, new_height) both divisible by 8
"""
if scale_factor <= 0:
raise ValueError(f"Scale factor must be positive, got {scale_factor}")
# Calculate new dimensions
new_width = int(width * scale_factor)
new_height = int(height * scale_factor)
# Ensure divisible by 8
return ensure_divisible_by_8(new_width, new_height)
def validate_scale_factor(
scale_factor: float, min_scale: float = 0.1, max_scale: float = 8.0
) -> None:
"""
Validate scale factor is within reasonable bounds
Args:
scale_factor: Scale factor to validate
min_scale: Minimum allowed scale factor
max_scale: Maximum allowed scale factor
Raises:
ValueError: If scale factor is out of bounds
"""
if not isinstance(scale_factor, (int, float)):
raise ValueError(
f"Scale factor must be a number, got {type(scale_factor).__name__}"
)
if scale_factor < min_scale:
raise ValueError(
f"Scale factor {scale_factor} is too small (minimum: {min_scale})"
)
if scale_factor > max_scale:
raise ValueError(
f"Scale factor {scale_factor} is too large (maximum: {max_scale})"
)
def calculate_resolution_from_input(
scale_factor: float,
image: Optional[torch.Tensor] = None,
latent: Optional[Dict[str, torch.Tensor]] = None,
) -> Tuple[int, int]:
"""
Main function to calculate resolution from input tensor and scale factor
Combines all the logic steps into a single function
Args:
scale_factor: Scaling factor
image: Optional IMAGE tensor
latent: Optional LATENT dict
Returns:
Tuple of (width, height) scaled and divisible by 8
Raises:
ValueError: For various validation errors
"""
# Validate scale factor
validate_scale_factor(scale_factor)
# Extract original dimensions
original_width, original_height = extract_dimensions(image=image, latent=latent)
# Calculate scaled dimensions
new_width, new_height = calculate_scaled_dimensions(
original_width, original_height, scale_factor
)
return new_width, new_height
@@ -0,0 +1,179 @@
"""
Resolution Calculator ComfyUI Node
Provides ComfyUI interface for calculating upscaled dimensions
"""
import torch
from typing import Dict, Any, Tuple, Optional
from ...base import ComfyAssetsBaseNode
from .logic import calculate_resolution_from_input
class ResolutionCalculatorNode(ComfyAssetsBaseNode):
"""
ComfyUI node for calculating upscaled resolution from image or latent inputs
Inputs:
- scale_factor (FLOAT): Scaling factor (1.0 to 8.0)
- image (IMAGE, optional): Input image tensor
- latent (LATENT, optional): Input latent tensor
Outputs:
- width (INT): Calculated width
- height (INT): Calculated height
"""
@classmethod
def INPUT_TYPES(cls) -> Dict[str, Any]:
"""
Define ComfyUI input interface
Returns:
Dict with required and optional input specifications
"""
return {
"required": {
"scale_factor": (
"FLOAT",
{
"default": 2.0,
"min": 1.0,
"max": 8.0,
"step": 0.1,
"display": "slider",
"tooltip": "Factor to scale the resolution by (e.g., 2.0 for 2x upscale)",
},
),
},
"optional": {
"image": (
"IMAGE",
{"tooltip": "Input image to calculate dimensions from"},
),
"latent": (
"LATENT",
{"tooltip": "Input latent to calculate dimensions from"},
),
},
}
RETURN_TYPES = ("INT", "INT")
RETURN_NAMES = ("width", "height")
FUNCTION = "calculate_resolution"
def calculate_resolution(
self,
scale_factor: float,
image: Optional[torch.Tensor] = None,
latent: Optional[Dict[str, torch.Tensor]] = None,
) -> Tuple[int, int]:
"""
Calculate upscaled resolution from input tensor and scale factor
Args:
scale_factor: Scaling factor to apply
image: Optional IMAGE tensor [batch, height, width, channels]
latent: Optional LATENT dict with 'samples' tensor
Returns:
Tuple of (width, height) as integers, both divisible by 8
Raises:
ValueError: If validation fails or no input provided
"""
try:
# Validate inputs using base class
self.validate_inputs(scale_factor=scale_factor, image=image, latent=latent)
# Log the operation
input_type = (
"IMAGE"
if image is not None
else "LATENT" if latent is not None else "NONE"
)
self.log_info(
f"Calculating resolution with scale_factor={scale_factor}, input_type={input_type}"
)
# Calculate the resolution
width, height = calculate_resolution_from_input(
scale_factor=scale_factor, image=image, latent=latent
)
# Log the result
self.log_info(f"Calculated resolution: {width}x{height}")
return width, height
except Exception as e:
# Handle and re-raise with context
error_msg = f"Failed to calculate resolution: {str(e)}"
self.handle_error(error_msg, e)
def validate_inputs(
self,
scale_factor: float,
image: Optional[torch.Tensor] = None,
latent: Optional[Dict[str, torch.Tensor]] = None,
) -> None:
"""
Validate inputs specific to resolution calculator
Args:
scale_factor: Scale factor to validate
image: Optional image tensor
latent: Optional latent dict
Raises:
ValueError: If validation fails
"""
# Check that at least one input is provided
if image is None and latent is None:
raise ValueError("Either 'image' or 'latent' input must be provided")
# Validate scale factor type
if not isinstance(scale_factor, (int, float)):
raise ValueError(
f"scale_factor must be a number, got {type(scale_factor).__name__}"
)
# Additional tensor validation
if image is not None:
if not isinstance(image, torch.Tensor):
raise ValueError(
f"image must be a torch.Tensor, got {type(image).__name__}"
)
if len(image.shape) != 4:
raise ValueError(
f"image tensor must have 4 dimensions [batch, height, width, channels], got {len(image.shape)}"
)
if latent is not None:
if not isinstance(latent, dict):
raise ValueError(f"latent must be a dict, got {type(latent).__name__}")
if "samples" not in latent:
raise ValueError("latent dict must contain 'samples' key")
samples = latent["samples"]
if not isinstance(samples, torch.Tensor):
raise ValueError(
f"latent['samples'] must be a torch.Tensor, got {type(samples).__name__}"
)
if len(samples.shape) != 4:
raise ValueError(
f"latent samples tensor must have 4 dimensions [batch, channels, height, width], got {len(samples.shape)}"
)
# Node class mappings for ComfyUI registration
NODE_CLASS_MAPPINGS = {
"ResolutionCalculator": ResolutionCalculatorNode,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"ResolutionCalculator": "Resolution Calculator",
}