From fd39ef1a09de4cb30851407cd441f85df215df73 Mon Sep 17 00:00:00 2001 From: neonr-0 Date: Tue, 18 Nov 2025 19:03:18 +0300 Subject: [PATCH] Upload --- README.md | 119 +++++++++++++++++++++++++ __init__.py | 3 + nodes.py | 231 +++++++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 14 +++ 4 files changed, 367 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 nodes.py create mode 100644 pyproject.toml diff --git a/README.md b/README.md new file mode 100644 index 0000000..8cbae28 --- /dev/null +++ b/README.md @@ -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. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d721463 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..da9bbe1 --- /dev/null +++ b/nodes.py @@ -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" +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa38a0b --- /dev/null +++ b/pyproject.toml @@ -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 = ""