Files
neonr-0-ComfyUI-PixelConstr…/nodes.py
T
2025-11-18 19:03:18 +03:00

231 lines
9.5 KiB
Python

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"
}