Add custom nodes, Civitai loras (LFS), and vast.ai setup script
Some checks failed
Python Linting / Run Ruff (push) Has been cancelled
Python Linting / Run Pylint (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Has been cancelled
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Has been cancelled
Execution Tests / test (macos-latest) (push) Has been cancelled
Execution Tests / test (ubuntu-latest) (push) Has been cancelled
Execution Tests / test (windows-latest) (push) Has been cancelled
Test server launches without errors / test (push) Has been cancelled
Unit Tests / test (macos-latest) (push) Has been cancelled
Unit Tests / test (ubuntu-latest) (push) Has been cancelled
Unit Tests / test (windows-2022) (push) Has been cancelled

Includes 30 custom nodes committed directly, 7 Civitai-exclusive
loras stored via Git LFS, and a setup script that installs all
dependencies and downloads HuggingFace-hosted models on vast.ai.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 00:55:26 +00:00
parent 2b70ab9ad0
commit f09734b0ee
2274 changed files with 748556 additions and 3 deletions

View File

@@ -0,0 +1,171 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# PyPI configuration file
.pypirc

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Joel Trauger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,2 @@
# ComfyUI-UltimateSDUpscale-GGUF
GGUF implementation for the ComfyUI Ultimate SD Upscale node.

View File

@@ -0,0 +1,13 @@
"""
ComfyUI Ultimate SD Upscale Node for GGUF models
"""
from .ultimate_sd_upscale_gguf import UltimateSDUpscaleGGUF
NODE_CLASS_MAPPINGS = {
"UltimateSDUpscaleGGUF": UltimateSDUpscaleGGUF
}
NODE_DISPLAY_NAME_MAPPINGS = {
"UltimateSDUpscaleGGUF": "Ultimate SD Upscale (GGUF)"
}

View File

@@ -0,0 +1,14 @@
[project]
name = "ComfyUI-UltimateSDUpscale-GGUF"
description = "Flux (GGUF) implementation for the ComfyUI Ultimate SD Upscale node."
version = "1.0.0"
license = {file = "LICENSE"}
[project.urls]
Repository = "https://github.com/traugdor/ComfyUI-UltimateSDUpscale-GGUF"
# Used by Comfy Registry https://comfyregistry.org
[tool.comfy]
PublisherId = "traugdor"
DisplayName = "ComfyUI-UltimateSDUpscale-GGUF"
Icon = ""

View File

@@ -0,0 +1,175 @@
import os
import sys
import torch
# Add ComfyUI path to sys.path
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
COMFY_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
if COMFY_DIR not in sys.path:
sys.path.append(COMFY_DIR)
from comfy_extras.nodes_custom_sampler import SamplerCustomAdvanced
import comfy.sample
import comfy.model_management
import latent_preview
# VRAM Management
import torch.cuda
from comfy.cli_args import args
import comfy.model_management
# Get the current device
device = comfy.model_management.get_torch_device()
class SamplerHelper:
@staticmethod
def force_memory_cleanup(unload_models=False):
comfy.model_management.cleanup_models()
if unload_models:
comfy.model_management.unload_all_models()
comfy.model_management.soft_empty_cache(True)
torch.cuda.empty_cache()
@staticmethod
def create_gaussian_blend_kernel(overlap, device):
"""Create a gaussian blend kernel that matches BHWC format."""
try:
y = torch.arange(overlap, device=device).float()
x = torch.arange(overlap, device=device).float()
# Calculate 1D gaussian weights
center = overlap / 2
sigma = overlap / 4 # Controls the spread of the gaussian
# Create 1D gaussians
y_kernel = torch.exp(-((y - center) ** 2) / (2 * sigma ** 2))
x_kernel = torch.exp(-((x - center) ** 2) / (2 * sigma ** 2))
# Expand to match dimensions properly
y_kernel = y_kernel.view(-1, 1) # Shape: [overlap, 1]
x_kernel = x_kernel.view(1, -1) # Shape: [1, overlap]
# Create 2D kernel through outer product
kernel = y_kernel @ x_kernel # Shape: [overlap, overlap]
# Normalize kernel
kernel = kernel / kernel.max()
# Add batch dimension and move to BHWC format
# [overlap, overlap] -> [1, overlap, overlap, 1]
kernel = kernel.view(1, overlap, overlap, 1)
return kernel
finally:
# Cleanup temporary tensors
del y, x, y_kernel, x_kernel
@staticmethod
def blend_tile_edges(tile, existing_output, kernel):
"""Blend tile edges using gaussian kernel. Expects BHWC format."""
overlap = kernel.shape[1] # Kernel is [B,H,W,C]
# Extract overlap regions
left = existing_output[:, :, :overlap, :] if tile.shape[2] > overlap else None
right = existing_output[:, :, -overlap:, :] if tile.shape[2] > overlap else None
top = existing_output[:, :overlap, :, :] if tile.shape[1] > overlap else None
bottom = existing_output[:, -overlap:, :, :] if tile.shape[1] > overlap else None
# Create kernels for each direction
kernel_horizontal = kernel # Already in BHWC format
kernel_vertical = kernel.permute(0, 2, 1, 3) # Swap H,W for vertical blending
# Blend edges
if left is not None:
tile[:, :, :overlap, :] = tile[:, :, :overlap, :] * kernel_horizontal + left * (1 - kernel_horizontal)
if right is not None:
tile[:, :, -overlap:, :] = tile[:, :, -overlap:, :] * kernel_horizontal.flip(2) + right * (1 - kernel_horizontal.flip(2))
if top is not None:
tile[:, :overlap, :, :] = tile[:, :overlap, :, :] * kernel_vertical + top * (1 - kernel_vertical)
if bottom is not None:
tile[:, -overlap:, :, :] = tile[:, -overlap:, :, :] * kernel_vertical.flip(1) + bottom * (1 - kernel_vertical.flip(1))
return tile
@staticmethod
def process_latent_batch(latents, noise, guider, sampler, sigmas):
processed_latents = []
for latent in latents:
# Process single latent
processed = Sampler.sample(noise, guider, sampler, sigmas, latent)
processed_latents.append(processed)
# Clear current latent
del latent
# Aggressive memory cleanup after function completes
SamplerHelper.force_memory_cleanup()
return processed_latents
class OptimizedSampler:
def __init__(self):
self.last_samples = None
self.callback_count = 0
def sample(self, noise, guider, sampler, sigmas, latent_image):
# Process latent
latent_image["samples"] = comfy.sample.fix_empty_latent_channels(guider.model_patcher, latent_image["samples"])
# Handle noise mask
noise_mask = None
if "noise_mask" in latent_image:
noise_mask = latent_image["noise_mask"]
# Setup callback for progress
x0_output = {}
callback = latent_preview.prepare_callback(guider.model_patcher, sigmas.shape[-1] - 1, x0_output)
try:
# Generate noise
noise_tensor = noise.generate_noise(latent_image)
# Sample
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
samples = guider.sample(
noise_tensor,
latent_image["samples"],
sampler,
sigmas,
denoise_mask=noise_mask,
callback=callback,
disable_pbar=disable_pbar,
seed=noise.seed
)
return {"samples": samples}
finally:
# Just cleanup tensors we created
if 'noise_tensor' in locals():
del noise_tensor
if noise_mask is not None:
del noise_mask
class Sampler:
@staticmethod
def sample(noise, guider, sampler, sigmas, latent):
# Create optimized sampler instance
opt_sampler = OptimizedSampler()
# Sample using optimized sampler
samples = opt_sampler.sample(noise, guider, sampler, sigmas, latent)
del opt_sampler
# Package output
output = {"samples": samples["samples"]}
if "noise_mask" in latent:
output["noise_mask"] = latent["noise_mask"]
return output
@staticmethod
def encode(image, vae):
t = vae.encode(image[:,:,:,:3])
return {"samples": t}

View File

@@ -0,0 +1,265 @@
import torch
import os
import sys
# Add ComfyUI path to sys.path
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
COMFY_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
if COMFY_DIR not in sys.path:
sys.path.append(COMFY_DIR)
from .upscale_settings import UpscaleSettings
from .sampler import Sampler
class SeamFixer:
VALID_MODES = ["None", "Band Pass", "Half Tile", "Half Tile + Intersections"]
def __init__(self, mode, width, mask_blur, padding, transition_sharpness, settings, device):
if mode not in self.VALID_MODES:
raise ValueError(f"Invalid seam fix mode: {mode}. Must be one of {self.VALID_MODES}")
self.mode = mode
self.width = width
self.mask_blur = mask_blur
self.padding = padding
self.upscale_settings = settings
self.transition_sharpness = transition_sharpness
self.device = device
def get_band_coordinates(self):
vertical_bands = []
horizontal_bands = []
# Vertical bands (along tile columns)
for x in range(1, self.upscale_settings.num_tiles_x):
# Calculate x position where tiles meet
seam_x = x * self.upscale_settings.tile_width
start_x = max(0, seam_x - self.width)
end_x = min(self.upscale_settings.target_width, seam_x + self.width)
# Band goes full height
vertical_bands.append((start_x, end_x, 0, self.upscale_settings.target_height))
# Horizontal bands (along tile rows)
for y in range(1, self.upscale_settings.num_tiles_y):
# Calculate y position where tiles meet
seam_y = y * self.upscale_settings.tile_height
start_y = max(0, seam_y - self.width)
end_y = min(self.upscale_settings.target_height, seam_y + self.width)
# Band goes full width
horizontal_bands.append((0, self.upscale_settings.target_width, start_y, end_y))
return vertical_bands, horizontal_bands
def get_half_tile_coordinates(self):
vertical_halves = []
horizontal_halves = []
# Vertical seams (process right half of left tile and left half of right tile)
for x in range(1, self.upscale_settings.num_tiles_x):
seam_x = x * self.upscale_settings.tile_width
# Right half of left tile
left_half = (
seam_x - self.upscale_settings.tile_width//2, # start at middle of left tile
seam_x + self.padding, # extend slightly into right tile
0, # full height
self.upscale_settings.target_height
)
# Left half of right tile
right_half = (
seam_x - self.padding, # start slightly in left tile
seam_x + self.upscale_settings.tile_width//2, # end at middle of right tile
0, # full height
self.upscale_settings.target_height
)
vertical_halves.extend([left_half, right_half])
# Horizontal seams (process bottom half of top tile and top half of bottom tile)
for y in range(1, self.upscale_settings.num_tiles_y):
seam_y = y * self.upscale_settings.tile_height
# Bottom half of top tile
top_half = (
0, # full width
self.upscale_settings.target_width,
seam_y - self.upscale_settings.tile_height//2, # start at middle of top tile
seam_y + self.padding # extend slightly into bottom tile
)
# Top half of bottom tile
bottom_half = (
0, # full width
self.upscale_settings.target_width,
seam_y - self.padding, # start slightly in top tile
seam_y + self.upscale_settings.tile_height//2 # end at middle of bottom tile
)
horizontal_halves.extend([top_half, bottom_half])
return vertical_halves, horizontal_halves
def get_intersection_coordinates(self):
intersections = []
# For each internal tile corner (where 4 tiles meet)
for y in range(1, self.upscale_settings.num_tiles_y):
for x in range(1, self.upscale_settings.num_tiles_x):
seam_x = x * self.upscale_settings.tile_width
seam_y = y * self.upscale_settings.tile_height
# Calculate the intersection region centered on the seam intersection
# This creates a square region that overlaps with the half-tiles
half_width = self.upscale_settings.tile_width // 4 # Quarter tile width
half_height = self.upscale_settings.tile_height // 4 # Quarter tile height
intersection = (
seam_x - half_width, # start quarter tile left of seam
seam_x + half_width, # end quarter tile right of seam
seam_y - half_height, # start quarter tile above seam
seam_y + half_height # end quarter tile below seam
)
intersections.append(intersection)
return intersections
def process_band(self, upscaled_image, band, vae, sampler, noise, guider, sigmas):
start_x, end_x, start_y, end_y = band
# Extract band region
band_image = upscaled_image[:, start_y:end_y, start_x:end_x, :]
# Create mask for blending (in BCHW for conv2d)
mask = torch.zeros((1, 1, end_y - start_y, end_x - start_x), device=self.device)
mask[:, :, :, :] = 1
# Apply mask blur if specified
if self.mask_blur > 0:
adjusted_blur = self.mask_blur * self.transition_sharpness
# Ensure kernel size is odd and not larger than input
kernel_size = min(
int(adjusted_blur * 2 + 1),
min(end_y - start_y, end_x - start_x) - 1 # Leave at least 1 pixel
)
if kernel_size % 2 == 0: # Make odd
kernel_size -= 1
if kernel_size > 0: # Only apply if we have a valid kernel size
kernel = torch.ones(1, 1, kernel_size, kernel_size, device=self.device)
kernel = kernel / kernel.numel()
mask = torch.nn.functional.conv2d(
mask,
kernel,
padding=kernel_size//2
)
mask = torch.clamp(mask, 0, 1)
# Process through VAE and sampling (VAE expects BHWC format)
latent = Sampler.encode(band_image, vae)
latent["noise_mask"] = mask # Noise mask stays in BCHW format
sampled = Sampler.sample(noise, guider, sampler, sigmas, latent)
processed_band = vae.decode(sampled["samples"])
# Convert mask to BHWC for blending
mask = mask.permute(0, 2, 3, 1)
return processed_band, mask
def fix_seams(self, upscaled_image, vae, sampler, noise, guider, sigmas):
if self.mode == "None":
return upscaled_image
result_image = upscaled_image.clone()
if self.mode == "Band Pass":
vertical_bands, horizontal_bands = self.get_band_coordinates()
# Process vertical bands
for band in vertical_bands:
processed_band, mask = self.process_band(
upscaled_image, band, vae, sampler, noise, guider, sigmas
)
start_x, end_x, start_y, end_y = band
# Blend band back into image
for c in range(upscaled_image.shape[-1]):
result_image[:, start_y:end_y, start_x:end_x, c] = \
processed_band[:, :, :, c] * mask[:, :, :, 0] + \
result_image[:, start_y:end_y, start_x:end_x, c] * (1 - mask[:, :, :, 0])
# Process horizontal bands
for band in horizontal_bands:
processed_band, mask = self.process_band(
result_image, band, vae, sampler, noise, guider, sigmas
)
start_x, end_x, start_y, end_y = band
# Blend band back into image
for c in range(upscaled_image.shape[-1]):
result_image[:, start_y:end_y, start_x:end_x, c] = \
processed_band[:, :, :, c] * mask[:, :, :, 0] + \
result_image[:, start_y:end_y, start_x:end_x, c] * (1 - mask[:, :, :, 0])
elif self.mode in ["Half Tile", "Half Tile + Intersections"]:
vertical_halves, horizontal_halves = self.get_half_tile_coordinates()
# Process vertical half-tiles
for half_tile in vertical_halves:
processed_half, mask = self.process_band(
upscaled_image, half_tile, vae, sampler, noise, guider, sigmas
)
start_x, end_x, start_y, end_y = half_tile
# Blend half-tile back into image
for c in range(upscaled_image.shape[-1]):
result_image[:, start_y:end_y, start_x:end_x, c] = \
processed_half[:, :, :, c] * mask[:, :, :, 0] + \
result_image[:, start_y:end_y, start_x:end_x, c] * (1 - mask[:, :, :, 0])
# Process horizontal half-tiles
for half_tile in horizontal_halves:
processed_half, mask = self.process_band(
result_image, half_tile, vae, sampler, noise, guider, sigmas
)
start_x, end_x, start_y, end_y = half_tile
# Blend half-tile back into image
for c in range(upscaled_image.shape[-1]):
result_image[:, start_y:end_y, start_x:end_x, c] = \
processed_half[:, :, :, c] * mask[:, :, :, 0] + \
result_image[:, start_y:end_y, start_x:end_x, c] * (1 - mask[:, :, :, 0])
# Process intersections if in intersection mode
if self.mode == "Half Tile + Intersections":
intersections = self.get_intersection_coordinates()
# Process each intersection region
for intersection in intersections:
processed_intersection, mask = self.process_band(
result_image, intersection, vae, sampler, noise, guider, sigmas
)
start_x, end_x, start_y, end_y = intersection
# Use radial gradient for intersection mask
# This creates a circular blend that smoothly transitions in all directions
center_x = (end_x - start_x) // 2
center_y = (end_y - start_y) // 2
y, x = torch.meshgrid(
torch.arange(end_y - start_y, device=self.device),
torch.arange(end_x - start_x, device=self.device),
indexing='ij'
)
radius = torch.sqrt((x - center_x)**2 + (y - center_y)**2)
max_radius = min(center_x, center_y)
radial_mask = torch.clamp(1 - radius / max_radius, 0, 1)
# Blend intersection back into image
radial_mask = radial_mask.unsqueeze(-1) # Add channel dimension
for c in range(upscaled_image.shape[-1]):
result_image[:, start_y:end_y, start_x:end_x, c] = \
processed_intersection[:, :, :, c] * radial_mask[:, :, :, 0] + \
result_image[:, start_y:end_y, start_x:end_x, c] * (1 - radial_mask[:, :, :, 0])
return result_image

View File

@@ -0,0 +1,171 @@
import math
import torch
import os
import sys
import time
import logging
import torch.cuda
import torch.nn.functional as F
# Add ComfyUI path to sys.path
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
COMFY_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "..", ".."))
if COMFY_DIR not in sys.path:
sys.path.append(COMFY_DIR)
import comfy.utils
import comfy_extras.nodes_upscale_model as numodel
from .upscale_settings import UpscaleSettings
from .sampler import SamplerHelper, Sampler
from .seam_fixer import SeamFixer
class UltimateSDUpscaleGGUF:
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"image": ("IMAGE", ),
"noise": ("NOISE", ),
"guider": ("GUIDER", ),
"sampler": ("SAMPLER", ),
"sigmas": ("SIGMAS", ),
"vae": ("VAE", ),
"upscale_model": ("UPSCALE_MODEL",),
"upscale_by": ("FLOAT", { "default": 2.0, "min": 1.0, "max": 8.0, "step": 0.1 }),
"max_tile_size": ("INT", { "default": 512, "min": 256, "max": 2048, "step": 64 }),
"mask_blur": ("INT", { "default": 8, "min": 0, "max": 64, "step": 1 }),
"transition_sharpness": ("FLOAT", { "default": 0.333, "min": 0.125, "max": 1.0, "step": 0.001 }),
"tile_padding": ("INT", { "default": 32, "min": 0, "max": 128, "step": 8 }),
"seam_fix_mode": ("STRING", { "default": "None", "options": ["None", "Band Pass", "Half Tile", "Half Tile + Intersections"] }),
"seam_fix_width": ("INT", { "default": 64, "min": 0, "max": 8192, "step": 8 }),
"seam_fix_mask_blur": ("INT", { "default": 8, "min": 0, "max": 64, "step": 1 }),
"seam_fix_padding": ("INT", { "default": 16, "min": 0, "max": 128, "step": 8 }),
"force_uniform_tiles": ("BOOLEAN", { "default": True })
}}
RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE") # (upscaled_image, tiles, masks)
RETURN_NAMES = ("upscaled", "tiles", "masks")
OUTPUT_IS_LIST = (False, True, True)
FUNCTION = "upscale"
CATEGORY = "image/upscaling"
def upscale(
self, image, noise, guider, sampler, sigmas, vae, upscale_model, upscale_by, max_tile_size,
mask_blur, transition_sharpness, tile_padding, seam_fix_mode, seam_fix_width,
seam_fix_mask_blur, seam_fix_padding, force_uniform_tiles
):
settings = UpscaleSettings(
target_width=int(image.shape[2] * upscale_by),
target_height=int(image.shape[1] * upscale_by),
max_tile_size=max_tile_size,
tile_padding=tile_padding,
force_uniform_tiles=force_uniform_tiles
)
upScalerWithModel = numodel.ImageUpscaleWithModel()
image_tuple = upScalerWithModel.upscale(upscale_model, image)
image = image_tuple[0]
samples = image.movedim(-1,1)
image = comfy.utils.common_upscale(samples, settings.sampling_width, settings.sampling_height, "area", "disabled")
image = image.movedim(1,-1)
output = image.to('cpu')
latents = []
tile_positions = []
tile_masks = []
all_tiles = []
all_masks = []
SamplerHelper.force_memory_cleanup(True)
for tile_y in range(settings.num_tiles_y):
for tile_x in range(settings.num_tiles_x):
x1, x2, y1, y2, pad_x1, pad_x2, pad_y1, pad_y2 = settings.get_tile_coordinates(tile_x, tile_y, tile_padding)
if tile_padding > 0:
pad = tile_padding
full_h, full_w = (y2-y1) + pad*2, (x2-x1) + pad*2
mask = torch.zeros((1, 1, full_h, full_w), device=image.device)
y_coords = torch.arange(full_h, device=image.device).view(-1, 1)
x_coords = torch.arange(full_w, device=image.device).view(1, -1)
tile_y1, tile_y2 = pad, pad + (y2-y1)
tile_x1, tile_x2 = pad, pad + (x2-x1)
dist_from_y1 = torch.abs(y_coords - tile_y1)
dist_from_y2 = torch.abs(y_coords - tile_y2)
dist_from_x1 = torch.abs(x_coords - tile_x1)
dist_from_x2 = torch.abs(x_coords - tile_x2)
y_dist = torch.minimum(dist_from_y1, dist_from_y2)
x_dist = torch.minimum(dist_from_x1, dist_from_x2)
y_dist = torch.where((y_coords >= tile_y1) & (y_coords <= tile_y2), 0, y_dist)
x_dist = torch.where((x_coords >= tile_x1) & (x_coords <= tile_x2), 0, x_dist)
dist = torch.sqrt(y_dist**2 + x_dist**2)
falloff = 1.0 - torch.clamp(dist / pad, min=0, max=1)
mask[0, 0] = falloff
if mask_blur > 0:
kernel_size = min(pad * 2 - 1, 63)
sigma = pad / 2 * math.ceil(1.0 / transition_sharpness) / 4
x = torch.arange(-(kernel_size//2), kernel_size//2 + 1, device=image.device).float()
gaussian = torch.exp(-(x**2)/(2*sigma**2))
gaussian = gaussian / gaussian.sum()
kernel = gaussian.view(1, 1, -1, 1) @ gaussian.view(1, 1, 1, -1)
mask = F.conv2d(mask, kernel, padding=(kernel_size-1) // 2)
mask[:, :, pad:pad+(y2-y1), pad:pad+(x2-x1)] = 1.0
x_start = 0 if tile_x > 0 else pad
x_end = full_w if tile_x < settings.num_tiles_x - 1 else full_w - pad
y_start = 0 if tile_y > 0 else pad
y_end = full_h if tile_y < settings.num_tiles_y - 1 else full_h - pad
mask = mask[:, :, y_start:y_end, x_start:x_end]
else:
mask = torch.ones((1, 1, y2-y1, x2-x1), device=image.device)
mask = torch.clamp(mask, 0, 1)
tile = image[:, pad_y1:pad_y2, pad_x1:pad_x2, :].clone()
latent = Sampler.encode(tile, vae)
latent_h, latent_w = latent["samples"].shape[2:4]
mask_latent = F.interpolate(mask, size=(latent_h, latent_w), mode='bilinear')
latents.append(latent)
tile_positions.append((x1, x2, y1, y2, pad_x1, pad_x2, pad_y1, pad_y2))
tile_masks.append(mask)
del tile, mask, mask_latent
torch.cuda.empty_cache()
SamplerHelper.force_memory_cleanup(True)
processed_latents = SamplerHelper.process_latent_batch(latents, noise, guider, sampler, sigmas)
#processed_latents = latents
del latents
SamplerHelper.force_memory_cleanup(True)
decoded_tiles = []
for processed_latent in processed_latents:
tile = vae.decode(processed_latent["samples"])
decoded_tiles.append(tile.cpu())
del processed_latent
torch.cuda.empty_cache()
del processed_latents
SamplerHelper.force_memory_cleanup(True)
for tile, pos, mask in zip(decoded_tiles, tile_positions, tile_masks):
x1, x2, y1, y2, tpad_x1, tpad_x2, tpad_y1, tpad_y2 = pos
output_slice = output[:, tpad_y1:tpad_y2, tpad_x1:tpad_x2, :]
tile_gpu = tile.to(image.device)
mask = mask.to(image.device)
mask = mask.movedim(1, -1)
mask = mask.expand(-1, -1, -1, 3)
blended = tile_gpu * mask + output_slice.to(image.device) * (1 - mask)
output[:, tpad_y1:tpad_y2, tpad_x1:tpad_x2, :] = blended
all_tiles.append(tile_gpu.cpu())
all_masks.append(mask.cpu())
del tile_gpu, mask
torch.cuda.empty_cache()
del decoded_tiles, tile_positions, tile_masks
SamplerHelper.force_memory_cleanup(True)
samples = output.movedim(-1,1)
output = comfy.utils.common_upscale(samples, settings.target_width, settings.target_height, "lanczos", "disabled")
output = output.movedim(1,-1)
return (output, all_tiles, all_masks)

View File

@@ -0,0 +1,92 @@
import math
class UpscaleSettings:
MIN_TILE_SIZE = 256
MINIMUM_BENEFICIAL_UPSCALE = 1.5
NEARLY_SQUARE_THRESHOLD = 0.01 # 1% difference threshold
def __init__(self, target_width, target_height, max_tile_size, tile_padding, force_uniform_tiles=False):
self.target_width = target_width
self.target_height = target_height
self.tile_padding = tile_padding
self.force_uniform = force_uniform_tiles
self.max_tile_size = max_tile_size
# Calculate final tile dimensions
self.tile_width, self.tile_height, self.sampling_width, self.sampling_height = self._calculate_tile_dimensions()
def _calculate_uniform_tile_dimensions(self):
# Calculate initial number of tiles
# x = width, y = height
# find longest side first and divide by max tile size
if self.target_width > self.target_height:
self.num_tiles_x = math.ceil(self.target_width / self.max_tile_size)
max_tile_width = self.max_tile_size
max_tile_height = math.ceil(self.max_tile_size * (self.target_height / self.target_width) / 8 ) * 8
self.num_tiles_y = math.ceil(self.target_height / max_tile_height)
else:
self.num_tiles_y = math.ceil(self.target_height / self.max_tile_size)
max_tile_width = math.ceil(self.max_tile_size * (self.target_width / self.target_height) / 8 ) * 8
max_tile_height = self.max_tile_size
self.num_tiles_x = math.ceil(self.target_width / max_tile_width)
sampling_width = self.num_tiles_x * max_tile_width
sampling_height = self.num_tiles_y * max_tile_height
return max_tile_width, max_tile_height, sampling_width, sampling_height
def _calculate_tile_dimensions(self):
# consume self.tile_ratio
if self.force_uniform:
tile_width, tile_height, sampling_width, sampling_height = self._calculate_uniform_tile_dimensions()
if tile_width is not None and tile_height is not None:
return tile_width, tile_height, sampling_width, sampling_height
# Double check that image dimensions are a multiple of 8 and adjust if necessary
# Return as sampling dimensions
sampling_width = max(8, ((self.target_width + 7) // 8) * 8)
sampling_height = max(8, ((self.target_height + 7) // 8) * 8)
# For non-uniform tiles, use square tiles with minimum size constraint
tile_size = max(64, math.ceil(self.max_tile_size / 8) * 8)
tile_size = min(tile_size, max(8, min(sampling_width, sampling_height)))
# Calculate number of full tiles needed, ensuring at least 1 tile in each dimension
self.num_tiles_x = max(1, math.ceil(sampling_width / tile_size))
self.num_tiles_y = max(1, math.ceil(sampling_height / tile_size))
return tile_size, tile_size, sampling_width, sampling_height
def get_tile_coordinates(self, tile_x, tile_y, tile_padding):
if not (0 <= tile_x < self.num_tiles_x and 0 <= tile_y < self.num_tiles_y):
return None, None, None, None
# Calculate base tile coordinates in sampling space
x1 = tile_x * self.tile_width
x2 = min((tile_x + 1) * self.tile_width, self.sampling_width)
y1 = tile_y * self.tile_height
y2 = min((tile_y + 1) * self.tile_height, self.sampling_height)
# Add padding only at seams
pad_left = tile_padding if tile_x > 0 else 0
pad_right = tile_padding if tile_x < self.num_tiles_x - 1 else 0
pad_top = tile_padding if tile_y > 0 else 0
pad_bottom = tile_padding if tile_y < self.num_tiles_y - 1 else 0
# Apply padding to coordinates
pad_x1 = max(0, x1 - pad_left)
pad_x2 = min(self.sampling_width, x2 + pad_right)
pad_y1 = max(0, y1 - pad_top)
pad_y2 = min(self.sampling_height, y2 + pad_bottom)
# All coordinates are already multiples of 8 due to how sampling_width/height
# and tile_width/height are calculated
return x1, x2, y1, y2, pad_x1, pad_x2, pad_y1, pad_y2
def get_tile_mask_area(self, tile_width, tile_height, tile_padding):
tile_start_x = max(0, tile_padding)
tile_end_x = min(tile_width, self.tile_width + tile_padding)
tile_start_y = max(0, tile_padding)
tile_end_y = min(tile_height, self.tile_height + tile_padding)
return tile_start_x, tile_end_x, tile_start_y, tile_end_y