231 lines
9.5 KiB
Python
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"
|
|
} |