This commit is contained in:
neonr-0
2025-11-18 19:03:18 +03:00
parent 2e07e0cd77
commit fd39ef1a09
4 changed files with 367 additions and 0 deletions
+119
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+231
View File
@@ -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"
}
+14
View File
@@ -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 = ""