Author SHA1 Message Date
jetthuangai 359a3b56dd Bump version 1.6.1 2026-10-18 08:33:37 +07:00
jetthuangai d23c54eda3 refactor: split image tools, share helpers, harden routes (no node changes)
- nodes/image/image_tools_nodes.py (1776 lines) split into save_image_path,
  folder_loaders, layer_composite, image_label, image_compare and
  image_resize_unit; folder helpers in core/image_folders.py (set-based
  dedupe), image helpers in core/image_ops.py
- One tensor -> PIL converter (core.utils.tensor_to_pil, rgb=/keep_alpha=)
  that keeps the tensor's dtype; bf16 supported; duplicates removed from grid,
  large save and preview cache
- core/mask_ops.mask_bbox replaces five bbox copies; model helpers moved to
  core/model_files.py; Pillow fromarray(mode=) uses removed
- Routes moved to core/folder_routes.py: no absolute paths in responses,
  recursive honoured, proper status codes, scans off the event loop; large
  preview routes reject malformed JSON and hide paths
- web/nh_widget_utils.js shared by all extensions; widgets hidden via the
  frontend's `hidden` property when available; folder image count honours
  recursive
- A wired Load Images Folder path no longer fingerprints ComfyUI's input folder
- Node ids, inputs and outputs unchanged (69 nodes); new tests for routes,
  web helpers, mask ops, channel handling and Pillow usage
2026-10-18 08:33:36 +07:00
jetthuangai e8a0f8c005 Bump version 1.6.0 2026-10-18 07:31:58 +07:00
jetthuangai d128024dff perf: lazy switches, faster morphology, smarter caching and VTON VRAM release
- If/Else, Switch N and Random Choice use lazy inputs: only the chosen
  branch is executed (same choice as before; connected inputs count even
  when their upstream returns nothing)
- Mask Morphology rewritten on torch: soft masks kept, replicate borders,
  even kernels act as the next odd size; large kernels ~100x faster
- Layer Stack is cached like a pure node; Load Images Matching caches on a
  file signature, searches only inside its root, never walks drive/share
  roots or the models folder, and on a miss returns found=False plus the
  new optional lazy fallback_images (or blocks images)
- Indexed loaders reuse the model ComfyUI still holds via a weak reference
  (strong only with --cache-classic); VAEs keep file metadata
- VTON: keep_model_loaded (default off) releases ONNX Runtime GPU sessions
  after runs that really used CUDA; ComfyUI is asked to make room first
- SegFormer free_memory now targets ComfyUI's device (cuda:0) so models are
  actually unloaded, and adds extra_reserved_memory
- Model downloads pinned to tested Hugging Face revisions with
  allow_patterns; partial VTON folders are repaired
- nh_nodes (.safetensors) and vton_mask (.onnx) folder names honour
  extra_model_paths.yaml; existing installs keep working
- Large preview/compare viewer: batch navigation with buttons and arrow keys
- Real PromptExecutor integration tests (CPU) for lazy and blocking paths
2026-10-18 07:31:58 +07:00
jetthuangai e21e2ee7ef Bump version 1.5.0 2026-10-18 04:40:24 +07:00
jetthuangai 71e42b303b fix: correctness fixes across image, resolution, logic, text and mask nodes
Image
- VTON, Face Paste and Agnostic Image process the whole batch (batch-1
  inputs broadcast); VTON converts input to RGB and names the batch index
  when no person is detected
- Values are clipped before 8-bit conversion (no wrap-around to black)
- Mask-Aware Resize honours the mode with an empty mask and clips Lanczos
  overshoot; Agnostic Image resizes/accepts 2-D masks, keeps soft masks and
  uses deterministic noise
- Layer Layout/Stack keep RGBA layer alpha; pad colours accept #RRGGBBAA
  (opaque by default instead of copying the blue value into alpha)
- Save Image Path: prefix/new folder cannot escape the chosen folder
  (Windows colons refused); position expressions use the shared safe
  evaluator (no more 9**9**9 freeze, no ast.Num)
- Save Large Image checks WEBP/JPEG limits before writing and respects
  --disable-metadata; Image Label caches fonts; Grid guards on free memory;
  Cache Manager re-runs every queue

Resolution
- Remove Resize Padding no longer stretches cover_crop results: with
  original_size the image is scaled back uniformly into the original frame
- Lanczos/box/hamming keep float precision, clipped between passes
- Picker reports stale presets/model names in info

Logic, text, lists, masks
- New core/safe_eval.py: float-only arithmetic, whitelist, no runaway ints;
  Math Eval errors name the expression, x/0 -> 0 with a warning
- Random Choice weights follow slots; literal seeds in four seeded nodes
- Prompt Template no longer glues words around empty variables; Regex
  Extract handles unmatched groups; Pack Universal packs by slot; Compare
  'in' is a plain membership test; Batch Merge aligns RGB/RGBA and never
  rounds a side to 0
- Mask Aspect Ratio Match placement fixed and batched; VTON garment boxes
  ignore undetected keypoints and fall back to the garment contour
- Universal Slider Builder marked DEPRECATED

Adds docs/CHANGELOG.md and ~460 regression tests.
2026-10-18 04:40:24 +07:00
54 changed files with 7756 additions and 2207 deletions
+6 -4
View File
@@ -44,14 +44,16 @@ example, ONNX Runtime sessions in the vendored code are created through
## Models downloaded at runtime
These files are **not** stored in this repository. The nodes download them from
Hugging Face on first use.
Hugging Face on first use, pinned to the revisions below (only the files the nodes need).
They are looked up first in the folders registered as `vton_mask` and `nh_nodes`
(configurable in `extra_model_paths.yaml`).
| Node | Hugging Face repository | Contents | Terms |
|------|-------------------------|----------|-------|
| VTON Ultimate Processor | [`kg-09/kg-vton-mask`](https://huggingface.co/kg-09/kg-vton-mask) | `dwpose/yolox_l.onnx`, `dwpose/dw-ll_ucoco_384.onnx` | DWPose / YOLOX weights, Apache-2.0 upstream |
| VTON Ultimate Processor | [`kg-09/kg-vton-mask`](https://huggingface.co/kg-09/kg-vton-mask) @ `4648778` | `dwpose/yolox_l.onnx`, `dwpose/dw-ll_ucoco_384.onnx` | DWPose / YOLOX weights, Apache-2.0 upstream |
| VTON Ultimate Processor | same | `humanparsing/parsing_atr.onnx`, `humanparsing/parsing_lip.onnx` | From the OOTDiffusion/FitDiT checkpoints (CC BY-NC-SA 4.0); trained on the ATR and LIP datasets, which are distributed for research use |
| Portrait Segment | [`jonathandinu/face-parsing`](https://huggingface.co/jonathandinu/face-parsing) | SegFormer fine-tuned on CelebAMask-HQ | Model card: "non-commercial research and educational purposes" |
| Garment Segment | [`1038lab/segformer_fashion`](https://huggingface.co/1038lab/segformer_fashion) | SegFormer trained for Fashionpedia classes | No license declared by the uploader (Fashionpedia annotations are CC BY 4.0) |
| Portrait Segment | [`jonathandinu/face-parsing`](https://huggingface.co/jonathandinu/face-parsing) @ `758b82e` | SegFormer fine-tuned on CelebAMask-HQ | Model card: "non-commercial research and educational purposes" |
| Garment Segment | [`1038lab/segformer_fashion`](https://huggingface.co/1038lab/segformer_fashion) @ `e5bae2d` | SegFormer trained for Fashionpedia classes | No license declared by the uploader (Fashionpedia annotations are CC BY 4.0) |
## Python dependencies
+3 -45
View File
@@ -45,58 +45,16 @@ for _category in pkgutil.iter_modules(_nodes_pkg.__path__):
NODE_DISPLAY_NAME_MAPPINGS.update(getattr(_module, "NODE_DISPLAY_NAME_MAPPINGS", {}))
_module_count += 1
# Route-only module: registers /nh-nodes/large-preview/* on import.
# Route-only modules: they register the /nh-nodes/* HTTP routes on import.
try:
from .core import large_preview_routes # noqa: F401
except Exception as e:
print(f"[NH-Nodes] Failed to import core.large_preview_routes: {e}")
try:
from aiohttp import web
from server import PromptServer
@PromptServer.instance.routes.get("/nh-nodes/load-images-folder/count")
async def nh_load_images_folder_count(request):
folder_path = request.query.get("folder_path", "")
recursive = request.query.get("recursive", "").lower() in {"1", "true", "yes", "on"}
try:
from .nodes.image.image_tools_nodes import _collect_image_files, _resolve_load_dir
resolved_folder = _resolve_load_dir(folder_path)
image_count = len(_collect_image_files(resolved_folder, recursive=recursive))
return web.json_response({"count": image_count, "path": resolved_folder})
except Exception as exc:
return web.json_response({"count": 0, "error": str(exc)})
@PromptServer.instance.routes.get("/nh-nodes/smart-resolution-picker/presets")
async def nh_smart_resolution_picker_presets(request):
try:
from .core.resolution_data import DEFAULT_MODEL_LABEL, DEFAULT_PRESET, MODEL_LABELS, PRESET_LABELS_BY_MODEL
from .nodes.resolution.smart_resolution_picker import TARGET_RESOLUTION_LEVELS, _resolution_candidates
ratios_by_model_level = {}
for model_label in MODEL_LABELS:
ratios_by_model_level[model_label] = {}
for resolution_level in TARGET_RESOLUTION_LEVELS:
candidates = _resolution_candidates(model_label, resolution_level)
ratios = []
for entry in candidates:
if entry["aspect"] not in ratios:
ratios.append(entry["aspect"])
ratios_by_model_level[model_label][resolution_level] = ratios
return web.json_response({
"models": MODEL_LABELS,
"default_model": DEFAULT_MODEL_LABEL,
"default_preset": DEFAULT_PRESET,
"presets_by_model": PRESET_LABELS_BY_MODEL,
"resolution_levels": TARGET_RESOLUTION_LEVELS,
"ratios_by_model_level": ratios_by_model_level,
})
except Exception as exc:
return web.json_response({"error": str(exc)}, status=500)
from .core import folder_routes # noqa: F401
except Exception as e:
print(f"[NH-Nodes] Failed to register web routes: {e}")
print(f"[NH-Nodes] Failed to import core.folder_routes: {e}")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
+103
View File
@@ -0,0 +1,103 @@
"""HTTP routes behind the NH-Nodes frontend extensions.
GET /nh-nodes/load-images-folder/count how many images a folder loader sees (auto increment / wrap)
GET /nh-nodes/smart-resolution-picker/presets the preset lists for the resolution pickers' dropdowns
Importing this module registers both on ComfyUI's PromptServer, like core.large_preview_routes. The handlers are plain
module-level functions, so a test can serve them from any aiohttp app through ``register_routes``.
"""
import asyncio
import os
from aiohttp import web
from .image_folders import collect_image_files, resolve_load_dir
COUNT_PATH = "/nh-nodes/load-images-folder/count"
PRESETS_PATH = "/nh-nodes/smart-resolution-picker/presets"
_MAX_FOLDER_PATH_CHARS = 4096
# Spellings of the ``recursive`` query parameter. Anything else is a bad request, not a silent "false".
_FLAGS = {"1": True, "true": True, "yes": True, "on": True, "": False, "0": False, "false": False, "no": False, "off": False}
def _error(status, message):
# Fixed short texts only: ComfyUI can be reachable from the network (--listen), so an answer never carries a
# resolved path or the text of an exception.
return web.json_response({"error": message}, status=status)
def _count_images(folder_path, recursive):
"""How many images a loader with these settings sees. Blocking (a recursive walk): run it in a worker thread."""
folder = resolve_load_dir(folder_path)
if not os.path.isdir(folder):
raise FileNotFoundError("folder not found")
try:
return len(collect_image_files(folder, recursive=recursive))
except ValueError:
return 0 # the folder exists but holds no image
async def nh_load_images_folder_count(request):
folder_path = request.query.get("folder_path", "")
recursive = _FLAGS.get(request.query.get("recursive", "").strip().lower())
if recursive is None:
return _error(400, "recursive must be true or false")
if len(folder_path) > _MAX_FOLDER_PATH_CHARS or "\0" in folder_path:
return _error(400, "invalid folder_path")
try:
count = await asyncio.to_thread(_count_images, folder_path, recursive)
except FileNotFoundError:
return _error(404, "folder not found")
except PermissionError:
return _error(403, "folder is not readable")
except Exception as exc:
print(f"[NH-Nodes] load-images-folder/count failed: {type(exc).__name__}: {exc}")
return _error(500, "could not read the folder")
return web.json_response({"count": count})
async def nh_smart_resolution_picker_presets(request):
try:
# Imported here: core must not depend on the node modules at import time.
from ..nodes.resolution.smart_resolution_picker import TARGET_RESOLUTION_LEVELS, _resolution_candidates
from .resolution_data import DEFAULT_MODEL_LABEL, DEFAULT_PRESET, MODEL_LABELS, PRESET_LABELS_BY_MODEL
ratios_by_model_level = {}
for model_label in MODEL_LABELS:
ratios_by_model_level[model_label] = {}
for resolution_level in TARGET_RESOLUTION_LEVELS:
candidates = _resolution_candidates(model_label, resolution_level)
ratios = []
for entry in candidates:
if entry["aspect"] not in ratios:
ratios.append(entry["aspect"])
ratios_by_model_level[model_label][resolution_level] = ratios
return web.json_response({
"models": MODEL_LABELS,
"default_model": DEFAULT_MODEL_LABEL,
"default_preset": DEFAULT_PRESET,
"presets_by_model": PRESET_LABELS_BY_MODEL,
"resolution_levels": TARGET_RESOLUTION_LEVELS,
"ratios_by_model_level": ratios_by_model_level,
})
except Exception as exc:
print(f"[NH-Nodes] smart-resolution-picker/presets failed: {type(exc).__name__}: {exc}")
return _error(500, "could not build the preset lists")
def register_routes(routes):
"""Attach the handlers to an aiohttp RouteTableDef (ComfyUI's ``PromptServer.instance.routes``)."""
routes.get(COUNT_PATH)(nh_load_images_folder_count)
routes.get(PRESETS_PATH)(nh_smart_resolution_picker_presets)
try:
from server import PromptServer
register_routes(PromptServer.instance.routes)
except Exception as exc:
print(f"[NH-Nodes] Failed to register folder routes: {exc}")
+78
View File
@@ -0,0 +1,78 @@
"""Image folder helpers shared by the folder loader nodes and the folder-count HTTP route.
``resolve_load_dir`` turns the folder text of a loader into a directory, ``collect_image_files`` lists the images in
it in natural order. Nothing here needs torch or Pillow.
"""
import os
import re
from pathlib import Path
import folder_paths
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".avif", ".bmp", ".tif", ".tiff"}
def natural_key(value):
"""Sort key that puts 'img2' before 'img10' and ignores case."""
return [int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", value)]
def resolve_load_dir(folder_path, image="", upload_probe=""):
"""The folder a loader reads.
``folder_path`` when given (relative to ComfyUI's input folder unless absolute; a file stands for its folder),
else the folder of the uploaded ``image`` / ``upload_probe``, else the input folder.
"""
folder_path = (folder_path or "").strip()
if folder_path:
resolved = folder_path
if not os.path.isabs(resolved):
resolved = os.path.join(folder_paths.get_input_directory(), resolved)
if os.path.isfile(resolved):
resolved = os.path.dirname(resolved)
return resolved
upload_name = image or upload_probe
if upload_name:
return os.path.dirname(folder_paths.get_annotated_filepath(upload_name))
return folder_paths.get_input_directory()
def collect_image_files(folder_path, recursive=False):
"""The images of ``folder_path`` in natural order, as names (paths relative to the folder below it).
Sub-folders are read when ``recursive`` is set, and also when the folder holds no image itself. ValueError for a
folder that does not exist or has no images.
"""
if not os.path.isdir(folder_path):
raise ValueError(f"Folder does not exist: {folder_path}")
files = []
for entry in os.listdir(folder_path):
full_path = os.path.join(folder_path, entry)
if os.path.isfile(full_path) and Path(entry).suffix.lower() in IMAGE_EXTENSIONS:
files.append(entry)
if recursive or not files:
seen = set(files)
models_dir = os.path.normcase(os.path.abspath(folder_paths.models_dir))
for current_root, dir_names, file_names in os.walk(folder_path):
# Natural order, and never into the ComfyUI models folder (a walk from one of its parents would crawl it).
dir_names[:] = sorted(
(name for name in dir_names if os.path.normcase(os.path.abspath(os.path.join(current_root, name))) != models_dir),
key=natural_key,
)
for file_name in sorted(file_names, key=natural_key):
full_path = os.path.join(current_root, file_name)
if os.path.isfile(full_path) and Path(file_name).suffix.lower() in IMAGE_EXTENSIONS:
rel_path = os.path.relpath(full_path, folder_path)
if rel_path not in seen:
seen.add(rel_path)
files.append(rel_path)
files.sort(key=natural_key)
if not files:
raise ValueError(f"No images found in folder: {folder_path}")
return files
+47
View File
@@ -0,0 +1,47 @@
"""Pillow / tensor helpers shared by the image nodes (save, load, compare, label, resize, layer composite).
IMAGE tensors become PIL images with core.utils.tensor_to_pil (rgb=True, plus keep_alpha=True for layers);
``pil_to_image_tensor`` converts back in ComfyUI's intermediate dtype.
"""
from PIL import Image, ImageColor
import comfy.model_management
from .utils import parse_hex_color, pil_to_tensor
RESAMPLING = getattr(Image, "Resampling", Image)
def parse_color(value, default):
"""Parse a colour into an (r, g, b, a) tuple of 0-255 ints; ``default`` when the text is not a colour.
'#RGB', '#RRGGBB' and '#RRGGBBAA' go through core.utils.parse_hex_color (opaque unless AA is given). Other CSS
colours Pillow understands ('red', 'rgb(1, 2, 3)') keep working, as they did before.
"""
text = str(value or "").strip()
color = parse_hex_color(text, ())
if color:
return color
try:
return ImageColor.getcolor(text, "RGBA")
except ValueError:
return tuple(default) + (255,) * (4 - len(default))
def pil_to_image_tensor(image):
"""A PIL image as a [1, H, W, C] float IMAGE in ComfyUI's intermediate dtype."""
return pil_to_tensor(image).to(dtype=comfy.model_management.intermediate_dtype())
def resize_pil(image, size, method):
"""``image`` resized to ``size`` (width, height) with the 'lanczos', 'bicubic', 'bilinear' or 'nearest' filter."""
resample = {
"lanczos": RESAMPLING.LANCZOS,
"bicubic": RESAMPLING.BICUBIC,
"bilinear": RESAMPLING.BILINEAR,
"nearest": RESAMPLING.NEAREST,
}[method]
return image.resize(size, resample)
+5 -17
View File
@@ -10,10 +10,11 @@ import uuid
from pathlib import Path
import folder_paths
import numpy as np
import torch
from PIL import Image, ImageChops, ImageDraw
from .utils import tensor_to_pil
CACHE_SUBDIR = "nh_large_preview"
CACHE_ID_RE = re.compile(r"^[a-f0-9]{32}$")
@@ -139,21 +140,6 @@ def delete_cache(cache_id):
}
def _tensor_to_pil(image_tensor):
if not isinstance(image_tensor, torch.Tensor):
raise TypeError(f"Expected IMAGE tensor, got {type(image_tensor).__name__}")
array = image_tensor.detach().cpu().numpy()
array = np.clip(array * 255.0, 0, 255).astype(np.uint8)
if array.ndim == 2:
return Image.fromarray(array).convert("RGB")
if array.ndim == 3 and array.shape[-1] == 1:
return Image.fromarray(array[:, :, 0]).convert("RGB")
if array.ndim == 3 and array.shape[-1] == 4:
return Image.fromarray(array).convert("RGBA")
return Image.fromarray(array).convert("RGB")
def _preview_dimensions(width, height, preview_megapixels):
width = max(1, int(width))
height = max(1, int(height))
@@ -292,7 +278,9 @@ def create_preview_cache(
batch_index=0,
batch_count=1,
):
source = _tensor_to_pil(image_tensor)
if not isinstance(image_tensor, torch.Tensor):
raise TypeError(f"Expected IMAGE tensor, got {type(image_tensor).__name__}")
source = tensor_to_pil(image_tensor, rgb=True, keep_alpha=True)
source_width, source_height = source.size
preview_width, preview_height = _preview_dimensions(source_width, source_height, preview_megapixels)
preview = source.copy()
+95 -56
View File
@@ -1,67 +1,106 @@
"""HTTP routes for NH large image preview cache."""
"""HTTP routes for NH large image preview cache.
Importing this module registers the routes on ComfyUI's PromptServer. The handlers are plain module-level functions,
so a test can serve them from any aiohttp app through ``register_routes``.
"""
import asyncio
import json
from aiohttp import web
from .large_preview_cache import cache_info, cleanup_cache, delete_cache, get_thumb_path, get_tile_path, read_manifest
def _without_paths(value):
"""Answers go to the browser: drop absolute filesystem paths (the Cache Manager node still shows them)."""
if isinstance(value, dict):
return {key: _without_paths(item) for key, item in value.items() if key not in ("path", "root")}
if isinstance(value, list):
return [_without_paths(item) for item in value]
return value
async def _read_json_object(request):
"""The JSON object in the request body ({} when the body is empty). ValueError for anything else."""
try:
text = await request.text()
payload = json.loads(text) if text.strip() else {}
except ValueError: # not JSON, or not UTF-8 text: json.JSONDecodeError and UnicodeDecodeError both are ValueErrors
raise ValueError("request body is not valid JSON") from None
if not isinstance(payload, dict):
raise ValueError("request body must be a JSON object")
return payload
async def nh_large_preview_manifest(request):
try:
manifest = read_manifest(request.match_info["cache_id"])
return web.json_response(manifest)
except FileNotFoundError:
return web.json_response({"error": "manifest not found"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
async def nh_large_preview_thumb(request):
try:
return web.FileResponse(get_thumb_path(request.match_info["cache_id"]))
except FileNotFoundError:
return web.json_response({"error": "thumbnail not found"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
async def nh_large_preview_tile(request):
try:
return web.FileResponse(get_tile_path(
request.match_info["cache_id"],
request.match_info["level"],
request.match_info["tile"],
))
except FileNotFoundError:
return web.json_response({"error": "tile not available in thumbnail-only preview"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
async def nh_large_preview_cache_info(request):
try:
# Directory walks run in a worker thread so a large cache never stalls the server's event loop.
return web.json_response(_without_paths(await asyncio.to_thread(cache_info)))
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
async def nh_large_preview_cleanup(request):
try:
payload = await _read_json_object(request)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
try:
cache_id = payload.get("cache_id", "")
if cache_id:
result = await asyncio.to_thread(delete_cache, cache_id)
else:
result = await asyncio.to_thread(cleanup_cache, payload.get("max_age_hours", 24), payload.get("max_total_mb", 0))
return web.json_response(_without_paths(result))
except (ValueError, TypeError, OverflowError) as exc: # a bad id, or a limit that is not a number
return web.json_response({"error": str(exc)}, status=400)
def register_routes(routes):
"""Attach the handlers to an aiohttp RouteTableDef (ComfyUI's ``PromptServer.instance.routes``)."""
routes.get("/nh-nodes/large-preview/manifest/{cache_id}")(nh_large_preview_manifest)
routes.get("/nh-nodes/large-preview/thumb/{cache_id}")(nh_large_preview_thumb)
routes.get("/nh-nodes/large-preview/tile/{cache_id}/{level}/{tile}")(nh_large_preview_tile)
routes.get("/nh-nodes/large-preview/cache-info")(nh_large_preview_cache_info)
routes.post("/nh-nodes/large-preview/cleanup")(nh_large_preview_cleanup)
try:
from server import PromptServer
@PromptServer.instance.routes.get("/nh-nodes/large-preview/manifest/{cache_id}")
async def nh_large_preview_manifest(request):
try:
manifest = read_manifest(request.match_info["cache_id"])
return web.json_response(manifest)
except FileNotFoundError:
return web.json_response({"error": "manifest not found"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
@PromptServer.instance.routes.get("/nh-nodes/large-preview/thumb/{cache_id}")
async def nh_large_preview_thumb(request):
try:
return web.FileResponse(get_thumb_path(request.match_info["cache_id"]))
except FileNotFoundError:
return web.json_response({"error": "thumbnail not found"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
@PromptServer.instance.routes.get("/nh-nodes/large-preview/tile/{cache_id}/{level}/{tile}")
async def nh_large_preview_tile(request):
try:
return web.FileResponse(get_tile_path(
request.match_info["cache_id"],
request.match_info["level"],
request.match_info["tile"],
))
except FileNotFoundError:
return web.json_response({"error": "tile not available in thumbnail-only preview"}, status=404)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
@PromptServer.instance.routes.get("/nh-nodes/large-preview/cache-info")
async def nh_large_preview_cache_info(request):
try:
return web.json_response(cache_info())
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
@PromptServer.instance.routes.post("/nh-nodes/large-preview/cleanup")
async def nh_large_preview_cleanup(request):
try:
payload = await request.json()
except Exception:
payload = {}
try:
cache_id = payload.get("cache_id", "")
if cache_id:
result = delete_cache(cache_id)
else:
result = cleanup_cache(payload.get("max_age_hours", 24), payload.get("max_total_mb", 0))
return web.json_response(result)
except ValueError as exc:
return web.json_response({"error": str(exc)}, status=400)
register_routes(PromptServer.instance.routes)
except Exception as exc:
print(f"[NH-Nodes] Failed to register large preview routes: {exc}")
+25
View File
@@ -0,0 +1,25 @@
"""Mask helpers shared by the NH mask and image nodes.
NH-Nodes' own code (MIT). It is deliberately not part of core/utils_mask.py: that file is third-party
FitDiT code under CC BY-NC-SA 4.0 (see THIRD_PARTY_NOTICES.md), and plain MIT nodes must not depend on it.
"""
import numpy as np
def mask_bbox(mask, threshold=0.5):
"""Inclusive bounding box ``(x1, y1, x2, y2)`` of the pixels of a 2-D mask that are above ``threshold``.
``mask`` is an [H, W] array (a torch mask goes through ``.cpu().numpy()`` first). A pixel counts only when it is
strictly greater than ``threshold``, so a value of exactly 0.5 is outside, as in every NH mask node. The result is
four Python ints; (x2, y2) is the last pixel inside, so width = x2 - x1 + 1 and height = y2 - y1 + 1.
Returns None when no pixel is above the threshold, i.e. for an empty mask. Anything that is not 2-D raises ValueError.
"""
inside = np.asarray(mask) > threshold
if inside.ndim != 2:
raise ValueError(f"mask_bbox expects a 2-D [H, W] mask, got shape {inside.shape}")
rows = np.flatnonzero(inside.any(axis=1))
if rows.size == 0:
return None
cols = np.flatnonzero(inside.any(axis=0))
return int(cols[0]), int(rows[0]), int(cols[-1]), int(rows[-1])
+131
View File
@@ -0,0 +1,131 @@
"""Model-folder, download and VRAM helpers shared by the SegFormer and VTON model loaders.
Both model families follow the same rules:
* Model folders are registered with ComfyUI's ``folder_paths`` under the names
``nh_nodes`` (SegFormer) and ``vton_mask`` (VTON). Users can add more
locations with those names in ``extra_model_paths.yaml``; every registered
location is searched before anything is downloaded. Each name is registered
with the extension of its model files (``.safetensors``, ``.onnx``), so
ComfyUI's model lists show those files and not the Hugging Face cache
metadata that downloads leave in the same folder.
* Downloads always go to the default folder under the models dir, pinned to a
fixed Hugging Face revision.
"""
import os
import torch
try:
import folder_paths
except Exception: # running outside ComfyUI (tests)
folder_paths = None
try:
import comfy.model_management as _mm
except Exception:
_mm = None
def models_dir():
"""ComfyUI's models directory, read on every call (--models-directory, tests)."""
if folder_paths is not None:
return folder_paths.models_dir
return os.path.join(os.path.dirname(__file__), "models")
def unique_dirs(paths):
"""Drop repeats (same folder spelled differently) but keep the first spelling and the order."""
seen, unique = set(), []
for path in paths:
key = os.path.normcase(os.path.abspath(path))
if key not in seen:
seen.add(key)
unique.append(path)
return unique
def _list_only(folder_name, extensions):
"""Add `extensions` to the file types ComfyUI lists for the registered `folder_name`.
add_model_folder_path registers a name with an empty extension set, which ComfyUI reads as "list
every file": /models and the Model Library then show the .cache/huggingface metadata of our
downloads and a repo's .gitattributes. The set is replaced, never changed in place (ComfyUI shares
one set between many folder names), and the locations list stays the very same object, so every
extra_model_paths.yaml entry survives.
"""
if not extensions:
return
registry = folder_paths.folder_names_and_paths
paths, current = registry[folder_name]
merged = {*current, *extensions}
if merged != set(current):
registry[folder_name] = (paths, merged)
def register_model_folder(folder_name, default_dir, extensions=()):
"""Register `folder_name` with ComfyUI and return every folder searched for it, best first.
ComfyUI applies extra_model_paths.yaml before custom nodes load, so locations the user
configured under `folder_name` are already registered; they come first. `default_dir`
is added too (a no-op when already present) and is always part of the result, so models
that live there are found whether or not the user configured anything. Safe to call repeatedly.
`extensions` (".onnx") are the file types of the models in these folders: ComfyUI's lists for
`folder_name` then show only those, also for the locations the user added.
"""
registered = []
if folder_paths is not None:
try:
folder_paths.add_model_folder_path(folder_name, default_dir)
registered = folder_paths.get_folder_paths(folder_name)
_list_only(folder_name, extensions) # last: if it fails, the locations above are still returned
except Exception as exc: # an unusual ComfyUI build: fall back to the default location alone
print(f"[NH-Nodes] Could not register model folder '{folder_name}' with ComfyUI: {exc}")
return unique_dirs([*registered, default_dir])
def download_pinned(repo_id, revision, target, allow_patterns):
"""snapshot_download of `allow_patterns` from `repo_id` at `revision` into `target`.
Finished files are kept and partial ones resume, so calling it again after an interrupted
download completes the folder. Failures are re-raised with where to get the files by hand.
"""
from huggingface_hub import snapshot_download
os.makedirs(target, exist_ok=True)
print(f"[NH-Nodes] Downloading {repo_id}@{revision[:8]} -> {target}")
try:
snapshot_download(repo_id=repo_id, revision=revision, local_dir=target, allow_patterns=list(allow_patterns))
except Exception as exc:
raise RuntimeError(
f"[NH-Nodes] Downloading {repo_id} (revision {revision[:8]}) into {target} failed: {exc}\n"
f"Run again once the connection works (finished files are kept), or download "
f"{', '.join(allow_patterns)} from https://huggingface.co/{repo_id}/tree/{revision} into that folder."
) from exc
def make_room_on_gpu(nbytes):
"""Ask ComfyUI to unload its own models until about `nbytes` of VRAM are free.
ComfyUI's own loaders ask for their size plus the VRAM it keeps in reserve (extra_reserved_memory(),
set by --reserve-vram or its built-in default), so that reserve is added here as well: asking for
`nbytes` alone would leave us that much short. Also hands PyTorch's cached blocks back to the
driver: libraries that allocate outside PyTorch (ONNX Runtime) cannot use them. Nothing happens
without a CUDA device, when ComfyUI itself runs on the CPU, or outside ComfyUI. A failure is
reported, not raised: the caller still tries its allocation.
"""
if _mm is None or not torch.cuda.is_available():
return
try:
# ComfyUI's loaded models carry an indexed device (cuda:0); an index-less
# torch.device("cuda") compares unequal to it and would match none of them.
device = _mm.get_torch_device()
if device.type != "cuda":
return
reserved = getattr(_mm, "extra_reserved_memory", None) # absent in older ComfyUI builds
_mm.free_memory(nbytes + (reserved() if reserved else 0), device)
_mm.soft_empty_cache()
except Exception as exc:
print(f"[NH-Nodes] Could not ask ComfyUI to free VRAM ({exc}); continuing")
+62 -31
View File
@@ -3,6 +3,14 @@
Handles model discovery (reuse dirs already on disk before downloading),
per-model caching with CPU offload between runs, and full-resolution label
map prediction.
The model-folder, download and VRAM helpers it shares with the VTON node live
in model_files.py, so both model families follow the same rules. SegFormer
models are registered with ComfyUI's ``folder_paths`` under the name
``nh_nodes``; users can add more locations with that name in
``extra_model_paths.yaml`` and every registered location is searched before
anything is downloaded. Downloads always go to the default folder under the
models dir, pinned to a fixed Hugging Face revision.
"""
import os
@@ -12,35 +20,34 @@ import numpy as np
import torch
import torch.nn.functional as F
try:
import folder_paths
_MODELS_DIR = folder_paths.models_dir
except Exception: # running outside ComfyUI (tests)
_MODELS_DIR = os.path.join(os.path.dirname(__file__), "models")
try:
import comfy.model_management as _mm
except Exception:
_mm = None
from .model_files import download_pinned, make_room_on_gpu, models_dir, register_model_folder, unique_dirs
_REQUIRED_FILES = ("config.json", "model.safetensors", "preprocessor_config.json")
# Logits are interpolated at most to this size before argmax; the label map is
# then nearest-resized to the input. Keeps VRAM bounded on very large inputs.
_MAX_LOGIT_SIDE = 2048
# name -> HF repo, local dirs to probe before downloading (relative to models
# dir; other packs may already hold the same weights) and a label signature
# that guards against a different checkpoint sitting in a probed dir.
# extra_model_paths.yaml key for the SegFormer models. Each registered location holds
# one sub-folder per model (see MODEL_SPECS), e.g. <location>/segformer_fashion/config.json.
FOLDER_NAME = "nh_nodes"
FOLDER_EXTENSIONS = (".safetensors",) # what ComfyUI lists for FOLDER_NAME: the weights, not config.json or cache metadata
_DEFAULT_FOLDER = "NH-Nodes" # under the models dir: the download target, searched first by default
# name -> HF repo + pinned revision, legacy local dirs to probe after the registered
# folders (relative to the models dir; older versions of this pack and other packs may
# already hold the same weights) and a label signature that guards against a different
# checkpoint sitting in a probed dir.
MODEL_SPECS = {
"segformer_fashion": {
"repo_id": "1038lab/segformer_fashion",
"probe_dirs": ("NH-Nodes/segformer_fashion", "RMBG/segformer_fashion"),
"revision": "e5bae2d8e3333be9f12eb6aae454c0542c1371d3",
"legacy_dirs": ("RMBG/segformer_fashion",),
"signature": {1: "shirt, blouse", 24: "shoe", 46: "tassel"},
},
"face_parsing": {
"repo_id": "jonathandinu/face-parsing",
"probe_dirs": ("NH-Nodes/face_parsing", "face_parsing", "RMBG/segformer_face"),
"revision": "758b82e15a0178c9db39c1ff666a8b56e3a550c8",
"legacy_dirs": ("face_parsing", "RMBG/segformer_face"),
"signature": {1: "skin", 13: "hair", 17: "neck"},
},
}
@@ -49,35 +56,54 @@ _LOADED = {}
_warned_no_cuda = False
# ---------------------------------------------------------------------------
# Model folders and downloads (the shared helpers are in model_files.py)
# ---------------------------------------------------------------------------
def default_folder():
"""Where SegFormer models are downloaded and searched first: <models dir>/NH-Nodes."""
return os.path.join(models_dir(), _DEFAULT_FOLDER)
def _has_required_files(path):
return all(os.path.isfile(os.path.join(path, name)) for name in _REQUIRED_FILES)
def model_search_dirs(model_name):
"""Folders that may hold `model_name`, best match first.
The registered ``nh_nodes`` locations come first (models/NH-Nodes by default, plus whatever
extra_model_paths.yaml adds), then the legacy dirs under the models dir.
"""
spec = MODEL_SPECS[model_name]
dirs = [os.path.join(base, model_name)
for base in register_model_folder(FOLDER_NAME, default_folder(), FOLDER_EXTENSIONS)]
dirs += [os.path.normpath(os.path.join(models_dir(), *rel.split("/"))) for rel in spec["legacy_dirs"]]
return unique_dirs(dirs)
def resolve_model_dir(model_name, download=True):
"""Return a local dir holding the model, downloading into models/NH-Nodes if absent."""
spec = MODEL_SPECS[model_name]
for rel in spec["probe_dirs"]:
candidate = os.path.join(_MODELS_DIR, rel)
for candidate in model_search_dirs(model_name):
if _has_required_files(candidate):
return candidate
target = os.path.join(_MODELS_DIR, spec["probe_dirs"][0])
if not download:
return None
from huggingface_hub import snapshot_download
os.makedirs(target, exist_ok=True)
print(f"[NH-Nodes] Downloading {spec['repo_id']} -> {target}")
snapshot_download(
repo_id=spec["repo_id"],
local_dir=target,
allow_patterns=list(_REQUIRED_FILES),
)
target = os.path.join(default_folder(), model_name)
download_pinned(spec["repo_id"], spec["revision"], target, _REQUIRED_FILES)
if not _has_required_files(target):
raise RuntimeError(f"[NH-Nodes] Model files missing after download: {target}")
return target
# Make the folder name known to ComfyUI as soon as the pack loads.
register_model_folder(FOLDER_NAME, default_folder(), FOLDER_EXTENSIONS)
# ---------------------------------------------------------------------------
# SegFormer inference
# ---------------------------------------------------------------------------
def _torch_device(device):
global _warned_no_cuda
if device == "cuda":
@@ -121,14 +147,19 @@ def load_segformer(model_name, device="cuda"):
return bundle
def _is_on(model_device, wanted):
"""True when `model_device` satisfies `wanted`; an index-less "cuda" accepts any cuda:N."""
return model_device.type == wanted.type and (wanted.index is None or model_device.index == wanted.index)
def _ensure_on_device(bundle):
model, device = bundle["model"], bundle["device"]
if model.device == device:
if _is_on(model.device, device):
return
if device.type == "cuda" and _mm is not None:
if device.type == "cuda":
# Let ComfyUI evict its own models first instead of OOM-ing on ours.
size = sum(p.numel() * p.element_size() for p in model.parameters())
_mm.free_memory(size * 2, device)
make_room_on_gpu(size * 2)
model.to(device)
+14 -3
View File
@@ -11,11 +11,14 @@ def resize_image(image, target_size=768):
new_h, new_w = target_size, int(w * target_size / h)
return image.resize((new_w, new_h), Image.LANCZOS)
def tensor_to_pil(tensor_image):
def tensor_to_pil(tensor_image, *, rgb=False, keep_alpha=False):
"""Convert ONE ComfyUI image ([H,W,C] or [1,H,W,C]) to PIL.
Values are clipped to [0, 1] first, so overshoot from resampling cannot wrap around
in uint8. 1/3/4 channels become L/RGB/RGBA. Batches must be converted per image.
``rgb=True`` always gives an RGB picture: grey is expanded and alpha is dropped. With ``keep_alpha=True`` as
well, a 4-channel image stays RGBA instead (every other image is still RGB).
"""
if tensor_image.dim() == 4:
if tensor_image.shape[0] != 1:
@@ -24,10 +27,18 @@ def tensor_to_pil(tensor_image):
"convert each image of the batch separately"
)
tensor_image = tensor_image[0]
np_image = np.clip(tensor_image.detach().cpu().float().numpy() * 255.0, 0, 255).astype(np.uint8)
pixels = tensor_image.detach().cpu()
if pixels.dtype == torch.bfloat16: # numpy has no bfloat16; other dtypes keep their own rounding
pixels = pixels.float()
np_image = np.clip(pixels.numpy() * 255.0, 0, 255).astype(np.uint8)
if np_image.ndim == 3 and np_image.shape[2] == 1:
np_image = np_image[:, :, 0]
return Image.fromarray(np_image)
pil_image = Image.fromarray(np_image)
if rgb:
kept_modes = ("RGB", "RGBA") if keep_alpha else ("RGB",)
if pil_image.mode not in kept_modes:
pil_image = pil_image.convert("RGB")
return pil_image
def pil_to_tensor(pil_image):
"""Convert PIL image to tensor in ComfyUI format for IMAGE"""
+9 -8
View File
@@ -1,7 +1,8 @@
# Garment mask construction for VTON Ultimate Processor (NH).
# Third-party code: FitDiT src/utils_mask.py (https://github.com/BoyuanJiang/FitDiT),
# received via ComfyUI-Vton-Mask. License: CC BY-NC-SA 4.0 (non-commercial), not the
# pack's MIT license. NH-Nodes changes: lint cleanups and bug fixes. See THIRD_PARTY_NOTICES.md.
# pack's MIT license. NH-Nodes changes: lint cleanups, bug fixes and Image.fromarray calls without the mode
# argument that Pillow 11.3 deprecated. See THIRD_PARTY_NOTICES.md.
import numpy as np
import cv2
from PIL import Image, ImageDraw
@@ -125,8 +126,8 @@ def get_img_agnostic_upper_rectangle(im_parse, pose_data, offset_top, offset_bot
agnostic_draw.rectangle((max(0, x1-pad_x1+offset_left), max(0, y1-pad_y1+offset_top), min(x2+pad_x2+offset_right, parse_array.shape[1]), min(y2+pad_y2+offset_bottom, parse_array.shape[0])), 'gray', 'gray')
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255), 'L'))
# agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255)))
# agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255)))
mask_gray = agnostic.copy()
mask = agnostic.point(lambda p: p == 128 and 255)
@@ -213,8 +214,8 @@ def get_img_agnostic_lower_rectangle(im_parse, pose_data, offset_top, offset_bot
agnostic_draw.rectangle((max(0, x1-pad_x1+offset_left), max(0, y1-pad_y1+offset_top), min(x2+pad_x2+offset_right, parse_array.shape[1]), min(y2+pad_y2+offset_bottom, parse_array.shape[0])), 'gray', 'gray')
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255)))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255)))
mask_gray = agnostic.copy()
mask = agnostic.point(lambda p: p == 128 and 255)
@@ -305,9 +306,9 @@ def get_img_agnostic_dresses_rectangle(im_parse, pose_data, offset_top, offset_b
agnostic_draw.rectangle((max(0, x1-pad_x1+offset_left), max(0, y1-pad_y1+offset_top), min(x2+pad_x2+offset_right, parse_array.shape[1]), min(y2+pad_y2+offset_bottom, parse_array.shape[0])), 'gray', 'gray')
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_shoes * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255), 'L'))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_head * 255)))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_shoes * 255)))
agnostic.paste(img_black, None, Image.fromarray(np.uint8(parse_fixed * 255)))
mask_gray = agnostic.copy()
mask = agnostic.point(lambda p: p == 128 and 255)
+32
View File
@@ -3,6 +3,38 @@
All notable changes to NH-Nodes. Versions follow the Comfy Registry releases.
"Behaviour change" marks fixes that can change the output of an existing workflow.
## 1.6.1 — Cleanup and hardening
- Internal restructuring with no change to node ids, inputs or outputs: the large image-tools module is split into one
module per node family, shared helpers live in `core/` (`image_folders`, `image_ops`, `mask_ops`, `model_files`), and
the frontend extensions share one widget helper module.
- Widgets are hidden with the frontend's own `hidden` property when available (older frontends keep the previous
method); values are still saved with the workflow.
- `Load Images Folder`: the image count used for auto increment now honours `recursive`; a wired `folder_path` no
longer fingerprints ComfyUI's input folder by mistake.
- HTTP routes: the folder-count and large-preview routes no longer return absolute paths, answer with proper status
codes (malformed JSON → 400) and do their file work off the server's event loop.
- `NH Save Large Image` accepts single-channel images; bfloat16 IMAGE tensors convert correctly; image conversions no
longer use Pillow's deprecated `fromarray(mode=…)`.
## 1.6.0 — Performance, caching and VRAM
### Faster graphs
- **Lazy switches:** `If/Else (NH)`, `Switch N (NH)` and `Random Choice (NH)` now ask ComfyUI only for the branch they pick, so the other branches (for example a second sampler) are never executed. The choice itself is unchanged. A connected input whose upstream returns nothing still counts as connected.
- `Layer Stack Composite (NH)` is cached like any pure node instead of re-running on every queue.
- `Load images matching`: results are cached until the matched files change; the search stays inside the matching root (no more scanning parent folders, drive roots or the models folder). When nothing matches, `found` is `False` and `images` returns the new optional `fallback_images` input (evaluated only on a miss); without a fallback, `images` is blocked with a message and the rest of the workflow keeps running (behaviour change: it used to raise an error). Use `fallback_images` rather than routing `images` through a switch — a blocked input stops the switch too. A drive or network-share root cannot be searched recursively any more.
- Indexed loaders (`Load Diffusion Model Index`, `Load VAE Index`, `Load Clip Index`) reuse the model ComfyUI still holds, so editing a slot you did not select no longer reloads it from disk; they keep only a weak reference, so ComfyUI's RAM-pressure cache can still free the model (`--cache-classic` keeps a normal reference). VAEs are loaded with their file metadata and validated like the core loader does.
- `Mask Morphology (NH)`: rewritten on torch (CPU or GPU). Soft masks keep their gradient, masks touching the border no longer shrink from the edge, and large kernels are up to 100× faster. Even kernel lengths act as the next odd length (behaviour change).
### VRAM and models
- `VTON Ultimate Processor (NH)`: new optional `keep_model_loaded` (default **off**). Off frees the ONNX Runtime GPU memory after each run that really used the GPU (about 1.5 s slower per run); on keeps today's speed and is much faster for image lists, which ComfyUI runs one item at a time. ComfyUI is asked to make room before the models are loaded on the GPU, except once ONNX Runtime has fallen back to the CPU.
- SegFormer nodes now really make ComfyUI unload models when VRAM is tight (a device-index mismatch prevented it).
- Model downloads are pinned to tested Hugging Face revisions and only fetch the needed files; a half-downloaded VTON folder is repaired instead of trusted.
- `extra_model_paths.yaml` support: the folder names `nh_nodes` (SegFormer models, `.safetensors`) and `vton_mask` (VTON models, `.onnx`) can point to extra locations; existing installs keep working without moving anything.
### Viewer
- `NH Large Image Preview` / `NH Large Image Compare`: browse every image of a batch with ◀ n / N ▶ or the ←/→ keys.
## 1.5.0 — Correctness fixes
### Image
+7 -11
View File
@@ -2,14 +2,7 @@ import torch
import numpy as np
import cv2 # Cần thư viện OpenCV
def get_bounding_box(mask_np):
rows = np.any(mask_np, axis=1)
cols = np.any(mask_np, axis=0)
if not np.any(rows) or not np.any(cols):
return None, None, None, None
ymin, ymax = np.where(rows)[0][[0, -1]]
xmin, xmax = np.where(cols)[0][[0, -1]]
return xmin, ymin, xmax, ymax
from ...core.mask_ops import mask_bbox
def _to_uint8(image):
# Clip trước khi ép uint8: giá trị lệch khỏi [0,1] (vd. do resample) sẽ bị wrap về gần 0 nếu không clip.
@@ -60,12 +53,15 @@ class NH_SimpleFacePaste:
dest_img_np = _to_uint8(dest_image)
dest_mask_np = dest_mask.cpu().numpy()
src_x1, src_y1, src_x2, src_y2 = get_bounding_box(src_mask_np > 0.5)
dest_x1, dest_y1, dest_x2, dest_y2 = get_bounding_box(dest_mask_np > 0.5)
src_box = mask_bbox(src_mask_np)
dest_box = mask_bbox(dest_mask_np)
if src_x1 is None or dest_x1 is None:
if src_box is None or dest_box is None:
return None
src_x1, src_y1, src_x2, src_y2 = src_box
dest_x1, dest_y1, dest_x2, dest_y2 = dest_box
src_face = src_img_np[src_y1:src_y2+1, src_x1:src_x2+1]
src_face_mask = (np.clip(src_mask_np[src_y1:src_y2+1, src_x1:src_x2+1], 0.0, 1.0) * 255).astype(np.uint8)
+681
View File
@@ -0,0 +1,681 @@
"""Load Images Folder (NH) and Load images matching: read the images of a folder, or of the folder that matches a name."""
import hashlib
import os
import re
import unicodedata
from pathlib import Path
import torch
from PIL import Image, ImageOps
import folder_paths
import node_helpers
from comfy_execution.graph_utils import ExecutionBlocker
from ...core.image_folders import IMAGE_EXTENSIONS, collect_image_files, natural_key, resolve_load_dir
from ...core.image_ops import pil_to_image_tensor, resize_pil
def _normalize_match_value(value):
value = unicodedata.normalize("NFD", str(value or ""))
value = "".join(char for char in value if unicodedata.category(char) != "Mn")
value = re.sub(r"[_\-]+", " ", value)
value = re.sub(r"\s+", " ", value)
return value.strip().casefold()
def _path_parts(value):
return [part.strip() for part in re.split(r"[\\/]+", str(value or "")) if part.strip()]
def _normalized_path_parts(value):
return [part for part in (_normalize_match_value(item) for item in _path_parts(value)) if part]
def _longest_common_path_run(candidate_parts, source_parts):
if not candidate_parts or not source_parts:
return 0
previous = [0] * (len(source_parts) + 1)
best = 0
for candidate_part in candidate_parts:
current = [0] * (len(source_parts) + 1)
for index, source_part in enumerate(source_parts, start=1):
if candidate_part == source_part:
current[index] = previous[index - 1] + 1
best = max(best, current[index])
previous = current
return best
def _ordered_path_overlap(candidate_parts, source_parts):
if not candidate_parts or not source_parts:
return 0
count = 0
cursor = 0
for candidate_part in candidate_parts:
while cursor < len(source_parts) and source_parts[cursor] != candidate_part:
cursor += 1
if cursor >= len(source_parts):
break
count += 1
cursor += 1
return count
def _candidate_context_score(candidate_path, source_context_paths):
if not source_context_paths:
return 0
candidate_parts = _normalized_path_parts(candidate_path)
best_score = 0
for source_path in source_context_paths:
source_parts = _normalized_path_parts(source_path)
run = _longest_common_path_run(candidate_parts, source_parts)
ordered = _ordered_path_overlap(candidate_parts, source_parts)
score = run * 100 + ordered
if candidate_parts and len(source_parts) >= 2 and candidate_parts[-1] == source_parts[-2]:
score += 10
elif candidate_parts and source_parts and candidate_parts[-1] == source_parts[-1]:
score += 5
best_score = max(best_score, score)
return best_score
def _source_segment_set(source_context_paths):
segments = set()
for path_value in source_context_paths or []:
segments.update(_normalized_path_parts(path_value))
return segments
def _add_unique_path(paths, path_value):
if not path_value:
return
key = os.path.normcase(os.path.normpath(path_value))
if key not in {os.path.normcase(os.path.normpath(path)) for path in paths}:
paths.append(path_value)
_SIGNATURE_ENTRY_LIMIT = 20000 # most folders + files Load Images Matching hashes for IS_CHANGED before it gives up
class _NoMatchError(ValueError):
"""The search itself came up empty: no matching folder, or no images in it. A bad setting is a plain ValueError."""
def __init__(self, message, match_text=""):
super().__init__(message)
self.match_text = match_text
def _hash_parts(hasher, *parts):
for part in parts:
hasher.update(str(part).encode("utf-8", errors="replace"))
hasher.update(b"\0")
def _same_path(first, second):
return os.path.normcase(os.path.abspath(first)) == os.path.normcase(os.path.abspath(second))
def _is_inside(path, folder):
"""True when ``path`` lies below ``folder`` (``folder`` itself does not count)."""
path = os.path.normcase(os.path.abspath(path))
folder = os.path.normcase(os.path.abspath(folder))
return path != folder and path.startswith(folder.rstrip(os.sep) + os.sep)
def _is_drive_root(path):
path = os.path.abspath(path)
return os.path.dirname(path) == path
def _entry_is_dir(entry, follow_symlinks=True):
try:
return entry.is_dir(follow_symlinks=follow_symlinks)
except OSError:
return False
def _absolute_root(root_path):
"""The matching root as an absolute path; a relative one is taken relative to ComfyUI's input folder."""
root_path = (root_path or "").strip()
if root_path and not os.path.isabs(root_path):
root_path = os.path.join(folder_paths.get_input_directory(), root_path)
return root_path
def _check_search_root(root_path, recursive):
"""Refuse the roots a search must not scan: a whole drive (when it would be walked) and the models folder."""
if recursive and _is_drive_root(root_path):
raise ValueError(
f"Matching root is a drive root ({root_path}); refusing to scan a whole drive. "
"Point matching_root_path at the folder that holds the images, or turn recursive off."
)
if _same_path(root_path, folder_paths.models_dir):
raise ValueError(f"Matching root is the ComfyUI models folder ({root_path}), which holds no images to match.")
def _list_folder(folder_path):
"""The os.DirEntry objects in ``folder_path`` in natural order, without the ComfyUI models folder; [] if unreadable."""
try:
with os.scandir(folder_path) as scan:
entries = sorted(scan, key=lambda entry: natural_key(entry.name))
except OSError:
return []
models_dir = os.path.normcase(os.path.abspath(folder_paths.models_dir))
return [
entry for entry in entries
if not (_entry_is_dir(entry) and os.path.normcase(os.path.normpath(entry.path)) == models_dir)
]
def _child_folders(folder_path):
"""Paths of the sub-folders directly inside ``folder_path``, in natural order."""
return [entry.path for entry in _list_folder(folder_path) if _entry_is_dir(entry)]
def _walk_folders(root_path, recursive=True):
"""Yield ``(folder, entries)`` for ``root_path`` and, when ``recursive``, for every folder below it.
The matching search's stand-in for os.walk, with limits: it refuses the roots ``_check_search_root`` names,
never enters the ComfyUI models folder and, like os.walk, does not follow symlinks into other trees.
"""
_check_search_root(root_path, recursive)
pending = [root_path]
while pending:
folder = pending.pop()
entries = _list_folder(folder)
yield folder, entries
if recursive:
pending.extend(entry.path for entry in reversed(entries) if _entry_is_dir(entry, follow_symlinks=False))
def _context_twins(root_path, context_names, wanted=None):
"""Folders that stand in for ``root_path`` under another context folder.
When the root sits inside a context folder (``.../Nam/...``), the same place under a sibling context folder
(``.../Nu/...``) is a twin, and so is the sibling context folder as a whole. ``wanted`` limits the siblings to
those names (normalized); all other configured context names count when it is None.
"""
context = {_normalize_match_value(name) for name in context_names} - {""}
if not context:
return []
parts = list(Path(os.path.normpath(root_path)).parts)
twins = []
for position, part in enumerate(parts):
own_name = _normalize_match_value(part)
if position == 0 or own_name not in context:
continue
rest = parts[position + 1:]
for sibling_path in _child_folders(str(Path(*parts[:position]))):
sibling_name = _normalize_match_value(os.path.basename(sibling_path))
if sibling_name == own_name or sibling_name not in context:
continue
if wanted is not None and sibling_name not in wanted:
continue
twins.append(sibling_path)
if rest and os.path.isdir(os.path.join(sibling_path, *rest)):
twins.append(os.path.join(sibling_path, *rest))
return twins
def _context_root_candidates(root_path, source_context_paths, context_names=()):
"""Folders to search for the match, best context first.
That is the matching root, the sub-folders of it that the source path names (``Nu`` when the source lives in a
``Nu`` folder) and the context twins of the root. Nothing else above the root is listed, let alone walked.
"""
root_path = _absolute_root(root_path)
if not root_path:
return []
candidates = []
_add_unique_path(candidates, root_path)
source_segments = _source_segment_set(source_context_paths)
if not source_segments:
return candidates
for child_path in _child_folders(root_path):
if _normalize_match_value(os.path.basename(child_path)) in source_segments:
_add_unique_path(candidates, child_path)
for twin_path in _context_twins(root_path, context_names, source_segments):
_add_unique_path(candidates, twin_path)
candidates.sort(key=lambda item: (-_candidate_context_score(item, source_context_paths), natural_key(item)))
return candidates
def _resolve_matching_folder(root_path, match_text, match_mode, recursive, source_context_paths=None):
root_path = _absolute_root(root_path)
if not root_path:
raise ValueError("Matching root path is empty")
if not os.path.isdir(root_path):
raise ValueError(f"Matching root folder does not exist: {root_path}")
normalized_match = _normalize_match_value(match_text)
if not normalized_match:
raise ValueError("Match text is empty")
candidates = [root_path]
for _folder, entries in _walk_folders(root_path, recursive):
candidates.extend(entry.path for entry in entries if _entry_is_dir(entry))
exact_matches = []
contains_matches = []
for candidate in candidates:
normalized_name = _normalize_match_value(os.path.basename(candidate))
if normalized_name == normalized_match:
exact_matches.append(candidate)
if normalized_match in normalized_name:
contains_matches.append(candidate)
matches = exact_matches if match_mode == "exact" else contains_matches or exact_matches
if not matches:
raise _NoMatchError(f"No matching folder found for '{match_text}' under: {root_path}", match_text)
if source_context_paths:
matches.sort(key=lambda item: (
-_candidate_context_score(item, source_context_paths),
natural_key(os.path.relpath(item, root_path)),
))
else:
matches.sort(key=lambda item: natural_key(os.path.relpath(item, root_path)))
return matches[0]
def _resolve_best_matching_folder(root_path, match_text, match_mode, recursive, source_context_paths=None, context_names=()):
root_path = _absolute_root(root_path)
if not root_path:
raise ValueError("Matching root path is empty")
_check_search_root(root_path, recursive)
search_roots = _context_root_candidates(root_path, source_context_paths, context_names)
if recursive:
# Walking a folder covers everything below it, so a search root inside another one is not walked twice.
search_roots = [path for path in search_roots if not any(_is_inside(path, other) for other in search_roots)]
matched_folders = []
errors = []
for search_root in search_roots:
try:
matched_folder = _resolve_matching_folder(
search_root,
match_text,
match_mode,
recursive,
source_context_paths=source_context_paths,
)
_add_unique_path(matched_folders, matched_folder)
except ValueError as exc:
errors.append(exc)
if not matched_folders:
# A setting that cannot work (missing root, empty text) is reported as such; otherwise nothing matched.
for exc in errors:
if not isinstance(exc, _NoMatchError):
raise exc
raise _NoMatchError(f"No matching folder found for '{match_text}' under: {root_path}", match_text)
matched_folders.sort(key=lambda item: (-_candidate_context_score(item, source_context_paths), natural_key(item)))
return matched_folders[0]
def _parse_context_folder_names(value):
names = [item.strip() for item in re.split(r"[\n,;|]+", str(value or "")) if item.strip()]
return names
def _detect_context_folder(paths, context_folder_names):
if not context_folder_names:
return ""
normalized_context = {_normalize_match_value(name): name for name in context_folder_names}
for path_value in paths:
for part in re.split(r"[\\/]+", str(path_value or "")):
match = normalized_context.get(_normalize_match_value(part))
if match:
return match
return ""
def _matching_selection(
source_folder_path,
matching_root_path,
match_text,
match_mode,
recursive,
index,
step,
context_folder_names,
source_file_names,
person_folder_path=None,
):
"""Work out what Load images matching reads: one ``(match_text, folder, [file names])`` per match item.
Raises _NoMatchError when an item finds no folder, or a folder without images, and ValueError for a setting
that cannot work (missing root, empty match text, a root that must not be scanned).
"""
if not source_folder_path and person_folder_path:
source_folder_path = person_folder_path
source_paths = [line.strip() for line in str(source_folder_path or "").splitlines() if line.strip()]
first_source_path = source_paths[0] if source_paths else ""
resolved_source_folder = resolve_load_dir(first_source_path)
selected_match_text = (match_text or "").strip()
source_names = [line.strip() for line in str(source_file_names or "").splitlines() if line.strip()]
context_names = _parse_context_folder_names(context_folder_names)
match_items = []
if selected_match_text:
item_count = max(len(source_paths), len(source_names), 1)
for item_index in range(item_count):
match_items.append((
selected_match_text,
source_paths[item_index] if item_index < len(source_paths) else "",
source_names[item_index] if item_index < len(source_names) else "",
))
elif source_names:
for item_index, source_name in enumerate(source_names):
source_dir = os.path.dirname(source_name)
source_match_text = os.path.basename(os.path.normpath(source_dir)) if source_dir else os.path.basename(os.path.normpath(resolved_source_folder))
match_items.append((
source_match_text,
source_paths[item_index] if item_index < len(source_paths) else "",
source_name,
))
else:
selected_match_text = os.path.basename(os.path.normpath(resolved_source_folder))
match_items.append((selected_match_text, first_source_path, ""))
plan = []
for current_match_text, current_source_path, current_source_name in match_items:
context_paths = [current_source_path, current_source_name, resolved_source_folder]
context_folder = _detect_context_folder(context_paths, context_names)
if context_folder:
context_paths.append(context_folder)
matched_folder = _resolve_best_matching_folder(
matching_root_path,
current_match_text,
match_mode,
recursive,
source_context_paths=context_paths,
context_names=context_names,
)
try:
image_files = collect_image_files(matched_folder)
except ValueError as exc: # no images in the folder that matched
raise _NoMatchError(str(exc), current_match_text) from exc
start_index = index % len(image_files)
selected_files = [image_files[(start_index + offset) % len(image_files)] for offset in range(step)]
plan.append((current_match_text, matched_folder, selected_files))
return plan
def _selection_signature(plan):
"""Digest of what a matching plan reads: the folders chosen, and the selected files with size and mtime."""
hasher = hashlib.sha256()
for current_match_text, matched_folder, selected_files in plan:
_hash_parts(hasher, current_match_text, os.path.abspath(matched_folder))
for file_name in selected_files:
stat = os.stat(os.path.join(matched_folder, file_name))
_hash_parts(hasher, file_name, stat.st_size, stat.st_mtime_ns)
return hasher.hexdigest()
def _tree_signature(roots, limit=None):
"""Digest of every folder and image below ``roots`` (names, sizes, mtimes); None beyond ``limit`` entries."""
limit = _SIGNATURE_ENTRY_LIMIT if limit is None else limit
hasher = hashlib.sha256()
seen = 0
for root in roots:
_hash_parts(hasher, "root", os.path.abspath(root))
for folder, entries in _walk_folders(root, recursive=True):
_hash_parts(hasher, "folder", os.path.relpath(folder, root))
for entry in entries:
seen += 1
if seen > limit:
return None
if _entry_is_dir(entry):
_hash_parts(hasher, "dir", entry.name)
elif Path(entry.name).suffix.lower() in IMAGE_EXTENSIONS:
try:
stat = entry.stat()
except OSError:
_hash_parts(hasher, "image", entry.name)
else:
_hash_parts(hasher, "image", entry.name, stat.st_size, stat.st_mtime_ns)
return hasher.hexdigest()
def _load_image_batch(paths):
"""The images at ``paths`` as one RGB IMAGE batch; every image after the first is scaled to the size of the first."""
images = []
target_size = None
for path in paths:
img = node_helpers.pillow(Image.open, path)
img = node_helpers.pillow(ImageOps.exif_transpose, img).convert("RGB")
if target_size is None:
target_size = img.size
elif img.size != target_size:
img = resize_pil(img, target_size, "lanczos")
images.append(pil_to_image_tensor(img))
return torch.cat(images, dim=0)
class NH_LoadImagesFromFolder:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {"default": "", "multiline": False}),
"index": ("INT", {"default": 0, "min": 0, "max": 999999}),
"step": ("INT", {"default": 1, "min": 1, "max": 9999}),
"seed_mode": (["fixed", "increment", "decrement", "random"],),
"recursive": ("BOOLEAN", {"default": False}),
"filename_extension": ("BOOLEAN", {"default": True}),
"path_output": (["file_path", "folder_path"], {"default": "file_path"}),
},
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
RETURN_NAMES = ("images", "filename", "path")
FUNCTION = "load_images"
CATEGORY = "NH-Nodes/Image"
@classmethod
def IS_CHANGED(cls, folder_path, index, step, seed_mode, recursive=False, filename_extension=True, path_output="file_path", **kwargs):
# An input wired from another node arrives here as None, so the folder is unknown (resolving None would
# fingerprint ComfyUI's input folder instead). The upstream value is already part of ComfyUI's cache key, so a
# constant keeps an unchanged graph cached while a new path still runs the node again.
if folder_path is None:
return "wired-folder-path"
try:
resolved_folder = resolve_load_dir(folder_path)
image_files = collect_image_files(resolved_folder, recursive=recursive)
step = max(1, int(step))
start_index = int(index) % len(image_files)
if seed_mode == "decrement":
indices = [((start_index - offset) % len(image_files)) for offset in range(step)]
else:
indices = [((start_index + offset) % len(image_files)) for offset in range(step)]
hasher = hashlib.sha256()
_hash_parts(
hasher,
os.path.abspath(resolved_folder),
start_index,
step,
seed_mode,
bool(recursive),
bool(filename_extension),
path_output,
)
for selected_index in indices:
rel_path = image_files[selected_index]
stat = os.stat(os.path.join(resolved_folder, rel_path))
_hash_parts(hasher, rel_path, stat.st_size, stat.st_mtime_ns)
return hasher.hexdigest()
except Exception as exc:
return f"error:{type(exc).__name__}:{exc}"
def load_images(self, folder_path, index, step, seed_mode, recursive=False, filename_extension=True, path_output="file_path", **kwargs):
resolved_folder = resolve_load_dir(folder_path)
image_files = collect_image_files(resolved_folder, recursive=recursive)
start_index = index % len(image_files)
if seed_mode == "decrement":
indices = [((start_index - offset) % len(image_files)) for offset in range(step)]
else:
indices = [((start_index + offset) % len(image_files)) for offset in range(step)]
selected_files = [image_files[i] for i in indices]
full_paths = [os.path.join(resolved_folder, file_name) for file_name in selected_files]
if path_output == "folder_path":
output_paths = [os.path.dirname(full_path) for full_path in full_paths]
else:
output_paths = full_paths
display_filenames = []
for file_name in selected_files:
base_name = os.path.basename(file_name)
if not filename_extension:
base_name = os.path.splitext(base_name)[0]
display_filenames.append(base_name)
return (_load_image_batch(full_paths), "\n".join(display_filenames), "\n".join(output_paths))
class NH_LoadImagesMatching:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"source_folder_path": ("STRING", {"default": "", "multiline": False}),
"matching_root_path": ("STRING", {"default": "", "multiline": False}),
"match_text": ("STRING", {"default": "", "multiline": False}),
"match_mode": (["contains", "exact"],),
"recursive": ("BOOLEAN", {"default": True}),
"index": ("INT", {"default": 0, "min": 0, "max": 999999}),
"step": ("INT", {"default": 1, "min": 1, "max": 9999}),
"repeat_count": ("INT", {"default": 1, "min": 1, "max": 9999}),
"context_folder_names": ("STRING", {"default": "Nam\nNu", "multiline": True}),
},
"optional": {
"source_file_names": ("STRING", {"default": "", "multiline": True}),
"fallback_images": ("IMAGE", {
"lazy": True,
"tooltip": (
"Comes out as images when nothing matches, and is only evaluated then. Connect it here "
"instead of routing images through a switch or If/Else: a node that receives the held-back "
"images never runs."
),
}),
},
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "STRING", "BOOLEAN")
RETURN_NAMES = ("images", "filename", "path", "matched_text", "found")
FUNCTION = "load_matching"
CATEGORY = "NH-Nodes/Image"
DESCRIPTION = (
"Load the images of the folder under matching_root_path whose name matches the source folder (or match_text). "
"When nothing matches, found is False and images are held back: every node that receives them is skipped, "
"a switch or If/Else included. To carry on with another image, connect it to fallback_images instead of "
"routing images through a switch or If/Else: it is only evaluated when nothing matches, and then comes out "
"as images (found stays False)."
)
@classmethod
def IS_CHANGED(cls, source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step, repeat_count, context_folder_names="Nam\nNu", source_file_names="", **kwargs):
# ComfyUI's cache key already holds every setting and every upstream result; this adds what is on disk:
# the folder(s) the search picks and the selected files with their size and time, so an edit re-runs the node.
# An input wired from another node arrives here as None, which leaves the folder unknown: the whole matching
# tree is fingerprinted instead. A search that finds nothing, or a tree too big to hash cheaply, gives NaN:
# that result is recomputed every time (so a "not found" is never remembered).
unknown = float("nan")
try:
settings = (source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step, context_folder_names, source_file_names)
if None not in settings:
plan = _matching_selection(
source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step,
context_folder_names, source_file_names, kwargs.get("person_folder_path"),
)
return _selection_signature(plan)
root_path = _absolute_root(matching_root_path)
if not root_path or context_folder_names is None:
return unknown
roots = [root_path] + _context_twins(root_path, _parse_context_folder_names(context_folder_names))
signature = _tree_signature(roots)
return unknown if signature is None else signature
except (ValueError, OSError):
return unknown
def check_lazy_status(self, source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step, repeat_count, context_folder_names="Nam\nNu", source_file_names="", **kwargs):
# fallback_images is asked for only when it is wired (ComfyUI sends its key, with None until the input has
# run) and the search finds nothing; on a match its upstream nodes never run. The search is the one
# load_matching makes. A setting that cannot work asks for nothing: load_matching raises it right after.
if "fallback_images" not in kwargs or kwargs["fallback_images"] is not None:
return []
try:
_matching_selection(
source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step,
context_folder_names, source_file_names, kwargs.get("person_folder_path"),
)
except _NoMatchError:
return ["fallback_images"]
except (ValueError, OSError):
pass
return []
def load_matching(self, source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step, repeat_count, context_folder_names="Nam\nNu", source_file_names="", fallback_images=None, **kwargs):
try:
plan = _matching_selection(
source_folder_path, matching_root_path, match_text, match_mode, recursive, index, step,
context_folder_names, source_file_names, kwargs.get("person_folder_path"),
)
except _NoMatchError as exc:
# Nothing to load for this request. `found` lets a workflow carry on with something else, and so does
# fallback_images, which comes out as the images. Without it the images are held back (whatever needs
# them is told why). A setting that cannot work still raises.
message = str(exc)
if fallback_images is not None:
print(f"[NH-Nodes] Load images matching: {message} (using fallback_images)")
return (fallback_images, "", "", exc.match_text, False)
print(f"[NH-Nodes] Load images matching: {message}")
return (ExecutionBlocker(message), "", "", exc.match_text, False)
selected_files = []
matched_folders = []
matched_texts = []
for current_match_text, matched_folder, file_names in plan:
for file_name in file_names:
selected_files.append(file_name)
matched_folders.append(matched_folder)
matched_texts.append(current_match_text)
images = _load_image_batch(
[os.path.join(folder, file_name) for folder, file_name in zip(matched_folders, selected_files)]
)
if repeat_count > 1:
images = images.repeat(repeat_count, 1, 1, 1)
return (images, "\n".join(selected_files), "\n".join(matched_folders), "\n".join(matched_texts), True)
NODE_CLASS_MAPPINGS = {
"NH_LoadImagesFromFolder": NH_LoadImagesFromFolder,
"NH_LoadImagesMatching": NH_LoadImagesMatching,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_LoadImagesFromFolder": "Load Images Folder (NH)",
"NH_LoadImagesMatching": "Load images matching",
}
+95
View File
@@ -0,0 +1,95 @@
"""Image Compare (NH): two images side by side, split, blended or as a difference."""
import torch
from PIL import Image, ImageChops
from ...core.utils import tensor_to_pil
from ...core.image_ops import pil_to_image_tensor, resize_pil
def _match_batch_sizes(batch_a, batch_b):
count_a = batch_a.shape[0]
count_b = batch_b.shape[0]
if count_a == count_b:
return batch_a, batch_b
if count_a == 1:
return batch_a.repeat(count_b, 1, 1, 1), batch_b
if count_b == 1:
return batch_a, batch_b.repeat(count_a, 1, 1, 1)
shared = min(count_a, count_b)
print(f"[NH-Nodes] Image Compare: batch mismatch ({count_a} vs {count_b}), truncating to {shared}")
return batch_a[:shared], batch_b[:shared]
class NH_ImageCompare:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image_a": ("IMAGE",),
"image_b": ("IMAGE",),
"compare_mode": (
[
"side_by_side",
"top_bottom",
"split_vertical",
"split_horizontal",
"difference",
"overlay",
],
),
"split_position": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"overlay_opacity": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"resize_method": (["lanczos", "bicubic", "bilinear", "nearest"],),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "compare"
CATEGORY = "NH-Nodes/Image"
def compare(self, image_a, image_b, compare_mode, split_position, overlay_opacity, resize_method):
batch_a, batch_b = _match_batch_sizes(image_a, image_b)
compared = []
for tensor_a, tensor_b in zip(batch_a, batch_b):
pil_a = tensor_to_pil(tensor_a, rgb=True)
pil_b = resize_pil(tensor_to_pil(tensor_b, rgb=True), pil_a.size, resize_method)
width, height = pil_a.size
if compare_mode == "side_by_side":
canvas = Image.new("RGB", (width * 2, height))
canvas.paste(pil_a, (0, 0))
canvas.paste(pil_b, (width, 0))
elif compare_mode == "top_bottom":
canvas = Image.new("RGB", (width, height * 2))
canvas.paste(pil_a, (0, 0))
canvas.paste(pil_b, (0, height))
elif compare_mode == "split_vertical":
split_x = max(0, min(width, int(round(width * split_position))))
canvas = pil_b.copy()
canvas.paste(pil_a.crop((0, 0, split_x, height)), (0, 0))
elif compare_mode == "split_horizontal":
split_y = max(0, min(height, int(round(height * split_position))))
canvas = pil_b.copy()
canvas.paste(pil_a.crop((0, 0, width, split_y)), (0, 0))
elif compare_mode == "difference":
canvas = ImageChops.difference(pil_a, pil_b)
else:
canvas = Image.blend(pil_a, pil_b, overlay_opacity)
compared.append(pil_to_image_tensor(canvas))
return (torch.cat(compared, dim=0),)
NODE_CLASS_MAPPINGS = {
"NH_ImageCompare": NH_ImageCompare,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_ImageCompare": "Image Compare (NH)",
}
+8 -19
View File
@@ -1,7 +1,8 @@
import numpy as np
import torch
from PIL import Image, ImageColor
from ...core.utils import pil_to_tensor, tensor_to_pil
try:
import psutil
except ImportError: # ComfyUI installs psutil; without it the memory guard below is simply skipped
@@ -14,7 +15,7 @@ _RESAMPLING = getattr(Image, "Resampling", Image)
# (Image.MAX_IMAGE_PIXELS): that limit is process-wide, other packs raise or disable it, and it only applies to
# Image.open, never to an IMAGE that stays in memory.
#
# Peak RAM per canvas pixel, reached inside _pil_to_tensor (measured 28.1-28.2 B/px on Windows, Pillow 11, NumPy 2):
# Peak RAM per canvas pixel, reached inside pil_to_tensor (measured 28.1-28.2 B/px on Windows, Pillow 11, NumPy 2):
# 4 B the PIL canvas (Pillow stores an RGB image as 4 bytes per pixel)
# 12 B the float32 array from astype(np.float32)
# 12 B the float32 result of "/ 255.0" (NumPy only reuses that temporary on Linux)
@@ -71,26 +72,14 @@ def _check_canvas_size(canvas_width, canvas_height, columns, rows, cell_width, c
def _parse_hex_color(value, default):
# Pillow's own parser, not core.utils.parse_hex_color: it also reads CSS names and rgb(...), and it needs the '#'.
# Kept so that every background colour the node accepted before still gives the same fill.
try:
return ImageColor.getrgb((value or "").strip())
except Exception:
return default
def _tensor_to_pil(image_tensor):
array = np.clip(255.0 * image_tensor.cpu().numpy(), 0, 255).astype(np.uint8)
if array.ndim == 2:
return Image.fromarray(array).convert("RGB")
if array.ndim == 3 and array.shape[-1] == 1:
return Image.fromarray(array[:, :, 0]).convert("RGB")
return Image.fromarray(array).convert("RGB")
def _pil_to_tensor(image):
array = np.array(image).astype(np.float32) / 255.0
return torch.from_numpy(array).unsqueeze(0)
def _first_value(value):
if isinstance(value, (list, tuple)):
if not value:
@@ -110,10 +99,10 @@ def _flatten_images(images):
raise TypeError(f"Expected IMAGE tensor input, got {type(image_item).__name__}")
if image_item.ndim == 3:
pil_images.append(_tensor_to_pil(image_item))
pil_images.append(tensor_to_pil(image_item, rgb=True))
elif image_item.ndim == 4:
for batch_index in range(int(image_item.shape[0])):
pil_images.append(_tensor_to_pil(image_item[batch_index]))
pil_images.append(tensor_to_pil(image_item[batch_index], rgb=True))
else:
raise ValueError(f"Unsupported IMAGE tensor shape: {tuple(image_item.shape)}")
@@ -244,7 +233,7 @@ class NH_ImageGridComposite:
f"{image_index}: x={x}, y={y}, w={cell_width}, h={cell_height}, source={source.width}x{source.height}"
)
return (_pil_to_tensor(canvas), slot_count, used_count, "\n".join(info_lines))
return (pil_to_tensor(canvas), slot_count, used_count, "\n".join(info_lines))
NODE_CLASS_MAPPINGS = {
+214
View File
@@ -0,0 +1,214 @@
"""Image Label (NH): a text band above, below or over an image."""
import functools
import os
from pathlib import Path
import torch
from PIL import Image, ImageDraw, ImageFont
from ...core.utils import tensor_to_pil
from ...core.image_ops import RESAMPLING, parse_color, pil_to_image_tensor
_FONT_EXTENSIONS = {".ttf", ".otf", ".ttc"}
def _font_choices():
fonts = ["default"]
seen = set(fonts)
font_dir = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
if font_dir.exists():
for path in sorted(font_dir.iterdir(), key=lambda item: item.name.lower()):
if path.is_file() and path.suffix.lower() in _FONT_EXTENSIONS and path.name not in seen:
fonts.append(path.name)
seen.add(path.name)
return fonts
_FONT_CHOICES = _font_choices()
@functools.lru_cache(maxsize=64)
def _cached_truetype(path, size):
"""Open a TrueType font once per (path, size), as the Label node asks for many sizes. None if it cannot be opened."""
try:
return ImageFont.truetype(path, size)
except Exception:
return None
@functools.lru_cache(maxsize=1)
def _default_font():
return ImageFont.load_default()
def _load_font(font_name, font_size):
if font_name and font_name != "default":
font_path = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts" / font_name
if font_path.exists():
font = _cached_truetype(str(font_path), font_size)
if font is not None:
return font
font = _cached_truetype("arial.ttf", font_size)
return font if font is not None else _default_font()
_MIN_FIT_FONT_SIZE = 8
def _fit_font_size(font_size, fits):
"""Largest size in [8, font_size] for which ``fits(size)`` is true; 8 when none does.
A label gets wider as the font grows, so a binary search lands on the same size as shrinking one point at a
time, after about log2(font_size) measurements instead of up to font_size. Hinting can make a text a pixel or
two narrower at a few isolated sizes; then the size returned still fits but may sit a point or two below what
the one-point-at-a-time loop would have found.
"""
if font_size <= _MIN_FIT_FONT_SIZE or fits(font_size):
return font_size
low, high = _MIN_FIT_FONT_SIZE, font_size # `low` fits (or is the floor), `high` does not
while high - low > 1:
middle = (low + high) // 2
if fits(middle):
low = middle
else:
high = middle
return low
def _label_text_width(draw, text, font_name, font_size, stroke_width):
left, _top, right, _bottom = draw.multiline_textbbox(
(0, 0), text, font=_load_font(font_name, font_size), stroke_width=stroke_width
)
return right - left
class NH_ImageLabel:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"label_text": ("STRING", {"default": "NH Label", "multiline": True}),
"position": (["header", "bottom"],),
"frame_height": ("INT", {"default": 80, "min": 1, "max": 4096, "step": 1}),
"fill_enabled": ("BOOLEAN", {"default": True}),
"frame_color_hex": ("STRING", {"default": "#111111", "multiline": False}),
"text_color_hex": ("STRING", {"default": "#FFFFFF", "multiline": False}),
"font_name": (_FONT_CHOICES,),
"font_size": ("INT", {"default": 32, "min": 6, "max": 512, "step": 1}),
"fit_text": ("BOOLEAN", {"default": True}),
"overlay_on_image": ("BOOLEAN", {"default": False}),
"align": (["left", "center", "right"],),
"padding_x": ("INT", {"default": 20, "min": 0, "max": 2048, "step": 1}),
"outline_width": ("INT", {"default": 0, "min": 0, "max": 32, "step": 1}),
"outline_color_hex": ("STRING", {"default": "#000000", "multiline": False}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "add_label"
CATEGORY = "NH-Nodes/Image"
def _band_image(self, image, position, frame_height, fill_enabled, frame_color):
width, height = image.size
if fill_enabled:
return Image.new("RGB", (width, frame_height), frame_color)
sample_box = (0, 0, width, 1) if position == "header" else (0, max(0, height - 1), width, height)
return image.crop(sample_box).resize((width, frame_height), RESAMPLING.BILINEAR).convert("RGB")
def add_label(
self,
image,
label_text,
position,
frame_height,
fill_enabled,
frame_color_hex,
text_color_hex,
font_name,
font_size,
fit_text,
overlay_on_image,
align,
padding_x,
outline_width,
outline_color_hex,
):
frame_color = parse_color(frame_color_hex, (17, 17, 17))
text_color = parse_color(text_color_hex, (255, 255, 255))
outline_color = parse_color(outline_color_hex, (0, 0, 0))
labeled = []
for item in image:
pil_image = tensor_to_pil(item, rgb=True)
width, height = pil_image.size
band = self._band_image(pil_image, position, frame_height, fill_enabled, frame_color)
if overlay_on_image:
canvas = pil_image.copy()
max_band_height = min(frame_height, height)
if band.height != max_band_height:
band = band.resize((width, max_band_height), RESAMPLING.BILINEAR)
if position == "header":
band_top = 0
else:
band_top = height - max_band_height
canvas.paste(band, (0, band_top))
else:
canvas = Image.new("RGB", (width, height + frame_height))
if position == "header":
canvas.paste(band, (0, 0))
canvas.paste(pil_image, (0, frame_height))
band_top = 0
else:
canvas.paste(pil_image, (0, 0))
canvas.paste(band, (0, height))
band_top = height
draw = ImageDraw.Draw(canvas)
current_size = font_size
if fit_text:
max_width = max(1, width - (padding_x * 2))
current_size = _fit_font_size(
font_size,
lambda size: _label_text_width(draw, label_text, font_name, size, outline_width) <= max_width,
)
font = _load_font(font_name, current_size)
bbox = draw.multiline_textbbox((0, 0), label_text, font=font, stroke_width=outline_width)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
if align == "left":
text_x = padding_x
elif align == "right":
text_x = max(padding_x, width - padding_x - text_width)
else:
text_x = max(0, (width - text_width) // 2)
text_y = band_top + max(0, (frame_height - text_height) // 2) - bbox[1]
draw.multiline_text(
(text_x, text_y),
label_text,
font=font,
fill=text_color,
align=align,
stroke_width=outline_width,
stroke_fill=outline_color,
)
labeled.append(pil_to_image_tensor(canvas))
return (torch.cat(labeled, dim=0),)
NODE_CLASS_MAPPINGS = {
"NH_ImageLabel": NH_ImageLabel,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_ImageLabel": "Image Label (NH)",
}
+123
View File
@@ -0,0 +1,123 @@
"""Image Resize Unit (NH): resize to a size given in pixels, millimetres or centimetres at a DPI."""
import torch
import comfy.utils
from ...core.image_ops import parse_color
def _image_resize_methods():
return ["nearest", "bilinear", "bicubic", "area", "nearest-exact", "lanczos"]
def _resize_value_to_pixels(value, unit, dpi):
if unit == "pixel":
return max(0, int(round(value)))
divisor = 25.4 if unit == "mm" else 2.54
return max(0, int(round((value / divisor) * dpi)))
def _resolve_target_size(orig_width, orig_height, width_value, height_value, unit, dpi):
width_px = _resize_value_to_pixels(width_value, unit, dpi)
height_px = _resize_value_to_pixels(height_value, unit, dpi)
if width_px == 0 and height_px == 0:
return orig_width, orig_height
if width_px == 0:
width_px = max(1, round(orig_width * height_px / orig_height))
elif height_px == 0:
height_px = max(1, round(orig_height * width_px / orig_width))
return width_px, height_px
def _common_upscale_method(resize_method):
return "nearest-exact" if resize_method == "nearest" else resize_method
def _pad_samples(samples, target_width, target_height, fill_rgb):
"""Centre ``samples`` ([B, C, H, W]) on a canvas of the padding colour.
``fill_rgb`` is (r, g, b) or (r, g, b, a) in 0-255. The fourth channel of RGBA images takes the alpha value, which
is opaque when the colour has none.
"""
batch, channels, resized_h, resized_w = samples.shape
canvas = torch.empty((batch, channels, target_height, target_width), dtype=samples.dtype, device=samples.device)
fill_values = [channel / 255.0 for channel in fill_rgb[:3]] + [fill_rgb[3] / 255.0 if len(fill_rgb) > 3 else 1.0]
for channel in range(channels):
canvas[:, channel, :, :] = fill_values[min(channel, 3)]
offset_x = max(0, (target_width - resized_w) // 2)
offset_y = max(0, (target_height - resized_h) // 2)
canvas[:, :, offset_y:offset_y + resized_h, offset_x:offset_x + resized_w] = samples
return canvas
class NH_ImageResizeByUnit:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"unit": (["pixel", "mm", "cm"],),
"width_value": ("FLOAT", {"default": 1024.0, "min": 0.0, "max": 100000.0, "step": 0.1}),
"height_value": ("FLOAT", {"default": 1024.0, "min": 0.0, "max": 100000.0, "step": 0.1}),
"dpi": ("INT", {"default": 300, "min": 1, "max": 2400, "step": 1}),
"resize_mode": (["stretch", "pad", "crop", "lock_ratio"],),
"resize_method": (_image_resize_methods(),),
"pad_color_hex": ("STRING", {"default": "#000000", "multiline": False}),
}
}
RETURN_TYPES = ("IMAGE", "INT", "INT")
RETURN_NAMES = ("image", "width_px", "height_px")
FUNCTION = "resize"
CATEGORY = "NH-Nodes/Image"
def resize(self, image, unit, width_value, height_value, dpi, resize_mode, resize_method, pad_color_hex):
samples = image.movedim(-1, 1)
orig_height = samples.shape[-2]
orig_width = samples.shape[-1]
target_width, target_height = _resolve_target_size(
orig_width, orig_height, width_value, height_value, unit, dpi
)
upscale_method = _common_upscale_method(resize_method)
if resize_mode == "lock_ratio":
if width_value <= 0 and height_value <= 0:
result = samples
actual_width, actual_height = orig_width, orig_height
elif width_value <= 0 or height_value <= 0:
actual_width, actual_height = target_width, target_height
result = comfy.utils.common_upscale(samples, actual_width, actual_height, upscale_method, "disabled")
else:
scale = min(target_width / orig_width, target_height / orig_height)
actual_width = max(1, round(orig_width * scale))
actual_height = max(1, round(orig_height * scale))
result = comfy.utils.common_upscale(samples, actual_width, actual_height, upscale_method, "disabled")
elif resize_mode == "crop":
actual_width, actual_height = target_width, target_height
result = comfy.utils.common_upscale(samples, actual_width, actual_height, upscale_method, "center")
elif resize_mode == "pad":
actual_width, actual_height = target_width, target_height
scale = min(target_width / orig_width, target_height / orig_height)
scaled_width = max(1, round(orig_width * scale))
scaled_height = max(1, round(orig_height * scale))
resized = comfy.utils.common_upscale(samples, scaled_width, scaled_height, upscale_method, "disabled")
pad_color = parse_color(pad_color_hex, (0, 0, 0))
result = _pad_samples(resized, target_width, target_height, pad_color)
else:
actual_width, actual_height = target_width, target_height
result = comfy.utils.common_upscale(samples, actual_width, actual_height, upscale_method, "disabled")
return (result.movedim(1, -1), actual_width, actual_height)
NODE_CLASS_MAPPINGS = {
"NH_ImageResizeByUnit": NH_ImageResizeByUnit,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_ImageResizeByUnit": "Image Resize Unit (NH)",
}
File diff suppressed because it is too large Load Diff
+3 -11
View File
@@ -4,11 +4,11 @@ import json
import os
import folder_paths
import numpy as np
from comfy.cli_args import args
from PIL import Image
from PIL.PngImagePlugin import PngInfo
from ...core.utils import tensor_to_pil
# Cạnh dài nhất (px) mà từng định dạng ghi được; PNG/TIFF không bị giới hạn trong thực tế.
# JPEG: spec cho tới 65535 nhưng libjpeg (Pillow) từ chối > 65500 (JPEG_MAX_DIMENSION).
_MAX_SIDE_BY_EXTENSION = {"webp": 16383, "jpg": 65500}
@@ -34,14 +34,6 @@ def _extension_for_save_format(file_format):
return "png"
def _tensor_to_pil(image_tensor):
array = image_tensor.detach().cpu().numpy()
array = np.clip(array * 255.0, 0, 255).astype(np.uint8)
if array.ndim == 3 and array.shape[-1] == 4:
return Image.fromarray(array).convert("RGBA")
return Image.fromarray(array).convert("RGB")
def _metadata_pnginfo(prompt=None, extra_pnginfo=None):
metadata = PngInfo()
if prompt is not None:
@@ -116,7 +108,7 @@ class NH_SaveLargeImage:
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file_name = f"{filename_with_batch_num}_{counter:05}_.{extension}"
file_path = os.path.join(full_output_folder, file_name)
pil_image = _tensor_to_pil(image_tensor)
pil_image = tensor_to_pil(image_tensor, rgb=True, keep_alpha=True)
_save_image(pil_image, file_path, extension, quality, embed_metadata, prompt, extra_pnginfo)
saved_paths.append(file_path)
counter += 1
+426
View File
@@ -0,0 +1,426 @@
"""Layer Layout Composite (NH) and Layer Stack Composite (NH): paste images onto a canvas."""
import re
from PIL import Image
from ...core.image_ops import parse_color, pil_to_image_tensor, resize_pil
from ...core.safe_eval import evaluate
from ...core.utils import tensor_to_pil
_MAX_LAYER_STACK_INPUTS = 64
def _resize_cover(image, target_size):
target_width, target_height = target_size
width, height = image.size
scale = max(target_width / max(width, 1), target_height / max(height, 1))
resized = resize_pil(image, (max(1, round(width * scale)), max(1, round(height * scale))), "lanczos")
left = max(0, (resized.width - target_width) // 2)
top = max(0, (resized.height - target_height) // 2)
return resized.crop((left, top, left + target_width, top + target_height))
def _resize_contain(image, target_size):
target_width, target_height = target_size
width, height = image.size
scale = min(target_width / max(width, 1), target_height / max(height, 1))
return resize_pil(image, (max(1, round(width * scale)), max(1, round(height * scale))), "lanczos")
def _paste_layer(canvas, layer, position):
"""Paste ``layer`` onto ``canvas`` through its own alpha, so transparent areas leave the canvas untouched."""
if layer.mode != "RGBA":
canvas.paste(layer, position) # opaque, whatever the canvas mode
elif canvas.mode == "RGBA":
# Both have alpha: a straight paste would blend the alpha channels as if they were colours.
box = (position[0], position[1], position[0] + layer.width, position[1] + layer.height)
canvas.paste(Image.alpha_composite(canvas.crop(box), layer), position)
else:
canvas.paste(layer, position, layer)
def _fit_layer_image(image, target_size, resize_mode):
target_width, target_height = target_size
if target_width <= 0 or target_height <= 0:
raise ValueError("Layer slot width and height must be greater than 0")
if resize_mode == "stretch":
return resize_pil(image, (target_width, target_height), "lanczos"), 0, 0
if resize_mode == "contain":
fitted = _resize_contain(image, (target_width, target_height))
return fitted, (target_width - fitted.width) // 2, (target_height - fitted.height) // 2
fitted = _resize_cover(image, (target_width, target_height))
return fitted, 0, 0
def _grid_slots(canvas_width, canvas_height, columns, rows, margin_x, margin_y, gap_x, gap_y):
columns = max(1, int(columns))
rows = max(1, int(rows))
content_width = max(1, canvas_width - (2 * margin_x) - (gap_x * (columns - 1)))
content_height = max(1, canvas_height - (2 * margin_y) - (gap_y * (rows - 1)))
cell_width = max(1, content_width // columns)
cell_height = max(1, content_height // rows)
slots = []
right_edge = canvas_width - margin_x
bottom_edge = canvas_height - margin_y
for row in range(rows):
for column in range(columns):
x = margin_x + column * (cell_width + gap_x)
y = margin_y + row * (cell_height + gap_y)
width = max(1, (right_edge - x) if column == columns - 1 else cell_width)
height = max(1, (bottom_edge - y) if row == rows - 1 else cell_height)
slots.append((x, y, width, height))
return slots
def _relative_slot(canvas_width, canvas_height, x, y, width, height):
return (
int(round(canvas_width * x)),
int(round(canvas_height * y)),
int(round(canvas_width * width)),
int(round(canvas_height * height)),
)
def _preset_layout_slots(preset, canvas_width, canvas_height, margin_x, margin_y, gap_x, gap_y):
content_x = margin_x
content_y = margin_y
content_width = max(1, canvas_width - (2 * margin_x))
content_height = max(1, canvas_height - (2 * margin_y))
if preset == "1_full":
return [(content_x, content_y, content_width, content_height)]
if preset == "2_vertical":
return _grid_slots(canvas_width, canvas_height, 1, 2, margin_x, margin_y, gap_x, gap_y)
if preset == "2_horizontal":
return _grid_slots(canvas_width, canvas_height, 2, 1, margin_x, margin_y, gap_x, gap_y)
if preset == "3_vertical":
return _grid_slots(canvas_width, canvas_height, 1, 3, margin_x, margin_y, gap_x, gap_y)
if preset == "4_grid":
return _grid_slots(canvas_width, canvas_height, 2, 2, margin_x, margin_y, gap_x, gap_y)
if preset == "2_top_1_bottom":
top_height = max(1, round((content_height - gap_y) * 0.42))
bottom_y = content_y + top_height + gap_y
bottom_height = max(1, content_y + content_height - bottom_y)
half_width = max(1, (content_width - gap_x) // 2)
return [
(content_x, content_y, half_width, top_height),
(content_x + half_width + gap_x, content_y, max(1, content_width - half_width - gap_x), top_height),
(content_x, bottom_y, content_width, bottom_height),
]
if preset == "2x3_mixed":
row_heights = [0.20, 0.20, 0.44]
usable_height = max(1, content_height - (2 * gap_y))
heights = [max(1, round(usable_height * ratio / sum(row_heights))) for ratio in row_heights]
heights[-1] = max(1, content_height - (2 * gap_y) - heights[0] - heights[1])
half_width = max(1, (content_width - gap_x) // 2)
slots = []
y = content_y
for height in heights:
slots.append((content_x, y, half_width, height))
slots.append((content_x + half_width + gap_x, y, max(1, content_width - half_width - gap_x), height))
y += height + gap_y
return slots
return _grid_slots(canvas_width, canvas_height, 2, 3, margin_x, margin_y, gap_x, gap_y)
def _parse_custom_layout(custom_layout, canvas_width, canvas_height, custom_units):
slots = []
for raw_line in str(custom_layout or "").splitlines():
line = raw_line.split("#", 1)[0].strip()
if not line:
continue
values = [float(value) for value in re.split(r"[\s,;]+", line) if value.strip()]
if len(values) < 4:
raise ValueError(f"Invalid custom layout line: {raw_line}")
if len(values) >= 5:
values = values[-4:]
x, y, width, height = values[:4]
units = custom_units
if units == "auto":
units = "relative" if max(abs(x), abs(y), abs(width), abs(height)) <= 1.0 else "pixels"
if units == "relative":
slot = _relative_slot(canvas_width, canvas_height, x, y, width, height)
elif units == "percent":
slot = _relative_slot(canvas_width, canvas_height, x / 100.0, y / 100.0, width / 100.0, height / 100.0)
else:
slot = (int(round(x)), int(round(y)), int(round(width)), int(round(height)))
if slot[2] <= 0 or slot[3] <= 0:
raise ValueError(f"Custom layout slot size must be greater than 0: {raw_line}")
slots.append(slot)
return slots
def _layout_slots(layout_mode, layout_preset, canvas_width, canvas_height, columns, rows, margin_x, margin_y, gap_x, gap_y, custom_layout, custom_units):
if layout_mode == "custom":
slots = _parse_custom_layout(custom_layout, canvas_width, canvas_height, custom_units)
if not slots:
raise ValueError("Custom layout is empty")
return slots
if layout_mode == "grid":
return _grid_slots(canvas_width, canvas_height, columns, rows, margin_x, margin_y, gap_x, gap_y)
return _preset_layout_slots(layout_preset, canvas_width, canvas_height, margin_x, margin_y, gap_x, gap_y)
def _eval_position_expr(expression, variables):
# Variable keys are already casefolded. core.safe_eval works in floating point, so 9**9**9 overflows into an
# error instead of building a huge integer and freezing the worker.
try:
return evaluate(expression, variables, zero_division="error", case_insensitive=True)
except ValueError as exc: # core.safe_eval.ExpressionError is a ValueError
raise ValueError(f"Invalid position expression '{expression}': {exc}") from exc
def _split_position_line(line):
parts = [part.strip() for part in re.split(r"[,;]+", line, maxsplit=1)]
if len(parts) < 2 or not parts[0] or not parts[1]:
raise ValueError(f"Invalid position line: {line}")
return parts[0], parts[1]
def _parse_layer_positions(positions, count, background_width=0, background_height=0, layer_sizes=None):
variables = {
"a": int(background_width),
"b": int(background_height),
"bg_w": int(background_width),
"bg_h": int(background_height),
"width": int(background_width),
"height": int(background_height),
}
for index, (layer_width, layer_height) in enumerate(layer_sizes or [], start=1):
variables[f"w{index}"] = int(layer_width)
variables[f"h{index}"] = int(layer_height)
parsed = []
for raw_line in str(positions or "").splitlines():
line = raw_line.split("#", 1)[0].strip()
if not line:
continue
x_expr, y_expr = _split_position_line(line)
x = int(round(_eval_position_expr(x_expr, variables)))
y = int(round(_eval_position_expr(y_expr, variables)))
parsed.append((x, y))
index = len(parsed)
variables[f"x{index}"] = x
variables[f"y{index}"] = y
while len(parsed) < count:
parsed.append((0, 0))
return parsed[:count]
class NH_LayerLayoutComposite:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"canvas_width": ("INT", {"default": 2480, "min": 1, "max": 20000, "step": 1}),
"canvas_height": ("INT", {"default": 3508, "min": 1, "max": 20000, "step": 1}),
"background_color": ("STRING", {"default": "#FFFFFF", "multiline": False}),
"layout_mode": (["preset", "grid", "custom"],),
"layout_preset": (["2x3", "2x3_mixed", "2_top_1_bottom", "1_full", "2_vertical", "2_horizontal", "3_vertical", "4_grid"],),
"resize_mode": (["cover", "contain", "stretch"],),
"columns": ("INT", {"default": 2, "min": 1, "max": 20, "step": 1}),
"rows": ("INT", {"default": 3, "min": 1, "max": 20, "step": 1}),
"margin_x": ("INT", {"default": 150, "min": 0, "max": 10000, "step": 1}),
"margin_y": ("INT", {"default": 150, "min": 0, "max": 10000, "step": 1}),
"gap_x": ("INT", {"default": 150, "min": 0, "max": 10000, "step": 1}),
"gap_y": ("INT", {"default": 150, "min": 0, "max": 10000, "step": 1}),
"custom_units": (["auto", "relative", "percent", "pixels"],),
"custom_layout": ("STRING", {
"default": "0.06,0.04,0.39,0.20\n0.55,0.04,0.39,0.20\n0.06,0.28,0.39,0.20\n0.55,0.28,0.39,0.20\n0.06,0.58,0.39,0.36\n0.55,0.58,0.39,0.36",
"multiline": True,
}),
"background_fit": (["stretch", "cover", "contain"],),
},
"optional": {
"background_image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE", "INT", "INT", "STRING")
RETURN_NAMES = ("image", "slot_count", "used_count", "layout_info")
FUNCTION = "composite"
CATEGORY = "NH-Nodes/Image"
@staticmethod
def _paste_clipped(canvas, layer, x, y):
left = max(0, int(x))
top = max(0, int(y))
right = min(canvas.width, int(x) + layer.width)
bottom = min(canvas.height, int(y) + layer.height)
if right <= left or bottom <= top:
return
crop_box = (left - int(x), top - int(y), right - int(x), bottom - int(y))
_paste_layer(canvas, layer.crop(crop_box), (left, top))
def _create_canvas(self, canvas_width, canvas_height, background_color, background_fit, background_image=None):
fill = parse_color(background_color, (255, 255, 255))
# An opaque colour gives the RGB canvas (and RGB output) this node always had; a colour with transparency
# ('#RRGGBBAA' with AA below FF) gives an RGBA canvas so that the transparency survives.
canvas = Image.new("RGBA" if fill[3] < 255 else "RGB", (canvas_width, canvas_height), fill)
if background_image is None:
return canvas
background = tensor_to_pil(background_image[0], rgb=True, keep_alpha=True)
fitted, offset_x, offset_y = _fit_layer_image(background, (canvas_width, canvas_height), background_fit)
self._paste_clipped(canvas, fitted, offset_x, offset_y)
return canvas
def composite(
self,
images,
canvas_width,
canvas_height,
background_color,
layout_mode,
layout_preset,
resize_mode,
columns,
rows,
margin_x,
margin_y,
gap_x,
gap_y,
custom_units,
custom_layout,
background_fit,
background_image=None,
):
canvas_width = int(canvas_width)
canvas_height = int(canvas_height)
slots = _layout_slots(
layout_mode,
layout_preset,
canvas_width,
canvas_height,
int(columns),
int(rows),
int(margin_x),
int(margin_y),
int(gap_x),
int(gap_y),
custom_layout,
custom_units,
)
canvas = self._create_canvas(canvas_width, canvas_height, background_color, background_fit, background_image)
used_count = min(int(images.shape[0]), len(slots))
info_lines = []
for image_index in range(used_count):
x, y, width, height = slots[image_index]
layer = tensor_to_pil(images[image_index], rgb=True, keep_alpha=True)
fitted, offset_x, offset_y = _fit_layer_image(layer, (int(width), int(height)), resize_mode)
paste_x = int(x) + offset_x
paste_y = int(y) + offset_y
self._paste_clipped(canvas, fitted, paste_x, paste_y)
info_lines.append(
f"{image_index}: x={int(x)}, y={int(y)}, w={int(width)}, h={int(height)}, mode={resize_mode}"
)
return (pil_to_image_tensor(canvas), len(slots), used_count, "\n".join(info_lines))
class NH_LayerStackComposite:
@classmethod
def INPUT_TYPES(cls):
optional_inputs = {
f"layer_{index}": ("IMAGE",)
for index in range(1, _MAX_LAYER_STACK_INPUTS + 1)
}
return {
"required": {
"background_image": ("IMAGE",),
"layer_count": ("INT", {"default": 1, "min": 0, "max": _MAX_LAYER_STACK_INPUTS, "step": 1}),
"positions": ("STRING", {
"default": "0,0",
"multiline": True,
}),
},
"optional": optional_inputs,
}
RETURN_TYPES = ("IMAGE", "INT", "STRING")
RETURN_NAMES = ("image", "used_count", "layer_info")
FUNCTION = "composite"
CATEGORY = "NH-Nodes/Image"
@staticmethod
def _paste_clipped(canvas, layer, x, y):
x = int(x)
y = int(y)
left = max(0, x)
top = max(0, y)
right = min(canvas.width, x + layer.width)
bottom = min(canvas.height, y + layer.height)
if right <= left or bottom <= top:
return False
crop_box = (left - x, top - y, right - x, bottom - y)
_paste_layer(canvas, layer.crop(crop_box), (left, top))
return True
def composite(self, background_image, layer_count, positions, **kwargs):
# The output has the channels of the background: RGB stays RGB, an RGBA background gives RGBA.
background = tensor_to_pil(background_image[0], rgb=True, keep_alpha=True)
canvas = background.copy()
requested_count = max(0, min(int(layer_count), _MAX_LAYER_STACK_INPUTS))
layer_images = {}
layer_sizes = []
for index in range(1, requested_count + 1):
layer = kwargs.get(f"layer_{index}")
if layer is None:
layer_sizes.append((0, 0))
continue
layer_image = tensor_to_pil(layer[0], rgb=True, keep_alpha=True)
layer_images[index] = layer_image
layer_sizes.append((layer_image.width, layer_image.height))
layer_positions = _parse_layer_positions(
positions,
requested_count,
background.width,
background.height,
layer_sizes=layer_sizes,
)
used_count = 0
info_lines = []
for index in range(1, requested_count + 1):
layer_image = layer_images.get(index)
if layer_image is None:
info_lines.append(f"layer_{index}: missing")
continue
x, y = layer_positions[index - 1]
pasted = self._paste_clipped(canvas, layer_image, x, y)
if pasted:
used_count += 1
info_lines.append(
f"layer_{index}: x={x}, y={y}, w={layer_image.width}, h={layer_image.height}"
)
else:
info_lines.append(
f"layer_{index}: outside canvas x={x}, y={y}, w={layer_image.width}, h={layer_image.height}"
)
return (pil_to_image_tensor(canvas), used_count, "\n".join(info_lines))
NODE_CLASS_MAPPINGS = {
"NH_LayerLayoutComposite": NH_LayerLayoutComposite,
"NH_LayerStackComposite": NH_LayerStackComposite,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_LayerLayoutComposite": "Layer Layout Composite (NH)",
"NH_LayerStackComposite": "Layer Stack Composite (NH)",
}
+5 -4
View File
@@ -2,6 +2,8 @@ import torch
import numpy as np
import cv2
from ...core.mask_ops import mask_bbox
class NH_MaskResizeImage:
"""Resize ảnh theo kích thước target, đảm bảo vùng mask không bị mất.
@@ -54,13 +56,12 @@ class NH_MaskResizeImage:
sh, sw = img_np.shape[:2]
# Tìm bounding box của mask
rows, cols = np.where(mask_np > 0.5)
if len(rows) == 0:
bbox = mask_bbox(mask_np)
if bbox is None:
# Mask rỗng: resize bình thường
return self._simple_resize(img_np, mask_np, tw, th, mode, pad_color)
my1, my2 = int(np.min(rows)), int(np.max(rows))
mx1, mx2 = int(np.min(cols)), int(np.max(cols))
mx1, my1, mx2, my2 = bbox
mw = mx2 - mx1 + 1
mh = my2 - my1 + 1
+197
View File
@@ -0,0 +1,197 @@
"""Save Image Path (NH): numbered image files in any folder, with an optional preview."""
import os
import re
import uuid
import folder_paths
from ...core.utils import tensor_to_pil
_WINDOWS = os.name == "nt"
def _save_dir_from_text(folder_path):
folder_path = (folder_path or "").strip()
if not folder_path:
return folder_paths.get_output_directory()
if os.path.isfile(folder_path):
return os.path.dirname(folder_path)
if os.path.isabs(folder_path):
return folder_path
return os.path.join(folder_paths.get_output_directory(), folder_path)
def _contained_path(base_dir, relative, what):
"""Absolute path of ``relative`` below ``base_dir``; ValueError (naming ``what``) if it would leave ``base_dir``.
Both slash styles count as separators on every OS, so a workflow written on Windows is judged the same elsewhere.
"""
base_dir = os.path.abspath(base_dir)
path = os.path.abspath(os.path.join(base_dir, str(relative).replace("\\", "/")))
try:
inside = os.path.commonpath([base_dir, path]) == base_dir
except ValueError: # another drive or a UNC share
inside = False
if not inside:
raise ValueError(f"{what} must stay inside the target folder: {relative!r}")
return path
def _save_child_dir(save_dir, folder_name):
folder_name = str(folder_name or "").strip()
if not folder_name:
return save_dir
return _contained_path(save_dir, folder_name, "new_folder_name")
def _split_filename_prefix(save_dir, prefix):
"""('sub/dir/', 'name', directory) for a filename_prefix such as 'sub/dir/name'.
Subfolders are allowed, but the folder part must stay inside ``save_dir`` (no '..', no absolute path) and the
file name must be a plain name. Without a separator the name is not looked at by the folder check, and on
Windows a colon turns it into a path: os.path.join(save_dir, 'D:x0001.png') is a file on drive D:, 'C:x' is
relative to the current folder of drive C: (join drops the 'C:', so the reported name is wrong and the
numbering never finds the file again) and 'name:stream' writes an NTFS alternate data stream. A Windows file
name cannot contain a colon anyway, so such a prefix is refused whole, before anything is created.
"""
if _WINDOWS and ":" in prefix:
raise ValueError(
"filename_prefix cannot contain ':' on Windows (it selects a drive or an NTFS stream, "
f"not a file name): {prefix!r}"
)
folder_part, name = re.match(r"(.*[\\/])?([^\\/]*)\Z", prefix, re.DOTALL).groups()
if not folder_part:
return "", name, save_dir
return folder_part, name, _contained_path(save_dir, folder_part, "filename_prefix")
def _save_pil_image(image, save_path, file_format, dpi):
image = image.convert("RGB")
save_kwargs = {"dpi": (dpi, dpi)}
if file_format == "png":
pil_format = "PNG"
save_kwargs["compress_level"] = 4
elif file_format == "jpg":
pil_format = "JPEG"
save_kwargs["quality"] = 95
save_kwargs["subsampling"] = 0
elif file_format == "webp":
pil_format = "WEBP"
save_kwargs["quality"] = 95
save_kwargs["method"] = 6
elif file_format == "avif":
pil_format = "AVIF"
save_kwargs["quality"] = 90
else:
raise ValueError(f"Unsupported format: {file_format}")
try:
image.save(save_path, format=pil_format, **save_kwargs)
except TypeError:
if file_format == "avif" and "dpi" in save_kwargs:
save_kwargs.pop("dpi", None)
image.save(save_path, format=pil_format, **save_kwargs)
print("[NH-Nodes] Save Image: AVIF DPI metadata is not supported by the current Pillow build.")
else:
raise
class NH_SaveImagePath:
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"folder_path": ("STRING", {"default": "", "multiline": False}),
"create_new_folder": ("BOOLEAN", {"default": False}),
"new_folder_name": ("STRING", {"default": "", "multiline": False}),
"filename_prefix": ("STRING", {"default": "NH", "multiline": False}),
"number_suffix": ("BOOLEAN", {"default": True}),
"preview_image": ("BOOLEAN", {"default": False}),
"file_format": (["png", "jpg", "webp", "avif"],),
"dpi": ("INT", {"default": 300, "min": 1, "max": 2400, "step": 1}),
}
}
RETURN_TYPES = ("STRING", "STRING", "INT")
RETURN_NAMES = ("filenames", "path", "saved_count")
FUNCTION = "save_images"
CATEGORY = "NH-Nodes/Image"
def save_images(self, images, folder_path, filename_prefix, number_suffix=True, preview_image=False, file_format="png", dpi=300, create_new_folder=False, new_folder_name=""):
save_dir = _save_dir_from_text(folder_path)
if create_new_folder:
save_dir = _save_child_dir(save_dir, new_folder_name)
# A prefix may name subfolders ("renders/img"); they live below save_dir and are where files are counted.
prefix = (filename_prefix or "NH").strip() or "NH"
prefix_folder, prefix_name, target_dir = _split_filename_prefix(save_dir, prefix)
os.makedirs(target_dir, exist_ok=True)
numbered_pattern = re.compile(rf"^{re.escape(prefix_name)}(\d+)\.(?:png|jpe?g|webp|avif)$", re.IGNORECASE)
legacy_numbered_pattern = re.compile(rf"^(?:{re.escape(prefix_name)})?(\d+)\.(?:png|jpe?g|webp|avif)$", re.IGNORECASE)
bare_pattern = re.compile(rf"^{re.escape(prefix_name)}\.(?:png|jpe?g|webp|avif)$", re.IGNORECASE)
used_numbers = set()
bare_exists = False
for name in os.listdir(target_dir):
if bare_pattern.match(name):
bare_exists = True
continue
match = numbered_pattern.match(name)
if match is None and number_suffix:
match = legacy_numbered_pattern.match(name)
if match:
used_numbers.add(int(match.group(1)))
saved_files = []
preview_results = []
preview_dir = folder_paths.get_temp_directory()
next_number = 1
for batch_index, image in enumerate(images):
if number_suffix:
while next_number in used_numbers:
next_number += 1
filename = f"{prefix_name}{next_number:04d}.{file_format}"
used_numbers.add(next_number)
next_number += 1
elif not bare_exists and not used_numbers:
filename = f"{prefix_name}.{file_format}"
bare_exists = True
else:
while next_number in used_numbers:
next_number += 1
filename = f"{prefix_name}{next_number}.{file_format}"
used_numbers.add(next_number)
next_number += 1
pil_image = tensor_to_pil(image, rgb=True)
_save_pil_image(pil_image, os.path.join(target_dir, filename), file_format, int(dpi))
saved_files.append(prefix_folder + filename)
if preview_image:
preview_filename = f"NH_preview_{uuid.uuid4().hex}_{batch_index}.png"
_save_pil_image(pil_image, os.path.join(preview_dir, preview_filename), "png", int(dpi))
preview_results.append({
"filename": preview_filename,
"subfolder": "",
"type": "temp",
})
result = ("\n".join(saved_files), save_dir, len(saved_files))
if preview_image:
return {"ui": {"images": preview_results}, "result": result}
return result
NODE_CLASS_MAPPINGS = {
"NH_SaveImagePath": NH_SaveImagePath,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_SaveImagePath": "Save Image Path (NH)",
}
+83 -8
View File
@@ -1,8 +1,12 @@
"""Indexed loader nodes for LoRA and diffusion models."""
import os
import weakref
import torch
import folder_paths
from comfy.cli_args import args
from comfy_execution.graph_utils import ExecutionBlocker
@@ -41,6 +45,62 @@ def _selected_name(kwargs, prefix, count, index):
return selected or None
def _file_stamp(path):
"""(size, mtime) of a model file, so a file replaced under the same name is not taken for the one already loaded."""
try:
stat = os.stat(path)
except OSError:
return None
return (stat.st_size, stat.st_mtime_ns)
def _keeps_strong_reference():
"""True when ComfyUI runs with --cache-classic (--high-ram implies it)."""
return bool(getattr(args, "cache_classic", False))
class _LoadedObjectCache:
"""Remembers the last object a loader node built, under a key of everything that decides what gets loaded.
ComfyUI keeps one instance per graph node, but the node's cache key holds every one of its 64 slot widgets, so
editing a slot nobody selected runs the node again. Handing back the object that is already in memory then saves
reading it from disk again.
The object is held by a weak reference. While ComfyUI's own output cache holds it, the next run gets it back; when
that cache evicts it (the default RAM-pressure cache does, under memory pressure) nothing here keeps it alive, so
it can be freed. A strong reference would defeat that eviction. Only with --cache-classic, which drops an output as
soon as the prompt stops using it, the node holds the object itself: otherwise there would be nothing to reuse.
An object that cannot be weakly referenced is not remembered at all and is loaded again each time.
One object only: choosing another file drops the old one before the new one loads, so two big models never sit in
memory together, and "Free model and node cache" (which throws the instance away) frees it as well.
"""
def __init__(self):
self._loaded = None # None, or (key, a callable that returns the object - None once it has been freed)
def _load_cached(self, key, load):
remembered, self._loaded = self._loaded, None # whatever happens below, the node lets go of the old object
if remembered is not None and remembered[0] == key:
obj = remembered[1]()
if obj is not None:
self._loaded = remembered
return obj
del remembered # the old object must be gone before the next one loads
obj = load()
self._loaded = self._remember(key, obj)
return obj
@staticmethod
def _remember(key, obj):
if _keeps_strong_reference():
return (key, lambda: obj)
try:
return (key, weakref.ref(obj))
except TypeError: # e.g. a __slots__ class without __weakref__: loading it again beats keeping it for good
return None
class _IndexedLoraBase:
def __init__(self):
self.loaded_lora = None
@@ -180,7 +240,7 @@ class NH_LoraClipIndex(_IndexedLoraBase):
return (model_lora, clip_lora, lora_name, final_index)
class NH_DiffusionModelIndex:
class NH_DiffusionModelIndex(_LoadedObjectCache):
"""Load one diffusion model by 1-based index."""
@classmethod
@@ -240,11 +300,14 @@ class NH_DiffusionModelIndex:
model_options["dtype"] = torch.float8_e5m2
model_path = folder_paths.get_full_path_or_raise("diffusion_models", model_name)
model = comfy.sd.load_diffusion_model(model_path, model_options=model_options)
model = self._load_cached(
(model_path, weight_dtype, _file_stamp(model_path)),
lambda: comfy.sd.load_diffusion_model(model_path, model_options=model_options),
)
return (model, model_name, final_index)
class NH_VAEIndex:
class NH_VAEIndex(_LoadedObjectCache):
"""Load one VAE by 1-based index."""
@classmethod
@@ -288,16 +351,26 @@ class NH_VAEIndex:
if vae_name is None:
return (_block(), "", -1)
import folder_paths
import comfy.sd
import comfy.utils
vae_path = folder_paths.get_full_path_or_raise("vae", vae_name)
vae = comfy.sd.VAE(sd=comfy.utils.load_torch_file(vae_path))
def load():
# Like the core VAE loader: the file's metadata goes to the VAE as well (it can hold the VAE's config).
sd, metadata = comfy.utils.load_torch_file(vae_path, return_metadata=True)
vae = comfy.sd.VAE(sd=sd, metadata=metadata)
# The core VAE loader does this too: a file with no usable VAE fails here, not in the sampler's decode.
check = getattr(vae, "throw_exception_if_invalid", None)
if check is not None:
check()
return vae
vae = self._load_cached((vae_path, _file_stamp(vae_path)), load)
return (vae, vae_name, final_index)
class NH_ClipIndex:
class NH_ClipIndex(_LoadedObjectCache):
"""Load one CLIP by 1-based index."""
@classmethod
@@ -342,11 +415,13 @@ class NH_ClipIndex:
if clip_name is None:
return (_block(), "", -1)
import folder_paths
import comfy.sd
clip_path = folder_paths.get_full_path_or_raise("clip", clip_name)
clip_type = getattr(comfy.sd.CLIPType, type.upper(), comfy.sd.CLIPType.STABLE_DIFFUSION)
clip = comfy.sd.load_clip(ckpt_paths=[clip_path], embedding_directory=folder_paths.get_folder_paths("embeddings"), clip_type=clip_type)
clip = self._load_cached(
(clip_path, clip_type, _file_stamp(clip_path)),
lambda: comfy.sd.load_clip(ckpt_paths=[clip_path], embedding_directory=folder_paths.get_folder_paths("embeddings"), clip_type=clip_type),
)
return (clip, clip_name, final_index)
+52 -26
View File
@@ -4,6 +4,7 @@ from comfy_execution.graph_utils import ExecutionBlocker
_MAX_VALUE_MATCH_ITEMS = 64
_SWITCH_SLOTS = 10 # input_0 .. input_9 of Switch N
class NH_Compare:
@@ -141,8 +142,8 @@ class NH_IfElse:
return {
"required": {
"condition": ("BOOLEAN", {"default": True}),
"if_true": ("*",),
"if_false": ("*",),
"if_true": ("*", {"lazy": True}),
"if_false": ("*", {"lazy": True}),
}
}
@@ -150,8 +151,17 @@ class NH_IfElse:
RETURN_NAMES = ("result", "condition_out")
FUNCTION = "switch"
CATEGORY = "NH-Nodes/Logic"
DESCRIPTION = "Pick if_true or if_false by the condition. Only the chosen branch is executed; the other one never runs."
def switch(self, condition, if_true, if_false):
def check_lazy_status(self, condition, if_true=None, if_false=None):
# Chi xin nhanh duoc chon; nhanh con lai khong bao gio chay
if condition and if_true is None:
return ["if_true"]
if (not condition) and if_false is None:
return ["if_false"]
return []
def switch(self, condition, if_true=None, if_false=None):
return (if_true if condition else if_false, condition)
@@ -165,16 +175,16 @@ class NH_SwitchN:
"index": ("INT", {"default": 0, "min": 0, "max": 9}),
},
"optional": {
"input_0": ("*",),
"input_1": ("*",),
"input_2": ("*",),
"input_3": ("*",),
"input_4": ("*",),
"input_5": ("*",),
"input_6": ("*",),
"input_7": ("*",),
"input_8": ("*",),
"input_9": ("*",),
"input_0": ("*", {"lazy": True}),
"input_1": ("*", {"lazy": True}),
"input_2": ("*", {"lazy": True}),
"input_3": ("*", {"lazy": True}),
"input_4": ("*", {"lazy": True}),
"input_5": ("*", {"lazy": True}),
"input_6": ("*", {"lazy": True}),
"input_7": ("*", {"lazy": True}),
"input_8": ("*", {"lazy": True}),
"input_9": ("*", {"lazy": True}),
},
}
@@ -182,25 +192,41 @@ class NH_SwitchN:
RETURN_NAMES = ("result", "count")
FUNCTION = "switch"
CATEGORY = "NH-Nodes/Logic"
DESCRIPTION = (
"Pick input_<index>. Only the chosen input is executed. If that slot is not connected, the lowest connected "
"slot is used instead. count = number of connected slots. Connected inputs count even if they produce no "
"value (an upstream node that returns nothing still takes its slot)."
)
@staticmethod
def _connected_slots(kwargs):
# "Noi" = key co mat trong kwargs (slot chua noi thi ComfyUI khong gui key). Gia tri None chi co nghia
# la nhanh lazy chua chay, nen khong dung gia tri de biet slot nao dang noi. Tang dan theo slot.
return [i for i in range(_SWITCH_SLOTS) if f"input_{i}" in kwargs]
@staticmethod
def _chosen_slot(index, slots):
# Slot duoc chon; neu slot do khong noi thi roi ve slot noi thap nhat (nhu truoc day)
return next((slot for slot in slots if slot == index), slots[0])
def check_lazy_status(self, index, **kwargs):
slots = self._connected_slots(kwargs)
if not slots:
return []
key = f"input_{self._chosen_slot(index, slots)}"
return [key] if kwargs[key] is None else []
def switch(self, index, **kwargs):
connected = {}
for i in range(10):
key = f"input_{i}"
if key in kwargs and kwargs[key] is not None:
connected[i] = kwargs[key]
count = len(connected)
slots = self._connected_slots(kwargs)
count = len(slots)
if count == 0:
print("[NH-Nodes] Warning: SwitchN has no connected inputs")
return (None, 0)
if index in connected:
return (connected[index], count)
first_key = sorted(connected.keys())[0]
print(f"[NH-Nodes] SwitchN: index {index} not connected, falling back to input_{first_key}")
return (connected[first_key], count)
chosen = self._chosen_slot(index, slots)
if chosen != index:
print(f"[NH-Nodes] SwitchN: index {index} not connected, falling back to input_{chosen}")
return (kwargs[f"input_{chosen}"], count)
class NH_AnySwitchBoolean:
+37 -19
View File
@@ -64,9 +64,9 @@ class NH_RandomChoice:
"weights": ("STRING", {"default": "1,1,1,1,1,1"}),
},
"optional": {
"input_0": ("*",), "input_1": ("*",),
"input_2": ("*",), "input_3": ("*",),
"input_4": ("*",), "input_5": ("*",),
"input_0": ("*", {"lazy": True}), "input_1": ("*", {"lazy": True}),
"input_2": ("*", {"lazy": True}), "input_3": ("*", {"lazy": True}),
"input_4": ("*", {"lazy": True}), "input_5": ("*", {"lazy": True}),
}
}
@@ -74,17 +74,22 @@ class NH_RandomChoice:
RETURN_NAMES = ("result", "picked_index", "probabilities")
FUNCTION = "choose"
CATEGORY = "NH-Nodes/Logic"
DESCRIPTION = (
"Pick one connected input at random (weight N belongs to input_N, the seed is used as given). "
"Only the picked input is executed; the other ones never run. Connected inputs count even if they produce "
"no value (an upstream node that returns nothing still takes its slot)."
)
def choose(self, seed, weights, **kwargs):
connected = []
for i in range(_CHOICE_SLOTS):
key = f"input_{i}"
if key in kwargs and kwargs[key] is not None:
connected.append((i, kwargs[key]))
if not connected:
return (None, -1, "")
@staticmethod
def _connected_slots(kwargs):
# "Noi" = key co mat trong kwargs (slot chua noi thi ComfyUI khong gui key). Gia tri None chi co nghia
# la nhanh lazy chua chay, nen khong dung gia tri de biet slot nao dang noi. Tang dan theo slot.
return [i for i in range(_CHOICE_SLOTS) if f"input_{i}" in kwargs]
@staticmethod
def _pick(seed, weights, slots):
"""Tra ve (slot duoc chon, xac suat cua cac slot dang noi). Chi phu thuoc seed, weights va tap slot
dang noi, khong dung gia tri input: nho vay check_lazy_status chon dung slot ma choose se tra ve."""
# Weight thu i thuoc input_i (khong phai input thu i dang noi), slot thieu weight = 1
slot_weights = []
for s in weights.split(",")[:_CHOICE_SLOTS]:
@@ -93,7 +98,7 @@ class NH_RandomChoice:
except (ValueError, TypeError):
slot_weights.append(1.0)
slot_weights += [1.0] * (_CHOICE_SLOTS - len(slot_weights))
w = [slot_weights[i] for i, _ in connected]
w = [slot_weights[i] for i in slots]
# Normalize
total = sum(w) or 1.0
@@ -105,16 +110,29 @@ class NH_RandomChoice:
# Weighted pick
r = rng.random()
cumsum = 0.0
for idx, (orig_i, item) in enumerate(connected):
cumsum += probs[idx]
for slot, prob in zip(slots, probs):
cumsum += prob
if r <= cumsum:
prob_str = ", ".join(f"{p:.1%}" for p in probs)
return (item, orig_i, prob_str)
return slot, probs
# Fallback
orig_i, item = connected[-1]
return slots[-1], probs
def check_lazy_status(self, seed, weights, **kwargs):
slots = self._connected_slots(kwargs)
if not slots:
return []
key = f"input_{self._pick(seed, weights, slots)[0]}"
return [key] if kwargs[key] is None else []
def choose(self, seed, weights, **kwargs):
slots = self._connected_slots(kwargs)
if not slots:
return (None, -1, "")
slot, probs = self._pick(seed, weights, slots)
prob_str = ", ".join(f"{p:.1%}" for p in probs)
return (item, orig_i, prob_str)
return (kwargs[f"input_{slot}"], slot, prob_str)
# --- Dang ky ---
+6 -21
View File
@@ -1,23 +1,7 @@
import torch
import numpy as np
def get_bounding_box_and_ratio(mask_tensor):
"""Tính toán bounding box và tỷ lệ (width / height) từ một mask tensor."""
m_np = mask_tensor.cpu().numpy()
rows, cols = np.where(m_np > 0.5)
if len(rows) == 0:
return None, 1.0 # Trả về None và tỷ lệ 1:1 nếu mask rỗng
ymin, ymax = np.min(rows), np.max(rows)
xmin, xmax = np.min(cols), np.max(cols)
bbox = (xmin, ymin, xmax, ymax)
width = xmax - xmin + 1
height = ymax - ymin + 1
aspect_ratio = width / height if height > 0 else 1.0
return bbox, aspect_ratio
from ...core.mask_ops import mask_bbox
def _fit_window(start, length, limit):
@@ -110,16 +94,17 @@ class NH_MaskAspectRatioMatch:
adjusted = []
for index in range(masks.shape[0]):
# Batch của mask A ngắn hơn thì lặp lại vòng tròn
bbox_a, _ = get_bounding_box_and_ratio(targets[index % targets.shape[0]])
bbox_b, _ = get_bounding_box_and_ratio(masks[index])
mask_np = masks[index].cpu().numpy()
bbox_b = mask_bbox(mask_np)
if bbox_b is None:
adjusted.append(masks[index]) # Mask B rỗng: giữ nguyên
continue
# Batch của mask A ngắn hơn thì lặp lại vòng tròn
bbox_a = mask_bbox(targets[index % targets.shape[0]].cpu().numpy())
# Mask A rỗng thì coi như tỷ lệ 1:1
target_size = (1, 1) if bbox_a is None else (bbox_a[2] - bbox_a[0] + 1, bbox_a[3] - bbox_a[1] + 1)
output_np = _adjust_mask(masks[index].cpu().numpy(), bbox_b, target_size, mode)
output_np = _adjust_mask(mask_np, bbox_b, target_size, mode)
adjusted.append(torch.from_numpy(output_np).to(masks.device))
return (torch.stack(adjusted),)
+93 -38
View File
@@ -1,10 +1,50 @@
import torch
import numpy as np
from scipy.ndimage import binary_fill_holes, gaussian_filter, binary_dilation, binary_erosion
import torch
import torch.nn.functional as F
from scipy.ndimage import binary_fill_holes, gaussian_filter
# Moi lan chi xu ly toi da ~16 trieu diem anh (64 MB moi buffer float32): batch dai (mask video) khong lam bo nho tang vot
_CHUNK_PIXELS = 1 << 24
def _running_extreme(x, radius, dim, reduce_fn):
"""Max (reduce_fn = torch.maximum) hoac min (torch.minimum) trong cua so 2*radius+1 diem tam la moi diem,
truot doc theo `dim` cua x [B, 1, H, W] (-1 = chieu rong, -2 = chieu cao).
Bien anh duoc nhan ban (replicate): ngoai mep coi nhu bang diem mep, nen mask cham mep khong bi an tu mep.
Khong quet tung diem cua cua so (O(radius), max_pool2d tren CPU mat vai giay o kernel 255) ma nhan doi cua so:
moi buoc la mot reduce_fn giua hai lat cat lech nhau `step` diem, cua so dai 1, 2, 4, ... -> O(log radius) buoc,
chay tren thiet bi cua tensor. Ket qua chinh xac (max/min khong lam tron).
"""
size = x.shape[dim]
radius = min(radius, size - 1) # cua so rong hon anh thi cung chi thay het truc do
if radius <= 0:
return x
window = 2 * radius + 1
pad = (radius, radius, 0, 0) if dim == -1 else (0, 0, radius, radius)
run = F.pad(x, pad, mode="replicate")
step = 1
while 2 * step <= window:
length = run.shape[dim] - step
run = reduce_fn(run.narrow(dim, 0, length), run.narrow(dim, step, length))
step *= 2
# run[i] = reduce(padded[i : i + step]); hai doan dai `step` chong nhau phu dung cua so `window`
return reduce_fn(run.narrow(dim, 0, size), run.narrow(dim, window - step, size))
def _expand(x, amount, dim):
"""amount > 0: no ra (dilation, max); amount < 0: co lai (erosion, min); 0: giu nguyen (tra ve chinh x).
|amount| la do dai kernel theo truc do: le thi dung nguyen (5 -> moi ben 2 diem). Chan thi lam tron len so
le ke tiep (10 -> 11, moi ben 5 diem) de ket qua doi xung; ban cu kernel chan lech nua diem ve mot phia."""
if amount == 0:
return x
reduce_fn = torch.maximum if amount > 0 else torch.minimum
return _running_extreme(x, abs(amount) // 2, dim, reduce_fn)
class NH_MaskMorphology:
@classmethod
def INPUT_TYPES(s):
def INPUT_TYPES(cls):
return {
"required": {
"mask": ("MASK",),
@@ -18,48 +58,63 @@ class NH_MaskMorphology:
RETURN_TYPES = ("MASK",)
FUNCTION = "process"
CATEGORY = "NH-Nodes/Mask"
DESCRIPTION = (
"Grow (positive) or shrink (negative) the mask along each axis; the value is the kernel length in pixels, "
"so 5 moves each edge by 2 px. An even length is rounded up to the next odd one (10 acts like 11) so the "
"result stays centred. Soft masks keep their gradient and the image border never erodes the mask. "
"Order: horizontal, vertical, fill_holes (mask > 0.5), then Gaussian blur_radius."
)
def process(self, mask, horizontal_expand, vertical_expand, fill_holes, blur_radius):
masks_out = []
for m in mask:
m_np = m.cpu().numpy()
# 1. Co giãn theo chiều ngang
if horizontal_expand != 0:
kernel_width = abs(horizontal_expand)
# Bỏ qua nếu kernel = 0
if kernel_width > 0:
structure_h = np.ones((1, kernel_width))
if horizontal_expand > 0:
m_np = binary_dilation(m_np, structure=structure_h).astype(m_np.dtype)
else:
m_np = binary_erosion(m_np, structure=structure_h).astype(m_np.dtype)
if mask.dim() == 2:
mask = mask.unsqueeze(0)
if mask.dim() != 3:
raise ValueError(f"Mask Morphology expects a MASK [batch, height, width], got shape {tuple(mask.shape)}")
# 2. Co giãn theo chiều dọc
if vertical_expand != 0:
kernel_height = abs(vertical_expand)
# Bỏ qua nếu kernel = 0
if kernel_height > 0:
structure_v = np.ones((kernel_height, 1))
if vertical_expand > 0:
m_np = binary_dilation(m_np, structure=structure_v).astype(m_np.dtype)
else:
m_np = binary_erosion(m_np, structure=structure_v).astype(m_np.dtype)
# Theo lo tren thiet bi cua tensor (CPU hoac GPU): anh nho gop chung mot lan chay, anh lon tung cai
source = (mask if mask.is_floating_point() else mask.float()).unsqueeze(1) # [B, 1, H, W]
count, _, height, width = source.shape
if height == 0 or width == 0:
# Khong co diem anh de no / co / lap / lam mo: tra ve nguyen ven (ban sao) nhu ban scipy cu. F.pad(replicate)
# khong pad duoc tensor rong nen khong de no chay.
return (source.squeeze(1).clone(),)
per_chunk = max(1, _CHUNK_PIXELS // max(1, height * width))
if count <= per_chunk:
grown = self._grow(source, horizontal_expand, vertical_expand)
else:
grown = torch.empty_like(source)
for start in range(0, count, per_chunk):
chunk = source[start : start + per_chunk]
grown[start : start + per_chunk] = self._grow(chunk, horizontal_expand, vertical_expand)
result = grown.squeeze(1)
# 3. Lấp đầy lỗ trống (nếu cần)
# 3. Lap day lo trong, 4. lam mo: scipy, tung anh tren CPU (giu nguyen ket qua cu)
if fill_holes or blur_radius > 0:
result = self._fill_and_blur(result, fill_holes, blur_radius)
elif grown is source:
result = result.clone() # khong doi gi: van tra ban sao, node phia sau co the sua tai cho
return (result,)
@staticmethod
def _grow(x, horizontal_expand, vertical_expand):
x = _expand(x, horizontal_expand, -1) # 1. chieu ngang truoc
return _expand(x, vertical_expand, -2) # 2. roi chieu doc
@staticmethod
def _fill_and_blur(x, fill_holes, blur_radius):
arrays = x.detach().cpu()
if arrays.dtype not in (torch.float32, torch.float64):
arrays = arrays.float() # numpy/scipy khong nhan half/bfloat16
arrays = arrays.numpy()
out = np.empty_like(arrays)
for i, m_np in enumerate(arrays):
if fill_holes:
m_bool = m_np > 0.5
m_filled_bool = binary_fill_holes(m_bool)
m_np = m_filled_bool.astype(m_np.dtype)
# 4. Làm mờ
m_np = binary_fill_holes(m_np > 0.5).astype(m_np.dtype)
if blur_radius > 0:
m_np = gaussian_filter(m_np, sigma=blur_radius)
masks_out.append(torch.from_numpy(m_np))
out[i] = m_np
return torch.from_numpy(out).to(device=x.device, dtype=x.dtype)
return (torch.stack(masks_out),)
NODE_CLASS_MAPPINGS = { "NH_MaskAdvancedMorphology": NH_MaskMorphology }
NODE_DISPLAY_NAME_MAPPINGS = { "NH_MaskAdvancedMorphology": "Mask Morphology (NH)" }
NODE_DISPLAY_NAME_MAPPINGS = { "NH_MaskAdvancedMorphology": "Mask Morphology (NH)" }
+10 -23
View File
@@ -1,4 +1,4 @@
import numpy as np
from ...core.mask_ops import mask_bbox
# File này chứa logic cho node Mask Properties (phiên bản gốc).
@@ -17,32 +17,19 @@ class NH_MaskProperties:
CATEGORY = "NH-Nodes/Mask"
def get_properties(self, mask):
# Chỉ xử lý mask đầu tiên trong batch
m = mask[0]
# Chuyển tensor sang numpy array
m_np = m.cpu().numpy()
# Lấy tọa độ của tất cả các pixel không phải màu đen (giá trị > 0.5)
rows, cols = np.where(m_np > 0.5)
# Chỉ xử lý mask đầu tiên trong batch; hộp bao quanh các pixel > 0.5
bbox = mask_bbox(mask[0].cpu().numpy())
# Xử lý trường hợp mask rỗng (toàn màu đen)
if len(rows) == 0:
if bbox is None:
return (0, 0, 0, 0, 0, 0, (0, 0, 0, 0))
# Tìm các giá trị min và max để xác định hộp bao quanh
x1 = int(np.min(cols))
y1 = int(np.min(rows))
x2 = int(np.max(cols))
y2 = int(np.max(rows))
x1, y1, x2, y2 = bbox
# Tính toán chiều rộng và chiều cao
width = x2 - x1 + 1
height = y2 - y1 + 1
# Tạo bounding box tuple để trả về
bbox = (x1, y1, x2, y2)
# Trả về tất cả các giá trị theo thứ tự đã định nghĩa
return (width, height, x1, y1, x2, y2, bbox)
@@ -54,4 +41,4 @@ NODE_CLASS_MAPPINGS = {
# Tên sẽ hiển thị trong menu của ComfyUI
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_MaskProperties": "Mask Properties (NH)"
}
}
+9 -9
View File
@@ -1,6 +1,8 @@
import torch
import numpy as np
from ...core.mask_ops import mask_bbox
class NH_MaskToBBox:
@classmethod
def INPUT_TYPES(cls):
@@ -22,21 +24,19 @@ class NH_MaskToBBox:
for i in range(batch_size):
single_mask_np = mask[i].cpu().numpy()
rows, cols = np.where(single_mask_np > 0.5)
bbox_mask_np = np.zeros_like(single_mask_np)
if len(rows) > 0 and len(cols) > 0:
ymin, ymax = np.min(rows), np.max(rows)
xmin, xmax = np.min(cols), np.max(cols)
bbox = mask_bbox(single_mask_np)
if bbox is not None:
xmin, ymin, xmax, ymax = bbox
bbox_mask_np[ymin:ymax+1, xmin:xmax+1] = 1.0
output_masks.append(torch.from_numpy(bbox_mask_np))
final_output = torch.stack(output_masks)
return (final_output,)
# Ánh xạ class không đổi
@@ -47,4 +47,4 @@ NODE_CLASS_MAPPINGS = {
# THAY ĐỔI Ở ĐÂY: Đổi tên hiển thị của node
NODE_DISPLAY_NAME_MAPPINGS = {
"NH_MaskToBBox": "NH Create Box Mask"
}
}
+42 -11
View File
@@ -7,6 +7,7 @@ use and explains how to install the right wheel.
import importlib.util
import os
import weakref
INSTALL_HINT = (
"VTON Ultimate Processor (NH) needs ONNX Runtime, which is not installed in ComfyUI's Python.\n"
@@ -16,6 +17,10 @@ INSTALL_HINT = (
)
_warned_cpu_fallback = False
# Where the sessions really run, as ONNX Runtime reports it (session.get_providers()[0]). The device widget only
# says what was asked for: a CUDA/cuDNN mismatch, or the CPU-only wheel, silently serves a "cuda" request from the CPU.
_cpu_fallback_seen = False # a cuda request was served from the CPU at least once in this process (never reset)
_cuda_sessions = weakref.WeakSet() # sessions served by the GPU; weak, so a session still dies with its last reference
def ensure_onnxruntime():
@@ -28,6 +33,28 @@ def ensure_onnxruntime():
raise RuntimeError(f"{INSTALL_HINT}\nThe installed onnxruntime failed to load: {exc}") from exc
def cuda_provider_available():
"""True when the installed onnxruntime can use the GPU (the onnxruntime-gpu wheel)."""
import onnxruntime as ort
return "CUDAExecutionProvider" in ort.get_available_providers()
def cpu_fallback_seen():
"""True once a session requested on cuda was served from the CPU in this process.
cuda_provider_available() only says the GPU wheel is installed; a CUDA/cuDNN mismatch still makes
ONNX Runtime fall back to the CPU. From then on, asking ComfyUI to free VRAM cannot help these sessions.
"""
return _cpu_fallback_seen
def cuda_sessions_alive():
"""True while a session made by create_session is served by the GPU, i.e. holds VRAM that ComfyUI's
model management cannot see or free. Sessions that run on the CPU never count."""
return len(_cuda_sessions) > 0
def providers_for(device):
# CPU stays in the list so a CUDA request degrades instead of failing outright.
if device == "cuda":
@@ -36,19 +63,23 @@ def providers_for(device):
def create_session(model_path, device, sess_options=None):
global _warned_cpu_fallback
global _warned_cpu_fallback, _cpu_fallback_seen
import onnxruntime as ort
session = ort.InferenceSession(model_path, sess_options=sess_options, providers=providers_for(device))
active = session.get_providers()
if device == "cuda" and (not active or active[0] != "CUDAExecutionProvider") and not _warned_cpu_fallback:
_warned_cpu_fallback = True
if "CUDAExecutionProvider" in ort.get_available_providers():
advice = "The CUDA provider is installed but could not start: check that a GPU is visible and that its CUDA/cuDNN versions match onnxruntime-gpu."
else:
advice = "Install onnxruntime-gpu built for your CUDA version to use the GPU."
print(
f"[NH-Nodes] VTON: device=cuda was requested but ONNX Runtime runs "
f"{os.path.basename(model_path)} on {active[0] if active else 'an unknown provider'}. {advice}"
)
if active and active[0] == "CUDAExecutionProvider":
_cuda_sessions.add(session)
elif device == "cuda":
_cpu_fallback_seen = True
if not _warned_cpu_fallback:
_warned_cpu_fallback = True
if "CUDAExecutionProvider" in ort.get_available_providers():
advice = "The CUDA provider is installed but could not start: check that a GPU is visible and that its CUDA/cuDNN versions match onnxruntime-gpu."
else:
advice = "Install onnxruntime-gpu built for your CUDA version to use the GPU."
print(
f"[NH-Nodes] VTON: device=cuda was requested but ONNX Runtime runs "
f"{os.path.basename(model_path)} on {active[0] if active else 'an unknown provider'}. {advice}"
)
return session
+127 -9
View File
@@ -1,10 +1,13 @@
# VTON Ultimate Processor (NH). Built on ComfyUI-Vton-Mask's mask generator and FitDiT
# preprocessing: CC BY-NC-SA 4.0 (non-commercial). See THIRD_PARTY_NOTICES.md.
import gc
import traceback
import torch
import numpy as np
from PIL import Image, ImageFilter
import os
import folder_paths
from ...core.model_files import download_pinned, make_room_on_gpu, register_model_folder
from ...core.utils import resize_image, tensor_to_pil, pil_to_tensor, pil_to_mask
# onnxruntime (via preprocess/) and scikit-image (via utils_mask) are imported
@@ -12,9 +15,86 @@ from ...core.utils import resize_image, tensor_to_pil, pil_to_tensor, pil_to_mas
# this node needs, and loading them at ComfyUI start-up makes the pack import
# heavier and breaks process memory snapshots (Modal restores segfault).
# Default location: where the models are downloaded and where existing installs already keep them.
MODELS_DIR = os.path.join(folder_paths.models_dir, "ComfyUI-Vton-Mask")
LOADED_VTON_MODELS = None
# extra_model_paths.yaml key. Each location holds dwpose/ and humanparsing/, like MODELS_DIR.
VTON_FOLDER_NAME = "vton_mask"
VTON_FOLDER_EXTENSIONS = (".onnx",) # what ComfyUI lists for VTON_FOLDER_NAME: the models, not the HF cache metadata
VTON_REPO_ID = "kg-09/kg-vton-mask"
# The repo is third-party: pin it so a moving "main" cannot change what gets downloaded.
VTON_REVISION = "4648778286cac2dca9aab8da167ea5257462487d"
VTON_MODEL_FILES = (
"dwpose/yolox_l.onnx", "dwpose/dw-ll_ucoco_384.onnx",
"humanparsing/parsing_atr.onnx", "humanparsing/parsing_lip.onnx",
)
_VTON_DOWNLOAD_PATTERNS = ("dwpose/*.onnx", "humanparsing/*.onnx") # skips the repo's other files
# Measured with the four CUDA sessions (RTX 5090): ~2.9 GB held after one run, ~0.9 GB of it weights
# and the rest ONNX Runtime's arena. Used to ask ComfyUI for that much room (plus the reserve it keeps
# for itself, see make_room_on_gpu) before they are created.
_VTON_VRAM_ESTIMATE = 3 * 1024 ** 3
register_model_folder(VTON_FOLDER_NAME, MODELS_DIR, VTON_FOLDER_EXTENSIONS) # make the folder name known to ComfyUI at load time
def _missing_vton_files(root):
return [name for name in VTON_MODEL_FILES if not os.path.isfile(os.path.join(root, *name.split("/")))]
def resolve_vton_models_dir(download=True):
"""Folder holding all four VTON .onnx files, downloading them into MODELS_DIR if none does.
Searched: MODELS_DIR (models/ComfyUI-Vton-Mask) and every `vton_mask` location from
extra_model_paths.yaml. A folder missing any of the four files, as an interrupted first
download leaves it, does not count: the next call finishes the download.
"""
for root in register_model_folder(VTON_FOLDER_NAME, MODELS_DIR, VTON_FOLDER_EXTENSIONS):
if not _missing_vton_files(root):
return root
if not download:
return None
download_pinned(VTON_REPO_ID, VTON_REVISION, MODELS_DIR, _VTON_DOWNLOAD_PATTERNS)
missing = _missing_vton_files(MODELS_DIR)
if missing:
raise RuntimeError(f"[NH-Nodes] VTON model files missing after download in {MODELS_DIR}: {', '.join(missing)}")
return MODELS_DIR
def release_vton_models():
"""Drop the cached ONNX Runtime sessions, which frees the GPU memory they hold.
ONNX Runtime allocates outside PyTorch, so ComfyUI's model management cannot see or unload
these sessions: their VRAM only comes back when the last reference to them goes away.
Sessions that ran on the CPU are dropped the same way, but hold no VRAM: torch's cache is left alone.
"""
global LOADED_VTON_MODELS
from .preprocess.ort_session import cuda_sessions_alive
on_gpu = cuda_sessions_alive() # asked before the sessions go: afterwards nothing is left to ask about
LOADED_VTON_MODELS = None
gc.collect()
if on_gpu and torch.cuda.is_available():
try:
torch.cuda.empty_cache()
except Exception as exc: # runs in a finally: after a CUDA error this must not replace the run's own error
print(f"[NH-Nodes] VTON: released the models but torch.cuda.empty_cache() failed: {exc}")
def _should_release(device, keep_model_loaded):
"""True when a run on `device` must hand its cached sessions back once it is over.
That is the case for sessions ONNX Runtime serves from the GPU while the user has not asked to
keep them. What counts is where they really run, not the device widget: a cuda request that
fell back to the CPU (CUDA/cuDNN mismatch, CPU-only wheel) holds plain RAM, no VRAM, so it keeps
its cache like device=cpu does: dropping it would only reload four models for every item of a list.
Called after the run, once the sessions exist.
"""
from .preprocess.ort_session import cuda_sessions_alive
return device == "cuda" and not keep_model_loaded and cuda_sessions_alive()
LIP_PALETTE = [
0,0,0, 128,0,0, 255,0,0, 0,85,0, 170,0,51, 255,85,0, 0,0,85, 0,119,221, 85,85,0, 0,85,85,
85,51,0, 52,86,128, 0,128,0, 0,0,255, 51,170,221, 0,255,255, 85,255,170, 170,255,85, 255,255,0, 255,170,85
@@ -38,6 +118,17 @@ class NH_VTonUltimateProcessor:
"offset_bottom": ("INT", {"default": 0, "min": -200, "max": 200, "step": 1}),
"offset_left": ("INT", {"default": 0, "min": -200, "max": 200, "step": 1}),
"offset_right": ("INT", {"default": 0, "min": -200, "max": 200, "step": 1}),
"keep_model_loaded": ("BOOLEAN", {"default": False, "tooltip": (
"Off (default): after each run on cuda the four ONNX models (about 3 GB of VRAM) are released, "
"so whatever runs next gets the whole GPU; the next run reloads them. "
"On: keep them in VRAM for faster repeated runs. With a list of images ComfyUI calls this node "
"once per item (a batch tensor is a single call), so keeping the models loaded is much faster "
"for lists and repeated runs. "
"ComfyUI cannot see or free that memory, so Free memory does nothing for it "
"and a large model run afterwards may run out of VRAM. "
"Ignored when the models run on the CPU (device cpu, or cuda without a working ONNX Runtime GPU "
"build): they always stay cached."
)}),
}
}
@@ -50,28 +141,54 @@ class NH_VTonUltimateProcessor:
global LOADED_VTON_MODELS
if LOADED_VTON_MODELS is not None and LOADED_VTON_MODELS.get("device") == device:
return LOADED_VTON_MODELS
from .preprocess.ort_session import ensure_onnxruntime
from .preprocess.ort_session import cpu_fallback_seen, cuda_provider_available, ensure_onnxruntime
ensure_onnxruntime()
from .preprocess.dwpose import DWposeDetector
from .preprocess.humanparsing.run_parsing import Parsing
if not os.path.exists(MODELS_DIR):
from huggingface_hub import snapshot_download
os.makedirs(MODELS_DIR, exist_ok=True)
snapshot_download(repo_id="kg-09/kg-vton-mask", local_dir=MODELS_DIR, local_dir_use_symlinks=False)
model_root = resolve_vton_models_dir()
if LOADED_VTON_MODELS is not None:
release_vton_models() # sessions cached for the other device go before the new ones are allocated
# Room is only worth making when the sessions can really use the GPU. Not with the CPU-only wheel, and not
# once a cuda request was seen served from the CPU in this process (the GPU wheel is installed but CUDA/cuDNN
# do not start; see create_session): evicting ComfyUI's models would not help those sessions. The very first
# run cannot know yet and asks once.
if device == "cuda" and cuda_provider_available() and not cpu_fallback_seen():
make_room_on_gpu(_VTON_VRAM_ESTIMATE)
LOADED_VTON_MODELS = {
'dwprocessor': DWposeDetector(model_root=MODELS_DIR, device=device),
'parsing_model': Parsing(model_root=MODELS_DIR, device=device),
'dwprocessor': DWposeDetector(model_root=model_root, device=device),
'parsing_model': Parsing(model_root=model_root, device=device),
'device': device
}
return LOADED_VTON_MODELS
def process(self, human_image, category, mask_feathering, cover_shoes, refine_hands, refine_hair, device,
offset_top=0, offset_bottom=0, offset_left=0, offset_right=0):
offset_top=0, offset_bottom=0, offset_left=0, offset_right=0, keep_model_loaded=False):
# Import trước load_model: thiếu scikit-image thì báo lỗi ngay, không phải tải model xong mới lỗi.
from ...core.utils_mask import get_mask_location
if human_image.shape[0] == 0:
raise ValueError("VTON Ultimate Processor: human_image is an empty batch")
# Only sessions served by the GPU pin VRAM that ComfyUI cannot manage. Asked after the run, because only
# then it is known where load_model's sessions ended up: on the CPU (device=cpu, or a cuda request that fell
# back to it) the cache stays whatever the flag says.
try:
return self._process_batch(
human_image, get_mask_location, category, mask_feathering, cover_shoes, refine_hands, refine_hair,
device, offset_top, offset_bottom, offset_left, offset_right,
)
except BaseException as exc:
if _should_release(device, keep_model_loaded):
# The traceback keeps the failed frames alive and they reference the sessions: clear them,
# or the release below frees nothing until the exception has been handled.
traceback.clear_frames(exc.__traceback__)
raise
finally:
if _should_release(device, keep_model_loaded):
release_vton_models()
def _process_batch(self, human_image, get_mask_location, category, mask_feathering, cover_shoes, refine_hands,
refine_hair, device, offset_top, offset_bottom, offset_left, offset_right):
# The models are only referenced from this frame and _process_image's, so release_vton_models() can free them.
vton_model = self.load_model(device)
# Mỗi ảnh trong batch chạy độc lập với cùng setting, rồi nối các output theo chiều batch.
results = [
@@ -181,7 +298,8 @@ class NH_VTonUltimateProcessor:
masked_image = Image.composite(final_mask_for_composite.convert("L"), human_img_pil, final_mask_for_composite)
pose_image_final = pose_image_pil.resize(human_img_pil.size, Image.LANCZOS)
parsing_map_colored_pil = Image.fromarray(parsing_map_full_np.astype(np.uint8), 'P')
# No fromarray(mode="P") (deprecated in Pillow 11.3): the uint8 map is read as "L", and putpalette turns it into "P".
parsing_map_colored_pil = Image.fromarray(parsing_map_full_np.astype(np.uint8))
parsing_map_colored_pil.putpalette(LIP_PALETTE)
parsing_map_raw_tensor = torch.from_numpy(parsing_map_full_np.astype(np.float32) / 19.0).unsqueeze(0)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "nh-nodes"
description = "ComfyUI custom nodes for image workflows, tiling, masks, smart resize, logic routing, prompt and text tools, batch automation, garment/portrait segmentation, and VTON preprocessing."
version = "1.5.0"
version = "1.6.1"
license = { file = "LICENSE" }
dependencies = [
"numpy",
File diff suppressed because it is too large Load Diff
+51
View File
@@ -126,6 +126,57 @@ def test_tensor_to_pil_rejects_batches_with_clear_message():
utils.tensor_to_pil(torch.rand((2, 4, 4, 3)))
def _flat(channels, level=0.5):
"""1x1 image whose channel k holds (10 * (k + 1) + level) / 255, i.e. the byte value 10 * (k + 1)."""
return torch.tensor([[[(10 * (k + 1) + level) / 255.0 for k in range(channels)]]])
@pytest.mark.parametrize("channels,expected", [(1, (10, 10, 10)), (3, (10, 20, 30)), (4, (10, 20, 30))])
def test_tensor_to_pil_rgb_always_gives_rgb(channels, expected):
pil = utils.tensor_to_pil(_flat(channels), rgb=True)
assert pil.mode == "RGB" and pil.getpixel((0, 0)) == expected # grey is expanded, alpha is dropped
@pytest.mark.parametrize(
"channels,mode,expected",
[(1, "RGB", (10, 10, 10)), (3, "RGB", (10, 20, 30)), (4, "RGBA", (10, 20, 30, 40))],
)
def test_tensor_to_pil_keep_alpha_keeps_a_fourth_channel_only(channels, mode, expected):
pil = utils.tensor_to_pil(_flat(channels), rgb=True, keep_alpha=True)
assert pil.mode == mode and pil.getpixel((0, 0)) == expected
def test_tensor_to_pil_two_channel_image_becomes_rgb_whatever_the_flags():
for keep_alpha in (False, True):
assert utils.tensor_to_pil(_flat(2), rgb=True, keep_alpha=keep_alpha).mode == "RGB"
def test_tensor_to_pil_keep_alpha_alone_changes_nothing():
for channels, mode in ((1, "L"), (3, "RGB"), (4, "RGBA")):
assert utils.tensor_to_pil(_flat(channels), keep_alpha=True).mode == mode
def test_tensor_to_pil_flags_are_keyword_only():
with pytest.raises(TypeError):
utils.tensor_to_pil(_flat(3), True)
def test_tensor_to_pil_accepts_a_single_image_batch_with_the_flags():
pil = utils.tensor_to_pil(torch.rand((1, 5, 4, 4)), rgb=True, keep_alpha=True)
assert pil.mode == "RGBA" and pil.size == (4, 5)
# --- pil_to_tensor ---
def test_pil_to_tensor_gives_a_batch_of_one_float32_image():
from PIL import Image
pil = Image.new("RGB", (4, 3), (255, 0, 51))
tensor = utils.pil_to_tensor(pil)
assert tensor.shape == (1, 3, 4, 3) and tensor.dtype == torch.float32
assert torch.allclose(tensor[0, 0, 0], torch.tensor([1.0, 0.0, 51 / 255]))
# --- parse_hex_color ---
@pytest.mark.parametrize(
+482
View File
@@ -0,0 +1,482 @@
"""Executor-level tests: ComfyUI's real PromptExecutor runs small graphs of NH nodes in-process.
The unit tests in the other files emulate execution.py. These run the real thing (prompt validation, lazy input
handling, ExecutionBlocker propagation, the output cache) with a stub in place of the web server, on the CPU, with no
GPU, model or network. ``Stub...`` nodes stand in for whatever feeds and consumes the NH nodes: they log what ran and
what reached the output nodes.
"""
import asyncio
import copy
import gc
import importlib
import os
import weakref
from pathlib import Path
from types import SimpleNamespace
import pytest
import torch
from PIL import Image
import comfy.cli_args
import comfy.sd
import execution
import nodes as comfy_nodes
logic_nodes = importlib.import_module("NH-Nodes.nodes.logic.logic_nodes")
math_nodes = importlib.import_module("NH-Nodes.nodes.logic.math_nodes")
folder_loaders = importlib.import_module("NH-Nodes.nodes.image.folder_loaders")
loader_nodes = importlib.import_module("NH-Nodes.nodes.loaders.loader_index_nodes")
# The node only reads the names in the source path, never the folder, so the tests use one that does not exist.
SOURCES = Path(os.path.abspath(os.sep)) / "nh_nodes_test_source_photos"
LOG = SimpleNamespace(ran=[], sunk={}) # reset for every test by the registered_nodes fixture
def _describe(value):
"""What the sink keeps of a value: never the value itself, so a sink cannot keep a model alive."""
if isinstance(value, torch.Tensor):
return (tuple(value.shape), round(float(value.mean()), 3))
if value is None or isinstance(value, (bool, int, float, str)):
return value
return type(value).__name__
# --- stand-ins for the nodes around the NH nodes ----------------------------------------------------------------
class StubImage:
"""Makes a small constant image and logs its tag when it runs."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"tag": ("STRING", {"default": "image"}), "value": ("FLOAT", {"default": 0.5})}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "run"
def run(self, tag, value):
LOG.ran.append(tag)
return (torch.full((1, 4, 4, 3), value),)
class StubNothing:
"""An upstream node that has no value to give (it returns None)."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"tag": ("STRING", {"default": "nothing"})}}
RETURN_TYPES = ("*",)
FUNCTION = "run"
def run(self, tag):
LOG.ran.append(tag)
return (None,)
class StubEcho:
"""Hands an image on: any node that needs the image of another one."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": ("IMAGE",)}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "run"
def run(self, image):
LOG.ran.append("echo")
return (image,)
class StubSink:
"""Output node: records what reached it under its name."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"name": ("STRING", {"default": "sink"}), "value": ("*",)}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "run"
def run(self, name, value):
LOG.sunk[name] = _describe(value)
return {}
STUBS = {
"StubImage": StubImage,
"StubNothing": StubNothing,
"StubEcho": StubEcho,
"StubSink": StubSink,
"NH_IfElse": logic_nodes.NH_IfElse,
"NH_SwitchN": logic_nodes.NH_SwitchN,
"NH_AnySwitchBoolean": logic_nodes.NH_AnySwitchBoolean,
"NH_RandomChoice": math_nodes.NH_RandomChoice,
"NH_MathEval": math_nodes.NH_MathEval,
"NH_LoadImagesMatching": folder_loaders.NH_LoadImagesMatching,
"NH_DiffusionModelIndex": loader_nodes.NH_DiffusionModelIndex,
}
@pytest.fixture(autouse=True)
def registered_nodes(monkeypatch):
for name, cls in STUBS.items():
monkeypatch.setitem(comfy_nodes.NODE_CLASS_MAPPINGS, name, cls)
LOG.ran.clear()
LOG.sunk.clear()
# --- running a graph ----------------------------------------------------------------------------------------------
class _Server:
"""The part of PromptServer that PromptExecutor uses: it sends events and has a client id."""
client_id = None
last_node_id = None
def __init__(self):
self.messages = []
def send_sync(self, event, data, sid=None):
self.messages.append((event, data))
def new_executor(cache_type=execution.CacheType.CLASSIC, lru=0, ram=0.0, ram_inactive=0.0):
"""An executor with one of ComfyUI's caches (ram and ram_inactive are the RAM-pressure headrooms, in GB)."""
cache_args = {"lru": lru, "ram": ram, "ram_inactive": ram_inactive}
return execution.PromptExecutor(_Server(), cache_type=cache_type, cache_args=cache_args)
def run(prompt, executor=None):
"""Validate `prompt` the way the server does, execute it, and report what happened."""
prompt = copy.deepcopy(prompt) # a queued prompt is new data every time; the executor writes into it
valid, error, outputs, node_errors = asyncio.run(execution.validate_prompt("test", prompt, None))
assert valid and not node_errors, (error, node_errors)
executor = executor or new_executor()
executor.server.messages.clear()
LOG.ran.clear()
LOG.sunk.clear()
executor.execute(prompt, "test", {"client_id": "test"}, outputs)
errors = [data for event, data in executor.server.messages if event == "execution_error"]
return SimpleNamespace(
ran=list(LOG.ran),
sunk=dict(LOG.sunk),
success=executor.success,
blocked=[data["exception_message"] for data in errors if data["exception_type"] == "ExecutionBlocked"],
failed=[data["exception_message"] for data in errors if data["exception_type"] != "ExecutionBlocked"],
)
def sink(name, link):
return {"class_type": "StubSink", "inputs": {"name": name, "value": link}}
def image(tag, value=0.5):
return {"class_type": "StubImage", "inputs": {"tag": tag, "value": value}}
def nothing(tag="nothing"):
return {"class_type": "StubNothing", "inputs": {"tag": tag}}
def math_eval(expression, **values):
return {"class_type": "NH_MathEval", "inputs": {"expression": expression, **values}}
GOOD = math_eval("a + b", a=1.0, b=2.0) # 3.0
BAD = math_eval("1 +") # a syntax error: the prompt fails if this node ever runs
# --- 1. lazy branches: the branch that is not taken never runs -------------------------------------------------
def test_the_invalid_math_node_does_fail_when_it_runs():
result = run({"1": BAD, "2": sink("result", ["1", 0])})
assert result.success is False
assert len(result.failed) == 1 and "Math Eval" in result.failed[0]
assert result.sunk == {}
@pytest.mark.parametrize("condition", [True, False])
def test_if_else_does_not_run_the_unchosen_branch(condition):
taken, dropped = ("if_true", "if_false") if condition else ("if_false", "if_true")
prompt = {
"1": GOOD,
"2": BAD,
"3": {"class_type": "NH_IfElse", "inputs": {"condition": condition, taken: ["1", 0], dropped: ["2", 0]}},
"4": sink("result", ["3", 0]),
"5": sink("condition", ["3", 1]),
}
result = run(prompt)
assert result.success and result.failed == [] and result.blocked == []
assert result.sunk == {"result": 3.0, "condition": condition}
@pytest.mark.parametrize("condition", [True, False])
def test_any_switch_does_not_run_the_unchosen_input(condition):
taken, dropped = ("input_true", "input_false") if condition else ("input_false", "input_true")
prompt = {
"1": GOOD,
"2": BAD,
"3": {"class_type": "NH_AnySwitchBoolean", "inputs": {"condition": condition, taken: ["1", 0], dropped: ["2", 0]}},
"4": sink("result", ["3", 0]),
}
result = run(prompt)
assert result.success and result.failed == []
assert result.sunk == {"result": 3.0}
def test_switch_n_does_not_run_the_inputs_that_were_not_picked():
prompt = {
"1": BAD,
"2": GOOD,
"3": BAD,
"4": {"class_type": "NH_SwitchN", "inputs": {"index": 1, "input_0": ["1", 0], "input_1": ["2", 0], "input_2": ["3", 0]}},
"5": sink("result", ["4", 0]),
"6": sink("count", ["4", 1]),
}
result = run(prompt)
assert result.success and result.failed == []
assert result.sunk == {"result": 3.0, "count": 3}
def test_random_choice_does_not_run_the_inputs_that_were_not_picked():
prompt = {
"1": BAD,
"2": GOOD,
"3": BAD,
"4": {"class_type": "NH_RandomChoice", "inputs": {"seed": 5, "weights": "0,1,0", "input_0": ["1", 0], "input_1": ["2", 0], "input_2": ["3", 0]}},
"5": sink("result", ["4", 0]),
"6": sink("picked", ["4", 1]),
}
result = run(prompt)
assert result.success and result.failed == []
assert result.sunk == {"result": 3.0, "picked": 1}
def test_a_connected_input_that_produces_nothing_still_counts_as_connected():
# What the DESCRIPTION of Switch N and Random Choice says: the picked slot is the connected one, value or not.
switch = {
"1": image("other"),
"2": nothing("empty"),
"3": {"class_type": "NH_SwitchN", "inputs": {"index": 1, "input_0": ["1", 0], "input_1": ["2", 0]}},
"4": sink("result", ["3", 0]),
"5": sink("count", ["3", 1]),
}
result = run(switch)
assert result.success and result.ran == ["empty"] # the other slot did not run
assert result.sunk == {"result": None, "count": 2}
choice = {
"1": image("other"),
"2": nothing("empty"),
"3": {"class_type": "NH_RandomChoice", "inputs": {"seed": 3, "weights": "0,1", "input_0": ["1", 0], "input_1": ["2", 0]}},
"4": sink("result", ["3", 0]),
"5": sink("picked", ["3", 1]),
}
result = run(choice)
assert result.success and result.ran == ["empty"]
assert result.sunk == {"result": None, "picked": 1}
# --- 2. Load Images Matching: a miss, with and without fallback_images ------------------------------------------
@pytest.fixture
def matching_tree(tmp_path):
"""<root>/Nu/Anh holds one white 8x8 picture; the source folder is called .../Nu/Anh, so it is a hit."""
root = tmp_path / "matching"
(root / "Nu" / "Anh").mkdir(parents=True)
Image.new("RGB", (8, 8), (255, 255, 255)).save(root / "Nu" / "Anh" / "01.png")
return SimpleNamespace(root=root, source=SOURCES / "Nu" / "Anh")
def matching(tree, **inputs):
settings = {
"source_folder_path": str(tree.source), "matching_root_path": str(tree.root), "match_text": "",
"match_mode": "contains", "recursive": True, "index": 0, "step": 1, "repeat_count": 1,
"context_folder_names": "Nam\nNu", "source_file_names": "",
}
settings.update(inputs)
return {"class_type": "NH_LoadImagesMatching", "inputs": settings}
# Without match_text the source folder's name ("Anh") is looked for, and it is there: a hit. "Zed" is not: a miss.
MISS = {"match_text": "Zed"}
def test_a_hit_loads_the_pictures_and_never_runs_the_fallback(matching_tree):
prompt = {
"1": matching(matching_tree, fallback_images=["2", 0]),
"2": image("fallback", 0.25),
"3": sink("images", ["1", 0]),
"4": sink("found", ["1", 4]),
}
result = run(prompt)
assert result.success and result.blocked == [] and result.failed == []
assert result.sunk == {"images": ((1, 8, 8, 3), 1.0), "found": True}
assert result.ran == [] # fallback_images is lazy and nothing asked for it
def test_a_miss_with_fallback_images_delivers_the_fallback(matching_tree):
prompt = {
"1": matching(matching_tree, fallback_images=["2", 0], **MISS),
"2": image("fallback", 0.25),
"3": sink("images", ["1", 0]),
"4": sink("found", ["1", 4]),
"5": sink("matched_text", ["1", 3]),
"6": sink("filename", ["1", 1]),
}
result = run(prompt)
assert result.success and result.blocked == [] and result.failed == []
assert result.sunk == {
"images": ((1, 4, 4, 3), 0.25), # the fallback, as it is
"found": False,
"matched_text": "Zed",
"filename": "",
}
assert result.ran == ["fallback"]
def test_a_miss_with_a_fallback_that_an_earlier_prompt_cached(matching_tree):
executor = new_executor()
prompt = {
"1": matching(matching_tree, fallback_images=["2", 0], **MISS),
"2": image("fallback", 0.25),
"3": sink("images", ["1", 0]),
}
first = run(prompt, executor)
assert first.ran == ["fallback"] and first.sunk == {"images": ((1, 4, 4, 3), 0.25)}
second = run(prompt, executor) # the miss is never remembered, the fallback comes from the cache
assert second.success and second.ran == []
assert second.sunk == {"images": ((1, 4, 4, 3), 0.25)}
def test_a_miss_without_fallback_images_blocks_only_its_own_consumers(matching_tree):
prompt = {
"1": matching(matching_tree, **MISS),
"2": {"class_type": "StubEcho", "inputs": {"image": ["1", 0]}},
"3": sink("echoed", ["2", 0]),
"4": image("independent", 0.75),
"5": sink("independent", ["4", 0]),
"6": sink("found", ["1", 4]),
}
result = run(prompt)
assert result.success and result.failed == []
assert result.ran == ["independent"] # the node that needed the images never ran
assert result.sunk == {"independent": ((1, 4, 4, 3), 0.75), "found": False} # nothing reached the echo's sink
assert len(result.blocked) == 1 # the reason is reported once
assert result.blocked[0].startswith("Execution Blocked: No matching folder found for 'Zed'")
def test_routing_the_held_back_images_through_if_else_does_not_reach_the_fallback(matching_tree):
# The reason fallback_images exists: a node with a held-back input is blocked before it can choose, whichever
# input it would have picked, so the other branch of the If/Else is never reached.
prompt = {
"1": matching(matching_tree, **MISS),
"2": image("fallback", 0.25),
"3": {"class_type": "NH_IfElse", "inputs": {"condition": ["1", 4], "if_true": ["1", 0], "if_false": ["2", 0]}},
"4": sink("result", ["3", 0]),
}
result = run(prompt)
assert result.sunk == {} and result.ran == []
assert len(result.blocked) == 1
def test_fallback_images_does_the_job_the_if_else_cannot(matching_tree):
prompt = {
"1": matching(matching_tree, fallback_images=["2", 0], **MISS),
"2": image("fallback", 0.25),
"3": sink("result", ["1", 0]),
}
result = run(prompt)
assert result.blocked == [] and result.sunk == {"result": ((1, 4, 4, 3), 0.25)}
def test_a_setting_that_cannot_work_fails_even_with_fallback_images(matching_tree):
prompt = {
"1": matching(matching_tree, matching_root_path=str(matching_tree.root / "missing"), fallback_images=["2", 0]),
"2": image("fallback", 0.25),
"3": sink("images", ["1", 0]),
}
result = run(prompt)
assert result.success is False and result.ran == []
assert len(result.failed) == 1 and "Matching root folder does not exist" in result.failed[0]
# --- 3. Indexed loaders under ComfyUI's own output caches -------------------------------------------------------
MODEL_FILES = ["a.safetensors", "b.safetensors", "c.safetensors", "d.safetensors"]
@pytest.fixture
def diffusion_models(tmp_path, monkeypatch):
"""The indexed diffusion loader with only the file reading faked: counts loads and watches the loaded objects."""
loads = SimpleNamespace(count=0, watchers=[])
class Loaded:
"""A plain object, like the ModelPatcher the real loader returns."""
def load_diffusion_model(path, model_options=None):
loads.count += 1
model = Loaded()
loads.watchers.append(weakref.ref(model))
return model
def full_path(folder, name):
path = tmp_path / name
if not path.exists(): # written once: the node tells a replaced file by its size and time
path.write_bytes(b"model")
return str(path)
monkeypatch.setattr(comfy.sd, "load_diffusion_model", load_diffusion_model)
monkeypatch.setattr(loader_nodes.folder_paths, "get_full_path_or_raise", full_path)
monkeypatch.setattr(loader_nodes.folder_paths, "get_filename_list", lambda folder: list(MODEL_FILES))
loads.alive = lambda: sum(watcher() is not None for watcher in loads.watchers)
return loads
def edit_unselected_slot_three_times(executor):
"""Queue the same loader prompt with a slot that nothing selected changed each time: the node runs again."""
for unselected in MODEL_FILES[1:]:
inputs = {
"select_mode": "index", "index": 1, "model_count": 2, "weight_dtype": "default",
"diffusion_model_1": "a.safetensors", "diffusion_model_2": unselected,
}
prompt = {"1": {"class_type": "NH_DiffusionModelIndex", "inputs": inputs}, "2": sink("model", ["1", 0])}
result = run(prompt, executor)
assert result.success and result.sunk == {"model": "Loaded"}
gc.collect()
def test_default_cache_the_loader_reuses_its_model_when_an_unselected_slot_changes(diffusion_models, monkeypatch):
monkeypatch.setattr(comfy.cli_args.args, "cache_classic", False)
executor = new_executor(execution.CacheType.RAM_PRESSURE, ram=0.001, ram_inactive=0.001) # plenty of free RAM
edit_unselected_slot_three_times(executor)
assert diffusion_models.count == 1 and diffusion_models.alive() == 1
def test_default_cache_memory_pressure_can_free_the_loaded_model(diffusion_models, monkeypatch):
monkeypatch.setattr(comfy.cli_args.args, "cache_classic", False)
# A headroom no machine has: the RAM-pressure cache is always short of memory and evicts what it holds.
executor = new_executor(execution.CacheType.RAM_PRESSURE, ram=1e6, ram_inactive=1e6)
edit_unselected_slot_three_times(executor)
assert diffusion_models.alive() == 0 # nothing of the node's kept the evicted models in memory
def test_cache_classic_the_loader_holds_the_model_the_classic_cache_dropped(diffusion_models, monkeypatch):
monkeypatch.setattr(comfy.cli_args.args, "cache_classic", True) # what --cache-classic sets
executor = new_executor(execution.CacheType.CLASSIC)
edit_unselected_slot_three_times(executor)
assert diffusion_models.count == 1 and diffusion_models.alive() == 1
def test_lru_cache_the_loader_reuses_its_model_like_the_default_cache(diffusion_models, monkeypatch):
monkeypatch.setattr(comfy.cli_args.args, "cache_classic", False)
executor = new_executor(execution.CacheType.LRU, lru=100)
edit_unselected_slot_three_times(executor)
assert diffusion_models.count == 1 and diffusion_models.alive() == 1
+7 -6
View File
@@ -12,7 +12,8 @@ import torch
from PIL import Image
grid = importlib.import_module("NH-Nodes.nodes.image.image_grid_nodes")
tools = importlib.import_module("NH-Nodes.nodes.image.image_tools_nodes")
save_image_path = importlib.import_module("NH-Nodes.nodes.image.save_image_path")
utils = importlib.import_module("NH-Nodes.core.utils")
GIB = 2**30
@@ -58,7 +59,7 @@ def test_grid_guard_accepts_big_canvases_whatever_pillow_limit_other_packs_set(
def test_grid_estimate_is_the_peak_of_the_canvas_to_tensor_conversion():
# PIL keeps an RGB canvas as 4 bytes per pixel; _pil_to_tensor then holds two float32 RGB arrays (astype, / 255.0).
# PIL keeps an RGB canvas as 4 bytes per pixel; pil_to_tensor then holds two float32 RGB arrays (astype, / 255.0).
pil_rgb, float32_rgb = 4, 3 * 4
peak_per_pixel = pil_rgb + 2 * float32_rgb
@@ -69,7 +70,7 @@ def test_grid_estimate_is_the_peak_of_the_canvas_to_tensor_conversion():
def test_grid_estimate_covers_what_the_node_keeps_alive():
canvas = Image.new("RGB", (64, 48))
tensor = grid._pil_to_tensor(canvas)
tensor = utils.pil_to_tensor(canvas)
held = tensor.numel() * tensor.element_size() + 64 * 48 * 4 # the IMAGE tensor and the PIL canvas
assert grid._estimate_canvas_bytes(64, 48) >= held
@@ -183,20 +184,20 @@ def written(monkeypatch):
"""Record the files Save Image Path would write instead of writing them, so that a prefix that escapes the
target folder cannot leave a stray file on the real disk."""
paths = []
monkeypatch.setattr(tools, "_save_pil_image", lambda image, path, file_format, dpi: paths.append(path))
monkeypatch.setattr(save_image_path, "_save_pil_image", lambda image, path, file_format, dpi: paths.append(path))
return paths
@pytest.fixture
def as_windows(monkeypatch):
"""Judge prefixes by the Windows rules even where the tests run on another OS (a no-op on Windows itself)."""
monkeypatch.setattr(tools, "_WINDOWS", True, raising=False)
monkeypatch.setattr(save_image_path, "_WINDOWS", True, raising=False)
def _save_image_path(tmp_path, prefix, **kwargs):
settings = dict(folder_path=str(tmp_path / "out"), create_new_folder=False, new_folder_name="")
settings.update(kwargs)
return tools.NH_SaveImagePath().save_images(torch.rand((1, 4, 4, 3)), filename_prefix=prefix, **settings)
return save_image_path.NH_SaveImagePath().save_images(torch.rand((1, 4, 4, 3)), filename_prefix=prefix, **settings)
def _png_files_outside_out(tmp_path):
+103
View File
@@ -0,0 +1,103 @@
"""Channel handling of the nodes that turn IMAGE tensors into PIL pictures through core.utils.tensor_to_pil.
NH Save Large Image and the large preview cache keep an alpha channel where the format can hold it and give RGB
otherwise (grey included); Image Grid Composite always builds RGB cells and drops alpha without compositing.
"""
import importlib
import os
import folder_paths
import numpy as np
import pytest
import torch
from PIL import Image
large_save = importlib.import_module("NH-Nodes.nodes.image.large_image_save")
preview_cache = importlib.import_module("NH-Nodes.core.large_preview_cache")
grid = importlib.import_module("NH-Nodes.nodes.image.image_grid_nodes")
def _flat(height, width, channels):
"""[1, H, W, C] image whose channel k holds the byte value 10 * (k + 1); the +0.5 keeps float rounding out of it."""
values = torch.tensor([(10 * (k + 1) + 0.5) / 255.0 for k in range(channels)])
return values.expand(1, height, width, channels).clone()
def _save(tmp_path, image, file_format):
node = large_save.NH_SaveLargeImage()
node.output_dir = str(tmp_path)
return node.save(image, "nh_channels", file_format)["result"][1].split("\n")[0]
# --- NH Save Large Image ---
def test_png_keeps_the_alpha_channel(tmp_path):
with Image.open(_save(tmp_path, _flat(4, 6, 4), "png")) as saved:
assert saved.mode == "RGBA" and saved.getpixel((2, 1)) == (10, 20, 30, 40)
@pytest.mark.parametrize("file_format", ["jpg", "webp"])
def test_formats_that_need_rgb_drop_the_alpha_channel(tmp_path, file_format):
with Image.open(_save(tmp_path, _flat(4, 6, 4), file_format)) as saved:
assert saved.mode == "RGB"
@pytest.mark.parametrize("channels,pixel", [(3, (10, 20, 30)), (1, (10, 10, 10))])
def test_rgb_and_grey_images_are_saved_as_rgb(tmp_path, channels, pixel):
with Image.open(_save(tmp_path, _flat(4, 6, channels), "png")) as saved:
assert saved.mode == "RGB" and saved.getpixel((2, 1)) == pixel
# --- large preview cache ---
@pytest.fixture
def temp_dir(tmp_path, monkeypatch):
monkeypatch.setattr(folder_paths, "temp_directory", str(tmp_path))
return tmp_path
def _thumbnail(manifest):
thumb = manifest["thumb"]
return Image.open(os.path.join(folder_paths.get_temp_directory(), thumb["subfolder"], thumb["filename"]))
@pytest.mark.parametrize("preview_format,mode", [("png", "RGBA"), ("jpg", "RGB"), ("webp", "RGB")])
def test_preview_thumbnail_keeps_alpha_only_in_png(temp_dir, preview_format, mode):
manifest = preview_cache.create_preview_cache(_flat(40, 30, 4)[0], preview_format=preview_format, tile_size=64)
with _thumbnail(manifest) as thumb:
assert thumb.mode == mode and thumb.size == (30, 40)
@pytest.mark.parametrize("shape", [(40, 30, 1), (40, 30, 3), (40, 30, 2), (40, 30)])
def test_preview_accepts_grey_rgb_and_two_dimensional_images(temp_dir, shape):
image = torch.rand(shape)
manifest = preview_cache.create_preview_cache(image, preview_format="png", tile_size=64)
assert (manifest["width"], manifest["height"]) == (30, 40)
with _thumbnail(manifest) as thumb:
assert thumb.mode == "RGB"
def test_preview_rejects_anything_but_a_tensor(temp_dir):
with pytest.raises(TypeError, match="Expected IMAGE tensor, got ndarray"):
preview_cache.create_preview_cache(np.zeros((8, 8, 3), np.float32))
# --- Image Grid Composite ---
def test_grid_cells_are_rgb_whatever_the_input_channels():
transparent = _flat(4, 4, 4)
transparent[..., 3] = 0.0 # fully transparent: the colour channels must survive, not be composited away
grey = _flat(4, 4, 1)
image, _slots, _used, _info = grid.NH_ImageGridComposite().composite(
[transparent, grey], columns=2, rows=1, cell_size_mode="manual", cell_width=4, cell_height=4,
resize_mode="stretch", gap_x=0, gap_y=0, background_color_hex="#000000",
)
assert image.shape == (1, 4, 8, 3)
assert torch.allclose(image[0, 0, 0], torch.tensor([10.0, 20.0, 30.0]) / 255.0)
assert torch.allclose(image[0, 0, 4], torch.tensor([10.0, 10.0, 10.0]) / 255.0)
@@ -20,7 +20,10 @@ import torch
import torch.nn.functional as F
from PIL import Image, ImageDraw, ImageFont
tools = importlib.import_module("NH-Nodes.nodes.image.image_tools_nodes")
image_label = importlib.import_module("NH-Nodes.nodes.image.image_label")
layer_composite = importlib.import_module("NH-Nodes.nodes.image.layer_composite")
resize_unit = importlib.import_module("NH-Nodes.nodes.image.image_resize_unit")
save_image_path = importlib.import_module("NH-Nodes.nodes.image.save_image_path")
picker = importlib.import_module("NH-Nodes.nodes.resolution.smart_resolution_picker")
restore = importlib.import_module("NH-Nodes.nodes.resolution.restore_image_geometry")
resolution_data = importlib.import_module("NH-Nodes.core.resolution_data")
@@ -62,7 +65,7 @@ def _abort_if_frozen(seconds):
[("#000000", 1.0), ("#FFFFFF", 1.0), ("#336699", 1.0), ("#336699FF", 1.0), ("#33669900", 0.0), ("#33669980", 128 / 255)],
)
def test_resize_unit_pad_alpha_follows_the_hex_alpha(color, alpha):
out, width, height = tools.NH_ImageResizeByUnit().resize(
out, width, height = resize_unit.NH_ImageResizeByUnit().resize(
_rgba(4, 4, (1.0, 1.0, 1.0, 1.0)), "pixel", 8, 4, 300, "pad", "nearest-exact", color
)
assert (width, height) == (8, 4) and out.shape == (1, 4, 8, 4)
@@ -75,7 +78,7 @@ def test_resize_unit_pad_alpha_follows_the_hex_alpha(color, alpha):
def test_resize_unit_pad_keeps_rgb_results():
image = torch.full((1, 4, 4, 3), 0.25)
out, _, _ = tools.NH_ImageResizeByUnit().resize(image, "pixel", 8, 4, 300, "pad", "nearest-exact", "#102030")
out, _, _ = resize_unit.NH_ImageResizeByUnit().resize(image, "pixel", 8, 4, 300, "pad", "nearest-exact", "#102030")
assert out.shape == (1, 4, 8, 3)
expected = torch.tensor([16 / 255.0, 32 / 255.0, 48 / 255.0]).expand(4, 2, 3)
assert torch.equal(out[0, :, :2], expected) and torch.equal(out[0, :, 6:], expected)
@@ -84,15 +87,15 @@ def test_resize_unit_pad_keeps_rgb_results():
def test_pad_samples_alpha_is_opaque_unless_given():
samples = torch.zeros((1, 4, 2, 2))
opaque = tools._pad_samples(samples, 4, 4, (0, 0, 0))
opaque = resize_unit._pad_samples(samples, 4, 4, (0, 0, 0))
assert opaque[0, :, 0, 0].tolist() == [0.0, 0.0, 0.0, 1.0]
translucent = tools._pad_samples(samples, 4, 4, (10, 20, 30, 64))
translucent = resize_unit._pad_samples(samples, 4, 4, (10, 20, 30, 64))
assert translucent[0, 3, 0, 0].item() == pytest.approx(64 / 255)
assert translucent[0, 0, 0, 0].item() == pytest.approx(10 / 255)
def test_pad_color_named_css_colours_still_work():
out, _, _ = tools.NH_ImageResizeByUnit().resize(
out, _, _ = resize_unit.NH_ImageResizeByUnit().resize(
torch.ones((1, 4, 4, 3)), "pixel", 8, 4, 300, "pad", "nearest-exact", "red"
)
assert out[0, 0, 0].tolist() == [1.0, 0.0, 0.0]
@@ -132,7 +135,7 @@ def _alpha_layer(size=4, opaque_rows=2):
def test_stack_composite_rgba_layer_keeps_transparent_areas():
background = torch.full((1, 8, 8, 3), _q(100))
out, used, _ = tools.NH_LayerStackComposite().composite(background, 1, "2,2", layer_1=_alpha_layer())
out, used, _ = layer_composite.NH_LayerStackComposite().composite(background, 1, "2,2", layer_1=_alpha_layer())
assert used == 1 and out.shape == (1, 8, 8, 3) # RGB background -> RGB output, as before
assert _px(out, 2, 2) == [255, 0, 0]
assert _px(out, 3, 5) == [255, 0, 0]
@@ -144,14 +147,14 @@ def test_stack_composite_rgba_layer_keeps_transparent_areas():
def test_stack_composite_blends_semi_transparent_layers():
background = torch.full((1, 4, 4, 3), _q(100))
layer = _rgba(2, 2, (_q(255), _q(0), _q(0), _q(128)))
out, _, _ = tools.NH_LayerStackComposite().composite(background, 1, "0,0", layer_1=layer)
out, _, _ = layer_composite.NH_LayerStackComposite().composite(background, 1, "0,0", layer_1=layer)
assert _close(_px(out, 0, 0), [178, 50, 50]) # 128/255 red over gray 100
def test_stack_composite_rgb_layers_are_unchanged():
background = torch.full((1, 6, 6, 3), _q(100))
layer = torch.full((1, 2, 3, 3), _q(200))
out, used, info = tools.NH_LayerStackComposite().composite(background, 1, "1,2", layer_1=layer)
out, used, info = layer_composite.NH_LayerStackComposite().composite(background, 1, "1,2", layer_1=layer)
assert used == 1 and "layer_1: x=1, y=2, w=3, h=2" in info
assert _px(out, 2, 1) == [200, 200, 200] and _px(out, 3, 3) == [200, 200, 200]
assert _px(out, 1, 1) == [100, 100, 100] and _px(out, 2, 4) == [100, 100, 100]
@@ -160,7 +163,7 @@ def test_stack_composite_rgb_layers_are_unchanged():
def test_stack_composite_rgba_background_gives_rgba_output():
background = _rgba(8, 8, (_q(0), _q(0), _q(0), _q(0))) # fully transparent canvas
layer = _rgba(2, 2, (_q(255), _q(0), _q(0), _q(128)))
out, _, _ = tools.NH_LayerStackComposite().composite(background, 1, "3,3", layer_1=layer)
out, _, _ = layer_composite.NH_LayerStackComposite().composite(background, 1, "3,3", layer_1=layer)
assert out.shape == (1, 8, 8, 4)
assert _close(_px(out, 3, 3), [255, 0, 0, 128]) # straight alpha over transparent keeps the colour
assert _px(out, 0, 0)[3] == 0
@@ -174,7 +177,7 @@ LAYOUT_ARGS = dict(
def _layout(images, **overrides):
return tools.NH_LayerLayoutComposite().composite(images, **{**LAYOUT_ARGS, **overrides})
return layer_composite.NH_LayerLayoutComposite().composite(images, **{**LAYOUT_ARGS, **overrides})
def test_layout_composite_rgba_layer_keeps_transparent_areas():
@@ -220,7 +223,7 @@ def test_layout_composite_rgb_layers_are_unchanged():
def _save(tmp_path, prefix, images=None, **kwargs):
settings = dict(folder_path=str(tmp_path / "out"), create_new_folder=False, new_folder_name="")
settings.update(kwargs)
return tools.NH_SaveImagePath().save_images(
return save_image_path.NH_SaveImagePath().save_images(
torch.rand((1, 4, 4, 3)) if images is None else images, filename_prefix=prefix, **settings
)
@@ -294,7 +297,7 @@ def test_dots_inside_a_name_are_not_an_escape(tmp_path):
# --- 4. Position expressions use the shared safe evaluator ---
def test_position_expressions_keep_their_values():
positions = tools._parse_layer_positions(
positions = layer_composite._parse_layer_positions(
"(bg_w - w1) // 2, BG_H / 2 - h1 / 2 # centred\nx1 + w1 + 10, y1\n\n2 ** 3, -(a - b) % 7",
3, background_width=100, background_height=60, layer_sizes=[(20, 10), (5, 5), (1, 1)],
)
@@ -302,7 +305,7 @@ def test_position_expressions_keep_their_values():
def test_position_positions_fill_missing_lines_with_zero():
assert tools._parse_layer_positions("5,6", 3) == [(5, 6), (0, 0), (0, 0)]
assert layer_composite._parse_layer_positions("5,6", 3) == [(5, 6), (0, 0), (0, 0)]
@pytest.mark.parametrize("expression", ["9**9**9", "10**10**6", "2**2**20", "(9**9)**(9**9)"])
@@ -310,7 +313,7 @@ def test_position_expression_power_bombs_fail_fast(expression):
started = time.perf_counter()
with _abort_if_frozen(60):
with pytest.raises(ValueError):
tools._parse_layer_positions(f"{expression}, 0", 1)
layer_composite._parse_layer_positions(f"{expression}, 0", 1)
assert time.perf_counter() - started < 5
@@ -319,16 +322,16 @@ def test_position_expression_power_bombs_fail_fast(expression):
)
def test_bad_position_expressions_raise_value_error(expression):
with pytest.raises(ValueError):
tools._parse_layer_positions(f"{expression}, 0", 1)
layer_composite._parse_layer_positions(f"{expression}, 0", 1)
def test_position_expression_error_names_the_expression():
with pytest.raises(ValueError, match="1 / 0"):
tools._eval_position_expr("1 / 0", {})
layer_composite._eval_position_expr("1 / 0", {})
def test_module_no_longer_uses_the_removed_ast_num():
assert "ast.Num" not in inspect.getsource(tools)
assert "ast.Num" not in inspect.getsource(layer_composite)
# --- 5. Image Label: cached fonts and a binary-searched fit ---
@@ -347,7 +350,7 @@ def test_fit_font_size_equals_the_linear_search_for_growing_widths(start, per_po
for box in list(range(1, 400, 7)) + [10_000, 100_000]:
def fits(size):
return math.ceil(per_point * size) <= box
assert tools._fit_font_size(start, fits) == _linear_fit(start, fits), (start, per_point, box)
assert image_label._fit_font_size(start, fits) == _linear_fit(start, fits), (start, per_point, box)
def test_fit_font_size_needs_only_a_logarithmic_number_of_probes():
@@ -357,7 +360,7 @@ def test_fit_font_size_needs_only_a_logarithmic_number_of_probes():
probes.append(size)
return size <= 100
assert tools._fit_font_size(512, fits) == 100
assert image_label._fit_font_size(512, fits) == 100
assert len(probes) <= 12 # the linear search needed 412
@@ -375,10 +378,10 @@ def scalable_font(monkeypatch):
loads.append((str(path), size))
return real_truetype(io.BytesIO(font_bytes), size)
cache = getattr(tools, "_cached_truetype", None)
cache = getattr(image_label, "_cached_truetype", None)
if cache is not None:
cache.cache_clear()
monkeypatch.setattr(tools.ImageFont, "truetype", fake_truetype)
monkeypatch.setattr(image_label.ImageFont, "truetype", fake_truetype)
yield loads
if cache is not None:
cache.cache_clear()
@@ -401,7 +404,7 @@ def test_fit_font_size_matches_the_linear_search_with_a_real_font(scalable_font,
for text in LABEL_TEXTS:
widths = {}
for size in range(6, 301):
box = draw.multiline_textbbox((0, 0), text, font=tools._load_font("default", size), stroke_width=stroke)
box = draw.multiline_textbbox((0, 0), text, font=image_label._load_font("default", size), stroke_width=stroke)
widths[size] = box[2] - box[0]
for start in (6, 8, 9, 24, 96, 300):
# Text width is not strictly increasing for every font (hinting makes it dip by a pixel or two now and
@@ -411,7 +414,7 @@ def test_fit_font_size_matches_the_linear_search_with_a_real_font(scalable_font,
def fits(size, box_width=box_width):
return widths[size] <= box_width
expected = _linear_fit(start, fits)
actual = tools._fit_font_size(start, fits)
actual = image_label._fit_font_size(start, fits)
checked += 1
if increasing:
assert actual == expected, (text[:20], start, box_width)
@@ -428,7 +431,7 @@ def test_label_node_fits_text_like_the_old_linear_search_and_loads_each_font_onc
font_name="default", overlay_on_image=False, align="left", padding_x=10, outline_width=1,
outline_color_hex="#000000",
)
node = tools.NH_ImageLabel()
node = image_label.NH_ImageLabel()
fitted, = node.add_label(image, text, font_size=120, fit_text=True, **settings)
loads_when_fitting = len(scalable_font)
@@ -436,7 +439,7 @@ def test_label_node_fits_text_like_the_old_linear_search_and_loads_each_font_onc
draw = ImageDraw.Draw(Image.new("RGB", (8, 8)))
def fits(size):
box = draw.multiline_textbbox((0, 0), text, font=tools._load_font("default", size), stroke_width=1)
box = draw.multiline_textbbox((0, 0), text, font=image_label._load_font("default", size), stroke_width=1)
return box[2] - box[0] <= 600 - 2 * 10
expected = _linear_fit(120, fits)
@@ -448,8 +451,8 @@ def test_label_node_fits_text_like_the_old_linear_search_and_loads_each_font_onc
def test_load_font_returns_the_cached_object(scalable_font):
assert tools._load_font("default", 24) is tools._load_font("default", 24)
assert tools._load_font("default", 24) is not tools._load_font("default", 25)
assert image_label._load_font("default", 24) is image_label._load_font("default", 24)
assert image_label._load_font("default", 24) is not image_label._load_font("default", 25)
assert len(scalable_font) == 2
@@ -463,16 +466,16 @@ def test_missing_fonts_fall_back_to_the_default_font_and_the_failure_is_remember
raise OSError("cannot open resource")
return real_truetype(font, *args, **kwargs)
tools._cached_truetype.cache_clear()
monkeypatch.setattr(tools.ImageFont, "truetype", truetype_without_system_fonts)
image_label._cached_truetype.cache_clear()
monkeypatch.setattr(image_label.ImageFont, "truetype", truetype_without_system_fonts)
try:
for name in ("no-such-font.ttf", "default", "default"):
font = tools._load_font(name, 20)
font = image_label._load_font(name, 20)
assert font.getbbox("Hello")[2] > 0
assert len(attempts) == 1 # arial.ttf is tried once per size, not once per label
finally:
monkeypatch.undo()
tools._cached_truetype.cache_clear()
image_label._cached_truetype.cache_clear()
# --- 6. Remove Resize Padding must not stretch cropped results ---
@@ -1016,6 +1019,7 @@ def layer_stack_js_result(tmp_path_factory):
assert JS_IMPORT in source, "the extension no longer imports app.js the way this test expects"
work = tmp_path_factory.mktemp("layer_stack_js")
(work / "extension.mjs").write_text(source.replace(JS_IMPORT, "const app = globalThis.__app;"), encoding="utf-8")
shutil.copy(PACK_DIR / "web" / "nh_widget_utils.js", work / "nh_widget_utils.js") # the extension imports it
(work / "harness.mjs").write_text(JS_HARNESS, encoding="utf-8")
run = subprocess.run(
[node, str(work / "harness.mjs"), str(work / "extension.mjs")],
+633
View File
@@ -0,0 +1,633 @@
"""Wave 3 tests: lazy branch selection (If/Else, Switch N, Random Choice) and the torch Mask Morphology.
The lazy tests run every node through a small emulation of ComfyUI's executor (execution.py, execute()):
connected-but-unevaluated lazy inputs arrive as None, unconnected optional inputs are absent from the kwargs, a
name returned by check_lazy_status is only honoured while that input is still unevaluated, and asking for an input
that is not connected is an error (NodeInputError). The ``old_*`` functions are verbatim copies of the node code
before the lazy change, so the tests prove the choice itself did not move.
"""
import contextlib
import importlib
import io
import itertools
import random
import numpy as np
import pytest
import torch
from scipy.ndimage import (
binary_dilation,
binary_erosion,
binary_fill_holes,
gaussian_filter,
maximum_filter1d,
minimum_filter1d,
)
logic_nodes = importlib.import_module("NH-Nodes.nodes.logic.logic_nodes")
math_nodes = importlib.import_module("NH-Nodes.nodes.logic.math_nodes")
morphology = importlib.import_module("NH-Nodes.nodes.mask.mask_morphology")
# --- executor emulation -------------------------------------------------------------------------------------
def run_lazy(node, function, widgets, connected):
"""Run `node` like ComfyUI does.
widgets: plain (non-lazy) inputs, always available. connected: {input name: upstream value} of every connected
lazy input; only the ones check_lazy_status asks for are delivered, the rest arrive as None.
Returns (outputs, requested) with `requested` = the lazy inputs that had to be evaluated upstream, in order.
"""
evaluated = {}
requested = []
while True:
kwargs = {**widgets, **{name: evaluated.get(name) for name in connected}}
wanted = [name for name in node.check_lazy_status(**kwargs) if name not in evaluated]
if not wanted:
return getattr(node, function)(**kwargs), requested
for name in wanted:
assert name in connected, f"asked for {name}, which is not connected (ComfyUI raises NodeInputError)"
evaluated[name] = connected[name]
requested.append(name)
# --- the node code before the lazy change (reference) -------------------------------------------------------
def old_switch_n(index, **kwargs):
connected = {}
for i in range(10):
key = f"input_{i}"
if key in kwargs and kwargs[key] is not None:
connected[i] = kwargs[key]
count = len(connected)
if count == 0:
return (None, 0)
if index in connected:
return (connected[index], count)
first_key = sorted(connected.keys())[0]
return (connected[first_key], count)
def old_random_choice(seed, weights, **kwargs):
slots = 6
connected = []
for i in range(slots):
key = f"input_{i}"
if key in kwargs and kwargs[key] is not None:
connected.append((i, kwargs[key]))
if not connected:
return (None, -1, "")
slot_weights = []
for s in weights.split(",")[:slots]:
try:
slot_weights.append(max(0.0, float(s.strip())))
except (ValueError, TypeError):
slot_weights.append(1.0)
slot_weights += [1.0] * (slots - len(slot_weights))
w = [slot_weights[i] for i, _ in connected]
total = sum(w) or 1.0
probs = [x / total for x in w]
rng = random.Random(seed)
r = rng.random()
cumsum = 0.0
for idx, (orig_i, item) in enumerate(connected):
cumsum += probs[idx]
if r <= cumsum:
prob_str = ", ".join(f"{p:.1%}" for p in probs)
return (item, orig_i, prob_str)
orig_i, item = connected[-1]
prob_str = ", ".join(f"{p:.1%}" for p in probs)
return (item, orig_i, prob_str)
# --- If/Else ------------------------------------------------------------------------------------------------
def test_if_else_branches_are_lazy_and_the_interface_is_unchanged():
spec = logic_nodes.NH_IfElse.INPUT_TYPES()
assert list(spec["required"]) == ["condition", "if_true", "if_false"]
assert "optional" not in spec
assert spec["required"]["if_true"] == ("*", {"lazy": True})
assert spec["required"]["if_false"] == ("*", {"lazy": True})
assert spec["required"]["condition"] == ("BOOLEAN", {"default": True}) # the condition must run first
assert logic_nodes.NH_IfElse.RETURN_TYPES == ("*", "BOOLEAN")
assert logic_nodes.NH_IfElse.RETURN_NAMES == ("result", "condition_out")
@pytest.mark.parametrize(
"condition,true_value,false_value,expected",
[
(True, None, None, ["if_true"]),
(False, None, None, ["if_false"]),
(True, None, "B", ["if_true"]), # the branch that is not chosen being ready does not help
(False, "A", None, ["if_false"]),
(True, "A", None, []),
(False, None, "B", []),
(True, "A", "B", []),
(False, "A", "B", []),
],
)
def test_if_else_check_lazy_status_asks_only_for_the_chosen_branch(condition, true_value, false_value, expected):
node = logic_nodes.NH_IfElse()
assert node.check_lazy_status(condition, if_true=true_value, if_false=false_value) == expected
assert node.check_lazy_status(condition) == (["if_true"] if condition else ["if_false"]) # defaults: nothing ready
@pytest.mark.parametrize("value", [0, 0.0, "", False, [], torch.zeros(2)])
def test_if_else_an_evaluated_falsy_value_is_not_asked_for_again(value):
node = logic_nodes.NH_IfElse()
assert node.check_lazy_status(True, if_true=value) == []
assert node.check_lazy_status(False, if_false=value) == []
@pytest.mark.parametrize("condition", [True, False])
def test_if_else_runs_only_the_chosen_branch_and_returns_it(condition):
node = logic_nodes.NH_IfElse()
connected = {"if_true": "TRUE-VALUE", "if_false": "FALSE-VALUE"}
outputs, requested = run_lazy(node, "switch", {"condition": condition}, connected)
assert outputs == ("TRUE-VALUE" if condition else "FALSE-VALUE", condition)
assert requested == (["if_true"] if condition else ["if_false"])
def test_if_else_keeps_the_exact_object_and_handles_an_empty_chosen_value():
node = logic_nodes.NH_IfElse()
tensor = torch.rand(1, 4, 4, 3)
outputs, requested = run_lazy(node, "switch", {"condition": False}, {"if_true": "x", "if_false": tensor})
assert outputs[0] is tensor and requested == ["if_false"]
# an upstream node that returned None: asked once (the executor drops the repeated ask), result is None
outputs, requested = run_lazy(node, "switch", {"condition": True}, {"if_true": None, "if_false": "x"})
assert outputs == (None, True) and requested == ["if_true"]
# --- Switch N -----------------------------------------------------------------------------------------------
def test_switch_n_inputs_are_lazy_and_the_interface_is_unchanged():
spec = logic_nodes.NH_SwitchN.INPUT_TYPES()
assert list(spec["required"]) == ["index"]
assert spec["required"]["index"] == ("INT", {"default": 0, "min": 0, "max": 9})
assert list(spec["optional"]) == [f"input_{i}" for i in range(10)]
assert all(spec["optional"][f"input_{i}"] == ("*", {"lazy": True}) for i in range(10))
assert logic_nodes.NH_SwitchN.RETURN_TYPES == ("*", "INT")
assert logic_nodes.NH_SwitchN.RETURN_NAMES == ("result", "count")
def test_switch_n_description_says_that_connected_inputs_count_even_without_a_value():
# With lazy inputs the node cannot see a value before it asks for it, so a connected slot whose upstream node
# returns None is picked (the old code skipped None values). The user is told.
description = logic_nodes.NH_SwitchN.DESCRIPTION
assert "Connected inputs count even if they produce no value" in description
assert "Only the chosen input is executed" in description
def test_switch_n_check_lazy_status_values():
node = logic_nodes.NH_SwitchN()
unevaluated = {"input_0": None, "input_2": None, "input_5": None} # connected, nothing run yet
assert node.check_lazy_status(2, **unevaluated) == ["input_2"]
assert node.check_lazy_status(5, **unevaluated) == ["input_5"]
assert node.check_lazy_status(1, **unevaluated) == ["input_0"] # slot 1 is not connected: lowest connected slot
assert node.check_lazy_status(9, **unevaluated) == ["input_0"]
assert node.check_lazy_status(2, input_0=None, input_2="two", input_5=None) == [] # chosen one is ready
assert node.check_lazy_status(2, input_0="zero", input_2=None, input_5="five") == ["input_2"]
assert node.check_lazy_status(3) == [] # nothing connected: nothing to ask for
assert node.check_lazy_status(0, input_4=0) == [] # a falsy value is an evaluated value
def test_switch_n_runs_only_the_selected_input():
node = logic_nodes.NH_SwitchN()
connected = {f"input_{i}": f"value-{i}" for i in range(4)}
outputs, requested = run_lazy(node, "switch", {"index": 2}, connected)
assert outputs == ("value-2", 4)
assert requested == ["input_2"]
def test_switch_n_unconnected_selected_slot_falls_back_to_the_lowest_connected_one(capsys):
node = logic_nodes.NH_SwitchN()
connected = {"input_2": "two", "input_5": "five", "input_7": "seven"}
outputs, requested = run_lazy(node, "switch", {"index": 3}, connected)
assert outputs == ("two", 3)
assert requested == ["input_2"] # the fallback slot, not every connected one
assert capsys.readouterr().out.count("[NH-Nodes] SwitchN: index 3 not connected, falling back to input_2") == 1
outputs, requested = run_lazy(node, "switch", {"index": 5}, connected)
assert (outputs, requested) == (("five", 3), ["input_5"])
assert capsys.readouterr().out == "" # no fallback message when the selected slot exists
def test_switch_n_with_nothing_connected_warns_and_returns_none(capsys):
node = logic_nodes.NH_SwitchN()
outputs, requested = run_lazy(node, "switch", {"index": 4}, {})
assert outputs == (None, 0) and requested == []
assert capsys.readouterr().out.count("[NH-Nodes] Warning: SwitchN has no connected inputs") == 1
def test_switch_n_count_is_the_number_of_connected_slots_not_of_evaluated_ones():
node = logic_nodes.NH_SwitchN()
outputs, requested = run_lazy(node, "switch", {"index": 1}, {"input_0": "a", "input_1": "b", "input_9": "c"})
assert outputs == ("b", 3) and requested == ["input_1"]
# A connected slot whose upstream produced None still counts as connected (it used to be skipped).
outputs, requested = run_lazy(node, "switch", {"index": 1}, {"input_0": "a", "input_1": None})
assert outputs == (None, 2) and requested == ["input_1"]
def test_switch_n_makes_the_same_choice_as_before_for_every_combination():
node = logic_nodes.NH_SwitchN()
checked = 0
with contextlib.redirect_stdout(io.StringIO()):
for subset in range(1 << 10):
slots = [i for i in range(10) if subset >> i & 1]
values = {f"input_{i}": f"value-{i}" for i in slots}
for index in range(10):
outputs, requested = run_lazy(node, "switch", {"index": index}, values)
assert outputs == old_switch_n(index, **values), (slots, index)
if slots:
chosen = index if index in slots else slots[0]
assert requested == [f"input_{chosen}"], (slots, index)
else:
assert requested == []
checked += 1
assert checked == 10 * 1024
# --- Random Choice ------------------------------------------------------------------------------------------
def test_random_choice_inputs_are_lazy_and_the_interface_is_unchanged():
spec = math_nodes.NH_RandomChoice.INPUT_TYPES()
assert list(spec["required"]) == ["seed", "weights"]
assert spec["required"]["seed"] == ("INT", {"default": 0, "min": 0, "max": 2**32 - 1})
assert spec["required"]["weights"] == ("STRING", {"default": "1,1,1,1,1,1"})
assert list(spec["optional"]) == [f"input_{i}" for i in range(6)]
assert all(spec["optional"][f"input_{i}"] == ("*", {"lazy": True}) for i in range(6))
assert math_nodes.NH_RandomChoice.RETURN_TYPES == ("*", "INT", "STRING")
assert math_nodes.NH_RandomChoice.RETURN_NAMES == ("result", "picked_index", "probabilities")
def test_random_choice_description_says_that_connected_inputs_count_even_without_a_value():
description = math_nodes.NH_RandomChoice.DESCRIPTION
assert "Connected inputs count even if they produce no value" in description
assert "Only the picked input is executed" in description
def test_random_choice_check_lazy_status_values():
node = math_nodes.NH_RandomChoice()
unevaluated = {f"input_{i}": None for i in range(6)}
assert node.check_lazy_status(1, "0,0,5,0,0,0", **unevaluated) == ["input_2"] # only slot 2 has any weight
assert node.check_lazy_status(1, "0,0,5,0,0,0", **{**unevaluated, "input_2": "ready"}) == []
assert node.check_lazy_status(1, "1,1,1,1,1,1") == [] # nothing connected
only_two = {"input_1": None, "input_4": None}
for seed in range(20):
picked = old_random_choice(seed, "1,1,1,1,1,1", input_1="a", input_4="b")[1]
assert node.check_lazy_status(seed, "1,1,1,1,1,1", **only_two) == [f"input_{picked}"]
def test_random_choice_runs_only_the_picked_input():
node = math_nodes.NH_RandomChoice()
for slot in range(6):
weights = ",".join("5" if i == slot else "0" for i in range(6))
connected = {f"input_{i}": f"value-{i}" for i in range(6)}
outputs, requested = run_lazy(node, "choose", {"seed": 11, "weights": weights}, connected)
assert outputs[:2] == (f"value-{slot}", slot)
assert requested == [f"input_{slot}"]
def test_random_choice_with_nothing_connected_returns_none():
outputs, requested = run_lazy(math_nodes.NH_RandomChoice(), "choose", {"seed": 3, "weights": "1,1"}, {})
assert outputs == (None, -1, "") and requested == []
def test_random_choice_makes_the_same_pick_as_before_for_every_seed_weights_and_connection():
node = math_nodes.NH_RandomChoice()
weight_sets = ["1,1,1,1,1,1", "3,0,1,2,5,0", "0,0,0,0,0,0", "1,1,10", "x,2,,4", "", "2", "0.5, 0.25 ,0.25,9,9,9,9,9"]
seeds = [0, 1, 2, 3, 7, 42, 99, 12345, 2**32 - 1] + list(range(10, 40))
checked = 0
for subset in range(1, 1 << 6):
slots = [i for i in range(6) if subset >> i & 1]
values = {f"input_{i}": f"value-{i}" for i in slots}
for weights, seed in itertools.product(weight_sets, seeds):
outputs, requested = run_lazy(node, "choose", {"seed": seed, "weights": weights}, values)
expected = old_random_choice(seed, weights, **values)
assert outputs == expected, (slots, weights, seed)
assert requested == [f"input_{expected[1]}"], (slots, weights, seed)
checked += 1
assert checked == 63 * len(weight_sets) * len(seeds)
def test_random_choice_empty_picked_value_is_returned_as_is():
node = math_nodes.NH_RandomChoice()
outputs, requested = run_lazy(node, "choose", {"seed": 1, "weights": "0,1"}, {"input_0": "a", "input_1": None})
assert outputs == (None, 1, "0.0%, 100.0%") and requested == ["input_1"]
# --- Mask Morphology ----------------------------------------------------------------------------------------
def process(mask, horizontal=0, vertical=0, fill_holes=False, blur=0.0):
return morphology.NH_MaskMorphology().process(mask, horizontal, vertical, fill_holes, blur)[0]
def blobs(shape, seed, sigma=3.0):
"""Binary mask made of blobs a few pixels wide, so dilation and erosion both have something to do."""
rng = np.random.default_rng(seed)
noise = gaussian_filter(rng.random(shape), sigma=sigma)
return (noise > np.median(noise)).astype(np.float32)
def old_expand(mask_np, horizontal_expand, vertical_expand):
"""The scipy code of Mask Morphology before the torch rewrite (expand steps only)."""
m = mask_np
if horizontal_expand != 0:
op = binary_dilation if horizontal_expand > 0 else binary_erosion
m = op(m, structure=np.ones((1, abs(horizontal_expand)))).astype(m.dtype)
if vertical_expand != 0:
op = binary_dilation if vertical_expand > 0 else binary_erosion
m = op(m, structure=np.ones((abs(vertical_expand), 1))).astype(m.dtype)
return m
def test_mask_morphology_interface_is_unchanged():
spec = morphology.NH_MaskMorphology.INPUT_TYPES()
assert list(spec["required"]) == ["mask", "horizontal_expand", "vertical_expand", "fill_holes", "blur_radius"]
assert spec["required"]["horizontal_expand"] == ("INT", {"default": 0, "min": -512, "max": 512, "step": 1})
assert spec["required"]["vertical_expand"] == ("INT", {"default": 0, "min": -512, "max": 512, "step": 1})
assert spec["required"]["blur_radius"] == ("FLOAT", {"default": 0.0, "min": 0.0, "max": 100.0, "step": 0.1})
assert morphology.NH_MaskMorphology.RETURN_TYPES == ("MASK",)
assert morphology.NODE_CLASS_MAPPINGS == {"NH_MaskAdvancedMorphology": morphology.NH_MaskMorphology}
assert morphology.NODE_DISPLAY_NAME_MAPPINGS == {"NH_MaskAdvancedMorphology": "Mask Morphology (NH)"}
@pytest.mark.parametrize("horizontal,vertical", list(itertools.product([-9, -5, -3, 0, 3, 5, 9], repeat=2)))
def test_binary_mask_with_odd_kernels_equals_the_old_result_away_from_the_borders(horizontal, vertical):
for seed in range(3):
mask_np = blobs((72, 80), seed) # not square: catches a swapped axis
expected = old_expand(mask_np, horizontal, vertical)
got = process(torch.from_numpy(mask_np)[None], horizontal, vertical)[0].numpy()
assert got.dtype == np.float32 and got.shape == expected.shape
rh, rv = abs(horizontal) // 2, abs(vertical) // 2
interior = (slice(rv, 72 - rv), slice(rh, 80 - rh))
assert np.array_equal(got[interior], expected[interior]), (horizontal, vertical, seed)
# The old code treated the outside as empty, so it could only eat more. Growing is identical everywhere.
assert np.all(got >= expected)
if horizontal >= 0 and vertical >= 0:
assert np.array_equal(got, expected)
def test_soft_masks_keep_their_gradient_instead_of_being_binarised():
soft = torch.zeros(1, 9, 9)
soft[0, 4, 4] = 0.3
grown = process(soft, 5, 0)
assert torch.equal(grown[0, 4], torch.tensor([0, 0, 0.3, 0.3, 0.3, 0.3, 0.3, 0, 0])) # old code gave 1.0 here
assert float(grown.sum()) == pytest.approx(5 * 0.3)
ramp = torch.linspace(0, 1, 11).repeat(5, 1)[None] # values rise from 0 to 1 along the width
assert torch.allclose(process(ramp, 3, 0)[0, 0, :-1], ramp[0, 0, 1:]) # max over 3 = the next pixel
assert torch.allclose(process(ramp, -3, 0)[0, 0, 1:], ramp[0, 0, :-1]) # min over 3 = the previous pixel
plateau = torch.full((1, 7, 7), 0.8)
plateau[0, 3, 3] = 0.2
eroded = process(plateau, -3, -3)
assert float(eroded[0, 3, 3]) == pytest.approx(0.2) and float(eroded[0, 0, 0]) == pytest.approx(0.8)
assert bool(((eroded > 0.19) & (eroded < 0.81)).all()) # nothing was pushed to 0 or 1
def test_erosion_does_not_eat_a_mask_from_the_image_border():
full = torch.ones(1, 12, 12)
assert torch.equal(process(full, -9, -9), full) # the old code emptied the border band of 4 px
left = torch.zeros(1, 6, 14)
left[0, :, :8] = 1.0 # the mask touches the left edge, the empty part starts at column 8
eroded = process(left, -5, 0)[0]
assert torch.equal(eroded[:, :6], torch.ones(6, 6)) # columns 0..5 stay (the old code lost columns 0 and 1)
assert float(eroded[:, 6:].sum()) == 0.0 # the side facing the empty area still shrinks by 2
top = torch.zeros(1, 14, 6)
top[0, :8, :] = 1.0
assert torch.equal(process(top, 0, -5)[0, :6], torch.ones(6, 6))
assert float(process(top, 0, -5)[0, 6:].sum()) == 0.0
def test_dilation_stops_at_the_image_border_and_does_not_wrap_around():
mask = torch.zeros(1, 5, 10)
mask[0, 2, 0] = 1.0
grown = process(mask, 5, 5)[0]
assert torch.equal(grown[:, :3], torch.ones(5, 3)) # radius 2 around (2, 0): rows 0..4, columns 0..2
assert float(grown[:, 3:].sum()) == 0.0 # nothing wrapped to the far side
@pytest.mark.parametrize(
"expand,columns",
[
(1, (5, 5)), # kernel length 1: nothing changes
(2, (4, 6)), # even sizes are rounded up to the next odd length: 2 -> 3
(3, (4, 6)),
(4, (3, 7)), # 4 -> 5
(5, (3, 7)),
(10, (0, 10)), # 10 -> 11 (radius 5), clipped by the 11 px wide image
],
)
def test_even_kernel_lengths_are_rounded_up_so_growing_stays_centred(expand, columns):
mask = torch.zeros(1, 3, 11)
mask[0, :, 5] = 1.0
grown = process(mask, expand, 0)[0]
first, last = columns
assert torch.equal(grown[1], torch.tensor([1.0 if first <= x <= last else 0.0 for x in range(11)]))
@pytest.mark.parametrize("expand,columns", [(-2, (2, 8)), (-3, (2, 8)), (-4, (3, 7)), (-5, (3, 7)), (-6, (4, 6))])
def test_even_kernel_lengths_are_rounded_up_so_shrinking_stays_centred(expand, columns):
mask = torch.zeros(1, 3, 11)
mask[0, :, 1:10] = 1.0 # run of 9 ones, columns 1..9
shrunk = process(mask, expand, 0)[0]
first, last = columns
assert torch.equal(shrunk[1], torch.tensor([1.0 if first <= x <= last else 0.0 for x in range(11)]))
def test_horizontal_expand_acts_on_columns_and_vertical_expand_on_rows_in_that_order():
mask = torch.zeros(1, 9, 9)
mask[0, 4, 4] = 1.0
wide = process(mask, 5, 0)[0]
assert torch.equal(wide.nonzero()[:, 0].unique(), torch.tensor([4])) and int(wide.sum()) == 5
tall = process(mask, 0, 5)[0]
assert torch.equal(tall.nonzero()[:, 1].unique(), torch.tensor([4])) and int(tall.sum()) == 5
assert int(process(mask, 5, 5).sum()) == 25
def test_horizontal_step_runs_before_the_vertical_step_like_before():
diagonal = torch.zeros(1, 9, 9)
for step in (3, 4, 5):
diagonal[0, step, step] = 1.0
# Grow sideways first: the three pixels overlap in column 4 only, so one pixel survives the vertical erosion.
# (Eroding first would leave nothing.)
result = process(diagonal, 3, -3)[0]
assert int(result.sum()) == 1 and result[4, 4] == 1.0
expected = old_expand(diagonal[0].numpy(), 3, -3)
assert np.array_equal(result.numpy(), expected)
@pytest.mark.parametrize("size", [3, 8, 31, 64, 255, 513])
def test_any_kernel_size_matches_an_independent_window_filter(size):
rng = np.random.default_rng(size)
soft = rng.random((2, 37, 41), dtype=np.float32)
radius_size = 2 * (size // 2) + 1 # odd length actually used (even lengths round up)
for sign, window_filter in ((1, maximum_filter1d), (-1, minimum_filter1d)):
horizontal_only = process(torch.from_numpy(soft), sign * size, 0).numpy()
expected = np.stack([window_filter(m, size=radius_size, axis=1, mode="nearest") for m in soft])
assert np.array_equal(horizontal_only, expected), (size, sign)
vertical_only = process(torch.from_numpy(soft), 0, sign * size).numpy()
expected = np.stack([window_filter(m, size=radius_size, axis=0, mode="nearest") for m in soft])
assert np.array_equal(vertical_only, expected), (size, sign)
both = process(torch.from_numpy(soft), sign * size, sign * size).numpy()
expected = np.stack([window_filter(window_filter(m, size=radius_size, axis=1, mode="nearest"),
size=radius_size, axis=0, mode="nearest") for m in soft])
assert np.array_equal(both, expected), (size, sign)
def test_a_kernel_wider_than_the_image_gives_the_global_maximum_or_minimum():
soft = torch.rand(1, 6, 9)
assert torch.equal(process(soft, 512, 0)[0], soft[0].amax(dim=1, keepdim=True).expand(6, 9))
assert torch.equal(process(soft, 0, -512)[0], soft[0].amin(dim=0, keepdim=True).expand(6, 9))
tiny = torch.rand(1, 1, 1)
assert torch.equal(process(tiny, 512, -512), tiny)
def test_batch_items_are_independent_and_the_input_is_not_modified():
batch = torch.from_numpy(np.stack([blobs((40, 50), seed) for seed in range(4)]))
original = batch.clone()
together = process(batch, 5, -3)
assert together.shape == batch.shape and together.dtype == torch.float32
assert torch.equal(batch, original)
for index in range(4):
assert torch.equal(together[index], process(batch[index : index + 1], 5, -3)[0])
@pytest.mark.parametrize("budget", [1, 42, 100, 126, 10**9])
def test_long_batches_are_processed_in_chunks_with_the_same_result(monkeypatch, budget):
batch = torch.from_numpy(np.stack([blobs((6, 7), seed) for seed in range(5)])) # 42 pixels per mask
expected = process(batch, 3, -3)
monkeypatch.setattr(morphology, "_CHUNK_PIXELS", budget) # 1 and 42: one mask per chunk; 100: 2+2+1; 126: 3+2
assert torch.equal(process(batch, 3, -3), expected)
assert torch.equal(process(batch, 0, 0), batch) and process(batch).data_ptr() != batch.data_ptr()
filled = process(batch, 3, 3, True, 0.8)
monkeypatch.setattr(morphology, "_CHUNK_PIXELS", 1 << 24)
assert torch.equal(filled, process(batch, 3, 3, True, 0.8))
def test_unchanged_mask_comes_back_as_a_copy():
mask = torch.rand(2, 5, 6)
out = process(mask)
assert torch.equal(out, mask) and out.data_ptr() != mask.data_ptr()
out += 1.0
assert not torch.equal(out, mask) # a node downstream may modify its input in place
# Kernel length 1 and an image only one pixel wide have nothing to do either, and still return a copy.
for horizontal, vertical in ((1, -1), (-1, 1)):
same = process(mask, horizontal, vertical)
assert torch.equal(same, mask) and same.data_ptr() != mask.data_ptr()
thin = torch.rand(1, 5, 1)
assert process(thin, 9, 0).data_ptr() != thin.data_ptr() and torch.equal(process(thin, -9, 0), thin)
def test_two_dimensional_masks_are_accepted_and_other_shapes_are_rejected_clearly():
flat = torch.zeros(7, 7)
flat[3, 3] = 1.0
out = process(flat, 3, 3)
assert out.shape == (1, 7, 7) and int(out.sum()) == 9
with pytest.raises(ValueError, match="MASK"):
process(torch.zeros(1, 1, 4, 4), 3, 3)
with pytest.raises(ValueError, match="MASK"):
process(torch.zeros(5), 3, 3)
def test_empty_batch_is_returned_empty():
out = process(torch.zeros(0, 8, 8), 5, -5, True, 1.0)
assert out.shape == (0, 8, 8)
@pytest.mark.parametrize("shape", [(1, 0, 8), (1, 8, 0), (3, 0, 0), (0, 8)])
@pytest.mark.parametrize(
"settings",
[
(0, 0),
(5, 0),
(0, 5),
(-5, 0),
(0, -5),
(5, -5, False, 0.0),
(9, 9, True, 0.0),
(0, 0, False, 1.5),
(3, 3, True, 2.0),
],
)
def test_a_mask_without_pixels_comes_back_unchanged_like_before(shape, settings):
# Zero height or width: there is nothing to grow, fill or blur. The scipy version passed such a mask through
# whatever the settings; replicate padding cannot pad it (it raises), so the node must not try.
mask = torch.zeros(shape)
out = process(mask, *settings)
assert out is not mask # a copy, like every other unchanged result
assert out.shape == ((1, *shape) if len(shape) == 2 else shape) # a 2-D mask is a batch of one
assert out.dtype == torch.float32 and out.numel() == 0
@pytest.mark.parametrize(
"dtype,expected",
[(torch.float32, torch.float32), (torch.float64, torch.float64), (torch.float16, torch.float16), (torch.bool, torch.float32)],
)
def test_a_mask_without_pixels_follows_the_same_dtype_rule_as_any_other(dtype, expected):
out = process(torch.zeros(2, 0, 8, dtype=dtype), 5, 5, True, 1.0)
assert out.shape == (2, 0, 8) and out.dtype == expected
def test_non_float_and_float64_masks_are_handled():
as_bool = torch.zeros(1, 5, 5, dtype=torch.bool)
as_bool[0, 2, 2] = True
out = process(as_bool, 3, 3)
assert out.dtype == torch.float32 and int(out.sum()) == 9
as_double = torch.zeros(1, 5, 5, dtype=torch.float64)
as_double[0, 2, 2] = 1.0
out = process(as_double, 3, 3)
assert out.dtype == torch.float64 and int(out.sum()) == 9
def test_fill_holes_and_blur_still_use_the_same_scipy_calls():
ring = np.zeros((30, 30), np.float32)
yy, xx = np.mgrid[:30, :30]
ring[(np.hypot(yy - 15, xx - 15) < 10) & (np.hypot(yy - 15, xx - 15) > 4)] = 1.0
ring_t = torch.from_numpy(ring)[None]
filled = process(ring_t, fill_holes=True)[0].numpy()
assert np.array_equal(filled, binary_fill_holes(ring > 0.5).astype(np.float32))
assert filled[15, 15] == 1.0 and ring[15, 15] == 0.0
blurred = process(ring_t, blur=2.5)[0].numpy()
assert np.array_equal(blurred, gaussian_filter(ring, sigma=2.5))
# expand -> fill holes -> blur, in that order
both = process(ring_t, 3, 3, True, 1.5)[0].numpy()
grown = process(ring_t, 3, 3)[0].numpy()
assert np.array_equal(both, gaussian_filter(binary_fill_holes(grown > 0.5).astype(np.float32), sigma=1.5))
def test_fill_holes_binarises_a_soft_mask_at_one_half_like_before():
soft = torch.zeros(1, 9, 9)
soft[0, 2:7, 2:7] = 0.9
soft[0, 4, 4] = 0.0 # hole
soft[0, 2, 2] = 0.4 # below the 0.5 threshold: not part of the mask
out = process(soft, fill_holes=True)[0]
assert out[4, 4] == 1.0 and out[2, 2] == 0.0 and out[3, 3] == 1.0
assert set(out.unique().tolist()) == {0.0, 1.0}
@pytest.mark.skipif(not torch.cuda.is_available(), reason="needs a CUDA device")
def test_morphology_runs_on_the_tensor_device_and_matches_the_cpu_result():
mask = torch.from_numpy(np.stack([blobs((64, 80), seed) for seed in range(2)]))
mask[0, 10:20, 10:20] *= 0.5 # keep some soft values
cpu = process(mask, 15, -9)
gpu = process(mask.cuda(), 15, -9)
assert gpu.device.type == "cuda" and gpu.dtype == torch.float32
assert torch.equal(gpu.cpu(), cpu)
gpu_filled = process(mask.cuda(), 5, 5, True, 1.0)
assert gpu_filled.device.type == "cuda"
assert torch.allclose(gpu_filled.cpu(), process(mask, 5, 5, True, 1.0))
+141
View File
@@ -0,0 +1,141 @@
"""core.mask_ops.mask_bbox and the mask nodes built on it (Mask Properties, Create Box Mask)."""
import importlib
import numpy as np
import pytest
import torch
mask_ops = importlib.import_module("NH-Nodes.core.mask_ops")
properties = importlib.import_module("NH-Nodes.nodes.mask.mask_properties")
box_mask = importlib.import_module("NH-Nodes.nodes.mask.mask_to_bbox")
mask_bbox = mask_ops.mask_bbox
def _mask(h, w, *boxes):
"""float32 [H, W] mask; each box is (x1, y1, x2, y2) with an exclusive end."""
mask = np.zeros((h, w), np.float32)
for x1, y1, x2, y2 in boxes:
mask[y1:y2, x1:x2] = 1.0
return mask
# --- mask_bbox ---
def test_bbox_is_inclusive_and_ordered_x1_y1_x2_y2():
assert mask_bbox(_mask(10, 12, (3, 2, 9, 5))) == (3, 2, 8, 4) # width 6, height 3
def test_bbox_covers_several_blobs():
assert mask_bbox(_mask(20, 30, (2, 3, 5, 6), (20, 15, 27, 18))) == (2, 3, 26, 17)
def test_bbox_of_an_empty_mask_is_none():
assert mask_bbox(np.zeros((6, 7), np.float32)) is None
@pytest.mark.parametrize("shape", [(0, 5), (5, 0), (0, 0)])
def test_bbox_of_a_zero_sized_mask_is_none(shape):
assert mask_bbox(np.zeros(shape, np.float32)) is None
def test_bbox_of_a_single_pixel_and_of_a_full_mask():
single = np.zeros((9, 11), np.float32)
single[7, 4] = 1.0
assert mask_bbox(single) == (4, 7, 4, 7)
assert mask_bbox(np.ones((9, 11), np.float32)) == (0, 0, 10, 8)
def test_threshold_is_strict_and_defaults_to_half():
half = np.full((4, 4), 0.5, np.float32)
assert mask_bbox(half) is None # exactly 0.5 is outside, as in every NH mask node
assert mask_bbox(np.full((4, 4), np.nextafter(np.float32(0.5), np.float32(1)), np.float32)) == (0, 0, 3, 3)
soft = _mask(8, 8, (2, 2, 6, 6)) * 0.4
assert mask_bbox(soft) is None
assert mask_bbox(soft, threshold=0.25) == (2, 2, 5, 5)
assert mask_bbox(soft, threshold=0.0) == (2, 2, 5, 5) # "any non-zero pixel"
def test_nan_pixels_are_outside():
mask = _mask(5, 5, (1, 1, 3, 3))
mask[0, 0] = np.nan
assert mask_bbox(mask) == (1, 1, 2, 2)
@pytest.mark.parametrize("shape", [(), (7,), (4, 5, 1), (2, 4, 5)])
def test_bbox_rejects_masks_that_are_not_2d(shape):
with pytest.raises(ValueError, match="2-D"):
mask_bbox(np.ones(shape, np.float32))
def test_bbox_is_four_python_ints():
bbox = mask_bbox(_mask(6, 6, (1, 2, 4, 5)))
assert all(type(value) is int for value in bbox)
@pytest.mark.parametrize("dtype", [np.float16, np.float64, np.uint8, np.bool_])
def test_bbox_accepts_other_array_types(dtype):
mask = _mask(8, 9, (2, 1, 7, 4)).astype(dtype)
if dtype is np.uint8:
mask *= 255
assert mask_bbox(mask) == (2, 1, 6, 3)
def test_bbox_matches_a_brute_force_search():
rng = np.random.default_rng(5)
for _ in range(200):
h, w = rng.integers(1, 30, 2)
mask = (rng.random((h, w)) * (rng.random((h, w)) < rng.choice([0.0, 0.03, 0.4]))).astype(np.float32)
points = [(x, y) for y in range(h) for x in range(w) if mask[y, x] > 0.5]
expected = None if not points else (
min(x for x, _ in points), min(y for _, y in points), max(x for x, _ in points), max(y for _, y in points)
)
assert mask_bbox(mask) == expected
# --- Mask Properties ---
def _properties(mask):
return properties.NH_MaskProperties().get_properties(torch.from_numpy(mask))
def test_properties_report_size_corners_and_bbox():
mask = torch.from_numpy(_mask(10, 12, (3, 2, 9, 5))).unsqueeze(0)
width, height, x1, y1, x2, y2, bbox = properties.NH_MaskProperties().get_properties(mask)
assert (width, height, x1, y1, x2, y2) == (6, 3, 3, 2, 8, 4)
assert bbox == (3, 2, 8, 4)
def test_properties_of_an_empty_mask_are_all_zero():
assert _properties(np.zeros((1, 6, 7), np.float32)) == (0, 0, 0, 0, 0, 0, (0, 0, 0, 0))
def test_properties_only_look_at_the_first_batch_item():
batch = torch.from_numpy(np.stack([_mask(8, 8, (1, 1, 3, 3)), _mask(8, 8, (4, 4, 8, 8))]))
assert _properties(batch.numpy())[:6] == (2, 2, 1, 1, 2, 2)
# --- Create Box Mask ---
def test_box_mask_fills_the_bounding_box_of_every_batch_item():
first = _mask(10, 10, (2, 2, 4, 3), (6, 5, 8, 8))
second = np.zeros((10, 10), np.float32) # empty: stays empty
third = _mask(10, 10, (0, 0, 1, 1))
masks = torch.from_numpy(np.stack([first, second, third]))
(result,) = box_mask.NH_MaskToBBox().create_bbox_mask(masks)
assert result.shape == masks.shape and result.dtype == masks.dtype
assert torch.equal(result[0], torch.from_numpy(_mask(10, 10, (2, 2, 8, 8))))
assert float(result[1].sum()) == 0.0
assert torch.equal(result[2], torch.from_numpy(_mask(10, 10, (0, 0, 1, 1))))
def test_box_mask_ignores_values_at_or_below_half():
soft = torch.from_numpy(_mask(6, 6, (1, 1, 5, 5)) * 0.5).unsqueeze(0)
(result,) = box_mask.NH_MaskToBBox().create_bbox_mask(soft)
assert float(result.sum()) == 0.0
+74
View File
@@ -0,0 +1,74 @@
"""Pillow deprecations on the garment-mask and VTON paths.
Pillow 11.3 deprecated Image.fromarray(array, mode) and warned at every call (12.0 took most of that back and 13.0
only rejects a mode that changes the data type). The code reads uint8 label maps with a plain Image.fromarray(array)
instead, which behaves the same on every Pillow version and stays quiet.
Both tests turn any "will be removed in Pillow N" DeprecationWarning into an error, so a `mode=` that comes back
fails here.
"""
import importlib
import warnings
import numpy as np
import pytest
import torch
from PIL import Image
pytest.importorskip("skimage") # core.utils_mask imports it at module level
utils_mask = importlib.import_module("NH-Nodes.core.utils_mask")
vton = importlib.import_module("NH-Nodes.nodes.vton.vton_preprocessor_nodes")
H, W = 48, 64
@pytest.fixture(autouse=True)
def pillow_deprecations_are_errors():
with warnings.catch_warnings():
warnings.filterwarnings("error", message=r".*removed in Pillow", category=DeprecationWarning)
yield
def _parsing_labels():
labels = np.zeros((H, W), np.uint8)
labels[5:12, 20:40] = 11 # head
labels[12:30, 15:45] = 4 # upper clothes
labels[30:44, 18:42] = 5 # trousers
labels[44:48, 18:42] = 9 # shoes
return labels
@pytest.mark.parametrize("category", ["Upper-body", "Lower-body", "Dresses"])
def test_garment_masks_do_not_trip_a_pillow_deprecation(category):
pose = np.full((134, 2), -1.0, np.float32) # nothing detected: the masks fall back to the garment contours
mask, mask_gray = utils_mask.get_mask_location(category, Image.fromarray(_parsing_labels()), pose, W, H, 0, 0, 0, 0)
assert mask.mode == mask_gray.mode == "L" and mask.size == (W, H)
class _Pose:
def __call__(self, bgr):
return np.zeros(bgr.shape, np.uint8), None, None, np.full((1, 134, 2), 0.5, np.float32)
class _Parsing:
def __call__(self, image):
labels = Image.fromarray(_parsing_labels())
return labels, labels
@pytest.mark.parametrize("category", ["Upper-body", "Lower-body", "Dresses"])
def test_vton_node_builds_its_coloured_parsing_image_without_a_pillow_deprecation(monkeypatch, category):
monkeypatch.setattr(vton, "LOADED_VTON_MODELS", {"dwprocessor": _Pose(), "parsing_model": _Parsing(), "device": "cpu"})
outputs = vton.NH_VTonUltimateProcessor().process(torch.full((1, H, W, 3), 0.5), category, 2, False, True, True, "cpu")
parsing_image = outputs[3]
assert parsing_image.shape == (1, H, W, 3)
# The palette is applied label by label: row 20, column 30 is label 4 (upper clothes), row 46, column 25 label 9 (shoes).
for (row, column), label in (((20, 30), 4), ((46, 25), 9), ((0, 0), 0)):
colour = torch.tensor(vton.LIP_PALETTE[3 * label:3 * label + 3], dtype=torch.float32) / 255.0
assert torch.allclose(parsing_image[0, row, column], colour), label
+843
View File
@@ -0,0 +1,843 @@
"""HTTP routes (core/folder_routes.py, core/large_preview_routes.py) and the frontend extensions (web/*.js).
The routes are served from a throw-away aiohttp app through each module's ``register_routes``; no ComfyUI server runs.
The extensions run in Node against a fake ComfyUI app and fake nodes (two kinds of widget: the one of the current
frontend, which has ``hidden``, and a plain one from an older frontend, which has not).
"""
import asyncio
import importlib
import json
import os
import re
import shutil
import subprocess
import threading
import time
from pathlib import Path
import pytest
import torch
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import folder_paths
pack = importlib.import_module("NH-Nodes")
folder_routes = importlib.import_module("NH-Nodes.core.folder_routes")
preview_routes = importlib.import_module("NH-Nodes.core.large_preview_routes")
preview_cache = importlib.import_module("NH-Nodes.core.large_preview_cache")
resolution_data = importlib.import_module("NH-Nodes.core.resolution_data")
picker = importlib.import_module("NH-Nodes.nodes.resolution.smart_resolution_picker")
PACK_DIR = Path(__file__).resolve().parent.parent
WEB_FILES = sorted((PACK_DIR / "web").glob("*.js"))
SHARED_MODULE = "nh_widget_utils.js"
COUNT = folder_routes.COUNT_PATH
PRESETS = folder_routes.PRESETS_PATH
CLEANUP = "/nh-nodes/large-preview/cleanup"
NO_CACHE_ID = "0" * 32
def call(method, path, **kwargs):
"""One request to the pack's routes. Returns (status, parsed JSON) or (status, raw bytes) for other content."""
async def run():
routes = web.RouteTableDef()
folder_routes.register_routes(routes)
preview_routes.register_routes(routes)
app = web.Application()
app.add_routes(routes)
async with TestClient(TestServer(app)) as client:
response = await client.request(method, path, **kwargs)
return response.status, response.content_type, await response.read()
status, content_type, body = asyncio.run(run())
return status, json.loads(body) if content_type == "application/json" else body
# --- registration -----------------------------------------------------------------------------------------------
def test_every_route_is_registered_and_the_package_imports_both_route_modules():
routes = web.RouteTableDef()
folder_routes.register_routes(routes)
preview_routes.register_routes(routes)
assert {(route.method, route.path) for route in routes} == {
("GET", "/nh-nodes/load-images-folder/count"),
("GET", "/nh-nodes/smart-resolution-picker/presets"),
("GET", "/nh-nodes/large-preview/manifest/{cache_id}"),
("GET", "/nh-nodes/large-preview/thumb/{cache_id}"),
("GET", "/nh-nodes/large-preview/tile/{cache_id}/{level}/{tile}"),
("GET", "/nh-nodes/large-preview/cache-info"),
("POST", "/nh-nodes/large-preview/cleanup"),
}
assert pack.folder_routes is folder_routes and pack.large_preview_routes is preview_routes
# --- /nh-nodes/load-images-folder/count --------------------------------------------------------------------------
@pytest.fixture
def image_tree(tmp_path, monkeypatch):
"""input/ holds 2 images, input/sub/ 2 more, input/sub/deeper/ 1 more; empty/ holds none."""
root = tmp_path / "input"
(root / "sub" / "deeper").mkdir(parents=True)
for relative in ("top1.png", "top2.JPG", "notes.txt", "sub/deep1.png", "sub/deep2.jpg", "sub/deeper/deep3.webp"):
(root / relative).write_bytes(b"")
(tmp_path / "empty").mkdir()
monkeypatch.setattr(folder_paths, "get_input_directory", lambda: str(root))
return root
def test_count_answers_with_the_count_and_nothing_else(image_tree):
status, body = call("GET", COUNT, params={"folder_path": str(image_tree)})
assert status == 200
assert body == {"count": 2} # in particular no resolved "path": ComfyUI may be reachable from the network
@pytest.mark.parametrize("recursive,expected", [("true", 5), ("TRUE", 5), ("1", 5), ("yes", 5), ("on", 5),
("false", 2), ("0", 2), ("no", 2), ("off", 2), ("", 2)])
def test_count_honours_the_recursive_parameter(image_tree, recursive, expected):
status, body = call("GET", COUNT, params={"folder_path": str(image_tree), "recursive": recursive})
assert (status, body) == (200, {"count": expected})
def test_count_without_recursive_is_not_recursive(image_tree):
assert call("GET", COUNT, params={"folder_path": str(image_tree)}) == (200, {"count": 2})
def test_count_resolves_like_the_loader_node(image_tree):
assert call("GET", COUNT, params={"folder_path": "sub"}) == (200, {"count": 2}) # relative to the input folder
assert call("GET", COUNT, params={"folder_path": "sub", "recursive": "true"}) == (200, {"count": 3})
assert call("GET", COUNT) == (200, {"count": 2}) # no folder: the input folder
assert call("GET", COUNT, params={"folder_path": str(image_tree / "top1.png")}) == (200, {"count": 2}) # a file: its folder
assert call("GET", COUNT, params={"folder_path": " sub "}) == (200, {"count": 2}) # surrounding blanks are ignored
def test_count_of_a_folder_without_images_is_zero(image_tree):
assert call("GET", COUNT, params={"folder_path": str(image_tree.parent / "empty")}) == (200, {"count": 0})
def test_count_of_a_missing_folder_is_404_and_does_not_echo_the_path(image_tree):
missing = image_tree.parent / "does-not-exist"
status, body = call("GET", COUNT, params={"folder_path": str(missing)})
assert status == 404
assert body == {"error": "folder not found"}
assert str(missing) not in json.dumps(body)
@pytest.mark.parametrize("params", [
{"folder_path": "x", "recursive": "maybe"},
{"folder_path": "x", "recursive": "2"},
{"folder_path": "bad\0name"},
{"folder_path": "a" * 5000},
])
def test_count_rejects_bad_input_with_400(image_tree, params):
status, body = call("GET", COUNT, params=params)
assert status == 400 and list(body) == ["error"]
def test_count_errors_are_short_and_never_leak_internals(image_tree, monkeypatch):
def denied(folder, recursive=False):
raise PermissionError(f"[Errno 13] Permission denied: {folder!r}")
monkeypatch.setattr(folder_routes, "collect_image_files", denied)
status, body = call("GET", COUNT, params={"folder_path": str(image_tree)})
assert (status, body) == (403, {"error": "folder is not readable"})
def broken(folder, recursive=False):
raise RuntimeError(f"secret detail about {folder}")
monkeypatch.setattr(folder_routes, "collect_image_files", broken)
status, body = call("GET", COUNT, params={"folder_path": str(image_tree)})
assert status == 500
assert body == {"error": "could not read the folder"}
def test_count_scans_in_a_worker_thread_not_on_the_event_loop(image_tree, monkeypatch):
seen = []
real = folder_routes.collect_image_files
def spy(folder, recursive=False):
seen.append(threading.get_ident())
return real(folder, recursive=recursive)
monkeypatch.setattr(folder_routes, "collect_image_files", spy)
assert call("GET", COUNT, params={"folder_path": str(image_tree)})[0] == 200
assert seen and seen[0] != threading.get_ident()
# --- /nh-nodes/smart-resolution-picker/presets --------------------------------------------------------------------
def test_presets_payload_matches_the_resolution_data():
status, body = call("GET", PRESETS)
assert status == 200
assert set(body) == {"models", "default_model", "default_preset", "presets_by_model", "resolution_levels",
"ratios_by_model_level"}
assert body["models"] == resolution_data.MODEL_LABELS
assert body["default_model"] == resolution_data.DEFAULT_MODEL_LABEL
assert body["default_preset"] == resolution_data.DEFAULT_PRESET
assert body["presets_by_model"] == resolution_data.PRESET_LABELS_BY_MODEL
assert body["resolution_levels"] == picker.TARGET_RESOLUTION_LEVELS
for model in body["models"]:
for level in body["resolution_levels"]:
ratios = body["ratios_by_model_level"][model][level]
assert ratios and len(ratios) == len(set(ratios))
# --- /nh-nodes/large-preview/* -----------------------------------------------------------------------------------
@pytest.fixture
def preview_temp(tmp_path, monkeypatch):
temp = tmp_path / "temp"
monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(temp))
return temp
@pytest.mark.parametrize("body", ["[]", "[1, 2]", '"text"', "5", "null", "true"])
def test_cleanup_rejects_a_json_body_that_is_not_an_object(preview_temp, body):
status, payload = call("POST", CLEANUP, data=body, headers={"Content-Type": "application/json"})
assert status == 400
assert payload == {"error": "request body must be a JSON object"}
def test_cleanup_rejects_a_body_that_is_not_json(preview_temp):
status, payload = call("POST", CLEANUP, data="{not json", headers={"Content-Type": "application/json"})
assert status == 400 and "JSON" in payload["error"]
status, _ = call("POST", CLEANUP, data=b"\xff\xfe\x00", headers={"Content-Type": "application/json"})
assert status == 400
@pytest.mark.parametrize("kwargs", [{}, {"data": ""}, {"data": " \n"}, {"json": {}}, {"json": {"max_age_hours": 24}}])
def test_cleanup_accepts_an_empty_body_or_an_object(preview_temp, kwargs):
status, payload = call("POST", CLEANUP, **kwargs)
assert status == 200 and payload["removed"] == 0
@pytest.mark.parametrize("payload", [
{"max_age_hours": [1]},
{"max_age_hours": "abc"},
{"max_total_mb": [1]},
{"max_age_hours": 10 ** 400},
{"cache_id": "../outside"},
{"cache_id": 5},
])
def test_cleanup_answers_400_for_values_it_cannot_use(preview_temp, payload):
status, body = call("POST", CLEANUP, json=payload)
assert status == 400 and list(body) == ["error"]
def test_cleanup_removes_old_entries_and_keeps_new_ones(preview_temp):
root = preview_temp / preview_cache.CACHE_SUBDIR
old, new = root / ("a" * 32), root / ("b" * 32)
for entry in (old, new):
entry.mkdir(parents=True)
(entry / "manifest.json").write_text("{}", encoding="utf-8")
long_ago = time.time() - 48 * 3600
os.utime(old, (long_ago, long_ago))
status, body = call("POST", CLEANUP, json={"max_age_hours": 24})
assert status == 200 and body["removed"] == 1
assert not old.exists() and new.exists()
def test_large_preview_routes_serve_and_delete_a_cache_entry(preview_temp):
manifest = preview_cache.create_preview_cache(torch.rand(48, 64, 3))
cache_id = manifest["cache_id"]
base = "/nh-nodes/large-preview"
status, body = call("GET", f"{base}/manifest/{cache_id}")
assert status == 200 and body["cache_id"] == cache_id
for path in (f"{base}/thumb/{cache_id}", f"{base}/tile/{cache_id}/0/0_0.jpg"):
status, data = call("GET", path)
assert status == 200 and data[:2] == b"\xff\xd8" # a JPEG
status, info = call("GET", f"{base}/cache-info")
assert status == 200 and info["cache_count"] == 1
assert "root" not in json.dumps(info) and str(preview_temp) not in json.dumps(info) # no absolute paths leak
status, body = call("POST", CLEANUP, json={"cache_id": cache_id})
assert status == 200 and body["removed"] == 1
assert call("GET", f"{base}/manifest/{cache_id}")[0] == 404
@pytest.mark.parametrize("path,status", [
(f"/nh-nodes/large-preview/manifest/{NO_CACHE_ID}", 404),
("/nh-nodes/large-preview/manifest/not-an-id", 400),
(f"/nh-nodes/large-preview/thumb/{NO_CACHE_ID}", 404),
("/nh-nodes/large-preview/thumb/zz", 400),
(f"/nh-nodes/large-preview/tile/{NO_CACHE_ID}/0/0_0.jpg", 404),
(f"/nh-nodes/large-preview/tile/{NO_CACHE_ID}/x/0_0.jpg", 400),
(f"/nh-nodes/large-preview/tile/{NO_CACHE_ID}/0/passwd", 400),
])
def test_large_preview_routes_answer_404_for_missing_and_400_for_malformed(preview_temp, path, status):
assert call("GET", path)[0] == status
def test_large_preview_tile_route_does_not_leave_the_cache_folder(preview_temp):
cache_id = preview_cache.create_preview_cache(torch.rand(8, 8, 3))["cache_id"]
status, _ = call("GET", f"/nh-nodes/large-preview/tile/{cache_id}/0/..%2F..%2Fmanifest.json")
assert status in (400, 404)
# --- web/*.js: syntax and structure ------------------------------------------------------------------------------
needs_node = pytest.mark.skipif(shutil.which("node") is None, reason="node is not installed")
@needs_node
@pytest.mark.parametrize("path", WEB_FILES, ids=lambda path: path.name)
def test_every_js_file_is_valid_syntax(path):
check = subprocess.run([shutil.which("node"), "--check", str(path)], capture_output=True, text=True,
encoding="utf-8", timeout=60, check=False)
assert check.returncode == 0, check.stderr
def test_the_shared_module_imports_nothing_and_registers_no_extension():
source = (PACK_DIR / "web" / SHARED_MODULE).read_text(encoding="utf-8")
assert "registerExtension" not in source
assert not re.search(r"^\s*import\b", source, re.MULTILINE)
COPIED_HELPER = re.compile(
r"^\s*(?:async\s+)?function\s+(findWidget|toInt|clampInt|hideWidget|showWidget|chainCallback|markDirty|refreshNode)\b",
re.MULTILINE,
)
@pytest.mark.parametrize("path", [path for path in WEB_FILES if path.name != SHARED_MODULE], ids=lambda path: path.name)
def test_extensions_import_the_shared_helpers_instead_of_copying_them(path):
source = path.read_text(encoding="utf-8")
assert not COPIED_HELPER.search(source)
if re.search(r"\b(?:findWidget|toInt|clampInt|hideWidget|showWidget|chainCallback|markDirty|refreshNode)\(", source):
assert f'from "./{SHARED_MODULE}"' in source
# --- web/*.js: behaviour in Node ----------------------------------------------------------------------------------
JS_HARNESS = r"""
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const webDir = process.argv[2];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const toUrl = (file) => pathToFileURL(path.join(webDir, file)).href;
// --- a fake ComfyUI application: it records which extension each file registers ---
const registered = [];
let loading = null;
const appGraph = { dirty: 0, setDirtyCanvas() { this.dirty++; } };
globalThis.__nhApp = { graph: appGraph, registerExtension(extension) { registered.push({ file: loading, extension }); } };
globalThis.__nhApi = { apiURL: (url) => url };
const files = fs.readdirSync(webDir).filter((name) => name.endsWith(".js")).sort();
for (const file of files) {
loading = file;
await import(toUrl(file));
}
loading = null;
const utils = await import(toUrl("nh_widget_utils.js"));
// --- two kinds of widget ---
// "modern": ComfyUI frontend 1.55.17 (litegraph BaseWidget). `hidden` is an accessor on the prototype, and a type that
// contains "hidden" hides the widget as well. "legacy": a plain object of an older frontend, no `hidden` at all.
class ModernWidget {
constructor(name, type, value, options) {
this.name = name;
this._type = type;
this.value = value;
this.options = options ?? {};
this._hidden = false;
this._hiddenByType = false;
}
get type() { return this._type; }
set type(value) { this._type = value; this._hiddenByType = /hidden/i.test(value); }
get hidden() { return this._hidden || this._hiddenByType; }
set hidden(value) { this._hidden = Boolean(value); }
computeSize() { return [0, 20]; }
}
function makeLegacyWidget(name, type, value, options) {
return { name, type, value, options: options ?? {}, computeSize() { return [0, 20]; } };
}
// A node with the parts of LGraphNode the extensions use, plus what the frontend does with its widgets: the
// workflow keeps the value of every widget unless widget.serialize is false (litegraph serialiseWidgetValues), the
// prompt gets every widget unless options.serialize is false (graphToPrompt), and a hidden widget takes no row.
function makeNode(kind, specs) {
const make = kind === "modern" ? (...args) => new ModernWidget(...args) : makeLegacyWidget;
return {
id: 1, graph: appGraph, size: [200, 100], inputs: [], outputs: [], sizeCalls: 0, dirtyCalls: 0,
widgets: specs.map(([name, type, value, options]) => make(name, type, value, options)),
addWidget(type, name, value, callback) {
const widget = make(name, type, value, {});
widget.callback = callback;
this.widgets.push(widget);
return widget;
},
addOutput(name, type) { this.outputs.push({ name, type }); },
removeOutput(slot) { this.outputs.splice(slot, 1); },
disconnectOutput() {},
widget(name) { return this.widgets.find((widget) => widget.name === name); },
isShown(widget) { return kind === "modern" ? !widget.hidden : widget.type !== "nh-hidden"; },
shown() { return this.widgets.filter((widget) => this.isShown(widget)).map((widget) => widget.name); },
hiddenNames() { return this.widgets.filter((widget) => !this.isShown(widget)).map((widget) => widget.name); },
types() { return Object.fromEntries(this.widgets.map((widget) => [widget.name, widget.type])); },
computeSize() { return [200, 40 + 24 * this.widgets.filter((widget) => this.isShown(widget)).length]; },
setSize(size) { this.size = size; this.sizeCalls++; },
setDirtyCanvas() { this.dirtyCalls++; },
workflowValues() { return this.widgets.filter((widget) => widget.serialize !== false).map((widget) => widget.value); },
promptInputs() {
const inputs = {};
for (const widget of this.widgets) {
if (widget.name && widget.options?.serialize !== false) inputs[widget.name] = widget.value;
}
return inputs;
},
};
}
// What ComfyUI does for a node: beforeRegisterNodeDef hooks the class, the node is created, then configured.
async function mountNode(file, nodeTypeName, kind, specs) {
const { extension } = registered.find((entry) => entry.file === file);
const NodeType = function () {};
await extension.beforeRegisterNodeDef(NodeType, { name: nodeTypeName });
const node = makeNode(kind, specs);
NodeType.prototype.onNodeCreated.call(node);
NodeType.prototype.onConfigure?.call(node, {});
await sleep(30);
return node;
}
const results = {};
async function scenario(name, run) {
try {
results[name] = await run();
} catch (error) {
results[name] = { error: String(error?.stack ?? error) };
}
}
const numbered = (prefix, count) => Array.from({ length: count }, (_, index) => `${prefix}_${index + 1}`);
// --- the files ---
results.registration = registered.map(({ file, extension }) => [file, extension.name]);
results.utilsExports = Object.keys(utils).sort();
// --- the shared helpers ---
await scenario("utils_basic", async () => {
const node = { widgets: [{ name: "a", value: 1 }, { name: "b", value: 2 }] };
return {
found: utils.findWidget(node, "b")?.value,
missing: utils.findWidget(node, "zzz") === undefined,
noWidgets: utils.findWidget({}, "a") === undefined,
nullNode: utils.findWidget(null, "a") === undefined,
toInt: [["12", 0], [" 7", 0], [3.9, 0], ["12px", 0], ["abc", -1], [undefined, -1], [null, -1], [NaN, -1], ["", -1], [Infinity, -1]]
.map(([value, fallback]) => utils.toInt(value, fallback)),
clampInt: [[5, 0, 10, 1], [-3, 0, 10, 1], [99, 0, 10, 1], ["x", 2, 10, 5], ["x", 2, 10, 50], [NaN, 1, 64, 5], ["7", 1, 64, 5]]
.map((args) => utils.clampInt(...args)),
};
});
await scenario("hide_show_modern", async () => {
const widget = new ModernWidget("w", "text", "keep");
const out = {};
utils.hideWidget(widget);
out.hidden = widget.hidden;
out.type = widget.type;
out.ownComputeSize = Object.hasOwn(widget, "computeSize");
utils.hideWidget(widget); // twice
utils.showWidget(widget);
out.shownAgain = widget.hidden;
out.typeAfter = widget.type;
utils.showWidget(widget); // twice
out.value = widget.value;
out.serializeUntouched = widget.serialize === undefined;
const hiddenByDefinition = new ModernWidget("d", "text", 1);
hiddenByDefinition.hidden = true;
utils.showWidget(hiddenByDefinition);
out.hiddenByDefinitionStays = hiddenByDefinition.hidden;
utils.hideWidget(undefined);
utils.showWidget(undefined);
utils.showWidget(null);
return out;
});
await scenario("hide_show_legacy", async () => {
const widget = makeLegacyWidget("w", "text", "keep");
const original = widget.computeSize;
const out = {};
utils.hideWidget(widget);
out.hiddenType = widget.type;
out.hiddenSize = widget.computeSize();
out.gotHiddenProperty = "hidden" in widget;
utils.hideWidget(widget); // a second call must not save "nh-hidden" as the type to restore
utils.showWidget(widget);
out.type = widget.type;
out.sizeRestored = widget.computeSize === original;
out.leftovers = Object.keys(widget).filter((key) => key.startsWith("__nh"));
out.value = widget.value;
const inherited = Object.assign(Object.create({ computeSize() { return [0, 33]; } }), { name: "i", type: "combo", value: 1, options: {} });
utils.hideWidget(inherited);
out.inheritedHiddenSize = inherited.computeSize();
utils.showWidget(inherited);
out.inheritedOwnSize = Object.hasOwn(inherited, "computeSize");
out.inheritedSize = inherited.computeSize();
out.inheritedType = inherited.type;
return out;
});
await scenario("chain_callback", async () => {
const log = [];
const plain = { name: "plain" };
utils.chainCallback(plain, function (...args) { log.push(["after", this === plain, args]); });
plain.callback(1, 2);
const withOriginal = { name: "original", callback(...args) { log.push(["original", this === withOriginal, args]); } };
utils.chainCallback(withOriginal, function (...args) { log.push(["after", this === withOriginal, args]); });
withOriginal.callback("x", "y");
return log;
});
await scenario("refresh", async () => {
const calls = [];
const node = {
size: [1, 1],
computeSize() { calls.push("computeSize"); return [5, 6]; },
setSize(size) { calls.push(["setSize", size]); },
setDirtyCanvas(...args) { calls.push(["nodeDirty", ...args]); },
};
utils.refreshNode(node, { setDirtyCanvas(...args) { calls.push(["graphDirty", ...args]); } });
const sizeless = { size: [9, 9], computeSize() { return null; }, setSize(size) { calls.push(["setSize", size]); } };
utils.refreshNode(sizeless, null);
utils.refreshNode({});
utils.markDirty({}, undefined);
return calls;
});
// --- the extensions, on both kinds of frontend ---
for (const kind of ["modern", "legacy"]) {
await scenario(`value_match_index_${kind}`, async () => {
const specs = [["value_count", "number", 3], ...numbered("value", 5).map((name) => [name, "text", `text ${name}`])];
const node = await mountNode("nh_value_match_index.js", "NH_ValueMatchIndex", kind, specs);
const out = { shown: node.shown(), hidden: node.hiddenNames(), types: node.types() };
const count = node.widget("value_count");
count.value = 5;
count.callback();
out.five = node.shown();
out.typesAfterFive = node.types();
count.value = 1;
count.callback();
out.one = node.shown();
out.workflowValues = node.workflowValues();
out.promptInputs = node.promptInputs();
count.value = "abc";
count.callback();
out.notANumber = { count: count.value, shown: node.shown() };
count.value = 1000;
count.callback();
out.tooMany = count.value;
out.buttonSerialize = node.widget("update values").serialize;
out.sized = node.sizeCalls > 0 && node.dirtyCalls > 0 && appGraph.dirty > 0;
return out;
});
await scenario(`save_image_path_${kind}`, async () => {
const node = await mountNode("nh_save_image_path.js", "NH_SaveImagePath", kind,
[["create_new_folder", "toggle", false], ["new_folder_name", "text", "renders"]]);
const out = { off: node.shown() };
const create = node.widget("create_new_folder");
create.value = true;
create.callback();
out.on = node.shown();
create.value = false;
create.callback();
out.offAgain = node.shown();
out.workflowValues = node.workflowValues();
out.promptInputs = node.promptInputs();
return out;
});
await scenario(`index_nodes_${kind}`, async () => {
const specs = [
["select_mode", "combo", "index"], ["index", "number", 0], ["lora_count", "number", 2],
...numbered("lora", 3).map((name) => [name, "combo", name]),
...numbered("enable", 3).map((name) => [name, "toggle", false]),
];
const node = await mountNode("nh_index_nodes.js", "NH_LoraModelIndex", kind, specs);
const out = { indexMode: node.shown() };
const mode = node.widget("select_mode");
mode.value = "boolean";
mode.callback();
out.booleanMode = node.shown();
const first = node.widget("enable_1");
const second = node.widget("enable_2");
first.value = true;
second.value = true;
second.callback();
out.exclusive = [first.value, second.value];
const count = node.widget("lora_count");
count.value = 3;
count.callback();
out.threeLoras = node.shown();
out.workflowValues = node.workflowValues();
const concat = await mountNode("nh_index_nodes.js", "NH_TextConcatenate", kind,
[["string_count", "number", 1], ...numbered("string", 3).map((name) => [name, "text", ""])]);
out.concat = { count: concat.widget("string_count").value, shown: concat.shown() };
return out;
});
}
await scenario("any_list_split", async () => {
const node = await mountNode("nh_any_list_split.js", "NH_AnyListSplit", "modern", [["index_count", "number", 3]]);
const names = () => node.outputs.map((output) => output.name);
const out = { three: names() };
const count = node.widget("index_count");
count.value = 1;
count.callback();
out.one = names();
count.value = 100;
count.callback();
out.clamped = { count: count.value, names: names() };
out.buttonSerialize = node.widget("update outputs").serialize;
return out;
});
await scenario("load_images_folder", async () => {
globalThis.window = { location: { origin: "http://127.0.0.1:8188" } };
const calls = [];
let reply = (url) => ({ ok: true, json: async () => ({ count: url.searchParams.get("recursive") === "true" ? 5 : 2 }) });
globalThis.fetch = async (input) => {
const url = new URL(String(input));
calls.push({ path: url.pathname, folder: url.searchParams.get("folder_path"), recursive: url.searchParams.get("recursive") });
return reply(url);
};
const specs = [["folder_path", "text", "C:/imgs"], ["index", "number", 3], ["step", "number", 1],
["seed_mode", "combo", "increment"], ["recursive", "toggle", true]];
const node = await mountNode("nh_load_images_folder.js", "NH_LoadImagesFromFolder", "modern", specs);
const index = node.widget("index");
const recursive = node.widget("recursive");
const out = {};
const queue = async () => { const sent = await index.serializeValue(); await sleep(10); return [sent, index.value]; };
out.recursiveOn = await queue(); // 5 images: 3 is kept, next is 4
recursive.value = false;
out.recursiveOff = await queue(); // 2 images: 4 wraps to 0, next is 1
recursive.value = true;
out.recursiveOnAgain = await queue();
out.calls = calls.map((call) => call.recursive);
out.paths = [...new Set(calls.map((call) => call.path))];
out.folders = [...new Set(calls.map((call) => call.folder))];
node.widget("seed_mode").value = "decrement";
node.widget("step").value = 2;
index.value = 0;
out.decrement = await queue(); // 5 images: 0 stays, next is 3
node.widget("seed_mode").value = "random";
const random = Math.random;
Math.random = () => 0.5;
out.random = await queue(); // floor(0.5 * 5)
Math.random = random;
node.widget("seed_mode").value = "fixed";
index.value = 7;
out.fixed = await queue(); // 7 % 5 = 2
reply = () => ({ ok: false, status: 404 });
index.value = 7;
out.notFound = await queue(); // no count: the index is left alone
globalThis.fetch = async () => { throw new Error("offline"); };
out.offline = await queue();
globalThis.fetch = async (input) => { calls.push({ recursive: new URL(String(input)).searchParams.get("recursive") }); return { ok: true, json: async () => ({ count: 4 }) }; };
const without = await mountNode("nh_load_images_folder.js", "NH_LoadImagesFromFolder", "modern", specs.slice(0, 4));
await without.widget("index").serializeValue();
out.withoutRecursiveWidget = calls[calls.length - 1].recursive;
return out;
});
await scenario("smart_resolution_picker", async () => {
const presets = {
models: ["A", "B"], default_model: "A", default_preset: "A-1",
presets_by_model: { A: ["A-1", "A-2"], B: ["B-1"] },
resolution_levels: ["1 MP", "2 MP"],
ratios_by_model_level: { A: { "1 MP": ["1:1", "3:2"], "2 MP": ["1:1"] }, B: { "1 MP": ["16:9"], "2 MP": ["16:9", "4:3"] } },
};
let fetches = 0;
globalThis.fetch = async () => { fetches++; return { ok: true, status: 200, json: async () => presets }; };
const out = {};
const pickerNode = await mountNode("nh_smart_resolution_picker.js", "NH_SmartResolutionPicker", "modern",
[["model_family", "combo", "A"], ["preset", "combo", "stale"]]);
const preset = () => [pickerNode.widget("preset").options.values, pickerNode.widget("preset").value];
out.pickerStart = preset();
pickerNode.widget("model_family").value = "B";
pickerNode.widget("model_family").callback();
out.pickerModelB = preset();
const ratioNode = await mountNode("nh_smart_resolution_picker.js", "NH_RatioPresetImageResize", "modern",
[["model_family", "combo", "A"], ["resolution_level", "combo", "1 MP"], ["target_ratio", "combo", "9:9"]]);
const ratio = () => [ratioNode.widget("target_ratio").options.values, ratioNode.widget("target_ratio").value];
out.ratioStart = ratio();
ratioNode.widget("resolution_level").value = "2 MP";
ratioNode.widget("resolution_level").callback();
out.ratioTwoMp = ratio();
ratioNode.widget("model_family").value = "B";
ratioNode.widget("model_family").callback();
out.ratioModelB = ratio();
out.fetches = fetches;
return out;
});
console.log("RESULT:" + JSON.stringify(results));
"""
@pytest.fixture(scope="module")
def js(tmp_path_factory):
"""The harness results. The files are copied unchanged into a folder shaped like ComfyUI's: the extensions'
``../../../scripts/app.js`` finds the fake app, and ``./nh_widget_utils.js`` finds its sibling."""
node = shutil.which("node")
if node is None:
pytest.skip("node is not installed")
work = tmp_path_factory.mktemp("web_harness")
(work / "package.json").write_text('{"type": "module"}', encoding="utf-8") # the copied .js files are ES modules
(work / "scripts").mkdir()
(work / "scripts" / "app.js").write_text("export const app = globalThis.__nhApp;\n", encoding="utf-8")
(work / "scripts" / "api.js").write_text("export const api = globalThis.__nhApi;\n", encoding="utf-8")
web_dir = work / "server" / "extensions" / "NH-Nodes"
web_dir.mkdir(parents=True)
for source in WEB_FILES:
shutil.copy(source, web_dir / source.name)
(work / "harness.mjs").write_text(JS_HARNESS, encoding="utf-8")
run = subprocess.run([node, str(work / "harness.mjs"), str(web_dir)], capture_output=True, text=True,
encoding="utf-8", timeout=120, check=False)
assert run.returncode == 0, run.stderr
line = next(line for line in run.stdout.splitlines() if line.startswith("RESULT:"))
return json.loads(line[len("RESULT:"):])
def scenario(js, name):
result = js[name]
assert "error" not in result, result.get("error")
return result
def test_each_extension_file_registers_exactly_one_uniquely_named_extension(js):
registration = js["registration"]
assert sorted(file for file, _ in registration) == sorted(path.name for path in WEB_FILES if path.name != SHARED_MODULE)
names = [name for _, name in registration]
assert len(names) == len(set(names)) and all(name.startswith("NH.Nodes.") for name in names)
assert js["utilsExports"] == sorted(
["chainCallback", "clampInt", "findWidget", "hideWidget", "markDirty", "refreshNode", "showWidget", "toInt"]
)
def test_findwidget_toint_and_clampint(js):
out = scenario(js, "utils_basic")
assert out["found"] == 2 and out["missing"] and out["noWidgets"] and out["nullNode"]
assert out["toInt"] == [12, 7, 3, 12, -1, -1, -1, -1, -1, -1]
assert out["clampInt"] == [5, 0, 10, 5, 10, 5, 7]
def test_hidewidget_uses_the_hidden_flag_when_the_frontend_has_it(js):
out = scenario(js, "hide_show_modern")
assert out["hidden"] is True
assert out["type"] == "text" and out["typeAfter"] == "text" # the type is not touched
assert out["ownComputeSize"] is False
assert out["shownAgain"] is False
assert out["value"] == "keep" and out["serializeUntouched"] is True # still saved with the workflow
assert out["hiddenByDefinitionStays"] is True # showWidget only undoes hideWidget
def test_hidewidget_falls_back_to_the_type_swap_on_older_frontends(js):
out = scenario(js, "hide_show_legacy")
assert out["hiddenType"] == "nh-hidden" and out["hiddenSize"] == [0, -4] and out["gotHiddenProperty"] is False
assert out["type"] == "text" and out["sizeRestored"] is True and out["leftovers"] == []
assert out["value"] == "keep"
assert out["inheritedHiddenSize"] == [0, -4]
assert out["inheritedOwnSize"] is False and out["inheritedSize"] == [0, 33] and out["inheritedType"] == "combo"
def test_chaincallback_runs_the_existing_callback_first_with_the_same_this_and_arguments(js):
assert scenario(js, "chain_callback") == [
["after", True, [1, 2]],
["original", True, ["x", "y"]],
["after", True, ["x", "y"]],
]
def test_refreshnode_resizes_then_repaints_and_tolerates_missing_methods(js):
assert scenario(js, "refresh") == [
"computeSize", ["setSize", [5, 6]], ["nodeDirty", True, True], ["graphDirty", True, True],
["setSize", [9, 9]], # computeSize gave nothing: the current size is kept
]
@pytest.mark.parametrize("kind", ["modern", "legacy"])
def test_value_match_index_shows_only_the_counted_values_and_still_saves_the_rest(js, kind):
out = scenario(js, f"value_match_index_{kind}")
assert out["shown"] == ["value_count", "value_1", "value_2", "value_3", "update values"]
assert out["hidden"] == ["value_4", "value_5"]
assert out["five"] == ["value_count", *[f"value_{i}" for i in range(1, 6)], "update values"]
assert out["one"] == ["value_count", "value_1", "update values"]
hidden_type = "text" if kind == "modern" else "nh-hidden"
assert out["types"]["value_4"] == hidden_type and out["types"]["value_1"] == "text"
assert set(out["typesAfterFive"].values()) <= {"text", "number", "button"} # shown again: the real type is back
# the hidden widgets still travel with the workflow and the prompt
assert out["workflowValues"] == [1, *[f"text value_{i}" for i in range(1, 6)]]
assert {f"value_{i}" for i in range(1, 6)} <= set(out["promptInputs"])
assert out["notANumber"] == {"count": 5, "shown": ["value_count", *[f"value_{i}" for i in range(1, 6)], "update values"]}
assert out["tooMany"] == 64
assert out["buttonSerialize"] is False and out["sized"] is True
@pytest.mark.parametrize("kind", ["modern", "legacy"])
def test_save_image_path_shows_the_folder_name_only_when_creating_a_folder(js, kind):
out = scenario(js, f"save_image_path_{kind}")
assert out["off"] == ["create_new_folder"]
assert out["on"] == ["create_new_folder", "new_folder_name"]
assert out["offAgain"] == ["create_new_folder"]
assert out["workflowValues"] == [False, "renders"]
assert out["promptInputs"] == {"create_new_folder": False, "new_folder_name": "renders"}
@pytest.mark.parametrize("kind", ["modern", "legacy"])
def test_index_nodes_switch_between_index_and_boolean_mode(js, kind):
out = scenario(js, f"index_nodes_{kind}")
assert out["indexMode"] == ["select_mode", "index", "lora_count", "lora_1", "lora_2", "update loras"]
assert out["booleanMode"] == ["select_mode", "lora_count", "lora_1", "lora_2", "enable_1", "enable_2", "update loras"]
assert out["exclusive"] == [False, True] # turning enable_2 on turns enable_1 off
assert out["threeLoras"] == ["select_mode", "lora_count", "lora_1", "lora_2", "lora_3", "enable_1", "enable_2",
"enable_3", "update loras"]
assert len(out["workflowValues"]) == 9 # all 9 widgets, shown or not (the button is not saved)
assert out["concat"] == {"count": 2, "shown": ["string_count", "string_1", "string_2", "update strings"]}
def test_any_list_split_follows_index_count(js):
out = scenario(js, "any_list_split")
assert out["three"] == ["All", "index 1", "index 2", "index 3"]
assert out["one"] == ["All", "index 1"]
assert out["clamped"] == {"count": 10, "names": ["All", *[f"index {i}" for i in range(1, 11)]]}
assert out["buttonSerialize"] is False
def test_load_images_folder_counts_with_the_nodes_recursive_setting(js):
out = scenario(js, "load_images_folder")
assert out["calls"][:3] == ["true", "false", "true"] # follows the widget on every queue
assert out["paths"] == ["/nh-nodes/load-images-folder/count"] and out["folders"] == ["C:/imgs"]
assert out["recursiveOn"] == [3, 4] # 5 images: 3, then 4
assert out["recursiveOff"] == [0, 1] # 2 images: 4 wraps to 0, then 1
assert out["recursiveOnAgain"] == [1, 2]
assert out["decrement"] == [0, 3] # step 2 back from 0 wraps to 3 of 5
assert out["random"] == [2, 2]
assert out["fixed"] == [2, 7] # 7 % 5 is sent, the widget keeps what the user typed
assert out["notFound"] == [7, 7] and out["offline"][0] == 7 # unknown count: no wrapping
assert out["withoutRecursiveWidget"] == "false"
def test_smart_resolution_picker_fills_the_dropdowns_from_one_request(js):
out = scenario(js, "smart_resolution_picker")
assert out["pickerStart"] == [["A-1", "A-2"], "A-1"]
assert out["pickerModelB"] == [["B-1"], "B-1"]
assert out["ratioStart"] == [["1:1", "3:2"], "1:1"]
assert out["ratioTwoMp"] == [["1:1"], "1:1"]
assert out["ratioModelB"] == [["16:9", "4:3"], "16:9"]
assert out["fetches"] == 1
+903
View File
@@ -0,0 +1,903 @@
"""VTON / SegFormer model handling: VRAM release (keep_model_loaded) and room-making that follow where
the ONNX Runtime sessions really run, complete and pinned downloads, and model folders that follow
extra_model_paths.yaml and list only model files.
Nothing here touches the network, a GPU or the real models folder: folder_paths points at a temp
dir, huggingface_hub is a recorder, onnxruntime is a fake and the ONNX-backed classes are stubs.
"""
import gc
import importlib
import os
import sys
import types
import weakref
import numpy as np
import pytest
import torch
from PIL import Image
PACK_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
COMFY_ROOT = os.path.dirname(os.path.dirname(PACK_DIR))
for path in (COMFY_ROOT, os.path.dirname(PACK_DIR)):
if path not in sys.path:
sys.path.insert(0, path)
folder_paths = importlib.import_module("folder_paths")
backend = importlib.import_module("NH-Nodes.core.segformer_backend")
model_files = importlib.import_module("NH-Nodes.core.model_files")
ort_session = importlib.import_module("NH-Nodes.nodes.vton.preprocess.ort_session")
vton = importlib.import_module("NH-Nodes.nodes.vton.vton_preprocessor_nodes")
VTON_FILES = (
"dwpose/yolox_l.onnx", "dwpose/dw-ll_ucoco_384.onnx",
"humanparsing/parsing_atr.onnx", "humanparsing/parsing_lip.onnx",
)
SEGFORMER_FILES = ("config.json", "model.safetensors", "preprocessor_config.json")
GIB = 1024 ** 3
# --- helpers -------------------------------------------------------------------------------------
def _norm(path):
return os.path.normcase(os.path.normpath(str(path)))
def _touch(root, relative_files):
for rel in relative_files:
target = os.path.join(str(root), *rel.split("/"))
os.makedirs(os.path.dirname(target), exist_ok=True)
with open(target, "wb") as handle:
handle.write(b"x")
def _model_list(folder_name):
"""ComfyUI's own file list for a registered folder name (what /models and the Model Library show)."""
return sorted(name.replace("\\", "/") for name in folder_paths.get_filename_list(folder_name))
class _FakeHub:
"""huggingface_hub stand-in: records snapshot_download calls and 'downloads' `payload`."""
def __init__(self):
self.calls = []
self.payload = ()
self.error = None
def snapshot_download(self, **kwargs):
self.calls.append(kwargs)
if self.error is not None:
raise self.error
_touch(kwargs["local_dir"], self.payload)
return kwargs["local_dir"]
@pytest.fixture
def hub(monkeypatch):
fake = _FakeHub()
monkeypatch.setitem(sys.modules, "huggingface_hub", types.SimpleNamespace(snapshot_download=fake.snapshot_download))
return fake
@pytest.fixture
def models_root(tmp_path, monkeypatch):
"""A fake ComfyUI install: empty models dir and a clean folder_paths registry under tmp_path."""
root = tmp_path / "ComfyUI" / "models"
root.mkdir(parents=True)
monkeypatch.setattr(folder_paths, "models_dir", str(root))
monkeypatch.setattr(folder_paths, "folder_names_and_paths", {})
monkeypatch.setattr(folder_paths, "filename_list_cache", {}) # file lists of earlier tests point at deleted dirs
monkeypatch.setattr(vton, "MODELS_DIR", str(root / "ComfyUI-Vton-Mask"))
return root
class _FakeComfyMemory:
"""comfy.model_management stand-in that logs into a shared event list."""
reserved = 700 * 1024 ** 2 # what extra_reserved_memory() reports: VRAM ComfyUI keeps for itself
def __init__(self, events, device=None):
self.events = events
self.device = torch.device("cuda", 0) if device is None else device
def get_torch_device(self):
return self.device
def extra_reserved_memory(self):
return self.reserved
def free_memory(self, nbytes, device):
self.events.append(("free_memory", nbytes, device))
def soft_empty_cache(self):
self.events.append(("soft_empty_cache",))
@pytest.fixture
def cuda_spy(monkeypatch):
"""Pretend CUDA exists and record the cache flushes the VTON node performs."""
calls = []
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(torch.cuda, "empty_cache", lambda: calls.append("empty_cache"))
real_collect = gc.collect
def collect(*args):
calls.append("gc.collect")
return real_collect(*args)
monkeypatch.setattr(gc, "collect", collect)
return calls
@pytest.fixture
def fake_ort(monkeypatch):
"""onnxruntime stand-in. `available`: providers the installed wheel ships. `working`: providers that
really start (a CUDA/cuDNN mismatch ships the CUDA provider but cannot start it).
Like the real one, a session is served by the first requested provider that works, else by the CPU.
"""
class Session:
def __init__(self, path, sess_options=None, providers=None):
self.path = path
self.active = [name for name in providers or () if name in fake.working] or ["CPUExecutionProvider"]
def get_providers(self):
return list(self.active)
fake = types.SimpleNamespace(
InferenceSession=Session,
available=["CUDAExecutionProvider", "CPUExecutionProvider"],
working={"CUDAExecutionProvider", "CPUExecutionProvider"},
get_available_providers=lambda: list(fake.available),
)
monkeypatch.setitem(sys.modules, "onnxruntime", fake)
return fake
@pytest.fixture(autouse=True)
def isolated_ort_state(monkeypatch):
"""Each test starts with no CPU fallback on record and no GPU session alive, whatever ran before it."""
monkeypatch.setattr(ort_session, "_cpu_fallback_seen", False)
monkeypatch.setattr(ort_session, "_warned_cpu_fallback", False)
monkeypatch.setattr(ort_session, "_cuda_sessions", weakref.WeakSet())
@pytest.fixture
def stub_mask_location(monkeypatch):
"""process() imports get_mask_location from utils_mask (needs scikit-image): use a trivial one."""
monkeypatch.setitem(sys.modules, "NH-Nodes.core.utils_mask",
types.SimpleNamespace(get_mask_location=_fake_get_mask_location))
# --- SegFormer model folders ---------------------------------------------------------------------
def test_pack_registers_its_folder_names_with_comfyui_at_import():
assert _norm(os.path.join(folder_paths.models_dir, "NH-Nodes")) in map(_norm, folder_paths.get_folder_paths("nh_nodes"))
assert _norm(vton.MODELS_DIR) in map(_norm, folder_paths.get_folder_paths("vton_mask"))
def test_pack_limits_its_model_lists_to_model_files_at_import():
registry = folder_paths.folder_names_and_paths
assert ".onnx" in registry["vton_mask"][1]
assert ".safetensors" in registry["nh_nodes"][1]
def test_model_lists_show_model_files_only_not_hf_cache_metadata(models_root):
_touch(vton.MODELS_DIR, [*VTON_FILES, ".gitattributes", "README.md",
".cache/huggingface/download/dwpose/yolox_l.onnx.metadata",
".cache/huggingface/download/dwpose/yolox_l.onnx.lock",
".cache/huggingface/.gitignore"])
_touch(models_root / "NH-Nodes", [
"segformer_fashion/config.json", "segformer_fashion/model.safetensors", "segformer_fashion/preprocessor_config.json",
"segformer_fashion/.cache/huggingface/download/model.safetensors.metadata",
"segformer_fashion/.gitattributes", "face_parsing/model.safetensors"])
vton.resolve_vton_models_dir(download=False) # registers vton_mask, as the pack does at start-up
backend.model_search_dirs("segformer_fashion") # registers nh_nodes
assert _model_list("vton_mask") == sorted(VTON_FILES)
assert _model_list("nh_nodes") == ["face_parsing/model.safetensors", "segformer_fashion/model.safetensors"]
def test_extension_filter_joins_a_folder_name_the_user_already_registered(models_root, tmp_path):
shared = tmp_path / "shared" / "ComfyUI-Vton-Mask"
_touch(shared, [*VTON_FILES, ".gitattributes"])
folder_paths.add_model_folder_path("vton_mask", str(shared)) # what extra_model_paths.yaml does: no extension filter
assert folder_paths.folder_names_and_paths["vton_mask"][1] == set() # i.e. ComfyUI would list every file
vton.resolve_vton_models_dir(download=False)
paths, extensions = folder_paths.folder_names_and_paths["vton_mask"]
assert [_norm(p) for p in paths] == [_norm(shared), _norm(vton.MODELS_DIR)] # the user's location is kept, first
assert extensions == {".onnx"}
assert _model_list("vton_mask") == sorted(VTON_FILES)
def test_extension_filter_does_not_change_a_set_comfyui_shares_between_folders(models_root, tmp_path):
shared_extensions = {".pt"} # like folder_paths.supported_pt_extensions, one set behind many folder names
folder_paths.folder_names_and_paths["vton_mask"] = ([str(tmp_path / "other")], shared_extensions)
vton.resolve_vton_models_dir(download=False)
assert shared_extensions == {".pt"}
assert folder_paths.folder_names_and_paths["vton_mask"][1] == {".pt", ".onnx"}
def test_registering_the_extension_filter_twice_changes_nothing(models_root):
default = str(models_root / "NH-Nodes")
for _ in range(2):
model_files.register_model_folder("nh_nodes", default, {".safetensors"})
assert folder_paths.folder_names_and_paths["nh_nodes"] == ([default], {".safetensors"})
def test_a_failing_extension_filter_does_not_lose_the_registered_locations(models_root, tmp_path, monkeypatch, capsys):
class ReadOnlyRegistry(dict):
def __setitem__(self, key, value):
raise RuntimeError("registry is read-only")
user_dir = str(tmp_path / "shared" / "NH-Nodes")
registry = ReadOnlyRegistry()
dict.__setitem__(registry, "nh_nodes", ([user_dir], set()))
monkeypatch.setattr(folder_paths, "folder_names_and_paths", registry)
default = str(models_root / "NH-Nodes")
assert model_files.register_model_folder("nh_nodes", default, {".safetensors"}) == [user_dir, default]
assert "read-only" in capsys.readouterr().out
def test_default_search_order_is_unchanged_for_existing_installs(models_root):
expected = {
"segformer_fashion": ["NH-Nodes/segformer_fashion", "RMBG/segformer_fashion"],
"face_parsing": ["NH-Nodes/face_parsing", "face_parsing", "RMBG/segformer_face"],
}
for model_name, relative in expected.items():
assert [_norm(p) for p in backend.model_search_dirs(model_name)] == [_norm(models_root / r) for r in relative]
@pytest.mark.parametrize("legacy", [("segformer_fashion", "RMBG/segformer_fashion"),
("face_parsing", "face_parsing"),
("face_parsing", "RMBG/segformer_face"),
("segformer_fashion", "NH-Nodes/segformer_fashion")])
def test_models_already_on_disk_are_found_without_download(models_root, hub, legacy):
model_name, relative = legacy
_touch(models_root / relative, SEGFORMER_FILES)
assert _norm(backend.resolve_model_dir(model_name)) == _norm(models_root / relative)
assert hub.calls == []
def test_extra_model_paths_location_is_found_and_wins_over_the_default(models_root, tmp_path, hub):
shared = tmp_path / "shared" / "NH-Nodes"
_touch(shared / "segformer_fashion", SEGFORMER_FILES)
# ComfyUI applies extra_model_paths.yaml before custom nodes load, so the user's path is registered first.
folder_paths.add_model_folder_path("nh_nodes", str(shared))
_touch(models_root / "NH-Nodes" / "segformer_fashion", SEGFORMER_FILES)
assert _norm(backend.resolve_model_dir("segformer_fashion")) == _norm(shared / "segformer_fashion")
assert hub.calls == []
# The default location stays searched, so models that already live there are never orphaned.
assert _norm(models_root / "NH-Nodes") in map(_norm, folder_paths.get_folder_paths("nh_nodes"))
def test_registering_twice_does_not_duplicate_the_default(models_root):
default = str(models_root / "NH-Nodes")
assert model_files.register_model_folder("nh_nodes", default) == [default]
assert model_files.register_model_folder("nh_nodes", default) == [default]
assert folder_paths.get_folder_paths("nh_nodes") == [default]
def test_unique_dirs_keeps_the_first_spelling_and_the_order(tmp_path):
a, b = tmp_path / "a", tmp_path / "b"
spellings = [str(a), str(b), str(a / ".." / "a"), str(b) + os.sep, str(tmp_path / "c")]
assert model_files.unique_dirs(spellings) == [str(a), str(b), str(tmp_path / "c")]
assert model_files.unique_dirs([]) == []
def test_segformer_and_vton_use_the_same_model_helpers():
for name in ("register_model_folder", "download_pinned", "make_room_on_gpu"):
assert getattr(backend, name) is getattr(model_files, name) is getattr(vton, name)
def test_incomplete_model_folder_is_skipped_and_the_next_candidate_is_used(models_root, hub):
_touch(models_root / "NH-Nodes" / "segformer_fashion", ["config.json"]) # interrupted download
_touch(models_root / "RMBG" / "segformer_fashion", SEGFORMER_FILES)
assert _norm(backend.resolve_model_dir("segformer_fashion", download=False)) == _norm(models_root / "RMBG" / "segformer_fashion")
(models_root / "RMBG" / "segformer_fashion" / "model.safetensors").unlink()
assert backend.resolve_model_dir("segformer_fashion", download=False) is None
assert hub.calls == []
@pytest.mark.parametrize("model_name, repo_id, revision", [
("segformer_fashion", "1038lab/segformer_fashion", "e5bae2d8e3333be9f12eb6aae454c0542c1371d3"),
("face_parsing", "jonathandinu/face-parsing", "758b82e15a0178c9db39c1ff666a8b56e3a550c8"),
])
def test_segformer_download_is_pinned_and_lands_in_the_default_folder(models_root, tmp_path, hub, model_name, repo_id, revision):
hub.payload = SEGFORMER_FILES
# An extra location without the model must not change where new downloads go.
folder_paths.add_model_folder_path("nh_nodes", str(tmp_path / "empty-shared"))
resolved = backend.resolve_model_dir(model_name)
assert _norm(resolved) == _norm(models_root / "NH-Nodes" / model_name)
assert len(hub.calls) == 1
call = hub.calls[0]
assert call["repo_id"] == repo_id
assert call["revision"] == revision
assert call["allow_patterns"] == list(SEGFORMER_FILES) # safetensors only: no pytorch_model.bin / onnx
assert _norm(call["local_dir"]) == _norm(resolved)
assert "local_dir_use_symlinks" not in call
def test_segformer_download_missing_files_and_failures_are_reported(models_root, hub):
hub.payload = SEGFORMER_FILES[:2]
with pytest.raises(RuntimeError, match="missing after download"):
backend.resolve_model_dir("face_parsing")
hub.error = OSError("network down")
with pytest.raises(RuntimeError, match="1038lab/segformer_fashion") as info:
backend.resolve_model_dir("segformer_fashion")
assert "network down" in str(info.value) and isinstance(info.value.__cause__, OSError)
def test_extra_model_paths_yaml_end_to_end(models_root, tmp_path, hub):
"""Feed a real extra_model_paths.yaml through ComfyUI's own loader."""
yaml = pytest.importorskip("yaml")
extra_config = pytest.importorskip("utils.extra_config")
if not hasattr(extra_config, "load_extra_path_config"):
pytest.skip("another 'utils' module shadows ComfyUI's")
shared = tmp_path / "shared"
_touch(shared / "NH-Nodes" / "face_parsing", [*SEGFORMER_FILES, ".gitattributes"])
_touch(shared / "ComfyUI-Vton-Mask", [*VTON_FILES, ".gitattributes"])
config = tmp_path / "extra_model_paths.yaml"
config.write_text(yaml.safe_dump({"my_drive": {
"base_path": str(shared), "nh_nodes": "NH-Nodes/", "vton_mask": "ComfyUI-Vton-Mask/"}}), encoding="utf-8")
extra_config.load_extra_path_config(str(config))
assert _norm(backend.resolve_model_dir("face_parsing")) == _norm(shared / "NH-Nodes" / "face_parsing")
assert _norm(vton.resolve_vton_models_dir()) == _norm(shared / "ComfyUI-Vton-Mask")
assert hub.calls == []
# The user's folders are listed like ours: model files only.
assert _model_list("vton_mask") == sorted(VTON_FILES)
assert _model_list("nh_nodes") == ["face_parsing/model.safetensors"]
# --- VTON model folders and downloads ------------------------------------------------------------
def test_vton_folder_with_missing_files_is_not_complete(models_root, hub):
_touch(vton.MODELS_DIR, VTON_FILES[:2]) # an interrupted first download leaves a half-filled folder
assert vton.resolve_vton_models_dir(download=False) is None
_touch(vton.MODELS_DIR, VTON_FILES)
assert _norm(vton.resolve_vton_models_dir(download=False)) == _norm(vton.MODELS_DIR)
assert hub.calls == []
def test_interrupted_download_is_completed_instead_of_skipped(models_root, hub):
_touch(vton.MODELS_DIR, VTON_FILES[:2])
hub.payload = VTON_FILES
resolved = vton.resolve_vton_models_dir()
assert len(hub.calls) == 1
assert all(os.path.isfile(os.path.join(resolved, *name.split("/"))) for name in VTON_FILES)
def test_vton_download_is_pinned_filtered_and_symlink_free(models_root, hub):
hub.payload = VTON_FILES
vton.resolve_vton_models_dir()
# An exact match also proves the deprecated local_dir_use_symlinks argument is gone.
assert hub.calls == [{
"repo_id": "kg-09/kg-vton-mask",
"revision": "4648778286cac2dca9aab8da167ea5257462487d",
"allow_patterns": ["dwpose/*.onnx", "humanparsing/*.onnx"],
"local_dir": vton.MODELS_DIR,
}]
def test_vton_download_that_leaves_files_missing_is_an_error(models_root, hub):
hub.payload = VTON_FILES[:3]
with pytest.raises(RuntimeError, match="parsing_lip.onnx"):
vton.resolve_vton_models_dir()
def test_vton_download_failure_explains_where_to_get_the_files(models_root, hub):
hub.error = OSError("network down")
with pytest.raises(RuntimeError, match="kg-09/kg-vton-mask") as info:
vton.resolve_vton_models_dir()
assert "network down" in str(info.value) and isinstance(info.value.__cause__, OSError)
def test_vton_models_in_extra_location_are_used_without_download(models_root, tmp_path, hub):
shared = tmp_path / "shared" / "ComfyUI-Vton-Mask"
_touch(shared, VTON_FILES)
folder_paths.add_model_folder_path("vton_mask", str(shared)) # what an extra_model_paths.yaml entry does
assert _norm(vton.resolve_vton_models_dir()) == _norm(shared)
assert hub.calls == []
def test_vton_download_goes_to_the_default_folder_even_with_extra_locations(models_root, tmp_path, hub):
hub.payload = VTON_FILES
folder_paths.add_model_folder_path("vton_mask", str(tmp_path / "empty-shared"))
assert _norm(vton.resolve_vton_models_dir()) == _norm(vton.MODELS_DIR)
def test_real_install_still_finds_its_models_in_the_old_places():
"""Existing installs must not need to move anything (skipped where the weights are absent)."""
models = folder_paths.models_dir
legacy = {
"segformer_fashion": ("NH-Nodes/segformer_fashion", "RMBG/segformer_fashion"),
"face_parsing": ("NH-Nodes/face_parsing", "face_parsing", "RMBG/segformer_face"),
}
checked = 0
for model_name, relative_dirs in legacy.items():
if any(all(os.path.isfile(os.path.join(models, rel, f)) for f in SEGFORMER_FILES) for rel in relative_dirs):
assert backend.resolve_model_dir(model_name, download=False) is not None
checked += 1
default_vton = os.path.join(models, "ComfyUI-Vton-Mask")
if all(os.path.isfile(os.path.join(default_vton, *name.split("/"))) for name in VTON_FILES):
assert vton.resolve_vton_models_dir(download=False) is not None
checked += 1
if not checked:
pytest.skip("no model weights on disk")
# --- keep_model_loaded: input definition ----------------------------------------------------------
def test_keep_model_loaded_is_a_new_optional_boolean_and_nothing_else_changed():
spec = vton.NH_VTonUltimateProcessor.INPUT_TYPES()
assert list(spec["required"]) == ["human_image", "category", "mask_feathering", "cover_shoes",
"refine_hands", "refine_hair", "device"]
assert list(spec["optional"]) == ["offset_top", "offset_bottom", "offset_left", "offset_right", "keep_model_loaded"]
kind, options = spec["optional"]["keep_model_loaded"]
assert kind == "BOOLEAN" and options["default"] is False
assert "VRAM" in options["tooltip"] and "cuda" in options["tooltip"]
assert vton.NH_VTonUltimateProcessor.RETURN_TYPES == ("MASK", "MASK", "IMAGE", "IMAGE", "IMAGE", "MASK", "MASK", "MASK")
assert vton.NH_VTonUltimateProcessor.RETURN_NAMES == (
"final_mask", "agnostic_mask", "densepose_image", "parsing_image", "masked_image",
"parsing_map_raw", "hair_mask", "hands_mask")
def test_keep_model_loaded_tooltip_tells_list_users_why_to_turn_it_on():
tooltip = vton.NH_VTonUltimateProcessor.INPUT_TYPES()["optional"]["keep_model_loaded"][1]["tooltip"]
# ComfyUI calls a node once per item of an image list, and every call reloads the models while this is off.
assert "list of images" in tooltip and "once per item" in tooltip
assert "much faster" in tooltip and "batch" in tooltip # a batch tensor is one call; lists and repeated runs benefit
assert "CPU" in tooltip # it has no effect on models that run on the CPU, including after a fallback
# --- keep_model_loaded: releasing the sessions ----------------------------------------------------
class _FakePose:
"""DWposeDetector stand-in. It references itself so only a real gc.collect() can free it, and it owns
a session made by create_session, so ort_session knows whether this model lives on the GPU."""
def __init__(self, device="cpu"):
self.me = self
self.session = ort_session.create_session("dwpose/yolox_l.onnx", device)
def __call__(self, bgr):
height, width = bgr.shape[:2]
return np.zeros((height, width, 3), np.uint8), None, None, np.full((1, 134, 2), 0.5, np.float32)
class _ExplodingPose(_FakePose):
def __call__(self, bgr):
raise RuntimeError("boom")
class _FakeParsing:
def __call__(self, image):
labels = np.zeros(np.array(image).shape[:2], np.uint8)
labels[:, : labels.shape[1] // 2] = 5
return Image.fromarray(labels), Image.fromarray(labels)
def _fake_get_mask_location(category, parsing_map, keypoints, width, height, *offsets):
return Image.fromarray(((np.array(parsing_map) == 5) * 255).astype(np.uint8)), None
@pytest.fixture
def cached_models(monkeypatch, fake_ort, stub_mask_location):
"""Factory that installs fake cached sessions for a device and returns a weakref to the pose model.
The session is served by whatever `fake_ort.working` allows when it is installed: set that first to
simulate a cuda request that fell back to the CPU. Only the weakref escapes, so a test never keeps
the sessions alive by accident.
"""
def install(device, pose_cls=_FakePose):
pose = pose_cls(device)
monkeypatch.setattr(vton, "LOADED_VTON_MODELS",
{"dwprocessor": pose, "parsing_model": _FakeParsing(), "device": device})
return weakref.ref(pose)
return install
def _process(device, **kwargs):
image = torch.full((1, 48, 64, 3), 0.5)
return vton.NH_VTonUltimateProcessor().process(image, "Upper-body", 4, False, True, True, device, **kwargs)
def test_cuda_run_releases_the_sessions_by_default(cached_models, cuda_spy):
pose_ref = cached_models("cuda")
assert ort_session.cuda_sessions_alive() # served by CUDAExecutionProvider: these pin VRAM
outputs = _process("cuda")
assert len(outputs) == 8
assert vton.LOADED_VTON_MODELS is None
assert pose_ref() is None
assert not ort_session.cuda_sessions_alive()
assert "gc.collect" in cuda_spy and "empty_cache" in cuda_spy
assert cuda_spy.index("gc.collect") < cuda_spy.index("empty_cache") # collect first, then hand VRAM back
def test_keep_model_loaded_keeps_the_cache(cached_models, cuda_spy):
pose_ref = cached_models("cuda")
cached = vton.LOADED_VTON_MODELS
_process("cuda", keep_model_loaded=True)
assert vton.LOADED_VTON_MODELS is cached and pose_ref() is not None
assert cuda_spy == []
_process("cuda", keep_model_loaded=True) # still cached and usable
assert vton.LOADED_VTON_MODELS is cached
def test_cuda_run_with_keep_off_after_a_keep_on_run_releases(cached_models, cuda_spy):
pose_ref = cached_models("cuda")
_process("cuda", keep_model_loaded=True)
assert pose_ref() is not None
_process("cuda", keep_model_loaded=False)
assert pose_ref() is None and vton.LOADED_VTON_MODELS is None
@pytest.mark.parametrize("keep", [False, True])
def test_cpu_behaviour_is_unchanged_models_stay_cached(cached_models, cuda_spy, keep):
pose_ref = cached_models("cpu")
cached = vton.LOADED_VTON_MODELS
_process("cpu", keep_model_loaded=keep)
assert vton.LOADED_VTON_MODELS is cached and pose_ref() is not None
assert cuda_spy == []
@pytest.mark.parametrize("keep", [False, True])
def test_cuda_request_that_onnxruntime_served_from_the_cpu_keeps_its_cache(cached_models, fake_ort, cuda_spy, keep):
fake_ort.working = {"CPUExecutionProvider"} # onnxruntime-gpu is installed but CUDA/cuDNN do not start
pose_ref = cached_models("cuda")
cached = vton.LOADED_VTON_MODELS
assert ort_session.cpu_fallback_seen() and not ort_session.cuda_sessions_alive()
_process("cuda", keep_model_loaded=keep)
_process("cuda", keep_model_loaded=keep) # the next item of a list: still the same, usable sessions
# Nothing sits in VRAM: dropping the sessions would only make every list item reload four models.
assert vton.LOADED_VTON_MODELS is cached and pose_ref() is not None
assert cuda_spy == [] # no gc.collect(), no torch.cuda.empty_cache()
def test_failed_cuda_run_served_from_the_cpu_keeps_the_cache(cached_models, fake_ort, cuda_spy):
fake_ort.working = {"CPUExecutionProvider"}
pose_ref = cached_models("cuda", pose_cls=_ExplodingPose)
with pytest.raises(RuntimeError, match="boom"):
_process("cuda")
assert vton.LOADED_VTON_MODELS is not None and pose_ref() is not None
assert cuda_spy == []
def test_a_gpu_cache_is_still_released_once_cpu_sessions_exist_next_to_it(cached_models, fake_ort, cuda_spy):
"""The decision follows the sessions that are alive, not a flag of the last session created."""
pose_ref = cached_models("cuda") # on the GPU
fake_ort.working = {"CPUExecutionProvider"}
ort_session.create_session("other.onnx", "cuda") # a later session that fell back
_process("cuda")
assert vton.LOADED_VTON_MODELS is None and pose_ref() is None
assert "empty_cache" in cuda_spy
def test_sessions_are_released_even_when_processing_fails(cached_models, cuda_spy):
pose_ref = cached_models("cuda", pose_cls=_ExplodingPose)
with pytest.raises(RuntimeError, match="boom"):
_process("cuda") # the traceback is still alive in this block: it must not pin the sessions
assert vton.LOADED_VTON_MODELS is None
assert pose_ref() is None
assert "empty_cache" in cuda_spy
def test_failed_run_on_cpu_keeps_the_cache(cached_models, cuda_spy):
pose_ref = cached_models("cpu", pose_cls=_ExplodingPose)
with pytest.raises(RuntimeError, match="boom"):
_process("cpu")
assert pose_ref() is not None and cuda_spy == []
def test_a_failing_cache_flush_does_not_hide_the_original_error(cached_models, cuda_spy, monkeypatch, capsys):
def broken_flush():
raise RuntimeError("CUDA error: device-side assert")
pose_ref = cached_models("cuda", pose_cls=_ExplodingPose)
monkeypatch.setattr(torch.cuda, "empty_cache", broken_flush)
with pytest.raises(RuntimeError, match="boom"): # the run's own error, not the flush error
_process("cuda")
assert vton.LOADED_VTON_MODELS is None and pose_ref() is None
assert "device-side assert" in capsys.readouterr().out
def test_release_without_cuda_skips_empty_cache(monkeypatch):
calls = []
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
monkeypatch.setattr(torch.cuda, "empty_cache", lambda: calls.append("empty_cache"))
monkeypatch.setattr(vton, "LOADED_VTON_MODELS", {"device": "cuda"})
vton.release_vton_models()
assert vton.LOADED_VTON_MODELS is None and calls == []
def test_dropping_sessions_that_ran_on_the_cpu_leaves_torchs_cache_alone(cached_models, fake_ort, cuda_spy):
fake_ort.working = {"CPUExecutionProvider"}
pose_ref = cached_models("cuda") # a cuda request that onnxruntime served from the CPU
vton.release_vton_models()
assert vton.LOADED_VTON_MODELS is None and pose_ref() is None # still dropped (and collected) ...
assert "gc.collect" in cuda_spy and "empty_cache" not in cuda_spy # ... but there is no VRAM to hand back
def test_dropping_sessions_that_ran_on_the_gpu_hands_the_vram_back(cached_models, cuda_spy):
pose_ref = cached_models("cuda")
vton.release_vton_models()
assert vton.LOADED_VTON_MODELS is None and pose_ref() is None
assert cuda_spy == ["gc.collect", "empty_cache"]
# --- making room before creating CUDA sessions ----------------------------------------------------
@pytest.fixture
def stub_loaders(monkeypatch, models_root, hub, fake_ort, stub_mask_location):
"""load_model() with stub ONNX classes and no model files yet; returns the event log.
The stubs open their sessions with create_session, so the fake onnxruntime decides where they run.
"""
events = []
monkeypatch.setattr(vton, "LOADED_VTON_MODELS", None)
monkeypatch.setattr(ort_session, "ensure_onnxruntime", lambda: None)
class Detector(_FakePose):
def __init__(self, model_root, device):
super().__init__(device)
events.append(("detector", device, model_root))
class Parsing(_FakeParsing):
def __init__(self, model_root, device):
self.session = ort_session.create_session("humanparsing/parsing_atr.onnx", device)
events.append(("parsing", device, model_root))
monkeypatch.setitem(sys.modules, "NH-Nodes.nodes.vton.preprocess.dwpose", types.SimpleNamespace(DWposeDetector=Detector))
monkeypatch.setitem(sys.modules, "NH-Nodes.nodes.vton.preprocess.humanparsing.run_parsing", types.SimpleNamespace(Parsing=Parsing))
return events
@pytest.fixture
def ready_loaders(stub_loaders):
"""stub_loaders plus a complete model folder in the default location."""
_touch(vton.MODELS_DIR, VTON_FILES)
return stub_loaders
def test_cuda_load_asks_comfyui_for_room_before_creating_sessions(ready_loaders, monkeypatch, cuda_spy):
comfy = _FakeComfyMemory(ready_loaders)
monkeypatch.setattr(model_files, "_mm", comfy)
vton.NH_VTonUltimateProcessor().load_model("cuda")
assert [event[0] for event in ready_loaders] == ["free_memory", "soft_empty_cache", "detector", "parsing"]
_, nbytes, device = ready_loaders[0]
assert vton._VTON_VRAM_ESTIMATE >= 2 * GIB # the four sessions hold about 2.9 GB after a run
assert nbytes == vton._VTON_VRAM_ESTIMATE + comfy.reserved # on top of the reserve ComfyUI keeps for itself
assert device == torch.device("cuda", 0) # ComfyUI's device, with the index its loaded models carry
def test_cpu_load_never_touches_comfyui_memory(ready_loaders, monkeypatch, cuda_spy):
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
vton.NH_VTonUltimateProcessor().load_model("cpu")
assert [event[0] for event in ready_loaders] == ["detector", "parsing"]
def test_cuda_request_without_the_cuda_provider_does_not_evict_comfyui_models(ready_loaders, fake_ort, monkeypatch, cuda_spy):
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
fake_ort.available = ["CPUExecutionProvider"] # CPU-only onnxruntime wheel
fake_ort.working = {"CPUExecutionProvider"}
vton.NH_VTonUltimateProcessor().load_model("cuda")
assert [event[0] for event in ready_loaders] == ["detector", "parsing"]
def test_cuda_run_that_falls_back_asks_for_room_once_then_stops_evicting(ready_loaders, fake_ort, monkeypatch, cuda_spy):
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
fake_ort.working = {"CPUExecutionProvider"} # onnxruntime-gpu is installed, but CUDA/cuDNN do not start
# First run: nothing says yet that the GPU is unusable, so ComfyUI is asked, once.
assert len(_process("cuda")) == 8
assert [event[0] for event in ready_loaders] == ["free_memory", "soft_empty_cache", "detector", "parsing"]
assert ort_session.cpu_fallback_seen()
assert vton.LOADED_VTON_MODELS is not None and cuda_spy == [] # CPU sessions are not released
# Every later load in this process skips it: whatever it would evict cannot help sessions on the CPU.
ready_loaders.clear()
monkeypatch.setattr(vton, "LOADED_VTON_MODELS", None)
_process("cuda")
vton.NH_VTonUltimateProcessor().load_model("cpu")
monkeypatch.setattr(vton, "LOADED_VTON_MODELS", None)
vton.NH_VTonUltimateProcessor().load_model("cuda")
assert [event[0] for event in ready_loaders] == ["detector", "parsing"] * 3
def test_cuda_runs_on_the_gpu_keep_asking_for_room_and_release(ready_loaders, monkeypatch, cuda_spy):
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
for _ in range(2):
ready_loaders.clear()
_process("cuda")
assert [event[0] for event in ready_loaders] == ["free_memory", "soft_empty_cache", "detector", "parsing"]
assert vton.LOADED_VTON_MODELS is None # released after the run, so the next one reloads
assert not ort_session.cpu_fallback_seen() and not ort_session.cuda_sessions_alive()
assert "empty_cache" in cuda_spy
def test_load_model_uses_the_resolved_folder_and_caches_per_device(stub_loaders, tmp_path):
shared = tmp_path / "shared" / "ComfyUI-Vton-Mask"
_touch(shared, VTON_FILES) # the default folder stays empty
folder_paths.add_model_folder_path("vton_mask", str(shared))
node = vton.NH_VTonUltimateProcessor()
first = node.load_model("cpu")
assert [_norm(event[2]) for event in stub_loaders] == [_norm(shared)] * 2
assert node.load_model("cpu") is first and len(stub_loaders) == 2 # cache hit: nothing is rebuilt
def test_switching_device_drops_the_old_sessions_first(ready_loaders, monkeypatch, cuda_spy):
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
node = vton.NH_VTonUltimateProcessor()
cuda_ref = weakref.ref(node.load_model("cuda")["dwprocessor"])
ready_loaders.clear()
cpu_models = node.load_model("cpu")
assert cpu_models["device"] == "cpu" and cuda_ref() is None
assert [event[0] for event in ready_loaders] == ["detector", "parsing"]
def test_switching_device_away_from_cpu_fallback_sessions_does_not_flush_torchs_cache(ready_loaders, fake_ort, monkeypatch, cuda_spy):
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(ready_loaders))
fake_ort.working = {"CPUExecutionProvider"}
node = vton.NH_VTonUltimateProcessor()
cuda_ref = weakref.ref(node.load_model("cuda")["dwprocessor"])
cuda_spy.clear()
assert node.load_model("cpu")["device"] == "cpu"
assert cuda_ref() is None and "empty_cache" not in cuda_spy
def test_missing_onnxruntime_still_fails_before_any_download(models_root, hub, monkeypatch):
def not_installed():
raise RuntimeError("ONNX Runtime missing")
monkeypatch.setattr(vton, "LOADED_VTON_MODELS", None)
monkeypatch.setattr(ort_session, "ensure_onnxruntime", not_installed)
with pytest.raises(RuntimeError, match="ONNX Runtime"):
vton.NH_VTonUltimateProcessor().load_model("cuda")
assert hub.calls == []
def test_cuda_provider_available_reflects_the_onnxruntime_build(monkeypatch):
fake = types.SimpleNamespace(get_available_providers=lambda: ["CPUExecutionProvider"])
monkeypatch.setitem(sys.modules, "onnxruntime", fake)
assert ort_session.cuda_provider_available() is False
fake.get_available_providers = lambda: ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]
assert ort_session.cuda_provider_available() is True
# --- where the sessions really run -------------------------------------------------------------------
def test_create_session_records_which_sessions_the_gpu_serves(fake_ort):
gpu = ort_session.create_session("a.onnx", "cuda")
cpu = ort_session.create_session("b.onnx", "cpu")
assert gpu.get_providers()[0] == "CUDAExecutionProvider" and cpu.get_providers() == ["CPUExecutionProvider"]
assert ort_session.cuda_sessions_alive() and not ort_session.cpu_fallback_seen()
del gpu
gc.collect()
assert not ort_session.cuda_sessions_alive() # the record never keeps a session (and its VRAM) alive
def test_a_cuda_request_served_by_the_cpu_is_a_fallback_not_a_gpu_session(fake_ort, capsys):
fake_ort.working = {"CPUExecutionProvider"}
session = ort_session.create_session("a.onnx", "cuda")
assert session.get_providers() == ["CPUExecutionProvider"]
assert ort_session.cpu_fallback_seen() and not ort_session.cuda_sessions_alive()
assert "a.onnx on CPUExecutionProvider" in capsys.readouterr().out # the existing warning is still printed
def test_the_fallback_is_remembered_even_after_its_warning_was_printed(fake_ort, monkeypatch):
fake_ort.working = {"CPUExecutionProvider"}
monkeypatch.setattr(ort_session, "_warned_cpu_fallback", True) # the one-time warning is already used up
ort_session.create_session("a.onnx", "cuda")
assert ort_session.cpu_fallback_seen()
def test_a_session_that_reports_no_provider_counts_as_a_fallback(fake_ort):
fake_ort.InferenceSession.get_providers = lambda self: []
ort_session.create_session("a.onnx", "cuda")
assert ort_session.cpu_fallback_seen() and not ort_session.cuda_sessions_alive()
def test_cpu_requests_are_never_a_fallback(fake_ort):
fake_ort.working = {"CPUExecutionProvider"}
ort_session.create_session("a.onnx", "cpu")
assert not ort_session.cpu_fallback_seen() and not ort_session.cuda_sessions_alive()
# --- the shared GPU helper -------------------------------------------------------------------------
def test_make_room_is_a_noop_without_cuda_in_comfy_cpu_mode_or_outside_comfyui(monkeypatch):
events = []
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(events))
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
model_files.make_room_on_gpu(GIB)
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(events, device=torch.device("cpu"))) # ComfyUI --cpu
model_files.make_room_on_gpu(GIB)
monkeypatch.setattr(model_files, "_mm", None)
model_files.make_room_on_gpu(GIB)
assert events == []
def test_make_room_asks_for_the_size_plus_the_reserve_comfyui_keeps_for_itself(monkeypatch):
events = []
comfy = _FakeComfyMemory(events)
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(model_files, "_mm", comfy)
model_files.make_room_on_gpu(GIB)
# ComfyUI's own loaders ask for their size plus extra_reserved_memory(); asking for less would leave us short.
assert events == [("free_memory", GIB + comfy.reserved, torch.device("cuda", 0)), ("soft_empty_cache",)]
def test_make_room_on_a_comfyui_without_extra_reserved_memory_asks_for_the_size_alone(monkeypatch):
events = []
class OlderComfy(_FakeComfyMemory):
extra_reserved_memory = None # the function does not exist in older ComfyUI builds
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(model_files, "_mm", OlderComfy(events))
model_files.make_room_on_gpu(GIB)
assert events[0] == ("free_memory", GIB, torch.device("cuda", 0))
def test_make_room_reports_comfyui_errors_instead_of_failing_the_run(monkeypatch, capsys):
class Broken(_FakeComfyMemory):
def free_memory(self, nbytes, device):
raise RuntimeError("model list changed")
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(model_files, "_mm", Broken([]))
model_files.make_room_on_gpu(GIB) # must not raise
assert "model list changed" in capsys.readouterr().out
class _FakeSegformer:
def __init__(self, device):
self.device = device
self.moved_to = []
def parameters(self):
return iter([torch.zeros(1000)]) # 4000 bytes
def to(self, device):
self.moved_to.append(device)
self.device = device
return self
def test_segformer_asks_comfyui_for_room_using_comfyuis_own_device(monkeypatch):
events = []
comfy = _FakeComfyMemory(events)
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(model_files, "_mm", comfy)
model = _FakeSegformer(torch.device("cpu"))
backend._ensure_on_device({"model": model, "device": torch.device("cuda")})
# ComfyUI's loaded models carry cuda:0; an index-less cuda device matches none of them.
# The weights (4000 bytes) are asked for twice over, plus the reserve ComfyUI keeps for itself.
assert events[0] == ("free_memory", 8000 + comfy.reserved, torch.device("cuda", 0))
assert model.moved_to == [torch.device("cuda")]
def test_segformer_already_on_the_gpu_is_not_moved_or_evicted_again(monkeypatch):
events = []
monkeypatch.setattr(torch.cuda, "is_available", lambda: True)
monkeypatch.setattr(model_files, "_mm", _FakeComfyMemory(events))
model = _FakeSegformer(torch.device("cuda", 0)) # what .to("cuda") leaves behind
backend._ensure_on_device({"model": model, "device": torch.device("cuda")})
assert events == [] and model.moved_to == []
+4 -18
View File
@@ -1,19 +1,11 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, clampInt, findWidget, refreshNode } from "./nh_widget_utils.js";
const NODE_NAME = "NH_AnyListSplit";
const MAX_INDEX_OUTPUTS = 10;
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampIndexCount(value) {
return Math.max(0, Math.min(MAX_INDEX_OUTPUTS, toInt(value, 4)));
return clampInt(value, 0, MAX_INDEX_OUTPUTS, 4);
}
function indexOutputNumber(output) {
@@ -51,9 +43,7 @@ function updateOutputs(node) {
}
}
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installControls(node) {
@@ -69,11 +59,7 @@ function installControls(node) {
const countWidget = findWidget(node, "index_count");
if (countWidget) {
const oldCallback = countWidget.callback;
countWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateOutputs(node);
};
chainCallback(countWidget, () => updateOutputs(node));
}
setTimeout(() => updateOutputs(node), 0);
+7 -52
View File
@@ -1,4 +1,5 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, clampInt, findWidget, hideWidget, refreshNode, showWidget } from "./nh_widget_utils.js";
const MAX_ITEMS = 64;
@@ -63,42 +64,8 @@ const NODE_CONFIGS = {
},
};
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampCount(value, minCount = 1) {
return Math.max(minCount, Math.min(MAX_ITEMS, toInt(value, minCount)));
}
function hideWidget(widget) {
if (!widget || widget.__nhIndexHidden) {
return;
}
widget.__nhIndexHidden = true;
widget.__nhIndexType = widget.type;
widget.__nhIndexComputeSize = widget.computeSize;
widget.computeSize = () => [0, -4];
widget.type = "nh-hidden";
}
function showWidget(widget) {
if (!widget || !widget.__nhIndexHidden) {
return;
}
widget.type = widget.__nhIndexType;
widget.computeSize = widget.__nhIndexComputeSize;
delete widget.__nhIndexHidden;
delete widget.__nhIndexType;
delete widget.__nhIndexComputeSize;
return clampInt(value, minCount, MAX_ITEMS, minCount);
}
function updateIndexedWidgets(node, config) {
@@ -137,9 +104,7 @@ function updateIndexedWidgets(node, config) {
}
}
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installIndexedControls(node, config) {
@@ -155,21 +120,13 @@ function installIndexedControls(node, config) {
const countWidget = findWidget(node, config.countWidget);
if (countWidget) {
const oldCallback = countWidget.callback;
countWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateIndexedWidgets(node, config);
};
chainCallback(countWidget, () => updateIndexedWidgets(node, config));
}
if (config.selectModeWidget) {
const smWidget = findWidget(node, config.selectModeWidget);
if (smWidget) {
const smOld = smWidget.callback;
smWidget.callback = function () {
smOld?.apply(this, arguments);
updateIndexedWidgets(node, config);
};
chainCallback(smWidget, () => updateIndexedWidgets(node, config));
}
}
@@ -177,9 +134,7 @@ function installIndexedControls(node, config) {
for (let i = 1; i <= MAX_ITEMS; i++) {
const enableWidget = findWidget(node, `${config.enablePrefix}_${i}`);
if (enableWidget) {
const enOld = enableWidget.callback;
enableWidget.callback = function () {
enOld?.apply(this, arguments);
chainCallback(enableWidget, function () {
if (this.value) {
for (let j = 1; j <= MAX_ITEMS; j++) {
if (j !== i) {
@@ -191,7 +146,7 @@ function installIndexedControls(node, config) {
}
updateIndexedWidgets(node, config);
}
};
});
}
}
}
+209 -37
View File
@@ -10,12 +10,12 @@ function ensurePreviewButton(node) {
}
node.__nhLargePreviewButton = node.addWidget("button", "Open Viewer", "Open Viewer", () => {
const manifest = node.__nhLargePreviewManifest?.[0];
if (!manifest) {
const manifests = node.__nhLargePreviewManifest;
if (!manifests?.length) {
alert("NH Large Image Preview has no viewer data yet. Queue the node first.");
return;
}
openManifest(manifest);
openManifest(manifests, 0);
});
}
@@ -25,12 +25,12 @@ function ensureCompareButton(node) {
}
node.__nhLargeCompareButton = node.addWidget("button", "Open Compare", "Open Compare", () => {
const manifest = node.__nhLargeCompareManifest?.[0];
if (!manifest) {
const manifests = node.__nhLargeCompareManifest;
if (!manifests?.length) {
alert("NH Large Image Compare has no viewer data yet. Queue the node first.");
return;
}
openCompareManifest(manifest);
openCompareManifest(manifests, 0);
});
}
@@ -63,9 +63,12 @@ function tileUrl(manifest, level, x, y) {
.replace("{y}", `${y}`));
}
function openManifest(manifest) {
if (!manifest?.levels?.length) {
const url = thumbUrl(manifest);
// A run can hold a whole batch: the viewers get every manifest of it and the one to start on.
function openManifest(manifests, startIndex = 0) {
const list = Array.isArray(manifests) ? manifests : [manifests];
const tiled = list.filter((item) => item?.levels?.length);
if (!tiled.length) {
const url = thumbUrl(list[startIndex] || list[0]);
if (url) {
window.open(url, "_blank", "noopener,noreferrer");
return;
@@ -73,15 +76,17 @@ function openManifest(manifest) {
alert("NH Large Image Preview has no thumbnail or tile data.");
return;
}
openTiledViewer(manifest);
openTiledViewer(tiled, Math.max(0, tiled.indexOf(list[startIndex])));
}
function openCompareManifest(compare) {
if (!compare?.a || !compare?.b) {
function openCompareManifest(compares, startIndex = 0) {
const list = Array.isArray(compares) ? compares : [compares];
const pairs = list.filter((item) => item?.a && item?.b);
if (!pairs.length) {
alert("NH Large Image Compare has no A/B tile data.");
return;
}
openCompareViewer(compare);
openCompareViewer(pairs, Math.max(0, pairs.indexOf(list[startIndex])));
}
function makeButton(label, onClick) {
@@ -100,15 +105,87 @@ function makeButton(label, onClick) {
return button;
}
function openTiledViewer(manifest) {
// Cancels the tile requests still running and forgets the tiles: the image was left, or the viewer closed.
function clearTileCache(tileCache) {
for (const item of tileCache.values()) {
item.image.onload = null;
item.image.onerror = null;
item.image.src = "";
}
tileCache.clear();
}
function setButtonEnabled(button, enabled) {
button.disabled = !enabled;
button.style.opacity = enabled ? "1" : "0.4";
button.style.cursor = enabled ? "pointer" : "default";
}
// Batch navigation. The part that needs no DOM lives here so both viewers share it.
function clampBatchIndex(index, count) {
const last = Math.max(0, count - 1);
const value = Number.isFinite(index) ? Math.trunc(index) : 0;
return Math.min(last, Math.max(0, value));
}
// -1 or +1 for an unmodified Left or Right arrow that was not pressed in a text field or slider, otherwise 0.
function batchStepForKey(event) {
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) {
return 0;
}
const tag = (event.target?.tagName || "").toLowerCase();
if (event.target?.isContentEditable || tag === "input" || tag === "textarea" || tag === "select") {
return 0;
}
if (event.key === "ArrowLeft") {
return -1;
}
return event.key === "ArrowRight" ? 1 : 0;
}
// The "previous / n of N / next" group of the toolbar; update(index) refreshes the counter and the end buttons.
function makeBatchNav(count, onStep) {
const element = document.createElement("div");
element.style.cssText = "display:flex;gap:6px;align-items:center";
const previous = makeButton("\u25C0", () => onStep(-1));
previous.title = "Previous image (Left arrow)";
const next = makeButton("\u25B6", () => onStep(1));
next.title = "Next image (Right arrow)";
const label = document.createElement("span");
label.style.cssText = "min-width:54px;text-align:center;color:#ddd;font:12px system-ui,sans-serif";
element.appendChild(previous);
element.appendChild(label);
element.appendChild(next);
return {
element,
update(index) {
label.textContent = `${index + 1} / ${count}`;
setButtonEnabled(previous, index > 0);
setButtonEnabled(next, index < count - 1);
},
};
}
function openTiledViewer(items, startIndex = 0) {
let index = clampBatchIndex(startIndex, items.length);
let manifest = items[index];
let levels = [];
let fullLevel = null;
let closed = false;
const overlay = document.createElement("div");
let closeOnEscape;
let onKeyDown;
let onMouseMove;
let onMouseUp;
const closeViewer = () => {
closed = true;
if (state.raf) {
cancelAnimationFrame(state.raf);
state.raf = 0;
}
clearTileCache(tileCache);
overlay.remove();
if (closeOnEscape) {
document.removeEventListener("keydown", closeOnEscape);
if (onKeyDown) {
document.removeEventListener("keydown", onKeyDown);
}
if (onMouseMove) {
window.removeEventListener("mousemove", onMouseMove);
@@ -119,10 +196,12 @@ function openTiledViewer(manifest) {
window.removeEventListener("resize", resizeCanvas);
};
overlay.tabIndex = -1; // focusable, so the arrow keys reach the viewer even if a text field was focused before
overlay.style.cssText = [
"position:fixed",
"inset:0",
"z-index:100000",
"outline:none",
"background:rgba(0,0,0,0.82)",
"display:flex",
"align-items:center",
@@ -156,7 +235,6 @@ function openTiledViewer(manifest) {
].join(";");
const title = document.createElement("div");
title.textContent = `${manifest.title || "NH Large Image Preview"} | ${manifest.width}x${manifest.height} | ${manifest.levels.length} levels`;
title.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
const buttons = document.createElement("div");
@@ -198,8 +276,6 @@ function openTiledViewer(manifest) {
document.body.appendChild(overlay);
const ctx = canvas.getContext("2d");
const levels = [...manifest.levels].sort((a, b) => a.scale - b.scale);
const fullLevel = levels[levels.length - 1];
const tileCache = new Map();
const state = {
zoom: 1,
@@ -211,6 +287,28 @@ function openTiledViewer(manifest) {
raf: 0,
};
function loadManifest() {
manifest = items[index];
levels = [...manifest.levels].sort((a, b) => a.scale - b.scale);
fullLevel = levels[levels.length - 1];
clearTileCache(tileCache);
title.textContent = `${manifest.title || "NH Large Image Preview"} | ${manifest.width}x${manifest.height} | ${levels.length} levels`;
if (nav) {
nav.update(index);
}
}
// Every image starts fitted: the zoom and pan of the one before are not carried over.
function showManifest(nextIndex) {
const target = clampBatchIndex(nextIndex, items.length);
if (target === index) {
return;
}
index = target;
loadManifest();
fit();
}
function getCanvasSize() {
return {
width: Math.max(1, canvas.clientWidth || canvasWrap.clientWidth || 1),
@@ -340,11 +438,15 @@ function openTiledViewer(manifest) {
}
function scheduleDraw() {
if (!state.raf) {
if (!state.raf && !closed) {
state.raf = requestAnimationFrame(draw);
}
}
const nav = items.length > 1 ? makeBatchNav(items.length, (delta) => showManifest(index + delta)) : null;
if (nav) {
buttons.appendChild(nav.element);
}
buttons.appendChild(makeButton("Fit", fit));
buttons.appendChild(makeButton("100%", setHundredPercent));
buttons.appendChild(makeButton("-", () => zoomAt(canvas.clientWidth / 2, canvas.clientHeight / 2, 0.8)));
@@ -389,31 +491,53 @@ function openTiledViewer(manifest) {
closeViewer();
}
});
closeOnEscape = function (event) {
if (event.key === "Escape" && document.body.contains(overlay)) {
onKeyDown = function (event) {
if (!document.body.contains(overlay)) {
return;
}
if (event.key === "Escape") {
closeViewer();
return;
}
const step = items.length > 1 ? batchStepForKey(event) : 0;
if (step) {
// The arrow keys belong to the viewer while it is open, not to the canvas behind it.
event.preventDefault();
event.stopPropagation();
showManifest(index + step);
}
};
document.addEventListener("keydown", closeOnEscape);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
loadManifest();
fit();
overlay.focus({ preventScroll: true });
}
function openCompareViewer(compare) {
const manifestA = compare.a;
const manifestB = compare.b;
const baseWidth = Math.max(manifestA.width || 1, manifestB.width || 1);
const baseHeight = Math.max(manifestA.height || 1, manifestB.height || 1);
function openCompareViewer(items, startIndex = 0) {
let index = clampBatchIndex(startIndex, items.length);
let compare = items[index];
let manifestA = compare.a;
let manifestB = compare.b;
let baseWidth = 1;
let baseHeight = 1;
let closed = false;
const overlay = document.createElement("div");
let closeOnEscape;
let onKeyDown;
let onMouseMove;
let onMouseUp;
const closeViewer = () => {
closed = true;
if (state.raf) {
cancelAnimationFrame(state.raf);
state.raf = 0;
}
clearTileCache(tileCache);
overlay.remove();
if (closeOnEscape) {
document.removeEventListener("keydown", closeOnEscape);
if (onKeyDown) {
document.removeEventListener("keydown", onKeyDown);
}
if (onMouseMove) {
window.removeEventListener("mousemove", onMouseMove);
@@ -424,10 +548,12 @@ function openCompareViewer(compare) {
window.removeEventListener("resize", resizeCanvas);
};
overlay.tabIndex = -1; // focusable, so the arrow keys reach the viewer even if a text field was focused before
overlay.style.cssText = [
"position:fixed",
"inset:0",
"z-index:100000",
"outline:none",
"background:rgba(0,0,0,0.84)",
"display:flex",
"align-items:center",
@@ -461,7 +587,6 @@ function openCompareViewer(compare) {
].join(";");
const title = document.createElement("div");
title.textContent = `${compare.title || "NH Large Image Compare"} | A ${manifestA.width}x${manifestA.height} | B ${manifestB.width}x${manifestB.height}`;
title.style.cssText = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
const buttons = document.createElement("div");
@@ -525,6 +650,32 @@ function openCompareViewer(compare) {
raf: 0,
};
function loadPair() {
compare = items[index];
manifestA = compare.a;
manifestB = compare.b;
baseWidth = Math.max(manifestA.width || 1, manifestB.width || 1);
baseHeight = Math.max(manifestA.height || 1, manifestB.height || 1);
frameLayouts.clear();
levelSets.clear();
clearTileCache(tileCache);
title.textContent = `${compare.title || "NH Large Image Compare"} | A ${manifestA.width}x${manifestA.height} | B ${manifestB.width}x${manifestB.height}`;
if (nav) {
nav.update(index);
}
}
// Every pair starts fitted; the compare mode and the split position stay as they are.
function showPair(nextIndex) {
const target = clampBatchIndex(nextIndex, items.length);
if (target === index) {
return;
}
index = target;
loadPair();
fit();
}
function getCanvasSize() {
return {
width: Math.max(1, canvas.clientWidth || canvasWrap.clientWidth || 1),
@@ -786,7 +937,7 @@ function openCompareViewer(compare) {
}
function scheduleDraw() {
if (!state.raf) {
if (!state.raf && !closed) {
state.raf = requestAnimationFrame(draw);
}
}
@@ -802,6 +953,10 @@ function openCompareViewer(compare) {
scheduleDraw();
};
const nav = items.length > 1 ? makeBatchNav(items.length, (delta) => showPair(index + delta)) : null;
if (nav) {
buttons.appendChild(nav.element);
}
buttons.appendChild(makeButton("Fit", fit));
buttons.appendChild(makeButton("100%", setHundredPercent));
buttons.appendChild(makeButton("Slider", () => setMode("slider")));
@@ -833,6 +988,10 @@ function openCompareViewer(compare) {
onMouseUp = () => {
state.dragging = false;
canvasWrap.style.cursor = "grab";
if (document.activeElement === splitInput) {
// A slider that was just dragged would keep the arrow keys; hand them back to the batch navigation.
splitInput.blur();
}
};
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
@@ -849,15 +1008,28 @@ function openCompareViewer(compare) {
closeViewer();
}
});
closeOnEscape = function (event) {
if (event.key === "Escape" && document.body.contains(overlay)) {
onKeyDown = function (event) {
if (!document.body.contains(overlay)) {
return;
}
if (event.key === "Escape") {
closeViewer();
return;
}
const step = items.length > 1 ? batchStepForKey(event) : 0;
if (step) {
// The arrow keys belong to the viewer while it is open, not to the canvas behind it.
event.preventDefault();
event.stopPropagation();
showPair(index + step);
}
};
document.addEventListener("keydown", closeOnEscape);
document.addEventListener("keydown", onKeyDown);
window.addEventListener("resize", resizeCanvas);
resizeCanvas();
loadPair();
setMode(state.mode);
overlay.focus({ preventScroll: true });
}
app.registerExtension({
+4 -18
View File
@@ -1,19 +1,11 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, clampInt, findWidget, refreshNode, toInt } from "./nh_widget_utils.js";
const NODE_NAME = "NH_LayerStackComposite";
const MAX_LAYERS = 64;
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampLayerCount(value) {
return Math.max(0, Math.min(MAX_LAYERS, toInt(value, 1)));
return clampInt(value, 0, MAX_LAYERS, 1);
}
function findInputIndex(node, name) {
@@ -120,9 +112,7 @@ function updateLayerInputs(node) {
addMissingLayerInputs(node, count);
sortLayerInputs(node);
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installLayerStackControls(node) {
@@ -138,11 +128,7 @@ function installLayerStackControls(node) {
const layerCountWidget = findWidget(node, "layer_count");
if (layerCountWidget) {
const oldCallback = layerCountWidget.callback;
layerCountWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateLayerInputs(node);
};
chainCallback(layerCountWidget, () => updateLayerInputs(node));
}
setTimeout(() => updateLayerInputs(node), 0);
+9 -13
View File
@@ -1,17 +1,9 @@
import { app } from "../../../scripts/app.js";
import { findWidget, markDirty, toInt } from "./nh_widget_utils.js";
const NODE_NAME = "NH_LoadImagesFromFolder";
const COUNT_ENDPOINT = "/nh-nodes/load-images-folder/count";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function wrapIndex(value, count) {
const index = Math.trunc(value);
if (count > 0) {
@@ -20,9 +12,13 @@ function wrapIndex(value, count) {
return Math.max(0, index);
}
async function getImageCount(folderPath) {
// How many images the node sees in the folder. `recursive` has to be the node's own setting: a recursive node sees the
// images of the sub-folders too, so counting only the top level would wrap the index too early. The count is asked for
// on every queue, so a changed folder_path or recursive value is picked up without a cache to refresh.
async function getImageCount(folderPath, recursive) {
const url = new URL(COUNT_ENDPOINT, window.location.origin);
url.searchParams.set("folder_path", folderPath || "");
url.searchParams.set("recursive", recursive ? "true" : "false");
try {
const response = await fetch(url);
@@ -38,8 +34,7 @@ async function getImageCount(folderPath) {
function setWidgetValue(node, widget, value) {
widget.value = value;
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
markDirty(node, app.graph);
}
function installIndexControl(node) {
@@ -51,6 +46,7 @@ function installIndexControl(node) {
const indexWidget = findWidget(node, "index");
const stepWidget = findWidget(node, "step");
const modeWidget = findWidget(node, "seed_mode");
const recursiveWidget = findWidget(node, "recursive"); // optional: a node without it counts as not recursive
if (!folderWidget || !indexWidget || !stepWidget || !modeWidget) {
return;
@@ -61,7 +57,7 @@ function installIndexControl(node) {
indexWidget.serializeValue = async function () {
const mode = modeWidget.value || "fixed";
const step = Math.max(1, toInt(stepWidget.value, 1));
const imageCount = await getImageCount(folderWidget.value);
const imageCount = await getImageCount(folderWidget.value, Boolean(recursiveWidget?.value));
let currentIndex = wrapIndex(toInt(indexWidget.value, 0), imageCount);
if (mode === "random") {
+3 -37
View File
@@ -1,36 +1,8 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, findWidget, hideWidget, refreshNode, showWidget } from "./nh_widget_utils.js";
const NODE_NAME = "NH_SaveImagePath";
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function hideWidget(widget) {
if (!widget || widget.__nhSaveImagePathHidden) {
return;
}
widget.__nhSaveImagePathHidden = true;
widget.__nhSaveImagePathType = widget.type;
widget.__nhSaveImagePathComputeSize = widget.computeSize;
widget.computeSize = () => [0, -4];
widget.type = "nh-hidden";
}
function showWidget(widget) {
if (!widget || !widget.__nhSaveImagePathHidden) {
return;
}
widget.type = widget.__nhSaveImagePathType;
widget.computeSize = widget.__nhSaveImagePathComputeSize;
delete widget.__nhSaveImagePathHidden;
delete widget.__nhSaveImagePathType;
delete widget.__nhSaveImagePathComputeSize;
}
function updateNewFolderWidget(node) {
const createWidget = findWidget(node, "create_new_folder");
const folderNameWidget = findWidget(node, "new_folder_name");
@@ -45,9 +17,7 @@ function updateNewFolderWidget(node) {
hideWidget(folderNameWidget);
}
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installSaveImagePathControls(node) {
@@ -58,11 +28,7 @@ function installSaveImagePathControls(node) {
const createWidget = findWidget(node, "create_new_folder");
if (createWidget) {
const oldCallback = createWidget.callback;
createWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateNewFolderWidget(node);
};
chainCallback(createWidget, () => updateNewFolderWidget(node));
}
setTimeout(() => updateNewFolderWidget(node), 0);
+5 -18
View File
@@ -1,4 +1,5 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, findWidget, markDirty } from "./nh_widget_utils.js";
const NODE_NAME = "NH_SmartResolutionPicker";
const RATIO_RESIZE_NODE_NAME = "NH_RatioPresetImageResize";
@@ -6,10 +7,6 @@ const PRESETS_ENDPOINT = "/nh-nodes/smart-resolution-picker/presets";
let cachedPresetData = null;
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
async function getPresetData() {
if (cachedPresetData) {
return cachedPresetData;
@@ -45,8 +42,7 @@ function updatePresetDropdown(node, presetData, keepCurrent = true) {
presetWidget.value = model === presetData.default_model ? presetData.default_preset : presets[0];
}
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
markDirty(node, app.graph);
}
function updateRatioDropdown(node, presetData, keepCurrent = true) {
@@ -69,8 +65,7 @@ function updateRatioDropdown(node, presetData, keepCurrent = true) {
ratioWidget.value = ratios.includes("1:1") ? "1:1" : ratios[0];
}
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
markDirty(node, app.graph);
}
function installSmartResolutionPicker(node) {
@@ -87,11 +82,7 @@ function installSmartResolutionPicker(node) {
return;
}
const oldCallback = modelWidget.callback;
modelWidget.callback = function () {
oldCallback?.apply(this, arguments);
updatePresetDropdown(node, presetData, false);
};
chainCallback(modelWidget, () => updatePresetDropdown(node, presetData, false));
}).catch((error) => {
console.warn("[NH-Nodes] Smart Resolution Picker preset update failed:", error);
});
@@ -112,11 +103,7 @@ function installRatioPresetImageResize(node) {
continue;
}
const oldCallback = widget.callback;
widget.callback = function () {
oldCallback?.apply(this, arguments);
updateRatioDropdown(node, presetData, false);
};
chainCallback(widget, () => updateRatioDropdown(node, presetData, false));
}
}).catch((error) => {
console.warn("[NH-Nodes] Ratio Preset Image Resize ratio update failed:", error);
+4 -43
View File
@@ -1,44 +1,11 @@
import { app } from "../../../scripts/app.js";
import { chainCallback, clampInt, findWidget, hideWidget, refreshNode, showWidget } from "./nh_widget_utils.js";
const NODE_NAME = "NH_ValueMatchIndex";
const MAX_VALUES = 64;
function findWidget(node, name) {
return node.widgets?.find((widget) => widget.name === name);
}
function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
function clampValueCount(value) {
return Math.max(1, Math.min(MAX_VALUES, toInt(value, 5)));
}
function hideWidget(widget) {
if (!widget || widget.__nhValueMatchHidden) {
return;
}
widget.__nhValueMatchHidden = true;
widget.__nhValueMatchType = widget.type;
widget.__nhValueMatchComputeSize = widget.computeSize;
widget.computeSize = () => [0, -4];
widget.type = "nh-hidden";
}
function showWidget(widget) {
if (!widget || !widget.__nhValueMatchHidden) {
return;
}
widget.type = widget.__nhValueMatchType;
widget.computeSize = widget.__nhValueMatchComputeSize;
delete widget.__nhValueMatchHidden;
delete widget.__nhValueMatchType;
delete widget.__nhValueMatchComputeSize;
return clampInt(value, 1, MAX_VALUES, 5);
}
function updateValueWidgets(node) {
@@ -58,9 +25,7 @@ function updateValueWidgets(node) {
}
}
node.setSize?.(node.computeSize?.() || node.size);
node.setDirtyCanvas?.(true, true);
app.graph?.setDirtyCanvas?.(true, true);
refreshNode(node, app.graph);
}
function installValueMatchControls(node) {
@@ -76,11 +41,7 @@ function installValueMatchControls(node) {
const countWidget = findWidget(node, "value_count");
if (countWidget) {
const oldCallback = countWidget.callback;
countWidget.callback = function () {
oldCallback?.apply(this, arguments);
updateValueWidgets(node);
};
chainCallback(countWidget, () => updateValueWidgets(node));
}
setTimeout(() => updateValueWidgets(node), 0);
+91
View File
@@ -0,0 +1,91 @@
// Helpers shared by the NH-Nodes frontend extensions. This file registers no extension and imports nothing: ComfyUI
// loads every .js file of the web folder, the extensions import this one with a relative path, and the browser keeps a
// single copy of it.
export function findWidget(node, name) {
return node?.widgets?.find((widget) => widget.name === name);
}
export function toInt(value, fallback) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
// `value` as an integer between min and max; `fallback` (also clamped) when it is not a number.
export function clampInt(value, min, max, fallback) {
return Math.max(min, Math.min(max, toInt(value, fallback)));
}
// Hiding a widget.
//
// ComfyUI frontend 1.55.17 (litegraph BaseWidget) has `widget.hidden`: a hidden widget is not drawn, takes no row, cannot
// be clicked, and is still saved with the workflow and sent with the prompt. Older frontends have no such property, so
// there a widget is hidden the way this pack always did it: a type no renderer knows and a computeSize that takes no
// room. `serialize` is never touched, so the value of a hidden widget is saved either way.
//
// showWidget only undoes what hideWidget did: a widget the node definition hides stays hidden.
const HIDDEN_STATE = "__nhHiddenState";
const HIDDEN_TYPE = "nh-hidden";
export function hideWidget(widget) {
if (!widget || widget[HIDDEN_STATE]) {
return;
}
if ("hidden" in widget) {
widget[HIDDEN_STATE] = { native: true };
widget.hidden = true;
return;
}
widget[HIDDEN_STATE] = {
native: false,
type: widget.type,
computeSize: widget.computeSize,
ownComputeSize: Object.prototype.hasOwnProperty.call(widget, "computeSize"),
};
widget.computeSize = () => [0, -4];
widget.type = HIDDEN_TYPE;
}
export function showWidget(widget) {
const state = widget?.[HIDDEN_STATE];
if (!state) {
return;
}
delete widget[HIDDEN_STATE];
if (state.native) {
widget.hidden = false;
return;
}
widget.type = state.type;
if (state.ownComputeSize) {
widget.computeSize = state.computeSize;
} else {
delete widget.computeSize;
}
}
// Calls `after` each time the widget's callback fires, once the callback that was already there (the frontend's, or
// another extension's) has run. `after` gets the same `this` and arguments.
export function chainCallback(widget, after) {
const original = widget.callback;
widget.callback = function () {
original?.apply(this, arguments);
after.apply(this, arguments);
};
}
// Repaints the canvas. `graph` is the application's graph (app.graph), which can differ from node.graph.
export function markDirty(node, graph) {
node.setDirtyCanvas?.(true, true);
graph?.setDirtyCanvas?.(true, true);
}
// Fits the node to the widgets and slots it has now, then repaints.
export function refreshNode(node, graph) {
node.setSize?.(node.computeSize?.() || node.size);
markDirty(node, graph);
}