41 Commits
Author SHA1 Message Date
Salvador E. Tropea 2c732ce7cb Bumped version to 1.3.0 2025-10-14 09:11:11 -03:00
Salvador E. Tropea e7d124d786 [ImagePad][Added] Copy input size when target_* is 0 2025-10-14 09:04:32 -03:00
Salvador E. Tropea c976f3d057 [DOCs] And another way to display the examples 2025-10-14 08:15:32 -03:00
Salvador E. Tropea c74e859aaa [DOCs] Trying a different way to show the examples 2025-10-14 08:14:28 -03:00
Salvador E. Tropea 684d3fcb6b [DOCs][Added] Image for the examples 2025-10-14 08:12:22 -03:00
Salvador E. Tropea 0f0157ed4f [DOCs] Moved node docs to a separated file
To make the README.md more clear
2025-10-14 08:07:31 -03:00
Salvador E. Tropea b306bd37a7 [DOCs] Separated nodes in categories (in the ToC) 2025-10-14 07:57:47 -03:00
Salvador E. Tropea ea94753216 [DOCs] Made the example links to point to raw files, easier to download 2025-10-14 07:52:54 -03:00
Salvador E. Tropea 3844dd20b5 [DOCs][Added] Documentation about the new nodes 2025-10-14 07:48:32 -03:00
Salvador E. Tropea 79f1003873 [Added] Resize example 2025-10-13 20:11:08 -03:00
Salvador E. Tropea e159265648 [ImagePad/Resize][Added] Control over padding transparency
By default is transparent
2025-10-13 20:00:58 -03:00
Salvador E. Tropea aa8dc7b2ce [Download][Added] Option to create RGBA images 2025-10-13 19:17:13 -03:00
Salvador E. Tropea 684afdf5e6 [ImageResize][Fixed] Mask processing for modes others than edge_pixel
Avoid replicating the edge for other modes.
2025-10-13 18:24:30 -03:00
Salvador E. Tropea 314c6bf609 [ResizeImage][Fixed] Comment about the difference when w/h==0 2025-10-13 17:50:04 -03:00
Salvador E. Tropea 529756d415 [ImagePad][Fixed] Error when padding an RGBA image 2025-10-13 17:47:54 -03:00
Salvador E. Tropea 79b14305d5 [ImageResize][Fixed] Size computation when w/h are 0 2025-10-13 13:24:30 -03:00
Salvador E. Tropea 968859fc5a [ImageResize][Fixed] Color must be [0,1] 2025-10-13 13:23:23 -03:00
Salvador E. Tropea f05c36f520 [Nodes][Added] ResizeMask also synced with Kijai 1.1.7 2025-10-06 13:36:24 -03:00
Salvador E. Tropea d99c165f3d Merge branch 'resize_pad'
Some operations are needed and Comfy-Core lacks them
2025-10-05 14:42:14 -03:00
Salvador E. Tropea 8134be8032 Bumped version to 1.2.0 2025-10-05 11:24:35 -03:00
Salvador E. Tropea 8d587bcab4 [Fixed][Downloader] Display image with the upstream name
Not the one we downloaded
2025-10-04 18:31:08 -03:00
Salvador E. Tropea 2ddd61ee87 [Added] Foreground estimation (2 nodes) and Empty Image (from ref) 2025-09-30 10:05:32 -03:00
Salvador E. Tropea 87ee4aebb7 [Apply Mask][Added] Option to process images one by one 2025-09-17 13:37:25 -03:00
Salvador E. Tropea d95ef9758d [Added][Downloader] Name for the local image 2025-09-17 09:20:21 -03:00
Salvador E. Tropea 01945ade86 [Added] Node to apply a mask to an image
Using Approximate Fast Foreground Colour Estimation
2025-09-16 13:50:05 -03:00
Salvador E. Tropea 015f8be8c9 [Added] Resize and Pad image from KJNodes
Just to keep dependencies low
But current Comfy-Core can handle it
2025-09-16 08:36:55 -03:00
Salvador E. Tropea 644a2fa7f4 [Added] Nodes for normalization
Useful for testing models or using low level models
2025-09-16 08:35:49 -03:00
Salvador E. Tropea 0d3c321ae3 [Added][Tools] Style fixer 2025-08-22 11:17:31 -03:00
Salvador E. Tropea 9a565f31b0 [Added] Ignore the sync script 2025-08-22 11:16:57 -03:00
Salvador E. Tropea 214bee12d9 [Added] Nodes to insert a video in a reference/s 2025-08-22 11:16:03 -03:00
Salvador E. Tropea a091559e25 [Fixed] Flake8 style details 2025-08-22 09:59:45 -03:00
Salvador E. Tropea bb890e2027 Bumped version to 1.1.1 2025-07-25 13:12:53 -03:00
Salvador E. Tropea d989087f08 Added (deprecated) requirements.txt
comfy-cli is really wrong
2025-07-25 13:11:58 -03:00
Salvador E. Tropea 13be95db99 [README][Added] SeCoNoHe as dependency 2025-07-25 12:58:58 -03:00
Salvador E. Tropea 018f62fed0 [Pre Commit][Added] Version check
To overcome comfy-cli limitations
2025-07-25 12:49:49 -03:00
Salvador E. Tropea d443749537 Adapted the project description to the obsolete format
comfy-cli enforces deprecated practices.
2025-07-25 12:48:37 -03:00
Salvador E. Tropea 35f1f1b772 [Nodes] Removed unneeded exclusion 2025-07-25 12:48:05 -03:00
Salvador E. Tropea 624e311edf [Examples] Removed extra new line 2025-07-25 12:47:28 -03:00
Salvador E. Tropea f08e90801e Adapted to logger as parameter 2025-07-23 07:58:27 -03:00
Salvador E. Tropea 4948f6c7d4 Migrated to SeCoNoHe [Experimental] 2025-07-22 10:29:50 -03:00
Salvador E. Tropea a992716243 Just a few comments for people trying to implement a similar node 2025-07-21 09:01:27 -03:00
23 changed files with 1933 additions and 712 deletions
+1
View File
@@ -196,3 +196,4 @@ cython_debug/
*.dst
*.epr
.*~
sync.sh
+16 -1
View File
@@ -51,7 +51,7 @@ repos:
# The command to execute. It's expected to be found in the PATH
# of the environment where you run `git commit`.
entry: mypy
args: ["--explicit-package-bases", "source/"]
args: ["--explicit-package-bases", "src/"]
# 'system' tells pre-commit to find the command in the current environment
# instead of building an isolated one.
language: system
@@ -64,3 +64,18 @@ repos:
# whole project as configured in mypy.ini, rather than just the changed files.
# Try without it first.
# pass_filenames: false
# --- Version checking ---
- repo: local
hooks:
- id: version-check
name: check for version consistency
# The command to execute. It's a Python script.
entry: python tools/check_versions.py
# Use 'system' to run it with the current environment's Python
language: system
# This hook doesn't need to run on every file.
# It should run if either of the version files change.
# This makes it very fast.
files: ^(pyproject\.toml|src/nodes/__init__\.py)$
# The regex `^...$` ensures it matches the full path from the repo root.
+50 -24
View File
@@ -1,13 +1,12 @@
# ComfyUI Image Miscellaneous Nodes 🎨
This repository provides a set of custom nodes for ComfyUI focused on image manipulation.
Currently we just have an image downloader node, but I didn't wanted to put it with
my audio nodes.
Currently we just have a few nodes used by other nodes I maintain.
## ⚙️ Main features
✅ No extra dependencies, we use the same modules as ComfyUI
✅ No bizarre extra dependencies, we use the same modules as ComfyUI
✅ Warnings and errors visible in the browser, configurable debug information in the console
@@ -18,7 +17,24 @@ my audio nodes.
- 📦 [Dependencies](#-dependencies)
- 🖼️ [Examples](#️-examples)
- ✨ [Nodes](#-extra-nodes)
- [1. Image Download and Load](#1-image-download-and-load)
- Foreground: (Foreground estimation)
- [Estimate foreground (AFFCE)](docs/nodes_img.md#8-estimate-foreground-affce)
- [Estimate foreground (FMLFE)](docs/nodes_img.md#9-estimate-foreground-fmlfe)
- Generation:
- [Create Empty Image](docs/nodes_img.md#10-create-empty-image)
- Input/Output:
- [Image Download and Load](docs/nodes_img.md#1-image-download-and-load)
- Manipulation:
- [Face Composite](docs/nodes_img.md#2-face-composite)
- [Face Composite (frame by frame)](docs/nodes_img.md#3-face-composite-frame-by-frame)
- [Apply Mask using AFFCE](docs/nodes_img.md#7-apply-mask-using-affce)
- [Pad Image](docs/nodes_img.md#11-pad-image-kjset)
- [Resize Image](docs/nodes_img.md#12-resize-image-kjset)
- [Resize Mask](docs/nodes_img.md#13-resize-mask-kjset)
- Normalization:
- [Normalize Image to ImageNet](docs/nodes_img.md#4-normalize-image-to-imagenet)
- [Normalize Image to [-0.5, 0.5]](docs/nodes_img.md#5-normalize-image-to-05-05)
- [Normalize Image to [-1, 1]](docs/nodes_img.md#6-normalize-image-to-1-1)
- 📝 [Usage Notes](#-usage-notes)
- 📜 [Project History](#-project-history)
- ⚖️ [License](#️-license)
@@ -26,23 +42,7 @@ my audio nodes.
## ✨ Nodes
### 1. Image Download and Load
- **Display Name:** `Image Download and Load`
- **Internal Name:** `SET_ImageDownload`
- **Category:** `image/io`
- **Description:** Downloads an image file from a URL into the `ComfyUI/input/` directory if it's not already there, and then loads it as an image and mask. This is perfect for creating self-contained, shareable workflows with example image.
- **Inputs:**
- `image_bypass` (IMAGE, Optional): If an image is provided here it will be used for the output. You can connect a `Load Image` node here, if the connected node is muted (bypassed) we download the file, otherwise we use the image from the `Load Image` node.
- `mask_bypass` (MASK, Optional): This input complements `image_bypass`.
- `base_url` (STRING): The URL of the directory containing the image file.
- `filename` (STRING): The name of the image file to download (e.g., photo.jpg, art.png).
- **Output:**
- `image` (IMAGE): The loaded image.
- `alpha_mask` (MASK): The alpha mask for the loaded image.
- **Behavior Details:**
- **Caching:** The node checks the `ComfyUI/input/` folder first. If the file with the specified `filename` already exists, the download is skipped.
- **Bypass:** If only one of `image_bypass` and `mask_bypass` is connected the other will be assumed to be empty. You should connect both or avoid using the output corresponding to the unconnected input.
The nodes are documented [here](docs/nodes_img.md). Use the above ToC to access them by category.
## 🚀 Installation
@@ -53,14 +53,18 @@ You can install the nodes from the ComfyUI nodes manager, the name is *Image Mis
cd ComfyUI/custom_nodes/
git clone https://github.com/set-soft/ComfyUI-ImageMisc ComfyUI-ImageMisc
```
2. Restart ComfyUI.
2. Install dependencies: `pip install -r ComfyUI/custom_nodes/ComfyUI-ImageMisc/requirements.txt`
3. Restart ComfyUI.
The nodes should then appear under the "image/io" category in the "Add Node" menu.
The nodes should then appear under the "image/io", "image/manipulation", "image/normalization" and "image/foreground" categories in the "Add Node" menu.
## 📦 Dependencies
- SeCoNoHe (seconohe): This is just some functionality I wrote shared by my nodes, only depends on ComfyUI.
- PyTorch: Installed by ComfyUI
- NumPy: Installed by ComfyUI
- Pillow: Installed by ComfyUI
- Requests (optional): Usually an indirect ComfyUI dependency. If installed it will be used for downloads, it should be more robust than then built-in `urllib`, used as fallback.
- Colorama (optional): Might help to get colored log messages on some terminals. We use ANSI escape sequences when it isn't installed.
@@ -69,7 +73,18 @@ The nodes should then appear under the "image/io" category in the "Add Node" men
Once installed the examples are available in the ComfyUI workflow templates, in the *Image Misc* section (or ComfyUI-ImageMisc).
- [image_download.json](example_workflows/image_download.json): Shows how to use the image downloader node.
### [image_download.json](https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/refs/heads/main/example_workflows/image_download.json)
Shows how to use the image downloader node.
[![image_download.json](example_workflows/image_download.jpg)](https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/refs/heads/main/example_workflows/image_download.json)
### [resize.json](https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/refs/heads/main/example_workflows/resize.json)
Shows the most common uses of the resize and padding nodes. You can see how various modes and options affects the result and how the different interpolation methods works. It shows how masks and RGBA images are handled.
[![resize.json](example_workflows/resize.jpg)](https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/refs/heads/main/example_workflows/resize.json)
## 📝 Usage Notes
@@ -84,6 +99,16 @@ Once installed the examples are available in the ComfyUI workflow templates, in
- 1.0.0 2025-07-21: Initial release
- Just the download image.
- 1.1.0 2025-07-25: Adapted to use SeCoNoHe
- 1.1.1 2025-07-25: Just small details for ComfyUI registry
- 1.2.0 2025-10-05:
- Face composite nodes
- Normalization nodes
- Apply mask nodes, including support for:
- "Fast Multi-Level Foreground Estimation" (from [PyMatting](https://github.com/pymatting/pymatting))
- "Approximate Fast Foreground Colour Estimation" (from [Photoroom](https://github.com/Photoroom/fast-foreground-estimation))
- Create empty image node, from reference image
- 1.3.0 2025-10-14: Pad Image, Resize Image and Resize Mask nodes
## ⚖️ License
@@ -93,3 +118,4 @@ Once installed the examples are available in the ComfyUI workflow templates, in
## 🙏 Attributions
- Good part of the initial code and this README was generated using Gemini 2.5 Pro.
- The *Pad Image*, *Resize Image* and *Resize Mask* are from [KJNodes](https://github.com/kijai/ComfyUI-KJNodes) by [Kijai](https://github.com/kijai) (Jukka Seppänen)
+6 -19
View File
@@ -3,26 +3,13 @@
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
# License: GPL-3.0
# Project: ComfyUI-ImageMisc
from .source.nodes import nodes_img
import inspect
import logging
from .source.nodes.utils.misc import NODES_NAME
init_logger = logging.getLogger(NODES_NAME + ".__init__")
# This is our first import so we initialize SeCoNoHe
from .src.nodes import nodes_img, main_logger
from seconohe.register_nodes import register_nodes
from seconohe import JS_PATH
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
for name, obj in inspect.getmembers(nodes_img):
# We skip nodes imported from the ComfyUI main nodes
if not inspect.isclass(obj) or not hasattr(obj, "INPUT_TYPES") or obj.__module__ == "nodes":
continue
assert hasattr(obj, "UNIQUE_NAME"), f"No name for {obj.__name__}"
NODE_CLASS_MAPPINGS[obj.UNIQUE_NAME] = obj
NODE_DISPLAY_NAME_MAPPINGS[obj.UNIQUE_NAME] = obj.DISPLAY_NAME
init_logger.info(f"Registering {len(NODE_CLASS_MAPPINGS)} node(s).")
init_logger.debug(f"{list(NODE_DISPLAY_NAME_MAPPINGS.values())}")
WEB_DIRECTORY = "./js"
NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS = register_nodes(main_logger, [nodes_img])
WEB_DIRECTORY = JS_PATH
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
+257
View File
@@ -0,0 +1,257 @@
# 1. Image Download and Load
- **Display Name:** `Image Download and Load`
- **Internal Name:** `SET_ImageDownload`
- **Category:** `image/io`
- **Description:** Downloads an image file from a URL into the `ComfyUI/input/` directory if it's not already there, and then loads it as an image and mask. This is perfect for creating self-contained, shareable workflows with example image.
- **Inputs:**
- `image_bypass` (IMAGE, Optional): If an image is provided here it will be used for the output. You can connect a `Load Image` node here, if the connected node is muted (bypassed) we download the file, otherwise we use the image from the `Load Image` node.
- `mask_bypass` (MASK, Optional): This input complements `image_bypass`.
- `base_url` (STRING): The URL of the directory containing the image file.
- `filename` (STRING): The name of the image file to download (e.g., photo.jpg, art.png).
- `local_name` (STRING, optional): The name used locally. Leave blank to use `filename`.
- `embed_transparency` (BOOLEAN, optional): Create RGBA images when they have transparency. Not all nodes supports them.
- **Output:**
- `image` (IMAGE): The loaded image.
- `alpha_mask` (MASK): The alpha mask for the loaded image.
- **Behavior Details:**
- **Caching:** The node checks the `ComfyUI/input/` folder first. If the file with the specified `filename` already exists, the download is skipped.
- **Bypass:** If only one of `image_bypass` and `mask_bypass` is connected the other will be assumed to be empty. You should connect both or avoid using the output corresponding to the unconnected input.
# 2. Face Composite
- **Display Name:** `Face Composite`
- **Internal Name:** `SET_CompositeFace`
- **Category:** `image/manipulation`
- **Description:** This node is designed for complex batch operations where a single reference image generates multiple animated or processed frames. It handles an **M-to-N** relationship.
- **Purpose:** The primary use case is for animation workflows (e.g., using AnimateDiff or SVD) where you:
1. Extract a face from **one** source image.
2. Generate **N** animated frames of that face.
3. Paste each of the **N** animated frames back onto the original source image to create an animated sequence.
- **Inputs:**
- `animated` (`IMAGE`): A batch of `M*N` cropped/processed images (e.g., the animated faces).
- `reference` (`IMAGE`): A batch of `M` original context images that the faces were extracted from.
- `bboxes` (`BBOX`): A list of `M` bounding boxes. Each box in the list must correspond to a `reference` image, defining the location for the paste operation. Format: (X, Y, W, H)
- **Output:**
- `images` (`IMAGE`): The final output batch of `M*N` full-size images. Each image consists of a `reference` image with one of the `animated` frames pasted onto it.
- **How it Works:**
- For each of the `M` reference images and its corresponding bounding box, the node takes the next `N` frames from the `animated` batch. It then pastes each of these `N` frames onto a *copy* of the reference image, generating `N` final output frames. This process is repeated for all `M` reference images.
- The animated frame is automatically resized to fit the bounding box dimensions before pasting. The pasting logic safely handles cases where the bbox is partially outside the image boundaries.
# 3. Face Composite (frame by frame)
- **Display Name:** `"Face Composite (frame by frame)`
- **Internal Name:** `SET_CompositeFaceFrameByFrame`
- **Category:** `image/manipulation`
- **Description:** This node is a simplified variant for **1-to-1** compositing. It is perfect for video processing workflows where you need to paste a sequence of processed frames back into the original video sequence at a static location.
- **Purpose:** Ideal for tasks like video face restoration, stylization, or simple lip-sync, where:
1. A video is loaded as a sequence of `N` frames.
2. A corresponding sequence of `N` processed/animated frames is generated.
3. Each processed frame needs to be pasted back into its corresponding original frame at the same location.
- **Inputs:**
- `animated` (`IMAGE`): A batch of `N` processed frames.
- `reference` (`IMAGE`): A batch of `N` original frames. **Must have the same batch size as `animated`**.
- `bboxes` (`BBOX`): A list of bounding boxes. **Only the first bbox in the list is used** as the static paste location for all frames.
- **Output:**
- `images` (`IMAGE`): The final batch of `N` composited frames.
- **How it Works:** The node iterates from frame 0 to N-1. In each step, it takes the i-th `animated` frame and the i-th `reference` frame. It then pastes the animated frame onto the reference frame using the coordinates from the single, static bounding box.
# 4. Normalize Image to ImageNet
- **Display Name:** `"Normalize Image to ImageNet`
- **Internal Name:** `SET_NormalizeToImageNetDataset`
- **Category:** `image/normalization`
- **Description:** Normalizes an image tensor using the mean and standard deviation of the ImageNet dataset.
- **Purpose:** Essential for pre-processing images before feeding them into models that were pre-trained on ImageNet (e.g., most ResNet, VGG, EfficientNet models).
- **Inputs:**
- `image` (`IMAGE`): A standard ComfyUI image tensor in the `[0, 1]` range.
- **Output:**
- `image` (`IMAGE`): The normalized image tensor. The value range will be altered significantly.
- **How it Works:** For each channel, it performs the operation `output = (input - mean) / std`, using the standard ImageNet values:
- **Mean:** `[0.485, 0.456, 0.406]`
- **Std Dev:** `[0.229, 0.224, 0.225]`
# 5. Normalize Image to [-0.5, 0.5]
- **Display Name:** `Normalize Image to [-0.5, 0.5]`
- **Internal Name:** `SET_NormalizeToMinus05_05`
- **Category:** `image/normalization`
- **Description:** Normalizes an image tensor by centering its values around zero.
- **Purpose:** Useful for models trained from scratch or those that expect input data in the `[-0.5, 0.5]` range. This can help stabilize training.
- **Inputs:**
- `image` (`IMAGE`): A standard ComfyUI image tensor in the `[0, 1]` range.
- **Output:**
- `image` (`IMAGE`): The normalized image tensor, with values in the `[-0.5, 0.5]` range.
- **How it Works:** For each channel, it performs the operation `output = (input - mean) / std`, using:
- **Mean:** `[0.5, 0.5, 0.5]`
- **Std Dev:** `[1.0, 1.0, 1.0]`
# 6. Normalize Image to [-1, 1]
- **Display Name:** `Normalize Image to [-1, 1]`
- **Internal Name:** `SET_NormalizeToMinus1_1`
- **Category:** `image/normalization`
- **Description:** Normalizes an image tensor to the `[-1, 1]` range.
- **Purpose:** A common requirement for certain model architectures, particularly Generative Adversarial Networks (GANs) and models using the `tanh` activation function in their output layer.
- **Inputs:**
- `image` (`IMAGE`): A standard ComfyUI image tensor in the `[0, 1]` range.
- **Output:**
- `image` (`IMAGE`): The normalized image tensor, with values in the `[-1, 1]` range.
- **How it Works:** For each channel, it performs the operation `output = (input - mean) / std`, using:
- **Mean:** `[0.5, 0.5, 0.5]`
- **Std Dev:** `[0.5, 0.5, 0.5]`
# 7. Apply Mask using AFFCE
- **Display Name:** `Apply Mask using AFFCE`
- **Internal Name:** `SET_ApplyMaskAFFCE`
- **Category:** `image/manipulation`
- **Description:** Applies a mask to an image using [Approximate Fast Foreground Colour Estimation](https://github.com/Photoroom/fast-foreground-estimation). This blends the image contour in a better way.
- **Purpose:** Used to apply the mask of a background removal model.
- **Inputs:**
- `images` (`IMAGE`): One ore more ComfyUI images
- `masks` (`MASK`): Masks to apply
- `blur_size` (`INT`): Diameter for the coarse gaussian blur
- `blur_size_two` (`INT`): Diameter for the fine gaussian blur
- `fill_color` (`BOOLEAN`): When enabled the removed image is replaced by a color, the output is an RGB image. Otherwise the removed part becomes transparent and the output is an RGBA image.
- `color` (`STRING`): A string representing a color to be used when `fill_color` is enabled. Can be an hexadecimal RGB (i.e. `#AABBCC`) or comma separated RGB components. The components can be in the [0-255] or [0-1.0] range.
- `batched` (`BOOLEAN`): Process all the images at once. Otherwise do it one at a time.
- **Output:**
- `image` (`IMAGE`): The image after applying the mask.
- `mask` (`MASK`): The input mask
# 8. Estimate foreground (AFFCE)
- **Display Name:** `Estimate foreground (AFFCE)`
- **Internal Name:** `SET_AFFCE`
- **Category:** `image/foreground`
- **Description:** Estimates the foreground of an image using [Approximate Fast Foreground Colour Estimation](https://github.com/Photoroom/fast-foreground-estimation). The result is suitable for background replacement.
- **Purpose:** Used to get a better foreground for background removal.
- **Inputs:**
- `images` (`IMAGE`): One ore more ComfyUI images
- `masks` (`MASK`): Masks to apply
- `blur_size` (`INT`): Diameter for the coarse gaussian blur
- `blur_size_two` (`INT`): Diameter for the fine gaussian blur
- `batched` (`BOOLEAN`): Process all the images at once. Otherwise do it one at a time.
- **Output:**
- `image` (`IMAGE`): The image after foreground estimation.
- `mask` (`MASK`): The input mask
# 9. Estimate foreground (FMLFE)
- **Display Name:** `Estimate foreground (FMLFE)`
- **Internal Name:** `SET_FMLFE`
- **Category:** `image/foreground`
- **Description:** Estimates the foreground of an image using [Fast Multi-Level Foreground Estimation](https://arxiv.org/abs/2006.14970). The result is suitable for background replacement.
- **Purpose:** Used to get a better foreground for background removal.
- **Inputs:**
- `images` (`IMAGE`): The source image(s) from which to estimate the foreground and background.
- `masks` (`MASK`): The alpha matte that guides the estimation. White areas are treated as known foreground, black as known background, and gray areas are the semi-transparent regions the algorithm will solve for.
- `implementation" (`auto`, `cupy`, `opencl`, `numba`, `torch`): Which implementation to use. The `auto` will use the fastest available. The `torch` implementation is slow and approximated, but doesn't need extra dependencies. The `numba` implementation is good and just needs [Numba](https://numba.pydata.org/). The `cupy` implementation is the fastest, but needs [CuPy](https://cupy.dev/) and a full CUDA environment.
- `regularization` (`FLOAT`): The regularization strength (epsilon). This acts as a smoothness prior. Higher values result in smoother, more blended foreground and background colors, but may lose very fine details. Lower values preserve more detail but can be noisier.
- `n_small_iterations` (`INT`): The number of solver iterations to perform on the lower-resolution levels of the image pyramid. More iterations can improve quality at the cost of speed.
- `n_big_iterations` (`INT`): The number of solver iterations to perform on the higher-resolution (larger) levels of the image pyramid. Fewer iterations are typically needed at high resolution as the details are propagated up from the smaller levels.
- `small_size` (`INT`): The pixel dimension threshold. Image pyramid levels smaller than this size will use the higher 'n_small_iterations' count, while larger levels will use 'n_big_iterations'.
- `gradient_weight` (`FLOAT`): Controls how strongly the edges in the alpha matte influence color blending. A higher value makes the algorithm respect the mask's edges more, leading to sharper color boundaries. A lower value allows more color bleeding, an effect similar to increasing regularization.
- **Output:**
- `image` (`IMAGE`): The estimated foreground image (F). This is a full-color image where the algorithm has estimated the true, un-blended color of the foreground object.
- `mask` (`MASK`): The estimated background image (B). The algorithm has effectively "inpainted" the area behind the foreground object, creating a clean background plate.
# 10. Create Empty Image
- **Display Name:** `Create Empty Image`
- **Internal Name:** `SET_CreateEmptyImage`
- **Category:** `image/generation`
- **Description:** Creates an image filled with a solid color. Similar to standard `EmptyImage`, but you can use an image as reference and you have more options to select the color.
- **Purpose:** Create an empty image
- **Inputs:**
- `width` (`INT`): The width of the new image in pixels. This value is ignored if a `reference` is provided.
- `height` (`INT`): The height of the new image in pixels. This value is ignored if a `reference` is provided.
- `batch_size` (`INT`): The number of images to create in the batch. This value is ignored if a `reference` is provided.
- `color` (`STRING`): The solid color to fill the image with. Can be a named color (e.g., "black", "red"), a hex string (e.g., "#FF0000", "00ff00"), or comma-separated components in the [0, 255] or [0.0, 1.0] range.
- `reference` (`IMAGE`, optional): If an image is connected here, its dimensions (batch size, height, and width) will be used for the new image, overriding the manual width, height, and batch_size inputs.
- **Output:**
- `image` (`IMAGE`): A new image tensor of the specified size and color.
# 11. Pad Image (KJ/SET)
- **Display Name:** `Pad Image (KJ/SET)`
- **Internal Name:** `SET_ImagePad`
- **Category:** `image/manipulation`
- **Description:** Pads the input image and an optional mask with specified padding on each side. It offers several modes to fill the padded area.
- **Purpose:** To add borders or expand the canvas of an image using various fill methods, such as solid color, edge pixel replication, or a blurred background effect.
- **Inputs:**
- `image` (`IMAGE`): The image to be padded.
- `left` (`INT`): The number of pixels of padding to add to the left side.
- `right` (`INT`): The number of pixels of padding to add to the right side.
- `top` (`INT`): The number of pixels of padding to add to the top side.
- `bottom` (`INT`): The number of pixels of padding to add to the bottom side.
- `extra_padding` (`INT`): Additional padding applied to all sides. Note: If `target_width` and `target_height` are used, this value will shrink the source image before padding.
- `pad_mode` (`STRING`): The method used to fill the padded area.
- `edge`: Fills with the average color of the nearest edge.
- `edge_pixel`: Repeats the outermost line of pixels.
- `color`: Fills with the specified solid `color`.
- `pillarbox_blur`: Fills with a blurred, desaturated, and dimmed version of the original image, scaled to fit the new dimensions.
- `color` (`STRING`): The solid color to use when `pad_mode` is "color". Accepts hex codes, color names, and RGB values. This is more flexible than in KJNodes.
- `mask` (`MASK`, optional): An optional mask to be padded along with the image.
- `target_width` (`INT`, optional): If provided with `target_height`, pads the image to this exact width, centering the original content. This overrides the `left` and `right` inputs. If you set it to 0 the width of the image is used. Note that you must also connect `target_height`.
- `target_height` (`INT`, optional): If provided with `target_width`, pads the image to this exact height, centering the original content. This overrides the `top` and `bottom` inputs. If you set it to 0 the heigth of the image is used. Note that you must also connect `target_width`.
- `pad_transparency` (`FLOAT`, optional): Controls the opacity of the padded area in the output mask. 1.0 is fully transparent (no mask), and 0.0 is fully opaque (masked). Not in KJNodes.
- **Outputs:**
- `images` (`IMAGE`): The padded image tensor.
- `masks` (`MASK`): The padded mask tensor.
# 12. Resize Image (KJ/SET)
- **Display Name:** `Resize Image (KJ/SET)`
- **Internal Name:** `SET_ImageResize`
- **Category:** `image/manipulation`
- **Description:** A comprehensive node to resize, crop, and pad images. It provides multiple methods for handling aspect ratio and can optionally process large batches in smaller chunks to conserve memory. This node uses [Pad Image](#11-pad-image-kjset) so please also read its documentation.
- **Purpose:** To change the dimensions of an image with fine-grained control over how the aspect ratio is preserved, including stretching, cropping, or padding to fit the target size.
- **Inputs:**
- `image` (`IMAGE`): The source image to resize.
- `width` (`INT`): The target width in pixels. If set to 0, it uses the original width or the width from `get_image_size`.
- `height` (`INT`): The target height in pixels. If set to 0, it uses the original height or the height from `get_image_size`.
- `upscale_method` (`STRING`): The interpolation algorithm to use for resizing (e.g., `bicubic`, `lanczos`, `nearest-exact`).
- `keep_proportion` (`STRING`): Defines how to handle the aspect ratio when resizing.
- `stretch`: Ignores aspect ratio and forces the image to the exact target dimensions.
- `resize`: Maintains aspect ratio, fitting the image within the target dimensions. The final output size may be smaller than the target on one axis.
- `pad`, `pad_edge`, `pad_edge_pixel`: Resizes the image to fit within the target dimensions while maintaining aspect ratio, then pads the remaining space to meet the exact target dimensions.
- `crop`: Resizes and crops the image to match the target aspect ratio and size.
- `pillarbox_blur`: Resizes the image to fit and fills the remaining space with a blurred background.
- `pad_color` (`STRING`): The solid color to use for padding when `keep_proportion` is set to `pad`. Accepts hex codes, color names, and RGB values. This is more flexible than in KJNodes.
- `crop_position` (`STRING`): The anchor point (`center`, `top`, `left`, etc.) for `crop` and `pad` operations.
- `divisible_by` (`INT`): Ensures the final width and height are multiples of this number, which is often required for models like VAEs.
- `mask` (`MASK`, optional): An optional mask to be transformed along with the image.
- `device` (`STRING`, optional): The computation device (`cpu` or `gpu`) to use for the operation. Note that `lanczos` is not supported on the GPU.
- `get_image_size` (`IMAGE`, optional): If an image is connected, its dimensions will be used as the target width and height, overriding the manual inputs. Not in KJNodes.
- `per_batch` (`INT`, optional): If greater than 0, processes the input batch in smaller sub-batches of this size to reduce memory usage.
- `pad_transparency` (`FLOAT`, optional): Controls the opacity of the padded area in the output mask. Not in KJNodes.
- **Outputs:**
- `IMAGE` (`IMAGE`): The transformed image.
- `width` (`INT`): The final width of the output image.
- `height` (`INT`): The final height of the output image.
- `mask` (`MASK`): The transformed mask.
# 13. Resize Mask (KJ/SET)
- **Display Name:** `Resize Mask (KJ/SET)`
- **Internal Name:** `SET_ResizeMask`
- **Category:** `image/manipulation`
- **Description:** Resizes a mask or a batch of masks to the specified width and height, with options to preserve proportions.
- **Purpose:** A dedicated and simplified node for resizing masks, which often require different interpolation methods (like `nearest-exact`) than images.
- **Inputs:**
- `mask` (`MASK`): The mask to be resized.
- `width` (`INT`): The target width for the new mask.
- `height` (`INT`): The target height for the new mask.
- `keep_proportions` (`BOOLEAN`): If `True`, maintains the original aspect ratio, fitting the mask within the target dimensions.
- `upscale_method` (`STRING`): The interpolation algorithm to use for resizing. Defaults to `nearest-exact`, which is ideal for masks to avoid creating grayscale values.
- `crop` (`STRING`): If and how to crop the mask (`disabled` or `center`).
- `get_image_size` (`IMAGE`, optional): If an image is connected here, its dimensions will be used as the target width and height.
- **Outputs:**
- `mask` (`MASK`): The resized mask tensor.
- `width` (`INT`): The final width of the output mask.
- `height` (`INT`): The final height of the output mask.
+1 -1
View File
@@ -1 +1 @@
{"id":"63a02d67-42e7-4217-9d52-28b9c456c35b","revision":0,"last_node_id":5,"last_link_id":4,"nodes":[{"id":4,"type":"MaskPreview","pos":[1457.7564697265625,1385.4490966796875],"size":[273.6515197753906,258],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":4}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"MaskPreview"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":3,"type":"PreviewImage","pos":[1461.320068359375,1076.2701416015625],"size":[273.6515197753906,258],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":3}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"PreviewImage"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":1,"type":"SET_ImageDownload","pos":[1077.0606689453125,1075.701416015625],"size":[270,314],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image_bypass","name":"image_bypass","shape":7,"type":"IMAGE","link":1},{"localized_name":"mask_bypass","name":"mask_bypass","shape":7,"type":"MASK","link":2},{"localized_name":"base_url","name":"base_url","type":"STRING","widget":{"name":"base_url"},"link":null},{"localized_name":"filename","name":"filename","type":"STRING","widget":{"name":"filename"},"link":null}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[3]},{"localized_name":"alpha_mask","name":"alpha_mask","type":"MASK","links":[4]}],"properties":{"aux_id":"set-soft/ComfyUI-ImageMisc","ver":"3ab5664ee7057737c3215db19b078afbcead7a08","Node name for S&R":"SET_ImageDownload"},"widgets_values":["https://raw.githubusercontent.com/set-soft/AudioSeparation/refs/heads/main/example_workflows/","audioseparation_logo.jpg"],"color":"#2a363b","bgcolor":"#3f5159"},{"id":2,"type":"LoadImage","pos":[727.1275024414062,1075.3787841796875],"size":[274.080078125,314],"flags":{},"order":0,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":[2]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"LoadImage"},"widgets_values":["example.png","image"],"color":"#222","bgcolor":"#000"},{"id":5,"type":"MarkdownNote","pos":[733.544921875,821.4869384765625],"size":[989.1290283203125,186.43255615234375],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# If you \"unbypass\" the *Load Image* node you'll get the image and mask from it\n# When the inputs are not connected or come from a bypassed node you get the downloaded image\n# Images are downloaded to the ComfyUI's *input* directory, so they become available to be selected in the *Load Image* node. Just press R to refresh the nodes.\n# To \"bypass\" a node just select it and choose the icon with an arrow around a circle.\n"],"color":"#432","bgcolor":"#653"}],"links":[[1,2,0,1,0,"IMAGE"],[2,2,1,1,1,"MASK"],[3,1,0,3,0,"IMAGE"],[4,1,1,4,0,"MASK"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8432169278238246,"offset":[-188.01505304476098,-782.3508645860205]}},"version":0.4}
{"id":"63a02d67-42e7-4217-9d52-28b9c456c35b","revision":0,"last_node_id":5,"last_link_id":4,"nodes":[{"id":4,"type":"MaskPreview","pos":[1457.7564697265625,1385.4490966796875],"size":[273.6515197753906,258],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":4}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"MaskPreview"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":3,"type":"PreviewImage","pos":[1461.320068359375,1076.2701416015625],"size":[273.6515197753906,258],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":3}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"PreviewImage"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":1,"type":"SET_ImageDownload","pos":[1077.0606689453125,1075.701416015625],"size":[270,314],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image_bypass","name":"image_bypass","shape":7,"type":"IMAGE","link":1},{"localized_name":"mask_bypass","name":"mask_bypass","shape":7,"type":"MASK","link":2},{"localized_name":"base_url","name":"base_url","type":"STRING","widget":{"name":"base_url"},"link":null},{"localized_name":"filename","name":"filename","type":"STRING","widget":{"name":"filename"},"link":null}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[3]},{"localized_name":"alpha_mask","name":"alpha_mask","type":"MASK","links":[4]}],"properties":{"aux_id":"set-soft/ComfyUI-ImageMisc","ver":"3ab5664ee7057737c3215db19b078afbcead7a08","Node name for S&R":"SET_ImageDownload"},"widgets_values":["https://raw.githubusercontent.com/set-soft/AudioSeparation/refs/heads/main/example_workflows/","audioseparation_logo.jpg"],"color":"#2a363b","bgcolor":"#3f5159"},{"id":2,"type":"LoadImage","pos":[727.1275024414062,1075.3787841796875],"size":[274.080078125,314],"flags":{},"order":0,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":[2]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"LoadImage"},"widgets_values":["example.png","image"],"color":"#222","bgcolor":"#000"},{"id":5,"type":"MarkdownNote","pos":[733.544921875,821.4869384765625],"size":[989.1290283203125,186.43255615234375],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# If you \"unbypass\" the *Load Image* node you'll get the image and mask from it\n# When the inputs are not connected or come from a bypassed node you get the downloaded image\n# Images are downloaded to the ComfyUI's *input* directory, so they become available to be selected in the *Load Image* node. Just press R to refresh the nodes.\n# To \"bypass\" a node just select it and choose the icon with an arrow around a circle.\n"],"color":"#432","bgcolor":"#653"}],"links":[[1,2,0,1,0,"IMAGE"],[2,2,1,1,1,"MASK"],[3,1,0,3,0,"IMAGE"],[4,1,1,4,0,"MASK"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8432169278238246,"offset":[-188.01505304476098,-782.3508645860205]}},"version":0.4}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long
-32
View File
@@ -1,32 +0,0 @@
// Copyright (c) 2025 Salvador E. Tropea
// Copyright (c) 2025 Instituto Nacional de Tecnología Industrial
// License: GPLv3
// Project: ComfyUI-ImageMisc
// This script adds an event named "set-imagemisc-toast"
// Used to notify the user in the GUI using the Toast API
import { app } from "/scripts/app.js";
// Register a new extension
app.registerExtension({
name: "SET.ImageMisc.ToastHandler", // Unique name
// The setup function is executed when the extension is loaded
setup() {
// Add a listener for our custom event
app.api.addEventListener("set-imagemisc-toast", (event) => {
// The data from Python is in event.detail
const { message, summary, severity } = event.detail;
// Use the ComfyUI toast API to show the message
// app.ui.toast.addMessage is the modern way to do this
app.extensionManager.toast.add({
severity: severity,
summary: summary,
detail: message,
life: 6000
});
});
},
});
+48 -3
View File
@@ -1,9 +1,18 @@
[project]
name = "image-misc"
description = "Miscellaneous nodes for image manipulation. Currently just download image with bypass, so you can create workflows including image examples. No extra dependencies."
version = "1.0.0"
description = """
Miscellaneous nodes for image manipulation.
Currently just download image with bypass, so you can create workflows including image examples.
No extra dependencies, just an internal module.
"""
# This is the modern Python mechanism, but not supported
# dynamic = ["version"]
# license = "GPL-3.0-or-later"
# Only this is supported :-(
version = "1.3.0"
license = { file = "LICENSE" }
dependencies = []
# The following is just ignored
dependencies = ["seconohe"]
[project.urls]
Repository = "https://github.com/set-soft/ComfyUI-ImageMisc"
@@ -11,3 +20,39 @@ Repository = "https://github.com/set-soft/ComfyUI-ImageMisc"
[tool.comfy]
PublisherId = "set-soft"
DisplayName = "Image Misc"
[tool.setuptools.dynamic]
version = {attr = "nodes.__version__"}
# #######################################
# MyPy (well, yours, useless hinting)
# #######################################
[tool.mypy]
# Basic configuration
python_version = "3.11"
warn_return_any = true
warn_unused_ignores = true
mypy_path = "src/"
# This is often needed when starting out, especially with libraries that lack stubs
ignore_missing_imports = true
# You can get stricter over time by removing ignore_missing_imports
# and setting flags like these:
# disallow_untyped_defs = true
# disallow_any_unimported = true
# Tell mypy to ignore errors from third-party libraries if they are not typed
[[tool.mypy.overrides]]
module = [
"torch.*",
"torchaudio.*",
"pytest.*",
"cv2.*",
"colorama.*",
"tqdm.*",
"requests.*",
"comfy.*", # For comfy.utils
"server.*" # For server.PromptServer
]
ignore_missing_imports = true
+3
View File
@@ -0,0 +1,3 @@
Pillow
numpy
seconohe
View File
-129
View File
@@ -1,129 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright (c) 2025 Salvador E. Tropea
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
# License: GPL-3.0
# Project: ComfyUI-ImageMisc
# From code generated by Gemini 2.5 Pro
import os
import torch
from typing import Optional
from .utils.logger import main_logger
from .utils.downloader import download_model
from .utils.comfy_notification import send_toast_notification
try:
from folder_paths import get_input_directory # To get the ComfyUI input directory
except ModuleNotFoundError:
# No ComfyUI, this is a test environment
def get_input_directory():
return ""
try:
# We need to import the built-in LoadImage class for ImageDownload
from nodes import LoadImage # type: ignore[attr-defined]
has_load_image = True
except Exception:
has_load_image = False
logger = main_logger
BASE_CATEGORY = "image"
IO_CATEGORY = "io"
if has_load_image:
class ImageDownload:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"base_url": ("STRING", {
"default":
"https://raw.githubusercontent.com/set-soft/AudioSeparation/refs/heads/main/example_workflows/",
"tooltip": "The base URL where the image file is located."
}),
"filename": ("STRING", {
"default": "audioseparation_logo.jpg",
"tooltip": "The name of the image file to download (e.g., photo.jpg, art.png)."
}),
},
"optional": {
"image_bypass": ("IMAGE", {
"tooltip": "If this image is present will be used instead of the downloaded one"
}),
"mask_bypass": ("MASK", {"tooltip": "If this mask is present will be used instead of the downloaded one"}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "alpha_mask")
FUNCTION = "load_or_download_image"
CATEGORY = BASE_CATEGORY + "/" + IO_CATEGORY
DESCRIPTION = ("Downloads an image to ComfyUI's 'input' directory if it doesn't exist, then loads it using the "
"built-in LoadImage logic.")
UNIQUE_NAME = "SET_ImageDownload"
DISPLAY_NAME = "Image Download and Load"
OUTPUT_NODE = True
def load_or_download_image(self, base_url: str, filename: str, image_bypass: Optional[torch.Tensor] = None,
mask_bypass: Optional[torch.Tensor] = None):
# If we have something at the bypass inputs use it
if image_bypass is not None or mask_bypass is not None:
if image_bypass is None:
# Just a mask
assert mask_bypass is not None, "This should not be possible if image_bypass is None" # For mypy
image_bypass = torch.zeros(mask_bypass.shape + (3,), dtype=torch.float32, device="cpu")
logger.warning("ImageDownload: Returning an empty image")
elif mask_bypass is None:
# This is ComfyUI behavior when we don't have transparency
mask_bypass = torch.zeros((64, 64), dtype=torch.float32, device="cpu").unsqueeze(0)
logger.warning("ImageDownload: Returning an empty mask")
return (image_bypass, mask_bypass)
save_dir = get_input_directory()
local_filepath = os.path.join(save_dir, filename)
if not os.path.exists(local_filepath):
logger.info(f"File '{filename}' not found locally. Attempting to download.")
if not base_url.endswith('/'):
base_url += '/'
download_url = base_url + filename
send_toast_notification(f"Downloading `{filename}`", "Download")
try:
download_model(url=download_url, save_dir=save_dir, file_name=filename, kind="image")
except Exception as e:
logger.error(f"Download failed for {download_url}: {e}", exc_info=True)
raise
send_toast_notification("Finished downloading", "Download", 'success')
else:
logger.info(f"Found existing file, skipping download: '{local_filepath}'")
# --- REUSE ComfyUI's LoadImage LOGIC ---
try:
# Instantiate the built-in LoadImage node
loader_instance = LoadImage()
# The LoadImage node's `load_image` method expects the filename as passed
# by the ComfyUI widget, which is just the filename. It internally
# resolves the path using folder_paths.
logger.debug(f"Calling built-in LoadImage.load_image() with filename: '{filename}'")
# Call the method and return its result directly
result = loader_instance.load_image(filename)
downloaded_file = {
"images": [{
"filename": filename,
"subfolder": "",
"type": "input" # We stored the file in the "input" folder
}]
}
return {"ui": downloaded_file, "result": result}
except Exception as e:
logger.error(f"Failed to load image '{filename}' using built-in LoadImage node: {e}", exc_info=True)
# Re-raise to make the error visible in ComfyUI
raise IOError(f"Could not load the image file '{filename}' using the standard loader. "
"It may be corrupt or in an unsupported format.") from e
else:
logger.error("Failed to import ComfyUI `LoadImage`, please fill an issue here: "
"https://github.com/set-soft/ComfyUI-ImageMisc/issues")
View File
-113
View File
@@ -1,113 +0,0 @@
# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file.
'''
This module generates ANSI character codes to printing colors to terminals.
See: http://en.wikipedia.org/wiki/ANSI_escape_code
'''
import sys
import os
CSI = '\033['
OSC = '\033]'
BEL = '\a'
is_a_tty = sys.stderr.isatty() and os.name == 'posix'
def code_to_chars(code):
return CSI + str(code) + 'm' if is_a_tty else ''
def set_title(title):
return OSC + '2;' + title + BEL
def clear_screen(mode=2):
return CSI + str(mode) + 'J'
def clear_line(mode=2):
return CSI + str(mode) + 'K'
class AnsiCodes(object):
def __init__(self):
# the subclasses declare class attributes which are numbers.
# Upon instantiation we define instance attributes, which are the same
# as the class attributes but wrapped with the ANSI escape sequence
for name in dir(self):
if not name.startswith('_'):
value = getattr(self, name)
setattr(self, name, code_to_chars(value))
class AnsiCursor(object):
def UP(self, n=1):
return CSI + str(n) + 'A'
def DOWN(self, n=1):
return CSI + str(n) + 'B'
def FORWARD(self, n=1):
return CSI + str(n) + 'C'
def BACK(self, n=1):
return CSI + str(n) + 'D'
def POS(self, x=1, y=1):
return CSI + str(y) + ';' + str(x) + 'H'
class AnsiFore(AnsiCodes):
BLACK = 30
RED = 31
GREEN = 32
YELLOW = 33
BLUE = 34
MAGENTA = 35
CYAN = 36
WHITE = 37
RESET = 39
# These are fairly well supported, but not part of the standard.
LIGHTBLACK_EX = 90
LIGHTRED_EX = 91
LIGHTGREEN_EX = 92
LIGHTYELLOW_EX = 93
LIGHTBLUE_EX = 94
LIGHTMAGENTA_EX = 95
LIGHTCYAN_EX = 96
LIGHTWHITE_EX = 97
class AnsiBack(AnsiCodes):
BLACK = 40
RED = 41
GREEN = 42
YELLOW = 43
BLUE = 44
MAGENTA = 45
CYAN = 46
WHITE = 47
RESET = 49
# These are fairly well supported, but not part of the standard.
LIGHTBLACK_EX = 100
LIGHTRED_EX = 101
LIGHTGREEN_EX = 102
LIGHTYELLOW_EX = 103
LIGHTBLUE_EX = 104
LIGHTMAGENTA_EX = 105
LIGHTCYAN_EX = 106
LIGHTWHITE_EX = 107
class AnsiStyle(AnsiCodes):
BRIGHT = 1
DIM = 2
NORMAL = 22
RESET_ALL = 0
Fore = AnsiFore()
Back = AnsiBack()
Style = AnsiStyle()
Cursor = AnsiCursor()
-47
View File
@@ -1,47 +0,0 @@
# Copyright (c) 2025 Salvador E. Tropea
# Copyright (c) 2025 Instituto Nacional de Tecnología Industrial
# License: GPLv3
# Project: ComfyUI-ImageMisc
#
# ComfyUI Toast API messages
# Original code from Gemini 2.5 Pro, which was really outdated
# Took ideas from Easy Use nodes and looking at ComfyUI code
import logging
from typing import Optional
# ComfyUI imports
try:
from server import PromptServer
with_comfy = True
except Exception:
with_comfy = False
# Local imports
from .misc import NODES_NAME
logger = logging.getLogger(f"{NODES_NAME}.comfy_notification")
def send_toast_notification(message: str, summary: str = "Warning", severity: str = "warn", sid: Optional[str] = None):
"""
Sends a toast notification event to the ComfyUI client.
Args:
message (str): The message content of the toast.
severity (str): The type of toast. Can be 'success' | 'info' | 'warn' | 'error' | 'secondary' | 'contrast'
summary (str): Short explanation
sid (str, optional): The session ID of the client to send to.
If None, broadcasts to all clients. Defaults to None.
"""
if not with_comfy:
return
try:
PromptServer.instance.send_sync(
"set-imagemisc-toast", # This is our custom event name
{
'message': message,
'summary': summary,
'severity': severity
},
sid
)
except Exception as e:
logger.error(f"when trying to use ComfyUI PromptServer: {e}")
-206
View File
@@ -1,206 +0,0 @@
# Copyright (c) 2025 Salvador E. Tropea
# Copyright (c) 2025 Instituto Nacional de Tecnología Industrial
# License: GPLv3
# Project: ComfyUI-AudioSeparation, ComfyUI-AudioBatch and ComfyUI-ImageMisc
#
# Model downloader w/TQDM and ComfyUI progress
# Original code from Gemini 2.5 Pro
import logging
import os
# Requests is better than the core Python urllib, and is a really common package
# But we don't really need it. Lets make it optional:
try:
import requests
with_requests = True
except Exception:
with_requests = False
import urllib
from tqdm import tqdm
# ComfyUI imports
try:
import comfy.utils
with_comfy = True
except Exception:
with_comfy = False
# Local imports
from .misc import NODES_NAME
logger = logging.getLogger(f"{NODES_NAME}.downloader")
def download_model_requests(url: str, save_dir: str, file_name: str):
"""
Downloads a file from a URL with progress bars for both console and ComfyUI.
Args:
url (str): The direct download URL for the file.
save_dir (str): The directory where the file will be saved.
file_name (str): The name of the file to be saved on disk.
"""
full_path = os.path.join(save_dir, file_name)
# Ensure the save directory exists
os.makedirs(save_dir, exist_ok=True)
try:
# Use a streaming request to handle large files and get content length
with requests.get(url, stream=True, timeout=10) as r:
r.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
# Get total file size from headers
total_size_in_bytes = int(r.headers.get('content-length', 0))
block_size = 1024 # 1 Kibibyte
# --- Setup Progress Bars ---
# Console progress bar using tqdm
progress_bar_console = tqdm(
total=total_size_in_bytes,
unit='iB',
unit_scale=True,
desc=f"Downloading {file_name}"
)
# ComfyUI progress bar
progress_bar_ui = comfy.utils.ProgressBar(total_size_in_bytes) if with_comfy else None
# --- Download Loop ---
downloaded_size = 0
with open(full_path, 'wb') as f:
for chunk in r.iter_content(chunk_size=block_size):
if chunk: # filter out keep-alive new chunks
chunk_size = len(chunk)
# Update console progress bar
progress_bar_console.update(chunk_size)
# Update ComfyUI progress bar
downloaded_size += chunk_size
if progress_bar_ui:
progress_bar_ui.update(chunk_size) # ProgressBar takes absolute value, but update is incremental
# Write chunk to file
f.write(chunk)
# --- Cleanup ---
progress_bar_console.close()
# Final check to see if download was complete
if total_size_in_bytes != 0 and progress_bar_console.n != total_size_in_bytes:
logger.error("Download failed: Size mismatch.")
# Optional: remove partial file
# os.remove(full_path)
raise IOError(f"Download failed for {file_name}. Expected {total_size_in_bytes} but got "
f"{progress_bar_console.n}")
return full_path
except requests.exceptions.RequestException as e:
logger.error(f"Network error while downloading {file_name}: {e}")
# Clean up partial file if it exists
if os.path.exists(full_path):
try:
os.remove(full_path)
except OSError:
pass
raise
except Exception as e:
logger.error(f"An error occurred during download: {e}")
if os.path.exists(full_path):
try:
os.remove(full_path)
except OSError:
pass
raise
# A simple version implemented using the Python urllib
class Downloader:
def __init__(self, model_path, model_name):
self.model_path = model_path
self.model_name = model_name
self.model_full_name = os.path.join(self.model_path, self.model_name)
# Ensure the directory for the model_path exists before __init__ if used elsewhere
# or create it at the start of download_model
# A TQDM helper class for urlretrieve reporthook
# This is a common pattern for this use case.
class TqdmUpTo(tqdm):
"""
Provides `update_to(block_num, block_size, total_size)`
and updates the TQDM bar.
"""
def __init__(self, unit, unit_scale, unit_divisor, miniters, desc):
super().__init__(unit=unit, unit_scale=unit_scale, unit_divisor=unit_divisor, miniters=miniters, desc=desc)
self.ui_bar = None
self.total = None
def update_to(self, block_num=1, block_size=1, total_size=None):
"""
block_num : int, optional
Number of blocks transferred so far [default: 1].
block_size : int, optional
Size of each block (in tqdm units) [default: 1].
total_size : int, optional
Total size (in tqdm units). If [default: None] remains unchanged.
"""
if total_size is not None and self.total is None:
self.total = total_size
# ComfyUI progress bar
if self.ui_bar is None and with_comfy:
self.ui_bar = comfy.utils.ProgressBar(total_size)
# self.update() will take the *difference* from the last call.
# So we pass the number of new blocks * block_size.
# Since block_num is cumulative, we calculate the new amount.
chunk_size = block_num * block_size - self.n
self.update(chunk_size) # self.n is current progress
if self.ui_bar:
self.ui_bar.update(chunk_size) # ProgressBar takes absolute value, but update is incremental
def download_model(self, url: str):
try:
# Ensure the directory exists
# Use or '.' for current dir if dirname is empty
os.makedirs(self.model_path or '.', exist_ok=True)
# Get filename for tqdm description
filename = self.model_name
# Use TqdmUpTo as a context manager
with self.TqdmUpTo(unit='iB', unit_scale=True, unit_divisor=1024, miniters=1,
desc=f"Downloading {filename}") as t:
# urlretrieve(url, filename=None, reporthook=None, data=None)
# reporthook is called with (block_num, block_size, total_size)
urllib.request.urlretrieve(url, self.model_full_name, reporthook=t.update_to)
# The 'with' statement ensures t.close() is called.
return filename
except urllib.error.URLError as e: # More specific exception for network issues
# Clean up partially downloaded file if an error occurs
if os.path.exists(self.model_full_name):
os.remove(self.model_full_name)
raise Exception(f"An error occurred while downloading the model (URL Error): {e.reason} from {url}")
except Exception as e:
# Clean up partially downloaded file if an error occurs
if os.path.exists(self.model_full_name):
os.remove(self.model_full_name)
raise Exception(f"An unexpected error occurred while downloading the model: {e}")
def download_model_urllib(url: str, save_dir: str, file_name: str):
return Downloader(save_dir, file_name).download_model(url)
def download_model(url: str, save_dir: str, file_name: str, force_urllib: bool = False, kind: str = "model"):
logger.info(f"Downloading {kind}: {file_name}")
logger.info(f"Source URL: {url}")
full_name = os.path.join(save_dir, file_name)
logger.info(f"Destination: {full_name}")
if with_requests and not force_urllib:
download_model_requests(url, save_dir, file_name)
else:
download_model_urllib(url, save_dir, file_name)
logger.info(f"Successfully downloaded {full_name}")
return full_name
-136
View File
@@ -1,136 +0,0 @@
# Copyright (c) 2025 Salvador E. Tropea
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
# License: GPL-3.0
# Project: ComfyUI-ImageMisc
from __future__ import annotations # Good practice
import logging
import os
import sys
from typing import Any, Callable
from .misc import NODES_NAME, NODES_DEBUG_VAR
from .comfy_notification import send_toast_notification
# 1. Initialize variables with the `Any` type.
# This tells mypy not to make assumptions about their specific class.
Fore: Any
Back: Any
Style: Any
# 2. Perform the runtime import logic as before.
try:
from colorama import init as colorama_init, Fore, Back, Style
colorama_init()
except ImportError:
# If colorama is not available, import our fallback.
# mypy will now allow this assignment because the variables were declared as Any.
from .ansi import Fore, Back, Style
class CustomFormatter(logging.Formatter):
"""Logging Formatter to add colors"""
def __init__(self):
super(logging.Formatter, self).__init__()
white = Fore.WHITE + Style.BRIGHT
yellow = Fore.YELLOW + Style.BRIGHT
red = Fore.RED + Style.BRIGHT
red_alarm = Fore.RED + Back.WHITE + Style.BRIGHT
cyan = Fore.CYAN + Style.BRIGHT
reset = Style.RESET_ALL
# format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s "
# "(%(filename)s:%(lineno)d)"
format = f"[{NODES_NAME} %(levelname)s] %(message)s (%(name)s - %(filename)s:%(lineno)d)"
format_simple = f"[{NODES_NAME}] %(message)s"
self.FORMATS = {
logging.DEBUG: cyan + format + reset,
logging.INFO: white + format_simple + reset,
logging.WARNING: yellow + format + reset,
logging.ERROR: red + format + reset,
logging.CRITICAL: red_alarm + format + reset
}
def format(self, record):
log_fmt = self.FORMATS.get(record.levelno)
formatter = logging.Formatter(log_fmt)
return formatter.format(record)
def on_log_error_or_warning(record: logging.LogRecord) -> None:
"""
This function is called whenever a log with level WARNING or higher is emitted.
The 'record' object contains all information about the log event.
"""
if record.levelno == logging.WARNING:
summary = "Warning"
severity = "warn"
else:
summary = "Error"
severity = "error"
send_toast_notification(record.getMessage(), summary=summary, severity=severity)
class WarningAndErrorFilter(logging.Filter):
"""
A custom log filter that intercepts logs of a certain level.
"""
def __init__(self, callback: Callable, level: int = logging.WARNING):
"""
Initializes the filter.
Args:
callback: The function to call when a log record meets the level criteria.
level: The minimum level to trigger the callback.
"""
super().__init__()
self._callback = callback
self._level = level
def filter(self, record: logging.LogRecord) -> bool:
"""
This method is called for every log record.
"""
# Check if the log level is WARNING or higher
if record.levelno >= self._level:
self._callback(record)
# Always return True to ensure the log is always processed
# by the handlers after this filter.
return True
# Create a new logger
logger = logging.getLogger(NODES_NAME)
logger.propagate = False
# Add the custom filter to the logger.
logger.addFilter(WarningAndErrorFilter(callback=on_log_error_or_warning))
# Add handler if we don't have one.
if not logger.handlers:
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(CustomFormatter())
logger.addHandler(handler)
# ######################
# Logger setup
# ######################
# 1. Determine the ComfyUI global log level (influenced by --verbose)
main_logger = logger
comfy_root_logger = logging.getLogger('comfy')
effective_comfy_level = logging.getLogger().getEffectiveLevel()
# 2. Check our custom environment variable for more verbosity
try:
nodes_debug_env = int(os.environ.get(NODES_DEBUG_VAR, "0"))
except ValueError:
nodes_debug_env = 0
# 3. Set node's logger level
if nodes_debug_env:
main_logger.setLevel(logging.DEBUG - (nodes_debug_env - 1))
final_level_str = f"DEBUG (due to {NODES_DEBUG_VAR}={nodes_debug_env})"
else:
main_logger.setLevel(effective_comfy_level)
final_level_str = logging.getLevelName(effective_comfy_level) + " (matching ComfyUI global)"
_initial_setup_logger = logging.getLogger(NODES_NAME + ".setup") # A temporary logger for this message
_initial_setup_logger.debug(f"{NODES_NAME} logger level set to: {final_level_str}")
@@ -2,5 +2,8 @@
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
# License: GPL-3.0
# Project: ComfyUI-ImageMisc
from seconohe.logger import initialize_logger
__version__ = "1.3.0"
NODES_NAME = "ImageMisc"
NODES_DEBUG_VAR = NODES_NAME.upper() + "_NODES_DEBUG"
main_logger = initialize_logger(NODES_NAME)
File diff suppressed because it is too large Load Diff
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
import re
import sys
from pathlib import Path
# --- Configuration ---
# The path to your pyproject.toml file, relative to the project root
PYPROJECT_PATH = Path("pyproject.toml")
# The path to the Python file containing the __version__ string
SOURCE_VERSION_PATH = Path("src/nodes/__init__.py")
# --- End Configuration ---
def get_version_from_pyproject(file_path: Path) -> str | None:
"""Extracts the version string from a pyproject.toml file."""
try:
content = file_path.read_text()
# A simple regex to find `version = "..."` under the `[project]` table
match = re.search(r'^version\s*=\s*"(.*?)"', content, re.MULTILINE)
if match:
return match.group(1)
except FileNotFoundError:
print(f"Error: {file_path} not found.", file=sys.stderr)
except Exception as e:
print(f"Error reading or parsing {file_path}: {e}", file=sys.stderr)
return None
def get_version_from_source(file_path: Path) -> str | None:
"""Extracts the __version__ string from a Python source file."""
try:
content = file_path.read_text()
# A simple regex to find `__version__ = "..."`
match = re.search(r'^__version__\s*=\s*"(.*?)"', content, re.MULTILINE)
if match:
return match.group(1)
except FileNotFoundError:
print(f"Error: {file_path} not found.", file=sys.stderr)
except Exception as e:
print(f"Error reading or parsing {file_path}: {e}", file=sys.stderr)
return None
def main() -> int:
"""
Compares version strings from pyproject.toml and the source code.
Exits with a non-zero status code if they do not match.
"""
print("--- Checking version consistency ---")
# Get versions
pyproject_version = get_version_from_pyproject(PYPROJECT_PATH)
source_version = get_version_from_source(SOURCE_VERSION_PATH)
# Validate that we found both
if not pyproject_version:
print(f"Error: Could not find version in {PYPROJECT_PATH}", file=sys.stderr)
return 1
if not source_version:
print(f"Error: Could not find `__version__` in {SOURCE_VERSION_PATH}", file=sys.stderr)
return 1
print(f"Version in {PYPROJECT_PATH}: {pyproject_version}")
print(f"Version in {SOURCE_VERSION_PATH}: {source_version}")
# Compare and exit
if pyproject_version == source_version:
print("✅ Versions are consistent.")
return 0
else:
print("\n❌ Error: Version mismatch!", file=sys.stderr)
print(f" pyproject.toml has version '{pyproject_version}'", file=sys.stderr)
print(f" {SOURCE_VERSION_PATH} has version '{source_version}'", file=sys.stderr)
print(" Please ensure both versions are identical.", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
import argparse
import os
import shutil # For file backup
import logging
# Setup logging
logger = logging.getLogger("StyleFixer")
logger.setLevel(logging.INFO) # Default level
# Create console handler and set level to debug
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG) # Let handler decide what to show based on logger's effective level
# Create formatter
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
# Add formatter to ch
ch.setFormatter(formatter)
# Add ch to logger
logger.addHandler(ch)
def expand_tabs(line, tab_size=4):
"""Expands tabs to spaces, respecting tab stops."""
out_line = []
current_col = 0
for char in line:
if char == '\t':
spaces_to_add = tab_size - (current_col % tab_size)
out_line.append(' ' * spaces_to_add)
current_col += spaces_to_add
else:
out_line.append(char)
current_col += 1
return "".join(out_line)
def fix_indentation(line, tab_size=4):
"""Rounds leading whitespace indentation to the nearest multiple of tab_size."""
stripped_line = line.lstrip(' ')
if not stripped_line or stripped_line == line: # No leading spaces or empty line
return line
leading_spaces_count = len(line) - len(stripped_line)
remainder = leading_spaces_count % tab_size
if remainder == 0:
new_indent_count = leading_spaces_count
elif remainder <= tab_size / 2: # Prioritize rounding down or to current if exact multiple
new_indent_count = leading_spaces_count - remainder
else: # Round up
new_indent_count = leading_spaces_count + (tab_size - remainder)
new_indent_count = max(0, new_indent_count) # Ensure non-negative
if new_indent_count != leading_spaces_count:
logger.debug(f"Adjusting indent: from {leading_spaces_count} to {new_indent_count} for line: {line.rstrip()!r}")
return ' ' * new_indent_count + stripped_line
def process_line(line_num, line_content_with_eol, expand_tabs_enabled=True):
original_line_for_debug = line_content_with_eol # Keep for debugging if needed
line = line_content_with_eol
# 1. Expand tabs (if enabled)
if expand_tabs_enabled:
line = expand_tabs(line)
if line != original_line_for_debug and not line.isspace(): # Log only if actual content changed
logger.debug(f"L{line_num}: Tabs expanded.")
# 2. W291: Remove trailing whitespace (done before other checks that might rely on EOL)
# Also handles W293 (blank line contains whitespace) implicitly if the line becomes empty.
processed_line = line.rstrip()
if len(processed_line) < len(line.rstrip('\r\n')): # Compare lengths without EOL
logger.debug(f"L{line_num}: Trailing whitespace removed.")
# If the line became empty after rstrip, it's a truly blank line
if not processed_line and line.strip() == "":
# Return an empty string for a blank line, newline char will be added later if original had it
if line.endswith(('\n', '\r\n', '\r')):
return '\n'
return ""
# 3. Fix indentation (E111) - only if line is not blank
if processed_line:
indented_line = fix_indentation(processed_line)
if indented_line != processed_line: # Log if indentation changed
# Already logged inside fix_indentation with more detail
pass
processed_line = indented_line
# 4. E261: At least two spaces before inline comment
in_string_char = None
temp_part = ""
comment_starts_at = -1
for idx, char_val in enumerate(processed_line):
if in_string_char:
temp_part += char_val
if char_val == in_string_char:
if len(temp_part) >= 2 and temp_part[-2] == '\\':
pass
else:
in_string_char = None
elif char_val in ("'", '"'):
temp_part += char_val
in_string_char = char_val
elif char_val == '#':
comment_starts_at = idx
break # Found the first non-string comment char
else:
temp_part += char_val
if comment_starts_at != -1:
code_part = processed_line[:comment_starts_at]
comment_part = processed_line[comment_starts_at:] # Includes '#'
rstripped_code_part = code_part.rstrip()
# Only add spaces if there's actual code before the comment
if rstripped_code_part:
if not code_part.endswith(' '): # Needs fixing (less than 2 spaces)
new_line_with_comment_spacing = rstripped_code_part + ' ' + comment_part
if new_line_with_comment_spacing != processed_line:
logger.debug(f"L{line_num}: Adjusted inline comment spacing.")
processed_line = new_line_with_comment_spacing
# If rstripped_code_part is empty, it's a comment at the start of the line (after indent), leave it.
# Add back the newline character that rstrip might have removed, if the original line had one
# or if it's not an empty line that was purely whitespace
if line_content_with_eol.endswith('\n'):
return processed_line + '\n'
elif line_content_with_eol.endswith('\r\n'):
return processed_line + '\r\n'
elif line_content_with_eol.endswith('\r'):
return processed_line + '\r'
else: # Original line did not end with EOL
return processed_line
def process_file(filepath, expand_tabs_enabled=True, dry_run=False, no_backup=False):
logger.info(f"Processing {filepath}...")
try:
# Read with universal newlines mode, then splitlines to preserve EOLs correctly for rejoining
with open(filepath, 'r', encoding='utf-8', newline='') as f:
# original_content = f.read() # Reading all at once
# original_lines = original_content.splitlines(keepends=True) # This is better
original_lines = f.readlines() # Simpler, usually works fine
except Exception as e:
logger.error(f"Error reading file {filepath}: {e}")
return False
fixed_lines = []
changes_made = False
for i, line_content_with_eol in enumerate(original_lines):
fixed_line_content = process_line(i + 1, line_content_with_eol, expand_tabs_enabled)
fixed_lines.append(fixed_line_content)
if fixed_line_content != line_content_with_eol:
changes_made = True
logger.debug(f"L{i+1}: Original: {line_content_with_eol.rstrip()!r}")
logger.debug(f"L{i+1}: Fixed : {fixed_line_content.rstrip()!r}")
if changes_made:
if dry_run:
logger.info(f"Would fix style issues in {filepath} (dry run)")
else:
if not no_backup:
backup_dir = os.path.dirname(filepath)
backup_filename = "." + os.path.basename(filepath) + "~"
backup_path = os.path.join(backup_dir, backup_filename)
try:
shutil.copy2(filepath, backup_path) # copy2 preserves metadata
logger.info(f"Backup of original file created at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup for {filepath}: {e}. File not modified.")
return False
try:
# Write back lines using the original EOLs if possible, or common EOL
with open(filepath, 'w', encoding='utf-8', newline='') as f:
f.writelines(fixed_lines)
logger.info(f"Fixed style issues in {filepath}")
except Exception as e:
logger.error(f"Error writing to file {filepath}: {e}")
return False
else:
logger.info(f"No style issues to fix in {filepath}")
return True
def main():
parser = argparse.ArgumentParser(description="Fix common Python code style issues (Flake8).")
parser.add_argument("files", metavar="FILE", type=str, nargs='+',
help="Python files to process")
parser.add_argument("--no-expand-tabs", action="store_false", dest="expand_tabs",
help="Disable expansion of tabs to spaces.")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be changed without modifying files.")
parser.add_argument("--no-backup", action="store_true",
help="Do not create a backup of the original file.")
parser.add_argument("-v", "--verbose", action="store_true",
help="Enable verbose (debug) logging.")
parser.set_defaults(expand_tabs=True)
args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
for filepath in args.files:
if not os.path.isfile(filepath):
logger.warning(f"File not found: {filepath}. Skipping.")
continue
process_file(filepath, args.expand_tabs, args.dry_run, args.no_backup)
if __name__ == "__main__":
main()