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
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:
171
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/.gitignore
vendored
Normal file
171
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/.gitignore
vendored
Normal 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
|
||||
21
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/LICENSE
Normal file
21
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/LICENSE
Normal 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.
|
||||
2
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/README.md
Normal file
2
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# ComfyUI-UltimateSDUpscale-GGUF
|
||||
GGUF implementation for the ComfyUI Ultimate SD Upscale node.
|
||||
13
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/__init__.py
Normal file
13
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/__init__.py
Normal 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)"
|
||||
}
|
||||
14
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/pyproject.toml
Normal file
14
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/pyproject.toml
Normal 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 = ""
|
||||
175
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/sampler.py
Normal file
175
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/sampler.py
Normal 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}
|
||||
265
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/seam_fixer.py
Normal file
265
custom_nodes/ComfyUI-UltimateSDUpscale-GGUF/seam_fixer.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user