1814 lines
91 KiB
Python
1814 lines
91 KiB
Python
"""
|
|
ComfyUI Distributed Upscale Module
|
|
|
|
This module implements a distributed version of Ultimate SD Upscale with multi-mode
|
|
batch handling for efficient processing of single images and video frames.
|
|
|
|
Processing Modes:
|
|
- Single GPU: Process all tiles locally when no workers are available
|
|
- Static Mode: For small batches (≤ dynamic_threshold), flattens tiles across batch
|
|
- Dynamic Mode: For large batches, assigns whole images to workers dynamically
|
|
"""
|
|
|
|
import torch
|
|
import numpy as np
|
|
from PIL import Image, ImageFilter, ImageDraw
|
|
import json
|
|
import asyncio
|
|
import aiohttp
|
|
from aiohttp import web
|
|
import io
|
|
import server
|
|
import math
|
|
import os
|
|
import time
|
|
from typing import List, Tuple
|
|
|
|
# Import ComfyUI modules
|
|
import comfy.samplers
|
|
|
|
# Import shared utilities
|
|
from .utils.logging import debug_log, log
|
|
from .utils.config import CONFIG_FILE
|
|
from .utils.image import tensor_to_pil, pil_to_tensor
|
|
from .utils.network import get_server_port, get_server_loop, get_client_session, handle_api_error
|
|
from .utils.async_helpers import run_async_in_server_loop
|
|
from .utils.constants import (
|
|
TILE_COLLECTION_TIMEOUT, TILE_WAIT_TIMEOUT, TILE_TRANSFER_TIMEOUT,
|
|
QUEUE_INIT_TIMEOUT, TILE_SEND_TIMEOUT
|
|
)
|
|
|
|
# Make MAX_BATCH configurable
|
|
MAX_BATCH = int(os.environ.get('COMFYUI_MAX_BATCH', '20'))
|
|
|
|
# Configure maximum payload size (50MB default, configurable via environment variable)
|
|
MAX_PAYLOAD_SIZE = int(os.environ.get('COMFYUI_MAX_PAYLOAD_SIZE', str(50 * 1024 * 1024)))
|
|
|
|
# Helper function to ensure persistent state is initialized
|
|
def ensure_tile_jobs_initialized():
|
|
"""Ensure tile job storage is initialized on the server instance."""
|
|
prompt_server = server.PromptServer.instance
|
|
if not hasattr(prompt_server, 'distributed_pending_tile_jobs'):
|
|
debug_log("Initializing persistent tile job queue on server instance.")
|
|
prompt_server.distributed_pending_tile_jobs = {}
|
|
prompt_server.distributed_tile_jobs_lock = asyncio.Lock()
|
|
else:
|
|
# Clean up any legacy queue structures that don't have the 'mode' field
|
|
# (Should be rare after fixes, but keep for safety)
|
|
to_remove = [job_id for job_id, job_data in prompt_server.distributed_pending_tile_jobs.items()
|
|
if not isinstance(job_data, dict) or 'mode' not in job_data]
|
|
for job_id in to_remove:
|
|
debug_log(f"Removing legacy queue structure for job {job_id}")
|
|
del prompt_server.distributed_pending_tile_jobs[job_id]
|
|
return prompt_server
|
|
|
|
# Note: tensor_to_pil and pil_to_tensor are imported from utils.image
|
|
|
|
class UltimateSDUpscaleDistributed:
|
|
"""
|
|
Distributed version of Ultimate SD Upscale (No Upscale).
|
|
|
|
Supports three processing modes:
|
|
1. Single GPU: No workers available, process everything locally
|
|
2. Static Mode: Small batches, distributes tiles across workers (flattened)
|
|
3. Dynamic Mode: Large batches, assigns whole images to workers dynamically
|
|
|
|
Features:
|
|
- Multi-mode batch handling for efficient video/image upscaling
|
|
- Tiled VAE support for memory efficiency
|
|
- Dynamic load balancing for large batches
|
|
- Backward compatible with single-image workflows
|
|
|
|
Environment Variables:
|
|
- COMFYUI_MAX_BATCH: Chunk size for tile sending (default 20)
|
|
- COMFYUI_MAX_PAYLOAD_SIZE: Max API payload bytes (default 50MB)
|
|
|
|
Threshold: dynamic_threshold input controls mode switch (default 8)
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the node and ensure persistent storage exists."""
|
|
# Pre-initialize the persistent storage on node creation
|
|
ensure_tile_jobs_initialized()
|
|
debug_log("UltimateSDUpscaleDistributed - Node initialized")
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"upscaled_image": ("IMAGE",),
|
|
"model": ("MODEL",),
|
|
"positive": ("CONDITIONING",),
|
|
"negative": ("CONDITIONING",),
|
|
"vae": ("VAE",),
|
|
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
|
|
"steps": ("INT", {"default": 20, "min": 1, "max": 10000}),
|
|
"cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0}),
|
|
"sampler_name": (comfy.samplers.KSampler.SAMPLERS,),
|
|
"scheduler": (comfy.samplers.KSampler.SCHEDULERS,),
|
|
"denoise": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"tile_width": ("INT", {"default": 512, "min": 64, "max": 2048, "step": 8}),
|
|
"tile_height": ("INT", {"default": 512, "min": 64, "max": 2048, "step": 8}),
|
|
"padding": ("INT", {"default": 32, "min": 0, "max": 256, "step": 8}),
|
|
"mask_blur": ("INT", {"default": 8, "min": 0, "max": 256}),
|
|
"force_uniform_tiles": ("BOOLEAN", {"default": True}),
|
|
"tiled_decode": ("BOOLEAN", {"default": False}),
|
|
},
|
|
"hidden": {
|
|
"multi_job_id": ("STRING", {"default": ""}),
|
|
"is_worker": ("BOOLEAN", {"default": False}),
|
|
"master_url": ("STRING", {"default": ""}),
|
|
"enabled_worker_ids": ("STRING", {"default": "[]"}),
|
|
"worker_id": ("STRING", {"default": ""}),
|
|
"tile_indices": ("STRING", {"default": ""}), # Unused - kept for compatibility
|
|
"dynamic_threshold": ("INT", {"default": 8, "min": 1, "max": 64}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
FUNCTION = "run"
|
|
CATEGORY = "image/upscaling"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
"""Force re-execution."""
|
|
return float("nan") # Always re-execute
|
|
|
|
def run(self, upscaled_image, model, positive, negative, vae, seed, steps, cfg,
|
|
sampler_name, scheduler, denoise, tile_width, tile_height, padding,
|
|
mask_blur, force_uniform_tiles, tiled_decode, multi_job_id="", is_worker=False,
|
|
master_url="", enabled_worker_ids="[]", worker_id="", tile_indices="", dynamic_threshold=8):
|
|
"""Entry point - runs SYNCHRONOUSLY like Ultimate SD Upscaler."""
|
|
if not multi_job_id:
|
|
# No distributed processing, run single GPU version
|
|
return self.process_single_gpu(upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur, force_uniform_tiles, tiled_decode)
|
|
|
|
if is_worker:
|
|
# Worker mode: process tiles synchronously
|
|
return self.process_worker_tiles(upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur,
|
|
force_uniform_tiles, tiled_decode, multi_job_id, master_url,
|
|
worker_id, enabled_worker_ids, dynamic_threshold)
|
|
else:
|
|
# Master mode: distribute and collect synchronously
|
|
return self.process_master(upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur,
|
|
force_uniform_tiles, tiled_decode, multi_job_id, enabled_worker_ids, dynamic_threshold)
|
|
|
|
def process_worker_tiles(self, upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur,
|
|
force_uniform_tiles, tiled_decode, multi_job_id, master_url,
|
|
worker_id, enabled_worker_ids, dynamic_threshold):
|
|
"""Worker processing - determines mode and processes accordingly."""
|
|
# Get batch size to determine mode
|
|
batch_size = upscaled_image.shape[0]
|
|
|
|
# Ensure mode consistency across master/workers via shared threshold
|
|
# Determine mode (must match master's logic)
|
|
enabled_workers = json.loads(enabled_worker_ids)
|
|
num_workers = len(enabled_workers)
|
|
|
|
if batch_size == 1 or batch_size <= dynamic_threshold:
|
|
mode = "static"
|
|
else:
|
|
mode = "dynamic"
|
|
|
|
debug_log(f"UltimateSDUpscale Worker - Mode: {mode}, batch_size: {batch_size}")
|
|
|
|
if mode == "dynamic":
|
|
return self.process_worker_dynamic(upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur,
|
|
force_uniform_tiles, tiled_decode, multi_job_id, master_url,
|
|
worker_id, enabled_worker_ids, dynamic_threshold)
|
|
|
|
# Static mode - continue with existing tile-based processing
|
|
# Round tile dimensions
|
|
tile_width = self.round_to_multiple(tile_width)
|
|
tile_height = self.round_to_multiple(tile_height)
|
|
|
|
# Calculate all tile positions
|
|
_, height, width, _ = upscaled_image.shape
|
|
all_tiles = self.calculate_tiles(width, height, tile_width, tile_height, force_uniform_tiles)
|
|
|
|
# Calculate total tiles across all images
|
|
num_tiles_per_image = len(all_tiles)
|
|
total_tiles = batch_size * num_tiles_per_image
|
|
|
|
# Get assigned global tile indices for this worker
|
|
assigned_global_indices = self._get_worker_global_indices(total_tiles, enabled_worker_ids, worker_id)
|
|
if not assigned_global_indices:
|
|
return (upscaled_image,)
|
|
|
|
debug_log(f"UltimateSDUpscale Worker - Worker {worker_id} processing {len(assigned_global_indices)} tiles from batch of {batch_size}")
|
|
|
|
# Process tiles SYNCHRONOUSLY
|
|
processed_tiles = []
|
|
for global_idx in assigned_global_indices:
|
|
# Calculate which image and tile this corresponds to
|
|
batch_idx = global_idx // num_tiles_per_image
|
|
tile_idx = global_idx % num_tiles_per_image
|
|
|
|
# Skip if batch_idx is out of range
|
|
if batch_idx >= upscaled_image.shape[0]:
|
|
debug_log(f"Warning: Calculated batch_idx {batch_idx} exceeds batch size {upscaled_image.shape[0]}")
|
|
continue
|
|
|
|
x, y = all_tiles[tile_idx]
|
|
|
|
# Extract tile from the specific image in the batch
|
|
tile_tensor, x1, y1, ew, eh = self.extract_tile_with_padding(
|
|
upscaled_image[batch_idx:batch_idx+1], x, y, tile_width, tile_height, padding
|
|
)
|
|
|
|
# Process tile through SD with unique seed
|
|
image_seed = seed + batch_idx * 1000
|
|
processed_tile = self.process_tile(tile_tensor, model, positive, negative, vae,
|
|
image_seed + tile_idx, steps, cfg, sampler_name,
|
|
scheduler, denoise, tiled_decode)
|
|
|
|
processed_tiles.append({
|
|
'tile': processed_tile,
|
|
'global_idx': global_idx,
|
|
'batch_idx': batch_idx,
|
|
'tile_idx': tile_idx,
|
|
'x': x1, # Use extraction position
|
|
'y': y1,
|
|
'extracted_width': ew,
|
|
'extracted_height': eh
|
|
})
|
|
|
|
# Send results back using the server's event loop in a single batch
|
|
try:
|
|
run_async_in_server_loop(
|
|
self.send_tiles_batch_to_master(processed_tiles, multi_job_id, master_url,
|
|
padding, worker_id),
|
|
timeout=TILE_SEND_TIMEOUT
|
|
)
|
|
except Exception as e:
|
|
log(f"UltimateSDUpscale Worker - Error sending tiles: {e}")
|
|
|
|
return (upscaled_image,)
|
|
|
|
def process_master(self, upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur,
|
|
force_uniform_tiles, tiled_decode, multi_job_id, enabled_worker_ids, dynamic_threshold):
|
|
"""Master processing - SYNCHRONOUS with async collection."""
|
|
# Round tile dimensions
|
|
tile_width = self.round_to_multiple(tile_width)
|
|
tile_height = self.round_to_multiple(tile_height)
|
|
|
|
# Get image dimensions and batch size
|
|
batch_size, height, width, _ = upscaled_image.shape
|
|
|
|
# Calculate all tiles
|
|
all_tiles = self.calculate_tiles(width, height, tile_width, tile_height, force_uniform_tiles)
|
|
total_tiles = len(all_tiles)
|
|
|
|
debug_log(f"UltimateSDUpscale Master - Total tiles: {total_tiles}")
|
|
|
|
# Parse enabled workers
|
|
enabled_workers = json.loads(enabled_worker_ids)
|
|
num_workers = len(enabled_workers)
|
|
|
|
# Determine processing mode
|
|
if num_workers == 0:
|
|
mode = "single_gpu"
|
|
elif batch_size == 1 or batch_size <= dynamic_threshold:
|
|
mode = "static"
|
|
else:
|
|
mode = "dynamic"
|
|
|
|
debug_log(f"UltimateSDUpscale Master - Mode: {mode}, batch_size: {batch_size}, workers: {num_workers}")
|
|
|
|
if mode == "single_gpu":
|
|
# No workers, process all tiles locally
|
|
return self.process_single_gpu(upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur, force_uniform_tiles, tiled_decode)
|
|
|
|
elif mode == "dynamic":
|
|
# Dynamic mode for large batches
|
|
return self.process_master_dynamic(upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur,
|
|
force_uniform_tiles, tiled_decode, multi_job_id, enabled_workers)
|
|
|
|
# Static mode - continue with existing implementation
|
|
|
|
# Initialize job queue FIRST before any processing
|
|
# Initialize the queue directly in the persistent server storage
|
|
# This ensures it's accessible from the web server's event loop
|
|
try:
|
|
run_async_in_server_loop(
|
|
self._init_job_queue(multi_job_id),
|
|
timeout=QUEUE_INIT_TIMEOUT
|
|
)
|
|
debug_log(f"UltimateSDUpscale Master - Queue initialization complete")
|
|
except Exception as e:
|
|
debug_log(f"UltimateSDUpscale Master - Queue initialization error: {e}")
|
|
raise RuntimeError(f"Failed to initialize static mode queue: {e}")
|
|
|
|
# Don't try to prepare via API since we're using local queues
|
|
# loop = asyncio.new_event_loop()
|
|
# asyncio.set_event_loop(loop)
|
|
|
|
# Convert batch to PIL list for processing
|
|
result_images = []
|
|
for b in range(batch_size):
|
|
image_pil = tensor_to_pil(upscaled_image[b:b+1], 0)
|
|
result_images.append(image_pil.copy())
|
|
|
|
# Mask will be created per tile for proper blending
|
|
|
|
# Calculate total tiles across all images
|
|
num_tiles_per_image = len(all_tiles)
|
|
total_tiles_all_images = batch_size * num_tiles_per_image
|
|
|
|
# Calculate master's global tile indices
|
|
master_global_indices = self._get_master_global_indices(total_tiles_all_images, num_workers)
|
|
master_tiles = len(master_global_indices)
|
|
|
|
debug_log(f"UltimateSDUpscale Master - Processing {master_tiles} tiles locally from batch of {batch_size}")
|
|
|
|
# Process master tiles SYNCHRONOUSLY
|
|
for global_idx in master_global_indices:
|
|
# Calculate which image and tile this corresponds to
|
|
batch_idx = global_idx // num_tiles_per_image
|
|
tile_idx = global_idx % num_tiles_per_image
|
|
|
|
# Skip if batch_idx is out of range
|
|
if batch_idx >= len(result_images):
|
|
debug_log(f"Warning: Calculated batch_idx {batch_idx} exceeds result_images length {len(result_images)}")
|
|
continue
|
|
|
|
# Use unique seed per image
|
|
image_seed = seed + batch_idx * 1000
|
|
|
|
result_images[batch_idx] = self._process_and_blend_tile(
|
|
tile_idx, all_tiles[tile_idx], upscaled_image[batch_idx:batch_idx+1], result_images[batch_idx],
|
|
model, positive, negative, vae, image_seed, steps, cfg,
|
|
sampler_name, scheduler, denoise, tile_width, tile_height,
|
|
padding, mask_blur, width, height, tiled_decode
|
|
)
|
|
|
|
# Collect worker tiles using async
|
|
worker_tiles_expected = total_tiles_all_images - master_tiles
|
|
if worker_tiles_expected > 0:
|
|
# Calculate tile distribution for active worker calculation
|
|
tiles_per_participant = total_tiles_all_images // (num_workers + 1)
|
|
remainder = total_tiles_all_images % (num_workers + 1)
|
|
|
|
# Calculate which workers will actually have tiles
|
|
active_workers = []
|
|
for i in range(num_workers):
|
|
worker_start_idx = master_tiles + (i * tiles_per_participant)
|
|
if i < remainder - 1:
|
|
worker_start_idx += i
|
|
worker_tile_count = tiles_per_participant + 1
|
|
else:
|
|
worker_start_idx += remainder - 1 if remainder > 0 else 0
|
|
worker_tile_count = tiles_per_participant
|
|
|
|
if worker_start_idx < total_tiles_all_images and worker_tile_count > 0:
|
|
active_workers.append(enabled_workers[i])
|
|
|
|
debug_log(f"UltimateSDUpscale Master - Waiting for {worker_tiles_expected} tiles from {len(active_workers)} active workers")
|
|
debug_log(f"UltimateSDUpscale Master - Active worker IDs: {active_workers}")
|
|
debug_log(f"UltimateSDUpscale Master - All enabled worker IDs: {enabled_workers}")
|
|
|
|
# Collect tiles from workers using the server's event loop
|
|
collected_tiles = run_async_in_server_loop(
|
|
self._async_collect_worker_tiles(multi_job_id, len(active_workers)),
|
|
timeout=TILE_COLLECTION_TIMEOUT
|
|
)
|
|
|
|
# Blend collected tiles SYNCHRONOUSLY
|
|
for tile_data in collected_tiles.values():
|
|
x = tile_data['x']
|
|
y = tile_data['y']
|
|
tile_tensor = tile_data['tensor']
|
|
tile_idx = tile_data['tile_idx']
|
|
extracted_width = tile_data.get('extracted_width', tile_width + 2 * padding)
|
|
extracted_height = tile_data.get('extracted_height', tile_height + 2 * padding)
|
|
|
|
# Get batch index if available (for batch processing)
|
|
batch_idx = tile_data.get('batch_idx', 0)
|
|
|
|
# Skip if batch_idx is out of range
|
|
if batch_idx >= len(result_images):
|
|
debug_log(f"Warning: Received tile for batch_idx {batch_idx} but only have {len(result_images)} images")
|
|
continue
|
|
|
|
# Convert and blend
|
|
tile_pil = tensor_to_pil(tile_tensor, 0)
|
|
# Get the original tile position from the tile index
|
|
orig_x, orig_y = all_tiles[tile_idx]
|
|
# Create mask for this specific tile
|
|
tile_mask = self.create_tile_mask(width, height, orig_x, orig_y, tile_width, tile_height, mask_blur)
|
|
# Use extraction position and size for blending
|
|
result_images[batch_idx] = self.blend_tile(result_images[batch_idx], tile_pil,
|
|
x, y, (extracted_width, extracted_height), tile_mask, padding)
|
|
|
|
# Convert back to tensor
|
|
# For batch processing, combine all result images
|
|
if batch_size == 1:
|
|
result_tensor = pil_to_tensor(result_images[0])
|
|
else:
|
|
# Convert all images and concatenate
|
|
result_tensors = [pil_to_tensor(img) for img in result_images]
|
|
result_tensor = torch.cat(result_tensors, dim=0)
|
|
|
|
if upscaled_image.is_cuda:
|
|
result_tensor = result_tensor.cuda()
|
|
|
|
debug_log(f"UltimateSDUpscale Master - Job {multi_job_id} complete")
|
|
return (result_tensor,)
|
|
|
|
def _get_worker_global_indices(self, total_tiles, enabled_worker_ids, worker_id):
|
|
"""Calculate which global tile indices are assigned to a specific worker in flattened mode."""
|
|
enabled_workers = json.loads(enabled_worker_ids)
|
|
num_workers = len(enabled_workers)
|
|
|
|
debug_log(f"UltimateSDUpscale Worker - Worker ID: {worker_id}, Enabled workers: {enabled_workers}")
|
|
|
|
try:
|
|
worker_index = enabled_workers.index(worker_id)
|
|
except ValueError:
|
|
log(f"UltimateSDUpscale Worker - Worker {worker_id} not found in enabled list {enabled_workers}")
|
|
return []
|
|
|
|
# Calculate tile distribution
|
|
tiles_per_participant = total_tiles // (num_workers + 1)
|
|
remainder = total_tiles % (num_workers + 1)
|
|
master_tiles = tiles_per_participant + (1 if remainder > 0 else 0)
|
|
|
|
start_idx = master_tiles + (worker_index * tiles_per_participant)
|
|
if worker_index < remainder - 1:
|
|
start_idx += worker_index
|
|
end_idx = start_idx + tiles_per_participant + 1
|
|
else:
|
|
start_idx += remainder - 1 if remainder > 0 else 0
|
|
end_idx = start_idx + tiles_per_participant
|
|
|
|
end_idx = min(end_idx, total_tiles)
|
|
return list(range(start_idx, end_idx))
|
|
|
|
def _get_worker_tiles(self, all_tiles, enabled_worker_ids, worker_id):
|
|
"""Calculate which tiles are assigned to a specific worker."""
|
|
enabled_workers = json.loads(enabled_worker_ids)
|
|
num_workers = len(enabled_workers)
|
|
total_tiles = len(all_tiles)
|
|
|
|
debug_log(f"UltimateSDUpscale Worker - Worker ID: {worker_id}, Enabled workers: {enabled_workers}")
|
|
|
|
try:
|
|
worker_index = enabled_workers.index(worker_id)
|
|
except ValueError:
|
|
log(f"UltimateSDUpscale Worker - Worker {worker_id} not found in enabled list {enabled_workers}")
|
|
return []
|
|
|
|
# Calculate tile distribution
|
|
tiles_per_participant = total_tiles // (num_workers + 1)
|
|
remainder = total_tiles % (num_workers + 1)
|
|
master_tiles = tiles_per_participant + (1 if remainder > 0 else 0)
|
|
|
|
start_idx = master_tiles + (worker_index * tiles_per_participant)
|
|
if worker_index < remainder - 1:
|
|
start_idx += worker_index
|
|
end_idx = start_idx + tiles_per_participant + 1
|
|
else:
|
|
start_idx += remainder - 1 if remainder > 0 else 0
|
|
end_idx = start_idx + tiles_per_participant
|
|
|
|
end_idx = min(end_idx, total_tiles)
|
|
return list(range(start_idx, end_idx))
|
|
|
|
def _get_master_global_indices(self, total_tiles, num_workers):
|
|
"""Calculate which global tile indices are assigned to the master in flattened mode."""
|
|
tiles_per_participant = total_tiles // (num_workers + 1)
|
|
remainder = total_tiles % (num_workers + 1)
|
|
master_tiles = tiles_per_participant + (1 if remainder > 0 else 0)
|
|
return list(range(master_tiles))
|
|
|
|
def _get_master_tiles(self, all_tiles, num_workers):
|
|
"""Calculate which tiles are assigned to the master."""
|
|
total_tiles = len(all_tiles)
|
|
tiles_per_participant = total_tiles // (num_workers + 1)
|
|
remainder = total_tiles % (num_workers + 1)
|
|
master_tiles = tiles_per_participant + (1 if remainder > 0 else 0)
|
|
return list(range(master_tiles))
|
|
|
|
def _process_and_blend_tile(self, tile_idx, tile_pos, upscaled_image, result_image,
|
|
model, positive, negative, vae, seed, steps, cfg,
|
|
sampler_name, scheduler, denoise, tile_width, tile_height,
|
|
padding, mask_blur, image_width, image_height, tiled_decode):
|
|
"""Process a single tile and blend it into the result image."""
|
|
x, y = tile_pos
|
|
|
|
# Extract and process tile
|
|
tile_tensor, x1, y1, ew, eh = self.extract_tile_with_padding(
|
|
upscaled_image, x, y, tile_width, tile_height, padding
|
|
)
|
|
|
|
processed_tile = self.process_tile(tile_tensor, model, positive, negative, vae,
|
|
seed + tile_idx, steps, cfg, sampler_name,
|
|
scheduler, denoise, tiled_decode)
|
|
|
|
# Convert and blend
|
|
processed_pil = tensor_to_pil(processed_tile, 0)
|
|
# Create mask for this specific tile
|
|
tile_mask = self.create_tile_mask(image_width, image_height, x, y, tile_width, tile_height, mask_blur)
|
|
# Use extraction position and size for blending
|
|
result_image = self.blend_tile(result_image, processed_pil,
|
|
x1, y1, (ew, eh), tile_mask, padding)
|
|
|
|
return result_image
|
|
|
|
async def _prepare_multigpu_job(self, multi_job_id):
|
|
"""Prepare job via API endpoint to ensure server is ready."""
|
|
session = await get_client_session()
|
|
# Use the actual server port (get from server instance)
|
|
port = get_server_port()
|
|
async with session.post(f"http://localhost:{port}/distributed/prepare_job",
|
|
json={"multi_job_id": multi_job_id}) as response:
|
|
if response.status != 200:
|
|
text = await response.text()
|
|
raise RuntimeError(f"Failed to prepare job: {response.status} - {text}")
|
|
|
|
async def _init_job_queue_dynamic(self, multi_job_id, batch_size, assigned_to_workers=None, worker_status=None, worker_indices=None):
|
|
"""Initialize the job queue for dynamic mode with pending images."""
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id not in prompt_server.distributed_pending_tile_jobs:
|
|
prompt_server.distributed_pending_tile_jobs[multi_job_id] = {
|
|
'queue': asyncio.Queue(),
|
|
'mode': 'dynamic',
|
|
'pending_images': asyncio.Queue(),
|
|
'completed_images': {},
|
|
'completed_tiles': {}, # For uniformity
|
|
'batch_size': batch_size,
|
|
'assigned_to_workers': assigned_to_workers or {},
|
|
'worker_status': worker_status or {}
|
|
}
|
|
# Initialize pending images queue with worker indices only
|
|
pending_images = prompt_server.distributed_pending_tile_jobs[multi_job_id]['pending_images']
|
|
for i in worker_indices:
|
|
await pending_images.put(i)
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Initialized job queue with {len(worker_indices)} pending images for workers")
|
|
else:
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Queue already exists for {multi_job_id}")
|
|
|
|
async def _init_job_queue(self, multi_job_id):
|
|
"""Initialize the job queue for collecting tiles."""
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
debug_log(f"UltimateSDUpscale Master - _init_job_queue: Checking if {multi_job_id} exists in distributed_pending_tile_jobs")
|
|
debug_log(f"UltimateSDUpscale Master - _init_job_queue: Current jobs: {list(prompt_server.distributed_pending_tile_jobs.keys())}")
|
|
if multi_job_id not in prompt_server.distributed_pending_tile_jobs:
|
|
prompt_server.distributed_pending_tile_jobs[multi_job_id] = {
|
|
'queue': asyncio.Queue(),
|
|
'mode': 'static',
|
|
'completed_tiles': {} # For static mode data
|
|
}
|
|
debug_log(f"UltimateSDUpscale Master - Initialized job queue for {multi_job_id}")
|
|
debug_log(f"UltimateSDUpscale Master - _init_job_queue: Created new queue for {multi_job_id}")
|
|
else:
|
|
debug_log(f"UltimateSDUpscale Master - _init_job_queue: Queue already exists for {multi_job_id}")
|
|
|
|
async def _async_collect_worker_tiles(self, multi_job_id, num_workers):
|
|
"""Async helper to collect tiles from workers."""
|
|
# Get the already initialized queue
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id not in prompt_server.distributed_pending_tile_jobs:
|
|
raise RuntimeError(f"Job queue not initialized for {multi_job_id}")
|
|
job_data = prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
if not isinstance(job_data, dict) or 'mode' not in job_data:
|
|
raise RuntimeError("Invalid job data structure")
|
|
if job_data['mode'] != 'static':
|
|
raise RuntimeError(f"Mode mismatch: expected static, got {job_data['mode']}")
|
|
q = job_data['queue']
|
|
|
|
collected_tiles = {}
|
|
workers_done = set()
|
|
timeout = TILE_WAIT_TIMEOUT
|
|
|
|
debug_log(f"UltimateSDUpscale Master - Starting collection, expecting {num_workers} workers to complete")
|
|
|
|
while len(workers_done) < num_workers:
|
|
debug_log(f"UltimateSDUpscale Master - Loop status: {len(workers_done)}/{num_workers} workers done, {len(collected_tiles)} tiles collected, workers_done set: {workers_done}")
|
|
try:
|
|
result = await asyncio.wait_for(q.get(), timeout=timeout)
|
|
worker_id = result['worker_id']
|
|
is_last = result.get('is_last', False)
|
|
|
|
# Check if batch mode
|
|
tiles = result.get('tiles', [])
|
|
if tiles:
|
|
# Batch mode
|
|
debug_log(f"UltimateSDUpscale Master - Received batch of {len(tiles)} tiles from worker '{worker_id}' (is_last={is_last})")
|
|
|
|
for tile_data in tiles:
|
|
# Validate required fields
|
|
if 'batch_idx' not in tile_data:
|
|
log(f"UltimateSDUpscale Master - Missing batch_idx in tile data, skipping")
|
|
continue
|
|
batch_idx = tile_data['batch_idx']
|
|
|
|
tile_idx = tile_data['tile_idx']
|
|
# Use global_idx as key if available (for batch processing)
|
|
key = tile_data.get('global_idx', tile_idx)
|
|
|
|
# Store the full tile data including metadata
|
|
collected_tiles[key] = {
|
|
'tensor': tile_data['tensor'],
|
|
'tile_idx': tile_idx,
|
|
'x': tile_data['x'],
|
|
'y': tile_data['y'],
|
|
'extracted_width': tile_data['extracted_width'],
|
|
'extracted_height': tile_data['extracted_height'],
|
|
'padding': tile_data['padding'],
|
|
'worker_id': worker_id,
|
|
'batch_idx': tile_data.get('batch_idx', 0),
|
|
'global_idx': tile_data.get('global_idx', tile_idx)
|
|
}
|
|
else:
|
|
# Single tile mode (backward compat)
|
|
tile_idx = result['tile_idx']
|
|
collected_tiles[tile_idx] = result
|
|
debug_log(f"UltimateSDUpscale Master - Received single tile {tile_idx} from worker '{worker_id}' (is_last={is_last})")
|
|
|
|
if is_last:
|
|
workers_done.add(worker_id)
|
|
debug_log(f"UltimateSDUpscale Master - Worker {worker_id} completed")
|
|
debug_log(f"UltimateSDUpscale Master - Worker '{worker_id}' marked as done. Total workers done: {len(workers_done)}/{num_workers}")
|
|
|
|
except asyncio.TimeoutError:
|
|
log(f"UltimateSDUpscale Master - Timeout waiting for tiles")
|
|
debug_log(f"UltimateSDUpscale Master - Final status: {len(workers_done)}/{num_workers} workers done, {len(collected_tiles)} tiles collected")
|
|
debug_log(f"UltimateSDUpscale Master - Workers that completed: {workers_done}")
|
|
break
|
|
|
|
debug_log(f"UltimateSDUpscale Master - Collection complete. Got {len(collected_tiles)} tiles from {len(workers_done)} workers")
|
|
|
|
# Clean up job queue
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
del prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
|
|
return collected_tiles
|
|
|
|
async def _async_process_dynamic(self, multi_job_id, expected_from_workers, num_workers):
|
|
"""Async helper for dynamic mode - collect processed whole images from workers.
|
|
|
|
This method implements dynamic load balancing where workers request images
|
|
as they become available, ensuring optimal utilization of GPU resources."""
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id not in prompt_server.distributed_pending_tile_jobs:
|
|
raise RuntimeError(f"Job queue not initialized for {multi_job_id}")
|
|
job_data = prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
if not isinstance(job_data, dict) or 'mode' not in job_data:
|
|
raise RuntimeError("Invalid job data structure")
|
|
if job_data['mode'] != 'dynamic':
|
|
raise RuntimeError(f"Mode mismatch: expected dynamic, got {job_data['mode']}")
|
|
q = job_data['queue']
|
|
completed_images = job_data['completed_images']
|
|
|
|
workers_done = set()
|
|
timeout = TILE_WAIT_TIMEOUT * 2 # Longer timeout for full images
|
|
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Waiting for {expected_from_workers} images from {num_workers} workers")
|
|
|
|
last_heartbeat_check = time.time()
|
|
|
|
while len(completed_images) < expected_from_workers and len(workers_done) < num_workers:
|
|
try:
|
|
result = await asyncio.wait_for(q.get(), timeout=10.0) # Shorter timeout for regular checks
|
|
worker_id = result['worker_id']
|
|
is_last = result.get('is_last', False)
|
|
|
|
if 'image_idx' in result and 'image' in result:
|
|
image_idx = result['image_idx']
|
|
image_pil = result['image']
|
|
completed_images[image_idx] = image_pil
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Received image {image_idx} from worker {worker_id}")
|
|
|
|
if is_last:
|
|
workers_done.add(worker_id)
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Worker {worker_id} completed")
|
|
|
|
except asyncio.TimeoutError:
|
|
# Check for worker timeouts every 10 seconds
|
|
current_time = time.time()
|
|
if current_time - last_heartbeat_check >= 10.0:
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
job_data_check = prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
# Check for timed out workers (60 second timeout)
|
|
for worker, last_heartbeat in list(job_data_check.get('worker_status', {}).items()):
|
|
if current_time - last_heartbeat > 60:
|
|
log(f"UltimateSDUpscale Master Dynamic - Worker {worker} timed out")
|
|
# Requeue assigned images from this worker
|
|
for idx in job_data_check.get('assigned_to_workers', {}).get(worker, []):
|
|
if idx not in completed_images:
|
|
await job_data_check['pending_images'].put(idx)
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Requeued image {idx} from timed out worker {worker}")
|
|
# Mark worker as done and clean up
|
|
workers_done.add(worker)
|
|
if 'worker_status' in job_data_check:
|
|
del job_data_check['worker_status'][worker]
|
|
if 'assigned_to_workers' in job_data_check:
|
|
job_data_check['assigned_to_workers'][worker] = []
|
|
last_heartbeat_check = current_time
|
|
|
|
# Check if we've been waiting too long overall
|
|
if current_time - last_heartbeat_check > timeout:
|
|
log(f"UltimateSDUpscale Master Dynamic - Overall timeout waiting for images")
|
|
# Requeue all unfinished images
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
pending_queue = prompt_server.distributed_pending_tile_jobs[multi_job_id]['pending_images']
|
|
for idx in range(job_data_check.get('batch_size', 0)):
|
|
if idx not in completed_images:
|
|
await pending_queue.put(idx)
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Requeued image {idx}")
|
|
break
|
|
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Collection complete. Got {len(completed_images)} images")
|
|
|
|
# Clean up job queue
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
del prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
|
|
return completed_images
|
|
|
|
def round_to_multiple(self, value: int, multiple: int = 8) -> int:
|
|
"""Round value to nearest multiple."""
|
|
return round(value / multiple) * multiple
|
|
|
|
def calculate_tiles(self, image_width: int, image_height: int,
|
|
tile_width: int, tile_height: int, force_uniform_tiles: bool = True) -> List[Tuple[int, int]]:
|
|
"""Calculate tile positions for the image.
|
|
|
|
Tiles are placed at grid positions without overlap in their placement.
|
|
The overlap happens during extraction with padding.
|
|
|
|
If force_uniform_tiles is True, tiles are distributed evenly across the image."""
|
|
if force_uniform_tiles:
|
|
# Calculate number of tiles needed
|
|
num_tiles_x = math.ceil(image_width / tile_width)
|
|
num_tiles_y = math.ceil(image_height / tile_height)
|
|
|
|
# Calculate uniform spacing
|
|
tiles = []
|
|
|
|
# Handle X dimension
|
|
if num_tiles_x == 1:
|
|
tiles_x = [0]
|
|
else:
|
|
# Calculate step to distribute tiles evenly
|
|
step_x = (image_width - tile_width) / (num_tiles_x - 1)
|
|
# Check for excessive overlap
|
|
if step_x < tile_width / 2:
|
|
debug_log(f"Warning: Uniform tiles would have excessive overlap (step_x={step_x}, tile_width={tile_width}). Falling back to non-uniform mode.")
|
|
force_uniform_tiles = False
|
|
else:
|
|
tiles_x = [round(i * step_x) for i in range(num_tiles_x)]
|
|
# Ensure tiles are aligned to 8-pixel boundaries
|
|
tiles_x = [self.round_to_multiple(x, 8) for x in tiles_x]
|
|
|
|
# Handle Y dimension (if still using uniform tiles)
|
|
if force_uniform_tiles:
|
|
if num_tiles_y == 1:
|
|
tiles_y = [0]
|
|
else:
|
|
# Calculate step to distribute tiles evenly
|
|
step_y = (image_height - tile_height) / (num_tiles_y - 1)
|
|
# Check for excessive overlap
|
|
if step_y < tile_height / 2:
|
|
debug_log(f"Warning: Uniform tiles would have excessive overlap (step_y={step_y}, tile_height={tile_height}). Falling back to non-uniform mode.")
|
|
force_uniform_tiles = False
|
|
else:
|
|
tiles_y = [round(i * step_y) for i in range(num_tiles_y)]
|
|
# Ensure tiles are aligned to 8-pixel boundaries
|
|
tiles_y = [self.round_to_multiple(y, 8) for y in tiles_y]
|
|
|
|
# If still using uniform tiles, generate positions
|
|
if force_uniform_tiles:
|
|
# Generate all tile positions
|
|
for y in tiles_y:
|
|
for x in tiles_x:
|
|
tiles.append((x, y))
|
|
|
|
return tiles
|
|
|
|
# Fall through to non-uniform mode if needed
|
|
if not force_uniform_tiles:
|
|
# Original non-uniform tile calculation
|
|
tiles = []
|
|
for y in range(0, image_height, tile_height):
|
|
for x in range(0, image_width, tile_width):
|
|
tiles.append((x, y))
|
|
return tiles
|
|
|
|
def extract_tile_with_padding(self, image: torch.Tensor, x: int, y: int,
|
|
tile_width: int, tile_height: int, padding: int) -> Tuple[torch.Tensor, int, int, int, int]:
|
|
"""Extract a tile from the image with padding."""
|
|
_, h, w, _ = image.shape
|
|
|
|
# Calculate extraction bounds with padding
|
|
x1 = max(0, x - padding)
|
|
y1 = max(0, y - padding)
|
|
x2 = min(w, x + tile_width + padding)
|
|
y2 = min(h, y + tile_height + padding)
|
|
|
|
# Store the actual extracted size before resizing
|
|
extracted_width = x2 - x1
|
|
extracted_height = y2 - y1
|
|
|
|
# Debug logging (uncomment if needed)
|
|
# debug_log(f"[Extract] Tile at ({x}, {y}) -> Extract from ({x1}, {y1}) to ({x2}, {y2}), size: {extracted_width}x{extracted_height}")
|
|
|
|
# Extract tile
|
|
tile = image[:, y1:y2, x1:x2, :]
|
|
|
|
# Convert to PIL using utility function
|
|
tile_pil = tensor_to_pil(tile, 0)
|
|
|
|
# Resize to target dimensions for processing
|
|
tile_pil = tile_pil.resize((tile_width, tile_height), Image.LANCZOS)
|
|
|
|
# Convert back to tensor using utility function
|
|
tile_tensor = pil_to_tensor(tile_pil)
|
|
|
|
# Move to same device as original image
|
|
if image.is_cuda:
|
|
tile_tensor = tile_tensor.cuda()
|
|
|
|
return tile_tensor, x1, y1, extracted_width, extracted_height
|
|
|
|
def process_tile(self, tile_tensor: torch.Tensor, model, positive, negative, vae,
|
|
seed: int, steps: int, cfg: float, sampler_name: str,
|
|
scheduler: str, denoise: float, tiled_decode: bool = False) -> torch.Tensor:
|
|
"""Process a single tile through SD sampling."""
|
|
# Import here to avoid circular dependencies
|
|
from nodes import common_ksampler, VAEEncode, VAEDecode
|
|
|
|
# Try to import tiled VAE nodes if available
|
|
try:
|
|
from nodes import VAEEncodeTiled, VAEDecodeTiled
|
|
tiled_vae_available = True
|
|
except ImportError:
|
|
tiled_vae_available = False
|
|
if tiled_decode:
|
|
debug_log("Tiled VAE nodes not available, falling back to standard VAE")
|
|
|
|
# Convert to PIL and back to ensure clean tensor without gradient tracking
|
|
tile_pil = tensor_to_pil(tile_tensor, 0)
|
|
clean_tensor = pil_to_tensor(tile_pil)
|
|
|
|
# Move to correct device
|
|
if tile_tensor.is_cuda:
|
|
clean_tensor = clean_tensor.cuda()
|
|
|
|
# Encode to latent
|
|
if tiled_decode and tiled_vae_available:
|
|
latent = VAEEncodeTiled().encode(vae, clean_tensor)[0]
|
|
else:
|
|
latent = VAEEncode().encode(vae, clean_tensor)[0]
|
|
|
|
# Sample
|
|
samples = common_ksampler(model, seed, steps, cfg, sampler_name, scheduler,
|
|
positive, negative, latent, denoise=denoise)[0]
|
|
|
|
# Decode back to image
|
|
if tiled_decode and tiled_vae_available:
|
|
image = VAEDecodeTiled().decode(vae, samples)[0]
|
|
else:
|
|
image = VAEDecode().decode(vae, samples)[0]
|
|
|
|
return image
|
|
|
|
def create_tile_mask(self, image_width: int, image_height: int,
|
|
x: int, y: int, tile_width: int, tile_height: int,
|
|
mask_blur: int) -> Image.Image:
|
|
"""Create a mask for blending tiles - matches Ultimate SD Upscale approach.
|
|
|
|
Creates a black image with a white rectangle at the tile position,
|
|
then applies blur to create soft edges.
|
|
"""
|
|
# Create a full-size mask matching the image dimensions
|
|
mask = Image.new('L', (image_width, image_height), 0) # Black background
|
|
|
|
# Draw white rectangle at tile position
|
|
draw = ImageDraw.Draw(mask)
|
|
draw.rectangle([x, y, x + tile_width, y + tile_height], fill=255)
|
|
|
|
# Apply blur to soften edges
|
|
if mask_blur > 0:
|
|
mask = mask.filter(ImageFilter.GaussianBlur(mask_blur))
|
|
|
|
return mask
|
|
|
|
def blend_tile(self, base_image: Image.Image, tile_image: Image.Image,
|
|
x: int, y: int, extracted_size: Tuple[int, int],
|
|
mask: int, padding: int) -> Image.Image:
|
|
"""Blend a processed tile back into the base image using Ultimate SD Upscale's exact approach.
|
|
|
|
This follows the exact method from ComfyUI_UltimateSDUpscale/modules/processing.py
|
|
"""
|
|
extracted_width, extracted_height = extracted_size
|
|
|
|
# Debug logging (uncomment if needed)
|
|
# debug_log(f"[Blend] Placing tile at ({x}, {y}), size: {extracted_width}x{extracted_height}")
|
|
|
|
# Calculate the crop region that was used for extraction
|
|
crop_region = (x, y, x + extracted_width, y + extracted_height)
|
|
|
|
# The mask is already full-size, no need to crop
|
|
|
|
# Resize the processed tile back to the extracted size
|
|
if tile_image.size != (extracted_width, extracted_height):
|
|
tile_resized = tile_image.resize((extracted_width, extracted_height), Image.LANCZOS)
|
|
else:
|
|
tile_resized = tile_image
|
|
|
|
# Follow Ultimate SD Upscale blending approach:
|
|
# Put the tile into position
|
|
image_tile_only = Image.new('RGBA', base_image.size)
|
|
image_tile_only.paste(tile_resized, crop_region[:2])
|
|
|
|
# Add the mask as an alpha channel
|
|
# Must make a copy due to the possibility of an edge becoming black
|
|
temp = image_tile_only.copy()
|
|
temp.putalpha(mask) # Use the full image mask
|
|
image_tile_only.paste(temp, image_tile_only)
|
|
|
|
# Add back the tile to the initial image according to the mask in the alpha channel
|
|
result = base_image.convert('RGBA')
|
|
result.alpha_composite(image_tile_only)
|
|
|
|
# Convert back to RGB
|
|
return result.convert('RGB')
|
|
|
|
|
|
async def send_tiles_batch_to_master(self, processed_tiles, multi_job_id, master_url,
|
|
padding, worker_id):
|
|
"""Send all processed tiles to master, chunked if large."""
|
|
if not processed_tiles:
|
|
return # Early exit if empty
|
|
|
|
total_tiles = len(processed_tiles)
|
|
debug_log(f"UltimateSDUpscale Worker - Sending {total_tiles} tiles in chunks of max {MAX_BATCH}")
|
|
|
|
for start in range(0, total_tiles, MAX_BATCH):
|
|
chunk = processed_tiles[start:start + MAX_BATCH]
|
|
chunk_size = len(chunk)
|
|
is_chunk_last = (start + chunk_size == total_tiles) # True only for final chunk
|
|
|
|
debug_log(f"UltimateSDUpscale Worker - Processing chunk {start//MAX_BATCH + 1}, tiles {start} to {start + chunk_size - 1}")
|
|
|
|
data = aiohttp.FormData()
|
|
data.add_field('multi_job_id', multi_job_id)
|
|
data.add_field('worker_id', str(worker_id))
|
|
data.add_field('is_last', str(is_chunk_last))
|
|
data.add_field('batch_size', str(chunk_size))
|
|
data.add_field('padding', str(padding))
|
|
|
|
# Chunk metadata: Keep absolute tile_idx and batch_idx if present
|
|
metadata = []
|
|
for tile_data in chunk:
|
|
meta = {
|
|
'tile_idx': tile_data['tile_idx'], # Original/absolute idx
|
|
'x': tile_data['x'],
|
|
'y': tile_data['y'],
|
|
'extracted_width': tile_data['extracted_width'],
|
|
'extracted_height': tile_data['extracted_height']
|
|
}
|
|
# Add batch_idx and global_idx if present (for batch processing)
|
|
if 'batch_idx' in tile_data:
|
|
meta['batch_idx'] = tile_data['batch_idx']
|
|
if 'global_idx' in tile_data:
|
|
meta['global_idx'] = tile_data['global_idx']
|
|
metadata.append(meta)
|
|
|
|
# Add JSON metadata
|
|
data.add_field('tiles_metadata', json.dumps(metadata), content_type='application/json')
|
|
|
|
# Add chunk images as 'tile_{j}' where j=0 to chunk_size-1
|
|
for j, tile_data in enumerate(chunk):
|
|
# Convert tensor to PIL
|
|
img = tensor_to_pil(tile_data['tile'], 0)
|
|
byte_io = io.BytesIO()
|
|
img.save(byte_io, format='PNG', compress_level=0)
|
|
byte_io.seek(0)
|
|
|
|
# Add image field
|
|
data.add_field(f'tile_{j}', byte_io, filename=f'tile_{j}.png', content_type='image/png')
|
|
|
|
# Estimate payload size for logging
|
|
payload_size = 0
|
|
for field in data._fields:
|
|
if hasattr(field[2], 'read'):
|
|
field[2].seek(0)
|
|
payload_size += len(field[2].read())
|
|
field[2].seek(0)
|
|
debug_log(f"UltimateSDUpscale Worker - Chunk payload size: {payload_size:,} bytes")
|
|
|
|
# Retry logic with exponential backoff
|
|
max_retries = 5
|
|
retry_delay = 0.5
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
session = await get_client_session()
|
|
url = f"{master_url}/distributed/tile_complete"
|
|
|
|
debug_log(f"UltimateSDUpscale Worker - Sending chunk of {chunk_size} tiles to {url}, attempt {attempt + 1}")
|
|
|
|
async with session.post(url, data=data) as response:
|
|
response.raise_for_status()
|
|
debug_log(f"UltimateSDUpscale Worker - Successfully sent chunk of {chunk_size} tiles")
|
|
break # Success, move to next chunk
|
|
|
|
except Exception as e:
|
|
if attempt < max_retries - 1:
|
|
debug_log(f"UltimateSDUpscale Worker - Retry {attempt + 1}/{max_retries} after error: {e}")
|
|
await asyncio.sleep(retry_delay)
|
|
retry_delay *= 2 # Exponential backoff
|
|
else:
|
|
log(f"UltimateSDUpscale Worker - Failed to send chunk after {max_retries} attempts: {e}")
|
|
raise
|
|
|
|
def process_single_gpu(self, upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur, force_uniform_tiles, tiled_decode):
|
|
"""Process all tiles on a single GPU (no distribution)."""
|
|
# Round tile dimensions
|
|
tile_width = self.round_to_multiple(tile_width)
|
|
tile_height = self.round_to_multiple(tile_height)
|
|
|
|
# Get image dimensions and batch size
|
|
batch_size, height, width, _ = upscaled_image.shape
|
|
|
|
# Calculate all tiles
|
|
all_tiles = self.calculate_tiles(width, height, tile_width, tile_height, force_uniform_tiles)
|
|
|
|
debug_log(f"UltimateSDUpscale - Processing {len(all_tiles)} tiles locally for batch of {batch_size}")
|
|
|
|
# Convert batch to PIL list
|
|
result_images = []
|
|
for b in range(batch_size):
|
|
image_pil = tensor_to_pil(upscaled_image[b:b+1], 0)
|
|
# Ensure RGB mode for consistency
|
|
if image_pil.mode != 'RGB':
|
|
image_pil = image_pil.convert('RGB')
|
|
result_images.append(image_pil.copy())
|
|
|
|
# Process each image in the batch
|
|
for batch_idx in range(batch_size):
|
|
# Process each tile for this image
|
|
for tile_idx, tile_pos in enumerate(all_tiles):
|
|
# Use unique seed per image
|
|
image_seed = seed + batch_idx * 1000
|
|
result_images[batch_idx] = self._process_and_blend_tile(
|
|
tile_idx, tile_pos, upscaled_image[batch_idx:batch_idx+1], result_images[batch_idx],
|
|
model, positive, negative, vae, image_seed, steps, cfg,
|
|
sampler_name, scheduler, denoise, tile_width, tile_height,
|
|
padding, mask_blur, width, height, tiled_decode
|
|
)
|
|
|
|
# Convert back to tensor
|
|
if batch_size == 1:
|
|
result_tensor = pil_to_tensor(result_images[0])
|
|
else:
|
|
result_tensors = [pil_to_tensor(img) for img in result_images]
|
|
result_tensor = torch.cat(result_tensors, dim=0)
|
|
|
|
if upscaled_image.is_cuda:
|
|
result_tensor = result_tensor.cuda()
|
|
|
|
return (result_tensor,)
|
|
|
|
def process_master_dynamic(self, upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur,
|
|
force_uniform_tiles, tiled_decode, multi_job_id, enabled_workers):
|
|
"""Dynamic mode for large batches - assigns whole images to workers dynamically."""
|
|
# Get batch size and dimensions
|
|
batch_size, height, width, _ = upscaled_image.shape
|
|
num_workers = len(enabled_workers)
|
|
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Processing batch of {batch_size} images with {num_workers} workers")
|
|
|
|
# Calculate master's share similar to static mode
|
|
num_participants = num_workers + 1
|
|
images_per_participant = batch_size // num_participants
|
|
remainder = batch_size % num_participants
|
|
master_images_count = images_per_participant + (1 if remainder > 0 else 0)
|
|
master_indices = list(range(master_images_count))
|
|
worker_indices = list(range(master_images_count, batch_size))
|
|
expected_from_workers = len(worker_indices)
|
|
|
|
# Calculate tiles for processing
|
|
all_tiles = self.calculate_tiles(width, height, tile_width, tile_height, force_uniform_tiles)
|
|
|
|
# Initialize job queue for communication - synchronous approach
|
|
try:
|
|
# Add worker tracking data
|
|
assigned_to_workers = {w: [] for w in enabled_workers}
|
|
worker_status = {w: time.time() for w in enabled_workers}
|
|
|
|
# Use short timeout for async init, with threading fallback
|
|
try:
|
|
run_async_in_server_loop(
|
|
self._init_job_queue_dynamic(multi_job_id, batch_size, assigned_to_workers, worker_status, worker_indices),
|
|
timeout=2.0 # Short timeout
|
|
)
|
|
except Exception as init_error:
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Async init failed, using threading: {init_error}")
|
|
# Threading fallback for dynamic mode
|
|
import threading
|
|
def sync_dynamic_init():
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
if multi_job_id not in prompt_server.distributed_pending_tile_jobs:
|
|
prompt_server.distributed_pending_tile_jobs[multi_job_id] = {
|
|
'queue': asyncio.Queue(),
|
|
'mode': 'dynamic',
|
|
'pending_images': asyncio.Queue(),
|
|
'completed_images': {},
|
|
'completed_tiles': {},
|
|
'batch_size': batch_size,
|
|
'assigned_to_workers': assigned_to_workers,
|
|
'worker_status': worker_status
|
|
}
|
|
# Initialize pending images queue with worker indices only
|
|
pending_images = prompt_server.distributed_pending_tile_jobs[multi_job_id]['pending_images']
|
|
for i in worker_indices:
|
|
pending_images.put_nowait(i)
|
|
debug_log(f"Synchronous dynamic init for {multi_job_id} with {len(worker_indices)} pending images for workers")
|
|
thread = threading.Thread(target=sync_dynamic_init)
|
|
thread.start()
|
|
thread.join(timeout=3.0)
|
|
if thread.is_alive():
|
|
raise RuntimeError("Dynamic queue init failed")
|
|
except Exception as e:
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Queue initialization error: {e}")
|
|
raise RuntimeError(f"Failed to initialize dynamic mode queue: {e}")
|
|
|
|
# Convert batch to PIL list
|
|
result_images = []
|
|
for b in range(batch_size):
|
|
image_pil = tensor_to_pil(upscaled_image[b:b+1], 0)
|
|
# Ensure RGB mode for consistency
|
|
if image_pil.mode != 'RGB':
|
|
image_pil = image_pil.convert('RGB')
|
|
result_images.append(image_pil.copy())
|
|
|
|
# Process master's share locally
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Processing {master_images_count} images locally")
|
|
for idx in master_indices:
|
|
single_tensor = upscaled_image[idx:idx+1]
|
|
local_image = result_images[idx]
|
|
image_seed = seed + idx * 1000
|
|
for tile_idx, pos in enumerate(all_tiles):
|
|
local_image = self._process_and_blend_tile(
|
|
tile_idx, pos, single_tensor, local_image,
|
|
model, positive, negative, vae, image_seed, steps, cfg,
|
|
sampler_name, scheduler, denoise, tile_width, tile_height,
|
|
padding, mask_blur, width, height, tiled_decode
|
|
)
|
|
result_images[idx] = local_image
|
|
|
|
# Process with workers using dynamic assignment
|
|
collected_images = run_async_in_server_loop(
|
|
self._async_process_dynamic(multi_job_id, expected_from_workers, num_workers),
|
|
timeout=TILE_COLLECTION_TIMEOUT * 2 # Longer timeout for full images
|
|
)
|
|
|
|
# Update result images with processed ones
|
|
for idx, processed_img in collected_images.items():
|
|
if idx < batch_size:
|
|
result_images[idx] = processed_img
|
|
|
|
# Convert back to tensor
|
|
if batch_size == 1:
|
|
result_tensor = pil_to_tensor(result_images[0])
|
|
else:
|
|
result_tensors = [pil_to_tensor(img) for img in result_images]
|
|
result_tensor = torch.cat(result_tensors, dim=0)
|
|
|
|
if upscaled_image.is_cuda:
|
|
result_tensor = result_tensor.cuda()
|
|
|
|
debug_log(f"UltimateSDUpscale Master Dynamic - Job {multi_job_id} complete")
|
|
return (result_tensor,)
|
|
|
|
def process_worker_dynamic(self, upscaled_image, model, positive, negative, vae,
|
|
seed, steps, cfg, sampler_name, scheduler, denoise,
|
|
tile_width, tile_height, padding, mask_blur,
|
|
force_uniform_tiles, tiled_decode, multi_job_id, master_url,
|
|
worker_id, enabled_worker_ids, dynamic_threshold):
|
|
"""Worker processing in dynamic mode - processes whole images."""
|
|
# Round tile dimensions
|
|
tile_width = self.round_to_multiple(tile_width)
|
|
tile_height = self.round_to_multiple(tile_height)
|
|
|
|
# Get dimensions
|
|
_, height, width, _ = upscaled_image.shape
|
|
all_tiles = self.calculate_tiles(width, height, tile_width, tile_height, force_uniform_tiles)
|
|
|
|
debug_log(f"UltimateSDUpscale Worker Dynamic - Worker {worker_id} ready to process images")
|
|
|
|
# Keep track of processed images for is_last detection
|
|
processed_count = 0
|
|
|
|
# Poll for job readiness to avoid races during master init
|
|
max_poll_attempts = 20 # ~20s at 1s sleep
|
|
for attempt in range(max_poll_attempts):
|
|
ready = run_async_in_server_loop(
|
|
self._check_job_status(multi_job_id, master_url),
|
|
timeout=5.0
|
|
)
|
|
if ready:
|
|
debug_log(f"Job {multi_job_id} ready after {attempt} attempts")
|
|
break
|
|
time.sleep(1.0) # Poll every 1s
|
|
else:
|
|
log(f"Job {multi_job_id} not ready after {max_poll_attempts} attempts, aborting")
|
|
return (upscaled_image,)
|
|
|
|
# Loop to request and process images
|
|
while True:
|
|
# Request an image to process
|
|
image_idx, estimated_remaining = run_async_in_server_loop(
|
|
self._request_image_from_master(multi_job_id, master_url, worker_id),
|
|
timeout=TILE_WAIT_TIMEOUT
|
|
)
|
|
|
|
if image_idx is None:
|
|
debug_log(f"UltimateSDUpscale Worker Dynamic - No more images to process")
|
|
break
|
|
|
|
debug_log(f"UltimateSDUpscale Worker Dynamic - Mode: dynamic, assigned image {image_idx} to worker {worker_id}")
|
|
processed_count += 1
|
|
|
|
# Determine if this should be marked as last for this worker
|
|
is_last_for_worker = (estimated_remaining == 0)
|
|
|
|
# Extract single image tensor
|
|
single_tensor = upscaled_image[image_idx:image_idx+1]
|
|
|
|
# Convert to PIL for processing
|
|
local_image = tensor_to_pil(single_tensor, 0).copy()
|
|
|
|
# Process all tiles for this image
|
|
image_seed = seed + image_idx * 1000
|
|
for tile_idx, pos in enumerate(all_tiles):
|
|
local_image = self._process_and_blend_tile(
|
|
tile_idx, pos, single_tensor, local_image,
|
|
model, positive, negative, vae, image_seed, steps, cfg,
|
|
sampler_name, scheduler, denoise, tile_width, tile_height,
|
|
padding, mask_blur, width, height, tiled_decode
|
|
)
|
|
|
|
# Send processed image back to master
|
|
try:
|
|
# Use the estimated remaining to determine if this is the last image
|
|
is_last = is_last_for_worker
|
|
run_async_in_server_loop(
|
|
self._send_full_image_to_master(local_image, image_idx, multi_job_id,
|
|
master_url, worker_id, is_last),
|
|
timeout=TILE_SEND_TIMEOUT
|
|
)
|
|
# Send heartbeat after processing
|
|
run_async_in_server_loop(
|
|
self._send_heartbeat_to_master(multi_job_id, master_url, worker_id),
|
|
timeout=5.0
|
|
)
|
|
if is_last:
|
|
break
|
|
except Exception as e:
|
|
log(f"UltimateSDUpscale Worker Dynamic - Error sending image {image_idx}: {e}")
|
|
# Continue processing other images
|
|
|
|
# Send final is_last signal
|
|
debug_log(f"UltimateSDUpscale Worker Dynamic - Worker {worker_id} processed {processed_count} images, sending completion signal")
|
|
try:
|
|
run_async_in_server_loop(
|
|
self._send_worker_complete_signal(multi_job_id, master_url, worker_id),
|
|
timeout=TILE_SEND_TIMEOUT
|
|
)
|
|
except Exception as e:
|
|
log(f"UltimateSDUpscale Worker Dynamic - Error sending completion signal: {e}")
|
|
|
|
return (upscaled_image,)
|
|
|
|
async def _request_image_from_master(self, multi_job_id, master_url, worker_id):
|
|
"""Request an image index to process from master in dynamic mode."""
|
|
# Enhanced retries with 404-specific delay, backoff cap, and total timeout to handle init races
|
|
max_retries = 10
|
|
retry_delay = 0.5
|
|
start_time = time.time()
|
|
|
|
for attempt in range(max_retries):
|
|
# Check total timeout
|
|
if time.time() - start_time > 30:
|
|
log(f"Total request timeout after 30s for worker {worker_id}")
|
|
return None, 0
|
|
|
|
try:
|
|
session = await get_client_session()
|
|
url = f"{master_url}/distributed/request_image"
|
|
|
|
async with session.post(url, json={
|
|
'worker_id': str(worker_id),
|
|
'multi_job_id': multi_job_id
|
|
}) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
image_idx = data.get('image_idx')
|
|
estimated_remaining = data.get('estimated_remaining', 0)
|
|
return image_idx, estimated_remaining
|
|
elif response.status == 404:
|
|
# Special handling for 404 - job not found yet
|
|
text = await response.text()
|
|
debug_log(f"Job not found (404), will retry: {text}")
|
|
await asyncio.sleep(1.0)
|
|
else:
|
|
text = await response.text()
|
|
debug_log(f"Request image failed: {response.status} - {text}")
|
|
|
|
except Exception as e:
|
|
if attempt < max_retries - 1:
|
|
debug_log(f"Retry {attempt + 1}/{max_retries} after error: {e}")
|
|
await asyncio.sleep(retry_delay)
|
|
retry_delay *= 2
|
|
retry_delay = min(retry_delay, 5.0) # Cap backoff at 5s
|
|
else:
|
|
log(f"Failed to request image after {max_retries} attempts: {e}")
|
|
raise
|
|
|
|
return None, 0
|
|
|
|
async def _check_job_status(self, multi_job_id, master_url):
|
|
"""Check if job is ready on the master."""
|
|
try:
|
|
session = await get_client_session()
|
|
url = f"{master_url}/distributed/job_status?multi_job_id={multi_job_id}"
|
|
async with session.get(url) as response:
|
|
if response.status == 200:
|
|
data = await response.json()
|
|
return data.get('ready', False)
|
|
return False
|
|
except Exception as e:
|
|
debug_log(f"Job status check failed: {e}")
|
|
return False
|
|
|
|
async def _send_full_image_to_master(self, image_pil, image_idx, multi_job_id,
|
|
master_url, worker_id, is_last):
|
|
"""Send a processed full image back to master in dynamic mode."""
|
|
# Serialize image to PNG
|
|
byte_io = io.BytesIO()
|
|
image_pil.save(byte_io, format='PNG', compress_level=0)
|
|
byte_io.seek(0)
|
|
|
|
# Prepare form data
|
|
data = aiohttp.FormData()
|
|
data.add_field('multi_job_id', multi_job_id)
|
|
data.add_field('worker_id', str(worker_id))
|
|
data.add_field('image_idx', str(image_idx))
|
|
data.add_field('is_last', str(is_last))
|
|
data.add_field('full_image', byte_io, filename=f'image_{image_idx}.png',
|
|
content_type='image/png')
|
|
|
|
# Retry logic
|
|
max_retries = 5
|
|
retry_delay = 0.5
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
session = await get_client_session()
|
|
url = f"{master_url}/distributed/tile_complete"
|
|
|
|
async with session.post(url, data=data) as response:
|
|
response.raise_for_status()
|
|
debug_log(f"Successfully sent image {image_idx} to master")
|
|
return
|
|
|
|
except Exception as e:
|
|
if attempt < max_retries - 1:
|
|
debug_log(f"Retry {attempt + 1}/{max_retries} after error: {e}")
|
|
await asyncio.sleep(retry_delay)
|
|
retry_delay *= 2
|
|
else:
|
|
log(f"Failed to send image {image_idx} after {max_retries} attempts: {e}")
|
|
raise
|
|
|
|
async def _send_heartbeat_to_master(self, multi_job_id, master_url, worker_id):
|
|
"""Send heartbeat signal to master."""
|
|
try:
|
|
data = {'multi_job_id': multi_job_id, 'worker_id': str(worker_id)}
|
|
session = await get_client_session()
|
|
url = f"{master_url}/distributed/heartbeat"
|
|
|
|
async with session.post(url, json=data) as response:
|
|
response.raise_for_status()
|
|
debug_log(f"Worker {worker_id} sent heartbeat")
|
|
except Exception as e:
|
|
debug_log(f"Worker {worker_id} heartbeat failed: {e}")
|
|
|
|
async def _send_worker_complete_signal(self, multi_job_id, master_url, worker_id):
|
|
"""Send completion signal to master in dynamic mode."""
|
|
# Send a dummy request with is_last=True
|
|
data = aiohttp.FormData()
|
|
data.add_field('multi_job_id', multi_job_id)
|
|
data.add_field('worker_id', str(worker_id))
|
|
data.add_field('is_last', 'true')
|
|
# Send empty tile data to signal completion
|
|
data.add_field('batch_size', '0')
|
|
|
|
session = await get_client_session()
|
|
url = f"{master_url}/distributed/tile_complete"
|
|
|
|
async with session.post(url, data=data) as response:
|
|
response.raise_for_status()
|
|
debug_log(f"Worker {worker_id} sent completion signal")
|
|
|
|
|
|
# Ensure initialization before registering routes
|
|
ensure_tile_jobs_initialized()
|
|
|
|
# API Endpoint for tile completion
|
|
@server.PromptServer.instance.routes.post("/distributed/request_image")
|
|
async def request_image_endpoint(request):
|
|
"""Endpoint for workers to request images in dynamic mode."""
|
|
try:
|
|
data = await request.json()
|
|
worker_id = data.get('worker_id')
|
|
multi_job_id = data.get('multi_job_id')
|
|
|
|
if not worker_id or not multi_job_id:
|
|
return await handle_api_error(request, "Missing worker_id or multi_job_id", 400)
|
|
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
job_data = prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
if not isinstance(job_data, dict) or 'mode' not in job_data:
|
|
return await handle_api_error(request, "Invalid job data structure", 500)
|
|
if job_data['mode'] != 'dynamic':
|
|
return await handle_api_error(request, f"Mode mismatch: expected dynamic, got {job_data['mode']}", 400)
|
|
if 'pending_images' in job_data:
|
|
pending_queue = job_data['pending_images']
|
|
try:
|
|
image_idx = await asyncio.wait_for(pending_queue.get(), timeout=0.1)
|
|
# Track assigned image
|
|
if 'assigned_to_workers' in job_data and worker_id in job_data['assigned_to_workers']:
|
|
job_data['assigned_to_workers'][worker_id].append(image_idx)
|
|
# Update worker heartbeat
|
|
if 'worker_status' in job_data:
|
|
job_data['worker_status'][worker_id] = time.time()
|
|
# Get estimated remaining count
|
|
remaining = pending_queue.qsize() # Approximate
|
|
debug_log(f"UltimateSDUpscale API - Assigned image {image_idx} to worker {worker_id}")
|
|
return web.json_response({"image_idx": image_idx, "estimated_remaining": remaining})
|
|
except asyncio.TimeoutError:
|
|
return web.json_response({"image_idx": None})
|
|
else:
|
|
return await handle_api_error(request, "Invalid dynamic mode configuration", 400)
|
|
else:
|
|
return await handle_api_error(request, "Job not found", 404)
|
|
except Exception as e:
|
|
return await handle_api_error(request, e, 500)
|
|
|
|
@server.PromptServer.instance.routes.get("/distributed/job_status")
|
|
async def job_status_endpoint(request):
|
|
"""Endpoint to check if a job is ready."""
|
|
multi_job_id = request.query.get('multi_job_id')
|
|
if not multi_job_id:
|
|
return web.json_response({"ready": False})
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
job_data = prompt_server.distributed_pending_tile_jobs.get(multi_job_id)
|
|
ready = bool(job_data and isinstance(job_data, dict) and 'queue' in job_data)
|
|
return web.json_response({"ready": ready})
|
|
|
|
@server.PromptServer.instance.routes.post("/distributed/heartbeat")
|
|
async def heartbeat_endpoint(request):
|
|
"""Endpoint for workers to send heartbeat signals."""
|
|
try:
|
|
data = await request.json()
|
|
worker_id = data.get('worker_id')
|
|
multi_job_id = data.get('multi_job_id')
|
|
|
|
if not worker_id or not multi_job_id:
|
|
return await handle_api_error(request, "Missing worker_id or multi_job_id", 400)
|
|
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
job_data = prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
if 'worker_status' in job_data:
|
|
job_data['worker_status'][worker_id] = time.time()
|
|
debug_log(f"UltimateSDUpscale API - Heartbeat from worker {worker_id}")
|
|
return web.json_response({"status": "success"})
|
|
else:
|
|
return await handle_api_error(request, "Worker status tracking not available", 400)
|
|
else:
|
|
return await handle_api_error(request, "Job not found", 404)
|
|
except Exception as e:
|
|
return await handle_api_error(request, e, 500)
|
|
|
|
@server.PromptServer.instance.routes.post("/distributed/tile_complete")
|
|
async def tile_complete_endpoint(request):
|
|
"""Endpoint for receiving completed tiles from workers."""
|
|
try:
|
|
# Check payload size before processing
|
|
content_length = request.headers.get('content-length')
|
|
if content_length and int(content_length) > MAX_PAYLOAD_SIZE:
|
|
return await handle_api_error(request, f"Payload too large: {content_length} bytes (max: {MAX_PAYLOAD_SIZE})", 413)
|
|
|
|
data = await request.post()
|
|
multi_job_id = data.get('multi_job_id')
|
|
worker_id = data.get('worker_id')
|
|
is_last = data.get('is_last', 'False').lower() == 'true'
|
|
|
|
if multi_job_id is None or worker_id is None:
|
|
return await handle_api_error(request, "Missing multi_job_id or worker_id", 400)
|
|
|
|
prompt_server = ensure_tile_jobs_initialized()
|
|
|
|
# Check if this is a full image submission (dynamic mode)
|
|
if 'full_image' in data and 'image_idx' in data:
|
|
image_idx = int(data.get('image_idx'))
|
|
is_last = data.get('is_last', 'False').lower() == 'true'
|
|
|
|
try:
|
|
# Process full image
|
|
img_data = data['full_image'].file.read()
|
|
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
|
|
|
debug_log(f"UltimateSDUpscale API - Received full image {image_idx} from worker {worker_id}")
|
|
|
|
# Put into dynamic mode queue
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
job_data = prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
if not isinstance(job_data, dict) or 'mode' not in job_data:
|
|
return await handle_api_error(request, "Invalid job data structure", 500)
|
|
if job_data['mode'] != 'dynamic':
|
|
return await handle_api_error(request, f"Mode mismatch: expected dynamic, got {job_data['mode']}", 400)
|
|
# Check for duplicate image submission
|
|
if 'completed_images' in job_data and image_idx in job_data['completed_images']:
|
|
log(f"UltimateSDUpscale API - Duplicate image {image_idx} from {worker_id}, ignoring")
|
|
return web.json_response({"status": "duplicate"})
|
|
if 'queue' in job_data:
|
|
await job_data['queue'].put({
|
|
'worker_id': worker_id,
|
|
'image_idx': image_idx,
|
|
'image': img,
|
|
'is_last': is_last
|
|
})
|
|
return web.json_response({"status": "success"})
|
|
else:
|
|
return await handle_api_error(request, "Invalid dynamic mode configuration", 400)
|
|
else:
|
|
return await handle_api_error(request, "Job not found", 404)
|
|
except Exception as e:
|
|
log(f"Error processing full image from worker {worker_id}: {e}")
|
|
return await handle_api_error(request, f"Image processing error: {e}", 400)
|
|
|
|
# Check for batch mode
|
|
batch_size = int(data.get('batch_size', 0))
|
|
tiles = []
|
|
|
|
# Handle completion signals (batch_size=0 with is_last=True)
|
|
if batch_size == 0 and is_last:
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
job_data = prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
if isinstance(job_data, dict) and 'queue' in job_data:
|
|
await job_data['queue'].put({
|
|
'worker_id': worker_id,
|
|
'is_last': True,
|
|
'tiles': []
|
|
})
|
|
debug_log(f"Received completion signal from worker {worker_id}")
|
|
return web.json_response({"status": "success"})
|
|
else:
|
|
return await handle_api_error(request, "Invalid job structure", 500)
|
|
else:
|
|
return await handle_api_error(request, "Job not found", 404)
|
|
|
|
if batch_size > 0:
|
|
# Batch mode: Extract multiple tiles
|
|
padding = int(data.get('padding', 32))
|
|
debug_log(f"UltimateSDUpscale - tile_complete batch - job_id: {multi_job_id}, worker: {worker_id}, batch_size: {batch_size}")
|
|
|
|
# Check for JSON metadata
|
|
metadata_field = data.get('tiles_metadata')
|
|
if metadata_field:
|
|
# New JSON metadata format
|
|
try:
|
|
# Handle different types of metadata field
|
|
if hasattr(metadata_field, 'file'):
|
|
# File-like object
|
|
metadata_str = metadata_field.file.read().decode('utf-8')
|
|
elif isinstance(metadata_field, (bytes, bytearray)):
|
|
# Direct bytes/bytearray
|
|
metadata_str = metadata_field.decode('utf-8')
|
|
else:
|
|
# String
|
|
metadata_str = str(metadata_field)
|
|
|
|
metadata = json.loads(metadata_str)
|
|
if len(metadata) != batch_size:
|
|
return await handle_api_error(request, "Metadata length mismatch", 400)
|
|
|
|
tile_data_list = [] # Temporary list to collect and sort tiles
|
|
|
|
for i in range(batch_size):
|
|
tile_field = data.get(f'tile_{i}')
|
|
if tile_field is None:
|
|
log(f"Missing tile_{i} from worker {worker_id}, skipping")
|
|
continue
|
|
|
|
try:
|
|
# Process image
|
|
img_data = tile_field.file.read()
|
|
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
|
img_np = np.array(img).astype(np.float32) / 255.0
|
|
tensor = torch.from_numpy(img_np)[None,]
|
|
|
|
# Get metadata for this tile
|
|
if i < len(metadata):
|
|
tile_data = metadata[i]
|
|
tile_idx = tile_data.get('tile_idx', i)
|
|
|
|
tile_info = {
|
|
'tensor': tensor,
|
|
'tile_idx': tile_idx,
|
|
'x': tile_data['x'],
|
|
'y': tile_data['y'],
|
|
'extracted_width': tile_data['extracted_width'],
|
|
'extracted_height': tile_data['extracted_height'],
|
|
'padding': padding,
|
|
'batch_idx': tile_data.get('batch_idx', 0),
|
|
'global_idx': tile_data.get('global_idx', tile_idx)
|
|
}
|
|
tile_data_list.append(tile_info)
|
|
else:
|
|
log(f"Missing metadata for tile {i} from worker {worker_id}, skipping")
|
|
continue
|
|
|
|
except Exception as e:
|
|
log(f"Error processing tile {i} from worker {worker_id}: {e}, skipping this tile")
|
|
continue
|
|
|
|
# Sort tiles by tile_idx to ensure correct order
|
|
tile_data_list.sort(key=lambda x: x['tile_idx'])
|
|
tiles.extend(tile_data_list)
|
|
|
|
if tile_data_list:
|
|
debug_log(f"Reordered {len(tile_data_list)} tiles based on tile_idx")
|
|
except Exception as e:
|
|
log(f"Error processing JSON metadata from worker {worker_id}: {e}")
|
|
return await handle_api_error(request, f"Metadata processing error: {e}", 400)
|
|
else:
|
|
# Legacy format: individual fields per tile (backward compatibility)
|
|
debug_log(f"WARNING: Worker {worker_id} using legacy field format. Please update to use JSON metadata.")
|
|
|
|
for i in range(batch_size):
|
|
tile_field = data.get(f'tile_{i}')
|
|
if tile_field is None:
|
|
log(f"Missing tile_{i} from worker {worker_id}, skipping")
|
|
continue
|
|
|
|
# Get metadata for this tile
|
|
tile_idx = int(data.get(f'tile_{i}_idx', i))
|
|
x = int(data.get(f'tile_{i}_x', 0))
|
|
y = int(data.get(f'tile_{i}_y', 0))
|
|
extracted_width = int(data.get(f'tile_{i}_width', 512))
|
|
extracted_height = int(data.get(f'tile_{i}_height', 512))
|
|
|
|
try:
|
|
# Process image
|
|
img_data = tile_field.file.read()
|
|
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
|
img_np = np.array(img).astype(np.float32) / 255.0
|
|
tensor = torch.from_numpy(img_np)[None,]
|
|
|
|
tiles.append({
|
|
'tensor': tensor,
|
|
'tile_idx': tile_idx,
|
|
'x': x,
|
|
'y': y,
|
|
'extracted_width': extracted_width,
|
|
'extracted_height': extracted_height,
|
|
'padding': padding
|
|
})
|
|
except Exception as e:
|
|
log(f"Error processing tile {i} from worker {worker_id}: {e}, skipping this tile")
|
|
continue
|
|
else:
|
|
# Single tile mode (backward compatibility)
|
|
image_file = data.get('image')
|
|
if not image_file:
|
|
return await handle_api_error(request, "Missing image data", 400)
|
|
|
|
tile_idx = int(data.get('tile_idx', 0))
|
|
x = int(data.get('x', 0))
|
|
y = int(data.get('y', 0))
|
|
extracted_width = int(data.get('extracted_width', 512))
|
|
extracted_height = int(data.get('extracted_height', 512))
|
|
padding = int(data.get('padding', 32))
|
|
|
|
debug_log(f"UltimateSDUpscale - tile_complete single - job_id: {multi_job_id}, worker: {worker_id}, tile: {tile_idx}")
|
|
|
|
try:
|
|
# Process image
|
|
img_data = image_file.file.read()
|
|
img = Image.open(io.BytesIO(img_data)).convert("RGB")
|
|
img_np = np.array(img).astype(np.float32) / 255.0
|
|
tensor = torch.from_numpy(img_np)[None,]
|
|
|
|
tiles = [{
|
|
'tensor': tensor,
|
|
'tile_idx': tile_idx,
|
|
'x': x,
|
|
'y': y,
|
|
'extracted_width': extracted_width,
|
|
'extracted_height': extracted_height,
|
|
'padding': padding,
|
|
'batch_idx': 0, # Default for legacy single-tile
|
|
'global_idx': tile_idx # Default for legacy single-tile
|
|
}]
|
|
except Exception as e:
|
|
log(f"Error processing tile from worker {worker_id}: {e}")
|
|
return await handle_api_error(request, f"Tile processing error: {e}", 400)
|
|
|
|
# Put tiles into queue
|
|
async with prompt_server.distributed_tile_jobs_lock:
|
|
debug_log(f"UltimateSDUpscale - tile_complete: Checking distributed_pending_tile_jobs for job {multi_job_id}")
|
|
debug_log(f"UltimateSDUpscale - tile_complete: Current jobs in distributed_pending_tile_jobs: {list(prompt_server.distributed_pending_tile_jobs.keys())}")
|
|
|
|
if multi_job_id in prompt_server.distributed_pending_tile_jobs:
|
|
job_data = prompt_server.distributed_pending_tile_jobs[multi_job_id]
|
|
if not isinstance(job_data, dict) or 'mode' not in job_data:
|
|
return await handle_api_error(request, "Invalid job data structure", 500)
|
|
if job_data['mode'] != 'static':
|
|
return await handle_api_error(request, f"Mode mismatch: expected static, got {job_data['mode']}", 400)
|
|
|
|
q = job_data['queue']
|
|
if batch_size > 0:
|
|
# Put batch as single item
|
|
await q.put({
|
|
'worker_id': worker_id,
|
|
'tiles': tiles,
|
|
'is_last': is_last
|
|
})
|
|
debug_log(f"UltimateSDUpscale - Received batch of {len(tiles)} tiles for job {multi_job_id} from worker {worker_id}")
|
|
else:
|
|
# Put single tile (backward compat)
|
|
tile_data = tiles[0]
|
|
await q.put({
|
|
'tensor': tile_data['tensor'],
|
|
'worker_id': worker_id,
|
|
'tile_idx': tile_data['tile_idx'],
|
|
'x': tile_data['x'],
|
|
'y': tile_data['y'],
|
|
'extracted_width': tile_data['extracted_width'],
|
|
'extracted_height': tile_data['extracted_height'],
|
|
'padding': tile_data['padding'],
|
|
'is_last': is_last
|
|
})
|
|
debug_log(f"UltimateSDUpscale - Received single tile {tile_data['tile_idx']} for job {multi_job_id} from worker {worker_id}")
|
|
|
|
return web.json_response({"status": "success"})
|
|
else:
|
|
debug_log(f"UltimateSDUpscale - API Error: Job {multi_job_id} not found in distributed_pending_tile_jobs")
|
|
debug_log(f"UltimateSDUpscale - API Error: Available jobs: {list(prompt_server.distributed_pending_tile_jobs.keys())}")
|
|
return await handle_api_error(request, "Job not found or already complete", 404)
|
|
except Exception as e:
|
|
return await handle_api_error(request, e, 500)
|
|
|
|
|
|
# Node registration
|
|
NODE_CLASS_MAPPINGS = {
|
|
"UltimateSDUpscaleDistributed": UltimateSDUpscaleDistributed,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"UltimateSDUpscaleDistributed": "Ultimate SD Upscale Distributed (No Upscale)",
|
|
} |