Files
marduk191andClaude Opus 4.5 b7b31e4ae4 Add auto-tiling for large images, clarify model domains
- Auto-tile images >1MP (tile_size=512, overlap=64)
- Add tile_size parameter to Denoise/Deblur nodes
- Fix tiled_inference boundary calculation
- Document that GoPro/REDS models are domain-specific
- SIDD models work for general denoising
- GoPro/REDS only work on their specific blur types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 00:51:43 -06:00

365 lines
12 KiB
Python

import os
import torch
import folder_paths
try:
from .nafnet_arch import NAFNet, NAFNetSR
except ImportError:
from nafnet_arch import NAFNet, NAFNetSR
# Register model folder
MODELS_DIR = os.path.join(os.path.dirname(__file__), "models")
os.makedirs(MODELS_DIR, exist_ok=True)
# Model configurations matching the official pretrained weights
MODEL_CONFIGS = {
# Denoising models (SIDD dataset)
"NAFNet-SIDD-width32.pth": {"type": "nafnet", "width": 32, "enc_blks": [2, 2, 4, 8], "middle_blk_num": 12, "dec_blks": [2, 2, 2, 2]},
"NAFNet-SIDD-width64.pth": {"type": "nafnet", "width": 64, "enc_blks": [2, 2, 4, 8], "middle_blk_num": 12, "dec_blks": [2, 2, 2, 2]},
# Deblurring models (GoPro dataset)
"NAFNet-GoPro-width32.pth": {"type": "nafnet", "width": 32, "enc_blks": [1, 1, 1, 28], "middle_blk_num": 1, "dec_blks": [1, 1, 1, 1]},
"NAFNet-GoPro-width64.pth": {"type": "nafnet", "width": 64, "enc_blks": [1, 1, 1, 28], "middle_blk_num": 1, "dec_blks": [1, 1, 1, 1]},
# REDS deblurring (video deblurring)
"NAFNet-REDS-width64.pth": {"type": "nafnet", "width": 64, "enc_blks": [1, 1, 1, 28], "middle_blk_num": 1, "dec_blks": [1, 1, 1, 1]},
# Stereo Super-Resolution (NAFSSR)
"NAFSSR-L_2x.pth": {"type": "nafssr", "up_scale": 2, "width": 64, "num_blks": 64, "fusion_from": 0, "fusion_to": 62},
"NAFSSR-L_4x.pth": {"type": "nafssr", "up_scale": 4, "width": 64, "num_blks": 64, "fusion_from": 0, "fusion_to": 62},
}
# Auto-tile threshold (images larger than this will be tiled automatically)
AUTO_TILE_THRESHOLD = 1024 * 1024 # 1 megapixel
DEFAULT_TILE_SIZE = 512
DEFAULT_TILE_OVERLAP = 64
def get_available_models():
"""Return list of available model files in the models directory."""
if not os.path.exists(MODELS_DIR):
return []
models = [f for f in os.listdir(MODELS_DIR) if f.endswith('.pth')]
return models if models else ["No models found - download from NAFNet repo"]
def load_nafnet_model(model_name, device):
"""Load a NAFNet model from the models directory."""
model_path = os.path.join(MODELS_DIR, model_name)
if not os.path.exists(model_path):
raise FileNotFoundError(f"Model not found: {model_path}\nDownload from: https://github.com/megvii-research/NAFNet")
# Get config for this model
config = MODEL_CONFIGS.get(model_name)
if config is None:
# Default config for unknown models
config = {"type": "nafnet", "width": 64, "enc_blks": [2, 2, 4, 8], "middle_blk_num": 12, "dec_blks": [2, 2, 2, 2]}
# Create model based on type
model_type = config.get("type", "nafnet")
if model_type == "nafssr":
model = NAFNetSR(
up_scale=config["up_scale"],
width=config["width"],
num_blks=config["num_blks"],
img_channel=3,
fusion_from=config.get("fusion_from", -1),
fusion_to=config.get("fusion_to", -1),
dual=True
)
else:
model = NAFNet(
img_channel=3,
width=config["width"],
middle_blk_num=config["middle_blk_num"],
enc_blk_nums=config["enc_blks"],
dec_blk_nums=config["dec_blks"]
)
# Load weights
checkpoint = torch.load(model_path, map_location='cpu', weights_only=False)
# Handle different checkpoint formats
if isinstance(checkpoint, dict):
if 'params' in checkpoint:
state_dict = checkpoint['params']
elif 'state_dict' in checkpoint:
state_dict = checkpoint['state_dict']
elif 'model' in checkpoint:
state_dict = checkpoint['model']
else:
state_dict = checkpoint
else:
state_dict = checkpoint
model.load_state_dict(state_dict, strict=True)
model.eval()
model.to(device)
return model
def tiled_inference(model, img, tile_size, overlap):
"""Process image in tiles for memory efficiency."""
B, C, H, W = img.shape
device = img.device
# Ensure tile_size is at least as big as needed for the model
tile_size = max(tile_size, 64)
# If image is smaller than tile, just process directly
if H <= tile_size and W <= tile_size:
return model(img)
# Calculate output size
out = torch.zeros_like(img)
count = torch.zeros_like(img)
# Calculate number of tiles
stride = tile_size - overlap
h_tiles = max(1, (H - overlap + stride - 1) // stride)
w_tiles = max(1, (W - overlap + stride - 1) // stride)
for h_idx in range(h_tiles):
for w_idx in range(w_tiles):
# Calculate tile boundaries
h_start = h_idx * stride
w_start = w_idx * stride
# Ensure we don't go past the image boundaries
h_start = min(h_start, max(0, H - tile_size))
w_start = min(w_start, max(0, W - tile_size))
h_end = min(h_start + tile_size, H)
w_end = min(w_start + tile_size, W)
# Extract tile
tile = img[:, :, h_start:h_end, w_start:w_end]
# Process tile
with torch.no_grad():
tile_out = model(tile)
# Accumulate results
out[:, :, h_start:h_end, w_start:w_end] += tile_out
count[:, :, h_start:h_end, w_start:w_end] += 1
# Average overlapping regions
return out / count
def process_image(model, image, tile_size=0, tile_overlap=DEFAULT_TILE_OVERLAP):
"""Process an image through NAFNet with optional tiling."""
device = next(model.parameters()).device
# ComfyUI format: [B, H, W, C] float32 0-1
# NAFNet expects: [B, C, H, W] float32 0-1
img_tensor = image.permute(0, 3, 1, 2).to(device)
B, C, H, W = img_tensor.shape
num_pixels = H * W
# Auto-tile if image is large and no tile_size specified
if tile_size == 0 and num_pixels > AUTO_TILE_THRESHOLD:
tile_size = DEFAULT_TILE_SIZE
print(f"[NAFNet] Auto-tiling enabled for {W}x{H} image (tile_size={tile_size})")
with torch.no_grad():
if tile_size > 0:
output = tiled_inference(model, img_tensor, tile_size, tile_overlap)
else:
output = model(img_tensor)
# Convert back to ComfyUI format [B, H, W, C]
output = output.permute(0, 2, 3, 1).cpu()
output = torch.clamp(output, 0, 1)
return output
class NAFNetLoader:
"""Load a NAFNet model for image restoration."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": (get_available_models(),),
}
}
RETURN_TYPES = ("NAFNET_MODEL",)
RETURN_NAMES = ("model",)
FUNCTION = "load_model"
CATEGORY = "image/restoration"
def load_model(self, model_name):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = load_nafnet_model(model_name, device)
return (model,)
class NAFNetRestore:
"""Apply NAFNet model to restore an image (denoise or deblur)."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"model": ("NAFNET_MODEL",),
},
"optional": {
"tile_size": ("INT", {"default": 512, "min": 0, "max": 2048, "step": 64,
"tooltip": "Tile size for processing. 0 = auto (tiles large images automatically)"}),
"tile_overlap": ("INT", {"default": 64, "min": 0, "max": 256, "step": 8}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "restore"
CATEGORY = "image/restoration"
def restore(self, image, model, tile_size=512, tile_overlap=64):
return (process_image(model, image, tile_size, tile_overlap),)
class NAFNetDenoise:
"""Denoise an image using NAFNet SIDD model."""
_model = None
_model_name = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"model_variant": (["width64", "width32"], {"default": "width64"}),
},
"optional": {
"tile_size": ("INT", {"default": 0, "min": 0, "max": 2048, "step": 64,
"tooltip": "Tile size for processing. 0 = auto (tiles large images automatically)"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "denoise"
CATEGORY = "image/restoration"
def denoise(self, image, model_variant="width64", tile_size=0):
model_name = f"NAFNet-SIDD-{model_variant}.pth"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Cache model
if NAFNetDenoise._model is None or NAFNetDenoise._model_name != model_name:
NAFNetDenoise._model = load_nafnet_model(model_name, device)
NAFNetDenoise._model_name = model_name
return (process_image(NAFNetDenoise._model, image, tile_size),)
class NAFNetDeblur:
"""Deblur an image using NAFNet GoPro model."""
_model = None
_model_name = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"model_variant": (["width64", "width32"], {"default": "width64"}),
},
"optional": {
"tile_size": ("INT", {"default": 0, "min": 0, "max": 2048, "step": 64,
"tooltip": "Tile size for processing. 0 = auto (tiles large images automatically)"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "deblur"
CATEGORY = "image/restoration"
def deblur(self, image, model_variant="width64", tile_size=0):
model_name = f"NAFNet-GoPro-{model_variant}.pth"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Cache model
if NAFNetDeblur._model is None or NAFNetDeblur._model_name != model_name:
NAFNetDeblur._model = load_nafnet_model(model_name, device)
NAFNetDeblur._model_name = model_name
return (process_image(NAFNetDeblur._model, image, tile_size),)
class NAFSSRStereoSR:
"""Stereo image super-resolution using NAFSSR."""
_model = None
_model_name = None
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"left_image": ("IMAGE",),
"right_image": ("IMAGE",),
"scale": (["2x", "4x"], {"default": "2x"}),
}
}
RETURN_TYPES = ("IMAGE", "IMAGE",)
RETURN_NAMES = ("left_sr", "right_sr",)
FUNCTION = "stereo_sr"
CATEGORY = "image/restoration"
def stereo_sr(self, left_image, right_image, scale="2x"):
model_name = f"NAFSSR-L_{scale}.pth"
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Cache model
if NAFSSRStereoSR._model is None or NAFSSRStereoSR._model_name != model_name:
NAFSSRStereoSR._model = load_nafnet_model(model_name, device)
NAFSSRStereoSR._model_name = model_name
model = NAFSSRStereoSR._model
# ComfyUI format: [B, H, W, C] -> [B, C, H, W]
left_tensor = left_image.permute(0, 3, 1, 2).to(device)
right_tensor = right_image.permute(0, 3, 1, 2).to(device)
# NAFSSR expects concatenated stereo pair: [B, 6, H, W]
stereo_input = torch.cat([left_tensor, right_tensor], dim=1)
with torch.no_grad():
output = model(stereo_input)
# Output is [B, 6, H*scale, W*scale], split into left and right
left_sr = output[:, :3, :, :]
right_sr = output[:, 3:, :, :]
# Convert back to ComfyUI format [B, H, W, C]
left_sr = left_sr.permute(0, 2, 3, 1).cpu()
right_sr = right_sr.permute(0, 2, 3, 1).cpu()
left_sr = torch.clamp(left_sr, 0, 1)
right_sr = torch.clamp(right_sr, 0, 1)
return (left_sr, right_sr)
NODE_CLASS_MAPPINGS = {
"NAFNetLoader": NAFNetLoader,
"NAFNetRestore": NAFNetRestore,
"NAFNetDenoise": NAFNetDenoise,
"NAFNetDeblur": NAFNetDeblur,
"NAFSSRStereoSR": NAFSSRStereoSR,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"NAFNetLoader": "NAFNet Load Model",
"NAFNetRestore": "NAFNet Restore",
"NAFNetDenoise": "NAFNet Denoise (SIDD)",
"NAFNetDeblur": "NAFNet Deblur (GoPro)",
"NAFSSRStereoSR": "NAFSSR Stereo Super-Resolution",
}