Files
ausboss-ComfyUI-AusBoss/nodes/node_load_image_pad.py
T
ausboss 8f6ac16d27 Validators: ignore inputs they do not name
A pack that wraps node validators in a catch-all takes **kwargs, so
ComfyUI hands it every input and it passes them all on. Load Image + Pad
then stopped the run with "VALIDATE_INPUTS() got an unexpected keyword
argument 'pad_left'" (#229), and every other validator in the pack would
fail the same way.

ignore_extra_inputs drops the inputs a validator does not name and shows
ComfyUI the original signature, so on a plain ComfyUI nothing changes:
only the named inputs are passed, ComfyUI still runs its own range and
list checks on the rest, and a failed check is still reported once.
Every validator in the pack wears it, and a test holds new ones to it.
2026-10-22 15:10:09 -05:00

414 lines
16 KiB
Python

"""Load Image + Pad 🆎."""
from __future__ import annotations
import re
from pathlib import Path
import numpy as np
import torch
from ._execution_helpers import ignore_extra_inputs
from ._inpaint_crop_helpers import build_canvas_stitcher
from ._krea2_helpers import build_reference_image
from ._media_helpers import (
encode_preview,
list_input_images,
load_image_frames,
resolve_input_path,
)
from ._pad_helpers import (
PAD_MODES,
feather_pad_mask,
pad_image,
plan_pad_canvas,
resize_source,
trim_source,
)
from ._preview_helpers import first_frame_to_pil
from ._transform_engine import stable_file_fingerprint
try:
import folder_paths
except ImportError: # Offline tests import this module without ComfyUI.
folder_paths = None
# Temp subfolder for the wired source's stage preview. One file per node,
# overwritten on every run, so a queue of thousands of images leaves one
# small JPEG behind rather than thousands.
STAGE_PREVIEW_SUBFOLDER = "ausboss_load_image_pad"
def _frames_to_tensor(frames) -> torch.Tensor:
"""RGBA PIL frames (from load_image_frames) to a BHWC RGB float batch."""
stacked = []
for frame in frames:
array = np.asarray(frame.convert("RGB"), dtype=np.float32) / 255.0
stacked.append(torch.from_numpy(array.copy()))
return torch.stack(stacked, dim=0)
def _wired_frames(image) -> torch.Tensor:
"""A wired IMAGE batch as the BHWC RGB float batch the file path yields.
Alpha is dropped the same way a loaded file's is, and a one-channel
batch is spread to RGB so padding and the reference see three channels.
"""
if not isinstance(image, torch.Tensor) or image.ndim != 4 or int(image.shape[0]) < 1:
raise ValueError("Load Image + Pad: source_image must be a BHWC IMAGE batch.")
frames = image.detach().to("cpu", torch.float32)
channels = int(frames.shape[-1])
if channels == 1:
frames = frames.expand(-1, -1, -1, 3)
elif channels >= 3:
frames = frames[..., :3]
else:
raise ValueError(f"Load Image + Pad: source_image has {channels} channels.")
return frames.contiguous()
def _with_stage_preview(frames: torch.Tensor, unique_id, result: tuple):
"""Attach a small preview of the wired source for the on-node canvas.
The canvas cannot fetch a wired picture the way it fetches a file, so the
run hands it the first frame and the true source size. A preview is a
convenience, never the job: if writing it fails the outputs still return.
"""
if folder_paths is None:
return result
width, height = int(frames.shape[2]), int(frames.shape[1])
# unique_id comes from the prompt, so only filename-safe characters
# reach the path (subgraph ids look like "12:5").
name = re.sub(r"[^A-Za-z0-9_-]", "_", str(unique_id or "node"))[:64] + ".jpg"
try:
folder = Path(folder_paths.get_temp_directory()) / STAGE_PREVIEW_SUBFOLDER
folder.mkdir(parents=True, exist_ok=True)
pil = first_frame_to_pil(frames, "Load Image + Pad")
(folder / name).write_bytes(encode_preview(pil, 512, 512))
except Exception as exc: # noqa: BLE001 - any failure here is non-fatal
detail = str(exc).encode("ascii", "replace").decode("ascii")
print(f"[AusBoss] Load Image + Pad: stage preview unavailable ({detail}).")
return result
return {
"ui": {
"ausboss_pad_preview": [
{"filename": name, "subfolder": STAGE_PREVIEW_SUBFOLDER, "type": "temp"}
],
"ausboss_pad_source": [[width, height]],
},
"result": result,
}
# Default for source_image in VALIDATE_INPUTS. ComfyUI hands a linked input
# to validation as None (its value only exists at execution), while an
# optional input nobody wired is left out and keeps this default.
_UNWIRED = object()
class AusBossLoadImagePad:
CATEGORY = "🆎 AusBoss/Image"
DESCRIPTION = (
"Loads an image and pads it into an outpaint canvas in one node. "
"Drag any edge of the canvas drawn on the node to set the per-side "
"padding visually; the mask covers exactly the new padding, "
"optionally feathered inward across the seam. The canvas rounds to "
"a clean multiple without adding a strip to an edge you did not "
"pad, and a megapixel target rescales the source first so the mask "
"seam stays crisp."
)
SEARCH_ALIASES = [
"load image",
"outpaint canvas",
"pad",
"outpaint pad",
"extend canvas",
"megapixel",
"ausboss",
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": (
list_input_images(),
{
"image_upload": True,
"tooltip": "Choose or upload an image from ComfyUI's input folder.",
},
),
"pad_left": (
"INT",
{
"default": 0,
"min": 0,
"max": 16384,
"step": 1,
"tooltip": "Pixels added to the left edge (drag the canvas edge on the node).",
},
),
"pad_top": (
"INT",
{
"default": 0,
"min": 0,
"max": 16384,
"step": 1,
"tooltip": "Pixels added to the top edge (drag the canvas edge on the node).",
},
),
"pad_right": (
"INT",
{
"default": 0,
"min": 0,
"max": 16384,
"step": 1,
"tooltip": "Pixels added to the right edge (drag the canvas edge on the node).",
},
),
"pad_bottom": (
"INT",
{
"default": 0,
"min": 0,
"max": 16384,
"step": 1,
"tooltip": "Pixels added to the bottom edge (drag the canvas edge on the node).",
},
),
"mode": (
list(PAD_MODES),
{
"default": "color",
"tooltip": (
"color = solid fill_color; edge = each side takes "
"the average color of the nearest image edge; "
"edge pixel = the outermost rows and columns "
"smear outward; pillarbox blur = the image itself, "
"stretched to cover the canvas, blurred and dimmed "
"behind the sharp original; mirror = the picture "
"flipped outward at every edge, so the padding "
"looks like more of the scene (the best margin "
"before an edit you realign with Realign to "
"Source)."
),
},
),
"fill_color": (
"STRING",
{
"default": "#808080",
"tooltip": (
"Padding color for the color mode; accepts "
"#RGB/#RRGGBB hex, R, G, B (0-255 or 0..1 floats), "
"one grayscale number, or a CSS color name. Other "
"modes ignore it."
),
},
),
"backdrop_blur": (
"FLOAT",
{
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.01,
"tooltip": (
"Pillarbox blur only: one knob for how strongly the "
"backdrop is blurred and dimmed; 0 keeps it sharp "
"and full brightness."
),
},
),
"feather": (
"INT",
{
"default": 0,
"min": 0,
"max": 2048,
"step": 1,
"tooltip": (
"Ramps the mask inward across the image edge on "
"each padded side so the sampler blends the seam; "
"0 keeps the seam hard."
),
},
),
"canvas_multiple": (
"INT",
{
"default": 8,
"min": 1,
"max": 4096,
"step": 1,
"tooltip": (
"Rounds the final canvas to this multiple. The "
"extra pixels join a side you padded; if you padded "
"neither left nor right (or neither top nor bottom), "
"none are added there: the source is scaled to fit "
"when a megapixel target is set, otherwise trimmed "
"by those few pixels, evenly from both edges."
),
},
),
"target_megapixels": (
"FLOAT",
{
"default": 0.0,
"min": 0.0,
"max": 64.0,
"step": 0.01,
"tooltip": (
"0 = off. Rescales the SOURCE so the padded canvas "
"lands on this many megapixels (then re-rounds to "
"canvas_multiple) — resizing before padding keeps "
"the mask seam crisp."
),
},
),
},
# Appended as optional, so saved workflows and API prompts load
# unchanged; the socket has no widget, so widget positions hold.
"optional": {
"source_image": (
"IMAGE",
{
"tooltip": (
"Optional. Wire an image here to pad it instead of "
"the file chosen above; the wire wins. The canvas "
"on the node shows the last image it padded, and "
"the same padding applies to every image that "
"arrives."
),
},
),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
# `reference` is appended, never inserted: a workflow stores links by
# output slot index, so putting it anywhere else would silently rewire
# every saved graph that already reads width/height/stitcher.
RETURN_TYPES = ("IMAGE", "MASK", "INT", "INT", "AUSBOSS_STITCHER", "IMAGE")
RETURN_NAMES = ("image", "mask", "width", "height", "stitcher", "reference")
OUTPUT_TOOLTIPS = (
"The padded image; the original pixels are untouched (resized only "
"when a megapixel target is set; without one, Multiple may trim a "
"few pixels off a pair of edges you did not pad).",
"White over the new padding, feathered inward per the feather widget "
"— feed it straight to an inpainter as the outpaint mask.",
"Final canvas width after multiple/megapixel rounding.",
"Final canvas height after multiple/megapixel rounding.",
"Hand to Stitch Inpaint 🆎 with the sampled result to keep only the "
"new padding and restore the original pixels bit-identically. Also "
"carries where the source sits on the canvas, which Krea 2 Outpaint "
"Model Patch 🆎 reads to place the reference and Realign to Source 🆎 "
"reads to cut your picture back out of an edit made on this canvas.",
"The source alone, no padding, fitted to a small multiple of 16 — "
"the reference image for Krea 2 Encode 🆎 and other reference "
"conditioning. It is a quick resize, made on every run whether "
"wired or not.",
)
FUNCTION = "load_pad"
def load_pad(
self,
image,
pad_left,
pad_top,
pad_right,
pad_bottom,
mode,
fill_color,
backdrop_blur,
feather,
canvas_multiple,
target_megapixels,
source_image=None,
unique_id=None,
):
if source_image is not None:
frames = _wired_frames(source_image)
else:
frames = _frames_to_tensor(load_image_frames(resolve_input_path(image)))
source = frames
plan = plan_pad_canvas(
frames.shape[2],
frames.shape[1],
int(pad_left),
int(pad_top),
int(pad_right),
int(pad_bottom),
int(canvas_multiple),
float(target_megapixels),
)
frames = resize_source(frames, plan["source_width"], plan["source_height"])
frames = trim_source(
frames, plan["trim_left"], plan["trim_top"], plan["trim_right"], plan["trim_bottom"]
)
output, mask = pad_image(
frames,
plan["left"],
plan["top"],
plan["right"],
plan["bottom"],
str(mode),
fill_color,
float(backdrop_blur),
)
mask = feather_pad_mask(
mask, plan["left"], plan["top"], plan["right"], plan["bottom"], int(feather)
)
# Padding knows exactly where the source landed, so the bbox rides on
# the stitcher rather than on a wire that can be left unplugged.
bbox = (
plan["left"],
plan["top"],
plan["left"] + frames.shape[2],
plan["top"] + frames.shape[1],
)
# The padded canvas is the stitch base, so whatever the sampler does
# outside the feathered band is discarded and the source survives.
stitcher = build_canvas_stitcher(output, mask, bbox=bbox, source="Load Image + Pad")
reference = build_reference_image(frames)
result = (
output,
mask,
int(plan["width"]),
int(plan["height"]),
stitcher,
reference,
)
if source_image is None:
return result
return _with_stage_preview(source, unique_id, result)
@classmethod
@ignore_extra_inputs
def VALIDATE_INPUTS(cls, image, source_image=_UNWIRED):
# A wired source replaces the file, so the file choice may be
# missing or stale without stopping the run.
if source_image is None:
return True
try:
resolve_input_path(image)
except Exception as exc:
return f"Load Image + Pad: {exc}"
return True
@classmethod
def IS_CHANGED(cls, image, **values):
try:
path = resolve_input_path(image)
except Exception:
path = image or ""
return stable_file_fingerprint(path, {"image": image, **values})
NODE_CLASS_MAPPINGS = {"AUSBOSS_NODES_LoadImagePad": AusBossLoadImagePad}
NODE_DISPLAY_NAME_MAPPINGS = {"AUSBOSS_NODES_LoadImagePad": "Load Image + Pad 🆎"}
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]