diff --git a/README.md b/README.md index 91276da..8bb395a 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,74 @@ # ComfyUI-SmartResize -Smart image resize nodes for ComfyUI - Batch processing, LoRA prep & SeedVR support + +Two focused resize nodes for ComfyUI — both with **Lanczos** interpolation and **multiple_of** snapping. + +--- + +## Nodes + +### 📐 Smart Resize +Resize any image (or batch) so the **longest side** equals `max_pixels`, with aspect ratio preserved. + +Use this as a **pipeline node** — IMAGE in, IMAGE out. Perfect for downscaling SeedVR upscales before LoRA training, or any workflow step where you need a clean downscale without saving to disk. + +**Inputs:** +| Input | Type | Default | Description | +|---|---|---|---| +| image | IMAGE | — | Single image or batch | +| max_pixels | INT | 1536 | Target size for longest side | +| multiple_of | INT | 8 | Snap dimensions to this multiple (8 = SD/Flux compatible) | +| interpolation | ENUM | lanczos | lanczos / bicubic / bilinear / nearest | +| upscale_if_smaller | BOOLEAN | false | Also upscale images already smaller than max_pixels | + +**Outputs:** `IMAGE`, `width`, `height` + +--- + +### 📁 Batch Resize & Save +Load **all images from a folder**, resize each one (longest side), and save them with the original filename + a suffix into a subfolder. + +Use this for **bulk preprocessing** — e.g. preparing a LoRA dataset from a folder of high-res images. + +**Inputs:** +| Input | Type | Default | Description | +|---|---|---|---| +| input_folder | STRING | D:/images/mijnmap | Source folder path | +| max_pixels | INT | 1536 | Target size for longest side | +| filename_suffix | STRING | _Small | Appended to each output filename | +| output_subfolder | STRING | resized | Subfolder created inside input_folder | +| multiple_of | INT | 8 | Snap dimensions to this multiple | +| interpolation | ENUM | lanczos | lanczos / nearest / bilinear / bicubic | +| skip_if_exists | BOOLEAN | false | Skip files that already exist in output | + +**Outputs:** `images` (list), `filenames` (list), `output_paths` (list), `count` + +--- + +## Why these nodes? + +Existing nodes like `JWImageResizeByLongerSide` lack **Lanczos** interpolation (the best choice for downscaling). Other batch nodes don't preserve original filenames or offer `multiple_of` snapping. + +These nodes fill that gap with a minimal, focused feature set. + +--- + +## Installation + +**Option A — ComfyUI Manager:** +Search for `ComfyUI-SmartResize` and install. + +**Option B — Manual:** +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/YOUR_USERNAME/ComfyUI-SmartResize +``` +Restart ComfyUI. Nodes appear under: +- `image/transform` → **Smart Resize 📐** +- `image/batch` → **Batch Resize & Save 📁** + +--- + +## Requirements +- ComfyUI +- Pillow (included with ComfyUI) +- PyTorch (included with ComfyUI) diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..4c774f5 --- /dev/null +++ b/__init__.py @@ -0,0 +1,8 @@ +from .smart_resize import NODE_CLASS_MAPPINGS as A, NODE_DISPLAY_NAME_MAPPINGS as A_NAMES +from .smart_batch_resize import NODE_CLASS_MAPPINGS as B, NODE_DISPLAY_NAME_MAPPINGS as B_NAMES +from .smart_size_multiplier import NODE_CLASS_MAPPINGS as C, NODE_DISPLAY_NAME_MAPPINGS as C_NAMES + +NODE_CLASS_MAPPINGS = {**A, **B, **C} +NODE_DISPLAY_NAME_MAPPINGS = {**A_NAMES, **B_NAMES, **C_NAMES} + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/smart_batch_resize.py b/smart_batch_resize.py new file mode 100644 index 0000000..6dda692 --- /dev/null +++ b/smart_batch_resize.py @@ -0,0 +1,132 @@ +import os +from pathlib import Path +from PIL import Image as PILImage +import numpy as np +import torch + + +INTERP_MAP = { + "lanczos": PILImage.LANCZOS, + "bicubic": PILImage.BICUBIC, + "bilinear": PILImage.BILINEAR, + "nearest": PILImage.NEAREST, +} + +SUPPORTED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif"} + + +def _calc_new_size(w, h, max_pixels, multiple_of): + longest = max(w, h) + if longest <= max_pixels: + new_w, new_h = w, h + else: + scale = max_pixels / longest + new_w = int(w * scale) + new_h = int(h * scale) + if multiple_of > 1: + new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of) + new_h = max(multiple_of, round(new_h / multiple_of) * multiple_of) + return new_w, new_h + + +class SmartBatchResize: + """ + Load all images from a folder, resize by longest side, save with suffix. + Original filenames preserved. Output subfolder auto-created. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "input_folder": ("STRING", { + "default": "D:/images/input", + "multiline": False, + "tooltip": "Folder containing images to resize." + }), + "max_pixels": ("INT", { + "default": 1536, "min": 64, "max": 8192, "step": 8, + "tooltip": "Longest side target in pixels. Aspect ratio is always preserved." + }), + "filename_suffix": ("STRING", { + "default": "_Small", + "multiline": False, + "tooltip": "Appended to each output filename before the extension." + }), + "output_subfolder": ("STRING", { + "default": "resized", + "multiline": False, + "tooltip": "Subfolder created inside input_folder for output files." + }), + "multiple_of": ("INT", { + "default": 8, "min": 1, "max": 64, "step": 1, + "tooltip": "Snap both dimensions to a multiple of this value. Use 8 for SD/Flux." + }), + "interpolation": (["lanczos", "bicubic", "bilinear", "nearest"],), + "skip_if_exists": ("BOOLEAN", { + "default": False, + "tooltip": "Skip files that already exist in the output folder." + }), + } + } + + RETURN_TYPES = ("IMAGE", "STRING", "STRING", "INT") + RETURN_NAMES = ("images", "filenames", "output_paths", "count") + OUTPUT_IS_LIST = (True, True, True, False) + FUNCTION = "run" + CATEGORY = "SmartResize" + OUTPUT_NODE = True + + def run(self, input_folder, max_pixels, filename_suffix, + output_subfolder, multiple_of, interpolation, skip_if_exists): + + input_folder = input_folder.strip() + if not os.path.isdir(input_folder): + raise ValueError(f"[SmartBatchResize] Folder not found: {input_folder}") + + files = sorted([ + f for f in Path(input_folder).iterdir() + if f.is_file() and f.suffix.lower() in SUPPORTED_EXT + ]) + + if not files: + raise ValueError(f"[SmartBatchResize] No images found in: {input_folder}") + + out_dir = Path(input_folder) / output_subfolder + out_dir.mkdir(parents=True, exist_ok=True) + + interp = INTERP_MAP[interpolation] + images_out, filenames_out, paths_out = [], [], [] + + for f in files: + out_name = f"{f.stem}{filename_suffix}{f.suffix.lower()}" + out_path = out_dir / out_name + + if skip_if_exists and out_path.exists(): + print(f"[SmartBatchResize] Skipped (exists): {out_name}") + continue + + img = PILImage.open(f).convert("RGB") + w, h = img.size + new_w, new_h = _calc_new_size(w, h, max_pixels, multiple_of) + + img_resized = img.resize((new_w, new_h), interp) + + save_kwargs = {"quality": 95} if f.suffix.lower() in (".jpg", ".jpeg") else {} + img_resized.save(out_path, **save_kwargs) + + print(f"[SmartBatchResize] {f.name} → {out_name} ({w}x{h} → {new_w}x{new_h})") + + arr = np.array(img_resized).astype("float32") / 255.0 + images_out.append(torch.from_numpy(arr).unsqueeze(0)) + filenames_out.append(out_name) + paths_out.append(str(out_path)) + + if not images_out: + raise ValueError("[SmartBatchResize] No images processed.") + + return (images_out, filenames_out, paths_out, len(images_out)) + + +NODE_CLASS_MAPPINGS = {"SmartBatchResize": SmartBatchResize} +NODE_DISPLAY_NAME_MAPPINGS = {"SmartBatchResize": "Smart Batch Resize 📁"} diff --git a/smart_resize.py b/smart_resize.py new file mode 100644 index 0000000..afc426e --- /dev/null +++ b/smart_resize.py @@ -0,0 +1,109 @@ +import math +from PIL import Image as PILImage +import numpy as np +import torch + + +class SmartResize: + """ + Resize images so the longest side equals max_pixels. + Supports lanczos interpolation and multiple_of snapping. + Works with single images and batches. + """ + + INTERP_MAP = { + "lanczos": PILImage.LANCZOS, + "bicubic": PILImage.BICUBIC, + "bilinear": PILImage.BILINEAR, + "nearest": PILImage.NEAREST, + } + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "max_pixels": ("INT", { + "default": 1536, + "min": 64, + "max": 8192, + "step": 8, + "tooltip": "Longest side will be resized to this value (aspect ratio preserved)" + }), + "multiple_of": ("INT", { + "default": 8, + "min": 1, + "max": 64, + "step": 1, + "tooltip": "Round dimensions to a multiple of this value (8 = SD compatible)" + }), + "interpolation": (["lanczos", "bicubic", "bilinear", "nearest"], { + "default": "lanczos" + }), + "upscale_if_smaller": ("BOOLEAN", { + "default": False, + "tooltip": "Also upscale images that are already smaller than max_pixels" + }), + } + } + + RETURN_TYPES = ("IMAGE", "INT", "INT") + RETURN_NAMES = ("image", "width", "height") + FUNCTION = "run" + CATEGORY = "image/transform" + + def _resize_one(self, img_tensor, max_pixels, multiple_of, interp, upscale_if_smaller): + """Resize a single [H, W, C] tensor.""" + h, w = img_tensor.shape[0], img_tensor.shape[1] + longest = max(w, h) + + # Skip if already small enough and upscale disabled + if longest <= max_pixels and not upscale_if_smaller: + # Still snap to multiple_of + new_w = max(multiple_of, round(w / multiple_of) * multiple_of) if multiple_of > 1 else w + new_h = max(multiple_of, round(h / multiple_of) * multiple_of) if multiple_of > 1 else h + if new_w == w and new_h == h: + return img_tensor, w, h + else: + scale = max_pixels / longest + new_w = int(w * scale) + new_h = int(h * scale) + + # Snap to multiple_of + if multiple_of > 1: + new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of) + new_h = max(multiple_of, round(new_h / multiple_of) * multiple_of) + + # Convert tensor [H, W, C] float32 → PIL → resize → tensor + arr = (img_tensor.numpy() * 255).astype(np.uint8) + pil = PILImage.fromarray(arr, mode="RGB") + pil_resized = pil.resize((new_w, new_h), interp) + arr_resized = np.array(pil_resized).astype(np.float32) / 255.0 + return torch.from_numpy(arr_resized), new_w, new_h + + def run(self, image, max_pixels, multiple_of, interpolation, upscale_if_smaller): + # image shape: [B, H, W, C] + interp = self.INTERP_MAP[interpolation] + results = [] + last_w, last_h = 0, 0 + + for i in range(image.shape[0]): + resized, w, h = self._resize_one( + image[i], max_pixels, multiple_of, interp, upscale_if_smaller + ) + results.append(resized) + last_w, last_h = w, h + + # Stack back to [B, H, W, C] — all frames must be same size + # (they will be if all input frames are same size, which is the ComfyUI norm) + output = torch.stack(results, dim=0) + return (output, last_w, last_h) + + +NODE_CLASS_MAPPINGS = { + "SmartResize": SmartResize, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "SmartResize": "Smart Resize 📐", +} diff --git a/smart_size_multiplier.py b/smart_size_multiplier.py new file mode 100644 index 0000000..189f3e1 --- /dev/null +++ b/smart_size_multiplier.py @@ -0,0 +1,52 @@ +class SmartSizeMultiplier: + """ + Multiplies image dimensions by a factor. + Outputs width, height, and resolution (longest side) as INT. + Use resolution output directly with SeedVR max_resolution. + """ + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "multiplier": ("FLOAT", { + "default": 2.0, + "min": 0.1, + "max": 16.0, + "step": 0.1, + "round": 0.01, + "tooltip": "Multiply width and height by this factor. E.g. 2.0 = double resolution." + }), + "multiple_of": ("INT", { + "default": 8, + "min": 1, + "max": 64, + "step": 1, + "tooltip": "Snap output dimensions to a multiple of this value." + }), + } + } + + RETURN_TYPES = ("INT", "INT", "INT") + RETURN_NAMES = ("width", "height", "resolution") + FUNCTION = "run" + CATEGORY = "SmartResize" + + def run(self, image, multiplier, multiple_of): + h, w = image.shape[1], image.shape[2] + + new_w = int(round(w * multiplier)) + new_h = int(round(h * multiplier)) + + if multiple_of > 1: + new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of) + new_h = max(multiple_of, round(new_h / multiple_of) * multiple_of) + + resolution = max(new_w, new_h) + print(f"[SmartSizeMultiplier] {w}x{h} x {multiplier} -> {new_w}x{new_h} (resolution: {resolution})") + return (new_w, new_h, resolution) + + +NODE_CLASS_MAPPINGS = {"SmartSizeMultiplier": SmartSizeMultiplier} +NODE_DISPLAY_NAME_MAPPINGS = {"SmartSizeMultiplier": "Smart Size Multiplier 📏"}