Upload
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
# Pixel Constrained Scaler (ComfyUI Node)
|
||||
|
||||
This node helps manage image sizes in a predictable way when working with ComfyUI.
|
||||
It keeps aspect ratio intact, respects a hard megapixel limit, applies an optional scaling factor, and makes sure dimensions land on clean multiples like 8 or 64.
|
||||
|
||||
The main goal is to avoid out-of-memory issues on GPUs while still giving you control over image scaling.
|
||||
|
||||
---
|
||||
|
||||
## What this node does
|
||||
|
||||
The scaler looks at your input image and runs through a simple logic:
|
||||
|
||||
1. If the input image is larger than the megapixel cap and you did not ask to scale down, it automatically shrinks the image to the pixel limit.
|
||||
2. If the input image is within the megapixel limit, it tries to apply the scaling factor you set.
|
||||
3. If the scaled image would exceed the limit, it pulls the size back down to fit.
|
||||
4. All dimensions are clamped within your min/max resolution settings.
|
||||
5. Final dimensions are rounded to the nearest multiple you specify (like 8, 16, 64).
|
||||
6. Aspect ratio is always preserved within a small tolerance.
|
||||
|
||||
This means:
|
||||
- Large images automatically shrink unless you ask for explicit downscaling.
|
||||
- Smaller images upscale when allowed.
|
||||
- You always stay under the GPU’s safe resolution range.
|
||||
|
||||
---
|
||||
|
||||
## Why this exists
|
||||
|
||||
ComfyUI workflows often need a reliable way to prevent OOM errors, especially on lower-VRAM GPUs.
|
||||
At the same time, people want predictable scaling behavior that respects their inputs.
|
||||
This node sits in the middle and solves both problems without surprises.
|
||||
|
||||
---
|
||||
|
||||
## Inputs
|
||||
|
||||
**image**
|
||||
The image you want to process. Accepts single images or batches.
|
||||
|
||||
**min_res**
|
||||
Minimum width or height allowed. Prevents tiny outputs.
|
||||
|
||||
**max_res**
|
||||
Maximum width or height allowed. Prevents accidental huge outputs.
|
||||
|
||||
**max_megapixels**
|
||||
Hard pixel limit. Anything above this gets scaled down.
|
||||
|
||||
**scaling_factor**
|
||||
How much to scale the image.
|
||||
1.0 keeps the same size, 2.0 doubles it, 0.5 halves it.
|
||||
The megapixel cap still applies.
|
||||
|
||||
**multiple_of**
|
||||
Rounds the final width and height to this value.
|
||||
Useful for models that expect multiples of 8 or 64.
|
||||
|
||||
---
|
||||
|
||||
## Outputs
|
||||
|
||||
**image_passthrough**
|
||||
Your original image, unchanged.
|
||||
The scaler only calculates numbers; it doesn't modify the image.
|
||||
|
||||
**constrained_width / constrained_height**
|
||||
The final recommended resolution.
|
||||
|
||||
**constrained_aspect_ratio**
|
||||
Final aspect ratio after constraints.
|
||||
|
||||
**original_aspect_ratio**
|
||||
The aspect ratio from your input.
|
||||
|
||||
---
|
||||
|
||||
## Behavior examples
|
||||
|
||||
### Input smaller than limit
|
||||
Input: 1366x768
|
||||
Scaling factor: 1.5
|
||||
Max MP: 2.6
|
||||
Result stays around 2048x1152 (rounded to nearest multiple).
|
||||
|
||||
### Input larger than limit
|
||||
Input: 4096x4096
|
||||
Scaling factor: 1.0
|
||||
Max MP: 2.36
|
||||
Result becomes 1536x1536 (fits under pixel cap).
|
||||
|
||||
### Explicit downscale
|
||||
Input: 4096x4096
|
||||
Scaling factor: 0.25
|
||||
Max MP: 2.36
|
||||
Result goes to 1024x1024 because the user explicitly asked for it.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
1. Create a `custom_nodes` folder in your ComfyUI directory if it doesn't exist
|
||||
2. Clone this repository into the `custom_nodes` folder:
|
||||
```bash
|
||||
git clone https://github.com/neonr-0/ComfyUI-PixelConstrainedScaler.git
|
||||
```
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- The node does not change or resize your image. It only calculates what size it should be.
|
||||
- To actually resize, plug the width and height outputs into your preferred image resize node.
|
||||
- No external dependencies are required.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the MIT license. See the [LICENSE](LICENSE) file for details.
|
||||
@@ -0,0 +1,3 @@
|
||||
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
@@ -0,0 +1,231 @@
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
class PixelConstrainedScaler:
|
||||
"""
|
||||
ComfyUI node: apply scaling_factor when possible, but respect a hard megapixel cap.
|
||||
Behavior:
|
||||
- If input_pixels > max_pixels and scaling_factor >= 1: reduce to max_pixels (preserve AR).
|
||||
- If scaling_factor < 1: always apply scaling_factor to original (then clamp to max_pixels/min/max).
|
||||
- If input_pixels <= max_pixels: attempt original * scaling_factor, but clamp to max_pixels.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE", {
|
||||
"tooltip": "Input image to analyze for resolution constraints"
|
||||
}),
|
||||
"min_res": ("INT", {
|
||||
"default": 256,
|
||||
"min": 64,
|
||||
"step": 8,
|
||||
"tooltip": "Minimum allowed dimension (width/height) in pixels"
|
||||
}),
|
||||
"max_res": ("INT", {
|
||||
"default": 2048,
|
||||
"min": 256,
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"tooltip": "Maximum allowed dimension (width/height) in pixels"
|
||||
}),
|
||||
"max_megapixels": ("FLOAT", {
|
||||
"default": 2.36,
|
||||
"min": 0.1,
|
||||
"step": 0.1,
|
||||
"tooltip": "Maximum allowed resolution in megapixels (e.g., 2.3 = 2.3 million pixels)"
|
||||
}),
|
||||
"scaling_factor": ("FLOAT", {
|
||||
"default": 1.5,
|
||||
"min": 0.1,
|
||||
"max": 4.0,
|
||||
"step": 0.1,
|
||||
"tooltip": "Additional scaling factor (1.0 = no change, 2.0 = double size). Megapixel limit takes priority when original image is larger than the cap."
|
||||
}),
|
||||
"multiple_of": ("INT", {
|
||||
"default": 8,
|
||||
"min": 2,
|
||||
"step": 2,
|
||||
"tooltip": "Ensure dimensions are multiples of this number (8, 64, etc)"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "INT", "INT", "FLOAT", "FLOAT")
|
||||
RETURN_NAMES = ("image_passthrough", "constrained_width", "constrained_height",
|
||||
"constrained_aspect_ratio", "original_aspect_ratio")
|
||||
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "image/resolution"
|
||||
|
||||
OUTPUT_TOOLTIPS = {
|
||||
"image_passthrough": "Original input image (unchanged)",
|
||||
"constrained_width": "Calculated width that fits all constraints",
|
||||
"constrained_height": "Calculated height that fits all constraints",
|
||||
"constrained_aspect_ratio": "Aspect ratio after constraints (width/height)",
|
||||
"original_aspect_ratio": "Original image aspect ratio (width/height)"
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def round_to_multiple(value: float, multiple: int) -> int:
|
||||
"""Round to nearest multiple using round-half-up tie breaking."""
|
||||
if multiple <= 1:
|
||||
# simple integer rounding-half-up
|
||||
return int(math.floor(value + 0.5))
|
||||
value = float(value)
|
||||
base = math.floor(value / multiple) * multiple
|
||||
rem = value - base
|
||||
if rem >= (multiple / 2.0):
|
||||
return int(base + multiple)
|
||||
return int(base)
|
||||
|
||||
@staticmethod
|
||||
def clamp(value: float, min_val: float, max_val: float) -> float:
|
||||
return max(min_val, min(value, max_val))
|
||||
|
||||
def clamp_by_pixel_limit(
|
||||
self,
|
||||
w: float,
|
||||
h: float,
|
||||
min_res: int,
|
||||
max_res: int,
|
||||
max_pixels: float,
|
||||
multiple_of: int
|
||||
) -> Tuple[int, int]:
|
||||
"""Enforce multiple_of, min/max res and megapixel cap while preserving AR when possible."""
|
||||
# Round inputs to multiples first (deterministic)
|
||||
w = max(multiple_of, self.round_to_multiple(w, multiple_of))
|
||||
h = max(multiple_of, self.round_to_multiple(h, multiple_of))
|
||||
|
||||
# Clamp to min/max bounds
|
||||
w = int(self.clamp(w, min_res, max_res))
|
||||
h = int(self.clamp(h, min_res, max_res))
|
||||
|
||||
# If within pixel limit already, return
|
||||
if w * h <= max_pixels:
|
||||
return w, h
|
||||
|
||||
# Try keeping width, reduce height
|
||||
max_h_for_w = int(self.clamp(self.round_to_multiple(max_pixels / float(w), multiple_of), min_res, max_res))
|
||||
if w * max_h_for_w <= max_pixels:
|
||||
return w, max_h_for_w
|
||||
|
||||
# Try keeping height, reduce width
|
||||
max_w_for_h = int(self.clamp(self.round_to_multiple(max_pixels / float(h), multiple_of), min_res, max_res))
|
||||
if max_w_for_h * h <= max_pixels:
|
||||
return max_w_for_h, h
|
||||
|
||||
# Fallback: make square of sqrt(max_pixels)
|
||||
side = int(self.clamp(self.round_to_multiple(math.sqrt(max_pixels), multiple_of), min_res, max_res))
|
||||
return side, side
|
||||
|
||||
def calculate_optimal_dimensions(
|
||||
self,
|
||||
width: int,
|
||||
height: int,
|
||||
min_res: int,
|
||||
max_res: int,
|
||||
max_megapixels: float,
|
||||
scaling_factor: float,
|
||||
multiple_of: int
|
||||
) -> Tuple[int, int]:
|
||||
max_pixels = max_megapixels * 1_000_000.0
|
||||
original_pixels = width * height
|
||||
aspect_ratio = (width / height) if height > 0 else 1.0
|
||||
|
||||
# CASE A: original > cap
|
||||
if original_pixels > max_pixels:
|
||||
if scaling_factor < 1.0:
|
||||
# user explicitly wants to downscale: apply scaling to original, then clamp
|
||||
target_w = width * scaling_factor
|
||||
target_h = height * scaling_factor
|
||||
else:
|
||||
# user did not ask to downscale -> enforce the MP cap (reduce original to cap)
|
||||
scale_to_cap = math.sqrt(max_pixels / original_pixels)
|
||||
target_w = width * scale_to_cap
|
||||
target_h = height * scale_to_cap
|
||||
else:
|
||||
# CASE B: original <= cap -> try to apply scaling_factor
|
||||
target_w = width * scaling_factor
|
||||
target_h = height * scaling_factor
|
||||
# if scaled image would exceed the cap, scale back to cap preserving AR
|
||||
if (target_w * target_h) > max_pixels:
|
||||
scale_back = math.sqrt(max_pixels / (target_w * target_h))
|
||||
target_w *= scale_back
|
||||
target_h *= scale_back
|
||||
|
||||
# Now apply final clamp-by-pixel-limit (this will round to multiples, apply min/max and final pixel enforcement)
|
||||
final_w, final_h = self.clamp_by_pixel_limit(
|
||||
target_w, target_h, min_res, max_res, max_pixels, multiple_of
|
||||
)
|
||||
|
||||
# final aspect ratio preservation attempt if tolerance exceeded
|
||||
final_ar = final_w / final_h if final_h > 0 else 1.0
|
||||
if abs(final_ar - aspect_ratio) > 0.05:
|
||||
if final_w >= final_h:
|
||||
# adjust height to preserve aspect ratio
|
||||
adj_h = self.round_to_multiple(final_w / aspect_ratio, multiple_of)
|
||||
final_h = int(self.clamp(adj_h, min_res, max_res))
|
||||
else:
|
||||
adj_w = self.round_to_multiple(final_h * aspect_ratio, multiple_of)
|
||||
final_w = int(self.clamp(adj_w, min_res, max_res))
|
||||
|
||||
# Ensure still within pixel cap
|
||||
if final_w * final_h > max_pixels:
|
||||
scale = math.sqrt(max_pixels / (final_w * final_h))
|
||||
final_w = int(self.round_to_multiple(final_w * scale, multiple_of))
|
||||
final_h = int(self.round_to_multiple(final_h * scale, multiple_of))
|
||||
|
||||
return int(final_w), int(final_h)
|
||||
|
||||
def process(self, image, min_res: int, max_res: int, max_megapixels: float, scaling_factor: float, multiple_of: int):
|
||||
# Input validation
|
||||
if max_res <= min_res:
|
||||
raise ValueError(f"max_res ({max_res}) must be greater than min_res ({min_res})")
|
||||
if max_megapixels <= 0:
|
||||
raise ValueError("max_megapixels must be positive")
|
||||
if scaling_factor <= 0:
|
||||
raise ValueError("scaling_factor must be positive")
|
||||
if multiple_of < 2:
|
||||
raise ValueError("multiple_of must be at least 2")
|
||||
|
||||
# Get image dimensions
|
||||
if len(image.shape) == 4: # Batch format: [B, H, W, C]
|
||||
_, height, width, _ = image.shape
|
||||
elif len(image.shape) == 3: # Single image: [H, W, C]
|
||||
height, width, _ = image.shape
|
||||
else:
|
||||
raise ValueError(f"Unsupported image shape: {image.shape}")
|
||||
|
||||
original_aspect_ratio = width / height if height > 0 else 1.0
|
||||
constrained_width, constrained_height = self.calculate_optimal_dimensions(
|
||||
width, height, min_res, max_res, max_megapixels, scaling_factor, multiple_of
|
||||
)
|
||||
constrained_aspect_ratio = constrained_width / constrained_height if constrained_height > 0 else 1.0
|
||||
|
||||
# Optional logging to help debugging
|
||||
original_pixels = width * height
|
||||
final_pixels = constrained_width * constrained_height
|
||||
target_pixels = original_pixels * (scaling_factor ** 2)
|
||||
actual_scale = math.sqrt(final_pixels / original_pixels) if original_pixels > 0 else 1.0
|
||||
|
||||
if abs(actual_scale - scaling_factor) > 0.05:
|
||||
print(f"Notice: Requested scale {scaling_factor:.2f}x limited by {max_megapixels}MP constraint. "
|
||||
f"Actual scale: {actual_scale:.3f}x")
|
||||
|
||||
return (
|
||||
image,
|
||||
int(constrained_width),
|
||||
int(constrained_height),
|
||||
round(constrained_aspect_ratio, 4),
|
||||
round(original_aspect_ratio, 4)
|
||||
)
|
||||
|
||||
# Node mapping
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PixelConstrainedScaler": PixelConstrainedScaler
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PixelConstrainedScaler": "Pixel Constrained Scaler"
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[project]
|
||||
name = "pixelconstrainedscaler"
|
||||
description = "Smart upscaling with pixel-budget awareness while preserving native aspect ratios. Ideal for Image to Image (I2I) and Image to Video (I2V) workflows!"
|
||||
version = "1.0.0"
|
||||
license = {file = "LICENSE"}
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/neonr-0/ComfyUI-PixelConstrainedScaler.git"
|
||||
# Used by Comfy Registry https://comfyregistry.org
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "neonr0"
|
||||
DisplayName = "ComfyUI-PixelConstrainedScaler"
|
||||
Icon = ""
|
||||
Reference in New Issue
Block a user