5143 lines
225 KiB
Python
5143 lines
225 KiB
Python
import json
|
|
import os
|
|
import base64
|
|
import hashlib
|
|
import math
|
|
import re
|
|
import cv2
|
|
import numpy as np
|
|
from PIL import Image, ImageFilter, ImageChops, ImageEnhance, ImageDraw, ImageOps
|
|
from io import BytesIO
|
|
from PIL.PngImagePlugin import PngInfo
|
|
import folder_paths
|
|
import node_helpers
|
|
import torch
|
|
import comfy.model_management
|
|
import comfy.sd
|
|
import comfy.samplers
|
|
import comfy.sample
|
|
import comfy.utils
|
|
import latent_preview
|
|
from typing import Any, List, Tuple, Optional, Union, Dict
|
|
|
|
try:
|
|
from aiohttp import web
|
|
from server import PromptServer
|
|
except Exception:
|
|
web = None
|
|
PromptServer = None
|
|
|
|
try:
|
|
from comfy.cli_args import args
|
|
except Exception:
|
|
class _TTPArgs:
|
|
disable_metadata = False
|
|
|
|
args = _TTPArgs()
|
|
|
|
_TTP_SMART_TILE_LOOP_SESSIONS = {}
|
|
_TTP_QWENVL3_MODEL_CACHE = {}
|
|
_TTP_QWENVL_PROMPT_CACHE = {}
|
|
|
|
_TTP_QWENVL_PRESETS = {
|
|
"tile_img2img_prompt": {
|
|
"system": "You are an image-to-generation-prompt tagger for tiled image refinement. Describe only visible visual facts useful for image-to-image refinement. Preserve identity, clothing, pose, material, lighting, camera perspective, and local details. Do not invent objects outside the tile. Return only strict JSON with exactly these string fields: label, caption, prompt, negative. Do not repeat the instruction. Do not wrap the JSON in markdown.",
|
|
"instruction": "Analyze the visible tile image and write a concrete img2img refinement prompt for this tile. If a reference image or contact sheet is provided, keep this tile consistent with the global subject, style, lighting, and identity. Return only JSON like {\"label\":\"short label\",\"caption\":\"specific visible description\",\"prompt\":\"specific positive generation prompt\",\"negative\":\"tile-specific negative prompt\"}.",
|
|
},
|
|
"tile_caption_only": {
|
|
"system": "You are a precise visual captioner for tiled image refinement. Describe visible facts only. Return strict JSON with fields: label, caption, prompt, negative.",
|
|
"instruction": "Caption this tile concisely. Put the useful visual caption in both caption and prompt. Keep negative empty unless a visible artifact should be avoided. Return only JSON.",
|
|
},
|
|
"tile_json_strict": {
|
|
"system": "You output strict compact JSON only. No markdown, no explanation, no extra text.",
|
|
"instruction": "Analyze this tile and return exactly {\"label\":\"...\",\"caption\":\"...\",\"prompt\":\"...\",\"negative\":\"...\"}. Keep all fields concise and grounded in visible pixels.",
|
|
},
|
|
"bbox_detect": {
|
|
"system": "You are a visual detector. Return strict JSON only.",
|
|
"instruction": "Detect important objects, faces, eyes, hands, text, and small high-detail regions. Return JSON list only: [{\"label\":\"face\",\"bbox\":[x1,y1,x2,y2],\"score\":0.9,\"priority\":2000,\"prompt_hint\":\"short visual hint\"}]. Coordinates must be normalized 0..1000 integers.",
|
|
},
|
|
"style_material_lighting": {
|
|
"system": "You are an image prompt engineer focused on style, material, lighting, and texture consistency. Return strict JSON with fields: label, caption, prompt, negative.",
|
|
"instruction": "Describe this tile's material, texture, lighting direction, color palette, camera perspective, and local visual details for img2img refinement. Do not invent hidden content. Return only JSON.",
|
|
},
|
|
}
|
|
|
|
def pil2tensor(image: Image) -> torch.Tensor:
|
|
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
def tensor2pil(t_image: torch.Tensor) -> Image:
|
|
return Image.fromarray(np.clip(255.0 * t_image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
|
|
|
|
def apply_gaussian_blur(image_np, ksize=5, sigmaX=1.0):
|
|
if ksize % 2 == 0:
|
|
ksize += 1 # ksize must be odd
|
|
blurred_image = cv2.GaussianBlur(image_np, (ksize, ksize), sigmaX=sigmaX)
|
|
return blurred_image
|
|
|
|
|
|
class TTPlanet_Tile_Preprocessor_Simple:
|
|
def __init__(self, blur_strength=3.0):
|
|
self.blur_strength = blur_strength
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"scale_factor": ("FLOAT", {"default": 2.00, "min": 1.00, "max": 8.00, "step": 0.05}),
|
|
"blur_strength": ("FLOAT", {"default": 1.0, "min": 1.0, "max": 20.0, "step": 0.1}),
|
|
},
|
|
"optional": {}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("image_output",)
|
|
FUNCTION = 'process_image'
|
|
CATEGORY = 'TTP/TILE'
|
|
|
|
def process_image(self, image, scale_factor, blur_strength):
|
|
ret_images = []
|
|
|
|
for i in image:
|
|
# Convert tensor to PIL for processing
|
|
_canvas = tensor2pil(torch.unsqueeze(i, 0)).convert('RGB')
|
|
|
|
# Convert PIL image to OpenCV format
|
|
img_np = np.array(_canvas)[:, :, ::-1] # RGB to BGR
|
|
|
|
# Resize image first if you want blur to apply after resizing
|
|
height, width = img_np.shape[:2]
|
|
new_width = int(width / scale_factor)
|
|
new_height = int(height / scale_factor)
|
|
resized_down = cv2.resize(img_np, (new_width, new_height), interpolation=cv2.INTER_AREA)
|
|
resized_img = cv2.resize(resized_down, (width, height), interpolation=cv2.INTER_LINEAR)
|
|
|
|
# Apply Gaussian blur after resizing
|
|
img_np = apply_gaussian_blur(resized_img, ksize=int(blur_strength), sigmaX=blur_strength / 2)
|
|
|
|
# Convert OpenCV back to PIL and then to tensor
|
|
_canvas = Image.fromarray(img_np[:, :, ::-1]) # BGR to RGB
|
|
tensor_img = pil2tensor(_canvas)
|
|
ret_images.append(tensor_img)
|
|
|
|
return (torch.cat(ret_images, dim=0),)
|
|
|
|
|
|
class TTP_Image_Tile_Batch:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"tile_width": ("INT", {"default": 1024, "min": 1}),
|
|
"tile_height": ("INT", {"default": 1024, "min": 1}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "LIST", "TUPLE", "TUPLE")
|
|
RETURN_NAMES = ("IMAGES", "POSITIONS", "ORIGINAL_SIZE", "GRID_SIZE")
|
|
FUNCTION = "tile_image"
|
|
|
|
CATEGORY = "TTP/Image"
|
|
|
|
def tile_image(self, image, tile_width=1024, tile_height=1024):
|
|
image = tensor2pil(image.squeeze(0))
|
|
img_width, img_height = image.size
|
|
|
|
if img_width <= tile_width and img_height <= tile_height:
|
|
return (pil2tensor(image), [(0, 0, img_width, img_height)], (img_width, img_height), (1, 1))
|
|
|
|
def calculate_step(size, tile_size):
|
|
if size <= tile_size:
|
|
return 1, 0
|
|
else:
|
|
num_tiles = (size + tile_size - 1) // tile_size
|
|
overlap = (num_tiles * tile_size - size) // (num_tiles - 1)
|
|
step = tile_size - overlap
|
|
return num_tiles, step
|
|
|
|
num_cols, step_x = calculate_step(img_width, tile_width)
|
|
num_rows, step_y = calculate_step(img_height, tile_height)
|
|
|
|
tiles = []
|
|
positions = []
|
|
for y in range(num_rows):
|
|
for x in range(num_cols):
|
|
left = x * step_x
|
|
upper = y * step_y
|
|
right = min(left + tile_width, img_width)
|
|
lower = min(upper + tile_height, img_height)
|
|
|
|
if right - left < tile_width:
|
|
left = max(0, img_width - tile_width)
|
|
if lower - upper < tile_height:
|
|
upper = max(0, img_height - tile_height)
|
|
|
|
tile = image.crop((left, upper, right, lower))
|
|
tile_tensor = pil2tensor(tile)
|
|
tiles.append(tile_tensor)
|
|
positions.append((left, upper, right, lower))
|
|
|
|
tiles = torch.stack(tiles, dim=0).squeeze(1)
|
|
return (tiles, positions, (img_width, img_height), (num_cols, num_rows))
|
|
|
|
|
|
class TTP_Image_Assy:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"tiles": ("IMAGE",),
|
|
"positions": ("LIST",),
|
|
"original_size": ("TUPLE",),
|
|
"grid_size": ("TUPLE",),
|
|
"padding": ("INT", {"default": 64, "min": 0}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("RECONSTRUCTED_IMAGE",)
|
|
FUNCTION = "assemble_image"
|
|
|
|
CATEGORY = "TTP/Image"
|
|
|
|
def create_gradient_mask(self, size, direction):
|
|
"""Create a gradient mask for blending."""
|
|
mask = Image.new("L", size)
|
|
for i in range(size[0] if direction == 'horizontal' else size[1]):
|
|
value = int(255 * (1 - (i / size[0] if direction == 'horizontal' else i / size[1])))
|
|
if direction == 'horizontal':
|
|
mask.paste(value, (i, 0, i+1, size[1]))
|
|
else:
|
|
mask.paste(value, (0, i, size[0], i+1))
|
|
return mask
|
|
|
|
def blend_tiles(self, tile1, tile2, overlap_size, direction, padding):
|
|
"""Blend two tiles with a smooth transition."""
|
|
blend_size = padding
|
|
if blend_size > overlap_size:
|
|
blend_size = overlap_size
|
|
|
|
if blend_size == 0:
|
|
# No blending, just concatenate the images at the correct overlap
|
|
if direction == 'horizontal':
|
|
result = Image.new("RGB", (tile1.width + tile2.width - overlap_size, tile1.height))
|
|
# Paste the left part of tile1 excluding the overlap
|
|
result.paste(tile1.crop((0, 0, tile1.width - overlap_size, tile1.height)), (0, 0))
|
|
# Paste tile2 directly after tile1
|
|
result.paste(tile2, (tile1.width - overlap_size, 0))
|
|
else:
|
|
# For vertical direction
|
|
result = Image.new("RGB", (tile1.width, tile1.height + tile2.height - overlap_size))
|
|
result.paste(tile1.crop((0, 0, tile1.width, tile1.height - overlap_size)), (0, 0))
|
|
result.paste(tile2, (0, tile1.height - overlap_size))
|
|
return result
|
|
|
|
# 以下为原有的混合代码,当 blend_size > 0 时执行
|
|
offset_total = overlap_size - blend_size
|
|
offset_left = offset_total // 2
|
|
offset_right = offset_total - offset_left
|
|
|
|
size = (blend_size, tile1.height) if direction == 'horizontal' else (tile1.width, blend_size)
|
|
mask = self.create_gradient_mask(size, direction)
|
|
|
|
if direction == 'horizontal':
|
|
crop_tile1 = tile1.crop((tile1.width - overlap_size + offset_left, 0, tile1.width - offset_right, tile1.height))
|
|
crop_tile2 = tile2.crop((offset_left, 0, offset_left + blend_size, tile2.height))
|
|
if crop_tile1.size != crop_tile2.size:
|
|
raise ValueError(f"Crop sizes do not match: {crop_tile1.size} vs {crop_tile2.size}")
|
|
|
|
blended = Image.composite(crop_tile1, crop_tile2, mask)
|
|
result = Image.new("RGB", (tile1.width + tile2.width - overlap_size, tile1.height))
|
|
result.paste(tile1.crop((0, 0, tile1.width - overlap_size + offset_left, tile1.height)), (0, 0))
|
|
result.paste(blended, (tile1.width - overlap_size + offset_left, 0))
|
|
result.paste(tile2.crop((offset_left + blend_size, 0, tile2.width, tile2.height)), (tile1.width - offset_right, 0))
|
|
else:
|
|
offset_total = overlap_size - blend_size
|
|
offset_top = offset_total // 2
|
|
offset_bottom = offset_total - offset_top
|
|
|
|
size = (tile1.width, blend_size)
|
|
mask = self.create_gradient_mask(size, direction)
|
|
|
|
crop_tile1 = tile1.crop((0, tile1.height - overlap_size + offset_top, tile1.width, tile1.height - offset_bottom))
|
|
crop_tile2 = tile2.crop((0, offset_top, tile2.width, offset_top + blend_size))
|
|
if crop_tile1.size != crop_tile2.size:
|
|
raise ValueError(f"Crop sizes do not match: {crop_tile1.size} vs {crop_tile2.size}")
|
|
|
|
blended = Image.composite(crop_tile1, crop_tile2, mask)
|
|
result = Image.new("RGB", (tile1.width, tile1.height + tile2.height - overlap_size))
|
|
result.paste(tile1.crop((0, 0, tile1.width, tile1.height - overlap_size + offset_top)), (0, 0))
|
|
result.paste(blended, (0, tile1.height - overlap_size + offset_top))
|
|
result.paste(tile2.crop((0, offset_top + blend_size, tile2.width, tile2.height)), (0, tile1.height - offset_bottom))
|
|
return result
|
|
|
|
def assemble_image(self, tiles, positions, original_size, grid_size, padding):
|
|
num_cols, num_rows = grid_size
|
|
reconstructed_image = Image.new("RGB", original_size)
|
|
|
|
# First, blend each row independently
|
|
row_images = []
|
|
for row in range(num_rows):
|
|
row_image = tensor2pil(tiles[row * num_cols].unsqueeze(0))
|
|
for col in range(1, num_cols):
|
|
index = row * num_cols + col
|
|
tile_image = tensor2pil(tiles[index].unsqueeze(0))
|
|
prev_right = positions[index - 1][2]
|
|
left = positions[index][0]
|
|
overlap_width = prev_right - left
|
|
if overlap_width > 0:
|
|
row_image = self.blend_tiles(row_image, tile_image, overlap_width, 'horizontal', padding)
|
|
else:
|
|
# Adjust the size of row_image to accommodate the new tile
|
|
new_width = row_image.width + tile_image.width
|
|
new_height = max(row_image.height, tile_image.height)
|
|
new_row_image = Image.new("RGB", (new_width, new_height))
|
|
new_row_image.paste(row_image, (0, 0))
|
|
new_row_image.paste(tile_image, (row_image.width, 0))
|
|
row_image = new_row_image
|
|
row_images.append(row_image)
|
|
|
|
# Now, blend each row together vertically
|
|
final_image = row_images[0]
|
|
for row in range(1, num_rows):
|
|
prev_lower = positions[(row - 1) * num_cols][3]
|
|
upper = positions[row * num_cols][1]
|
|
overlap_height = prev_lower - upper
|
|
if overlap_height > 0:
|
|
final_image = self.blend_tiles(final_image, row_images[row], overlap_height, 'vertical', padding)
|
|
else:
|
|
# Adjust the size of final_image to accommodate the new row image
|
|
new_width = max(final_image.width, row_images[row].width)
|
|
new_height = final_image.height + row_images[row].height
|
|
new_final_image = Image.new("RGB", (new_width, new_height))
|
|
new_final_image.paste(final_image, (0, 0))
|
|
new_final_image.paste(row_images[row], (0, final_image.height))
|
|
final_image = new_final_image
|
|
|
|
return pil2tensor(final_image).unsqueeze(0)
|
|
|
|
|
|
def _ttp_clamp(value, minimum, maximum):
|
|
return max(minimum, min(maximum, value))
|
|
|
|
|
|
def _ttp_safe_int(value, default=0, minimum=None, maximum=None):
|
|
try:
|
|
if isinstance(value, str) and not value.strip().replace(".", "", 1).lstrip("-").isdigit():
|
|
number = int(default)
|
|
else:
|
|
number = int(round(float(value)))
|
|
except Exception:
|
|
number = int(default)
|
|
if minimum is not None:
|
|
number = max(int(minimum), number)
|
|
if maximum is not None:
|
|
number = min(int(maximum), number)
|
|
return number
|
|
|
|
|
|
def _ttp_safe_float(value, default=0.0, minimum=None, maximum=None):
|
|
try:
|
|
number = float(value)
|
|
except Exception:
|
|
number = float(default)
|
|
if not np.isfinite(number):
|
|
number = float(default)
|
|
if minimum is not None:
|
|
number = max(float(minimum), number)
|
|
if maximum is not None:
|
|
number = min(float(maximum), number)
|
|
return number
|
|
|
|
|
|
def _ttp_safe_bool(value, default=False):
|
|
if isinstance(value, bool):
|
|
return value
|
|
if isinstance(value, (int, float)):
|
|
return bool(value)
|
|
if isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in ("true", "1", "yes", "on"):
|
|
return True
|
|
if lowered in ("false", "0", "no", "off"):
|
|
return False
|
|
return bool(default)
|
|
|
|
|
|
def _ttp_round_to(value, multiple):
|
|
if multiple <= 1:
|
|
return int(round(value))
|
|
return int(np.ceil(value / multiple) * multiple)
|
|
|
|
|
|
def _ttp_value_is_normalized(value):
|
|
if not isinstance(value, (int, float)):
|
|
return False
|
|
numeric = float(value)
|
|
return 0.0 <= numeric <= 1.0
|
|
|
|
|
|
def _ttp_parse_box_value(value, size, prefer_normalized=False):
|
|
if prefer_normalized and _ttp_value_is_normalized(value):
|
|
return int(round(float(value) * size))
|
|
if isinstance(value, float) and 0.0 <= value <= 1.0:
|
|
return int(round(value * size))
|
|
return int(round(value))
|
|
|
|
|
|
def _ttp_parse_margin_value(value, size):
|
|
if isinstance(value, float) and 0.0 <= value <= 1.0:
|
|
return int(round(value * size))
|
|
return int(round(value))
|
|
|
|
|
|
def _ttp_normalize_tile_box(tile, image_width, image_height, defaults):
|
|
if all(key in tile for key in ("x0", "y0", "x1", "y1")):
|
|
box_values = [tile.get("x0", 0), tile.get("y0", 0), tile.get("x1", image_width), tile.get("y1", image_height)]
|
|
prefer_normalized = all(_ttp_value_is_normalized(value) for value in box_values)
|
|
x0 = _ttp_parse_box_value(tile.get("x0", 0), image_width, prefer_normalized)
|
|
y0 = _ttp_parse_box_value(tile.get("y0", 0), image_height, prefer_normalized)
|
|
x1 = _ttp_parse_box_value(tile.get("x1", image_width), image_width, prefer_normalized)
|
|
y1 = _ttp_parse_box_value(tile.get("y1", image_height), image_height, prefer_normalized)
|
|
x, x_end = sorted((x0, x1))
|
|
y, y_end = sorted((y0, y1))
|
|
w = x_end - x
|
|
h = y_end - y
|
|
else:
|
|
box_values = [
|
|
tile.get("x", 0),
|
|
tile.get("y", 0),
|
|
tile.get("w", tile.get("width", image_width)),
|
|
tile.get("h", tile.get("height", image_height)),
|
|
]
|
|
prefer_normalized = all(_ttp_value_is_normalized(value) for value in box_values)
|
|
x = _ttp_parse_box_value(tile.get("x", 0), image_width, prefer_normalized)
|
|
y = _ttp_parse_box_value(tile.get("y", 0), image_height, prefer_normalized)
|
|
w = _ttp_parse_box_value(tile.get("w", tile.get("width", image_width)), image_width, prefer_normalized)
|
|
h = _ttp_parse_box_value(tile.get("h", tile.get("height", image_height)), image_height, prefer_normalized)
|
|
|
|
x = _ttp_clamp(x, 0, max(0, image_width - 1))
|
|
y = _ttp_clamp(y, 0, max(0, image_height - 1))
|
|
w = _ttp_clamp(w, 1, max(1, image_width - x))
|
|
h = _ttp_clamp(h, 1, max(1, image_height - y))
|
|
|
|
pad_value = tile.get("pad", tile.get("padding", defaults.get("pad", 0)))
|
|
blend_value = tile.get("blend", tile.get("feather", defaults.get("blend", 32)))
|
|
pad = _ttp_parse_margin_value(pad_value, max(image_width, image_height))
|
|
blend = _ttp_parse_margin_value(blend_value, max(w, h))
|
|
|
|
normalized = {
|
|
"name": str(tile.get("name", f"tile_{tile.get('id', 0)}")),
|
|
"core_box": [x, y, w, h],
|
|
"pad": max(0, pad),
|
|
"blend": max(0, blend),
|
|
"priority": float(tile.get("priority", defaults.get("priority", 50))),
|
|
"importance": float(tile.get("importance", defaults.get("importance", 1.0))),
|
|
"strength": float(tile.get("strength", defaults.get("strength", 1.0))),
|
|
"prompt_tag": str(tile.get("prompt_tag", tile.get("name", ""))),
|
|
"align": bool(tile.get("align", defaults.get("align", False))),
|
|
"source": str(tile.get("source", defaults.get("source", "manual"))),
|
|
"label": str(tile.get("label", tile.get("name", defaults.get("label", "")))),
|
|
"score": float(tile.get("score", defaults.get("score", 1.0))),
|
|
"layer": int(tile.get("layer", defaults.get("layer", 0))),
|
|
"object_id": int(tile.get("object_id", tile.get("id", defaults.get("object_id", 0)))),
|
|
"occlusion_priority": float(tile.get("occlusion_priority", defaults.get("occlusion_priority", 0.0))),
|
|
}
|
|
if isinstance(tile.get("object_mask"), dict):
|
|
normalized["object_mask"] = dict(tile["object_mask"])
|
|
return normalized
|
|
|
|
|
|
def _ttp_expand_grid_tiles(grid, defaults):
|
|
x_lines = grid.get("x", [])
|
|
y_lines = grid.get("y", [])
|
|
if len(x_lines) < 2 or len(y_lines) < 2:
|
|
raise ValueError("Smart Tile grid layout requires at least two x lines and two y lines")
|
|
|
|
prefix = str(grid.get("prefix", defaults.get("prefix", "grid")))
|
|
grid_defaults = defaults.copy()
|
|
grid_defaults.update({k: v for k, v in grid.items() if k not in {"x", "y", "prefix"}})
|
|
|
|
tiles = []
|
|
for row in range(len(y_lines) - 1):
|
|
for col in range(len(x_lines) - 1):
|
|
x0, x1 = x_lines[col], x_lines[col + 1]
|
|
y0, y1 = y_lines[row], y_lines[row + 1]
|
|
tiles.append({
|
|
**grid_defaults,
|
|
"name": f"{prefix}_{row}_{col}",
|
|
"x": x0,
|
|
"y": y0,
|
|
"w": x1 - x0,
|
|
"h": y1 - y0,
|
|
})
|
|
return tiles
|
|
|
|
|
|
def _ttp_intervals_overlap(a0, a1, b0, b1):
|
|
return min(int(a1), int(b1)) > max(int(a0), int(b0))
|
|
|
|
|
|
def _ttp_neighbor_overlap_edges(tiles_meta, tile_index):
|
|
tile = tiles_meta[int(tile_index)]
|
|
x, y, w, h = tile["core_box"]
|
|
x0, y0, x1, y1 = x, y, x + w, y + h
|
|
pad = max(0, int(tile.get("pad", 0)))
|
|
edges = {"left": 0, "right": 0, "top": 0, "bottom": 0}
|
|
if pad <= 0:
|
|
return edges
|
|
|
|
tolerance = 1
|
|
for index, other in enumerate(tiles_meta):
|
|
if index == int(tile_index):
|
|
continue
|
|
ox, oy, ow, oh = other["core_box"]
|
|
ox0, oy0, ox1, oy1 = ox, oy, ox + ow, oy + oh
|
|
vertical_overlap = _ttp_intervals_overlap(y0, y1, oy0, oy1)
|
|
horizontal_overlap = _ttp_intervals_overlap(x0, x1, ox0, ox1)
|
|
if vertical_overlap and (abs(ox1 - x0) <= tolerance or ox0 < x0 < ox1):
|
|
edges["left"] = pad
|
|
if vertical_overlap and (abs(ox0 - x1) <= tolerance or ox0 < x1 < ox1):
|
|
edges["right"] = pad
|
|
if horizontal_overlap and (abs(oy1 - y0) <= tolerance or oy0 < y0 < oy1):
|
|
edges["top"] = pad
|
|
if horizontal_overlap and (abs(oy0 - y1) <= tolerance or oy0 < y1 < oy1):
|
|
edges["bottom"] = pad
|
|
return edges
|
|
|
|
|
|
def _ttp_add_smart_tile_sample_boxes(tiles_meta, image_width, image_height):
|
|
for index, tile in enumerate(tiles_meta):
|
|
x, y, w, h = tile["core_box"]
|
|
edges = _ttp_neighbor_overlap_edges(tiles_meta, index)
|
|
sample_left = _ttp_clamp(x - edges["left"], 0, image_width)
|
|
sample_top = _ttp_clamp(y - edges["top"], 0, image_height)
|
|
sample_right = _ttp_clamp(x + w + edges["right"], 0, image_width)
|
|
sample_bottom = _ttp_clamp(y + h + edges["bottom"], 0, image_height)
|
|
tile["overlap_edges_px_source"] = edges
|
|
tile["sample_box"] = [
|
|
sample_left,
|
|
sample_top,
|
|
max(1, sample_right - sample_left),
|
|
max(1, sample_bottom - sample_top),
|
|
]
|
|
tile["paste_box"] = [x, y, w, h]
|
|
return tiles_meta
|
|
|
|
|
|
def _ttp_parse_smart_tile_layout(layout_text, image_width, image_height):
|
|
try:
|
|
layout = json.loads(layout_text)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid Smart Tile layout JSON: {exc}") from exc
|
|
|
|
defaults = {}
|
|
raw_tiles = []
|
|
|
|
if isinstance(layout, list):
|
|
raw_tiles = layout
|
|
elif isinstance(layout, dict):
|
|
defaults = dict(layout.get("defaults", {}))
|
|
if "grid" in layout:
|
|
raw_tiles.extend(_ttp_expand_grid_tiles(layout["grid"], defaults))
|
|
raw_tiles.extend(layout.get("tiles", []))
|
|
raw_tiles.extend(layout.get("custom", []))
|
|
else:
|
|
raise ValueError("Smart Tile layout must be a JSON list or object")
|
|
|
|
if not raw_tiles:
|
|
raw_tiles = [{"name": "full_image", "x": 0, "y": 0, "w": image_width, "h": image_height}]
|
|
|
|
normalized_tiles = []
|
|
for index, tile in enumerate(raw_tiles):
|
|
if not isinstance(tile, dict):
|
|
raise ValueError(f"Smart Tile entry {index} must be an object")
|
|
normalized = _ttp_normalize_tile_box({**tile, "id": index}, image_width, image_height, defaults)
|
|
normalized["id"] = index
|
|
normalized_tiles.append(normalized)
|
|
|
|
return _ttp_add_smart_tile_sample_boxes(normalized_tiles, image_width, image_height)
|
|
|
|
|
|
def _ttp_create_feather_mask(width, height, feather):
|
|
if feather <= 0:
|
|
return np.ones((height, width, 1), dtype=np.float32)
|
|
|
|
yy, xx = np.mgrid[0:height, 0:width]
|
|
distance_left = xx
|
|
distance_right = width - 1 - xx
|
|
distance_top = yy
|
|
distance_bottom = height - 1 - yy
|
|
distance = np.minimum(np.minimum(distance_left, distance_right), np.minimum(distance_top, distance_bottom))
|
|
mask = np.clip(distance / max(1, feather), 0.0, 1.0).astype(np.float32)
|
|
return mask[:, :, None]
|
|
|
|
|
|
def _ttp_create_sample_blend_mask(width, height, overlap_edges, feather, scale_x=1.0, scale_y=1.0):
|
|
mask = np.ones((height, width, 1), dtype=np.float32)
|
|
if feather <= 0:
|
|
return mask
|
|
|
|
def scaled_edge(name, scale, limit):
|
|
return _ttp_clamp(int(round(float(overlap_edges.get(name, 0)) * scale)), 0, limit)
|
|
|
|
left = scaled_edge("left", scale_x, width)
|
|
right = scaled_edge("right", scale_x, width)
|
|
top = scaled_edge("top", scale_y, height)
|
|
bottom = scaled_edge("bottom", scale_y, height)
|
|
|
|
if left > 0:
|
|
blend = min(int(feather), left, width)
|
|
zero_end = max(0, left - blend)
|
|
if zero_end > 0:
|
|
mask[:, :zero_end, :] = 0.0
|
|
if blend > 0:
|
|
ramp = np.linspace(0.0, 1.0, blend, dtype=np.float32)[None, :, None]
|
|
mask[:, zero_end:left, :] *= ramp
|
|
|
|
if right > 0:
|
|
blend = min(int(feather), right, width)
|
|
start = max(0, width - right)
|
|
blend_end = min(width, start + blend)
|
|
if blend_end > start:
|
|
ramp = np.linspace(1.0, 0.0, blend_end - start, dtype=np.float32)[None, :, None]
|
|
mask[:, start:blend_end, :] *= ramp
|
|
if blend_end < width:
|
|
mask[:, blend_end:, :] = 0.0
|
|
|
|
if top > 0:
|
|
blend = min(int(feather), top, height)
|
|
zero_end = max(0, top - blend)
|
|
if zero_end > 0:
|
|
mask[:zero_end, :, :] = 0.0
|
|
if blend > 0:
|
|
ramp = np.linspace(0.0, 1.0, blend, dtype=np.float32)[:, None, None]
|
|
mask[zero_end:top, :, :] *= ramp
|
|
|
|
if bottom > 0:
|
|
blend = min(int(feather), bottom, height)
|
|
start = max(0, height - bottom)
|
|
blend_end = min(height, start + blend)
|
|
if blend_end > start:
|
|
ramp = np.linspace(1.0, 0.0, blend_end - start, dtype=np.float32)[:, None, None]
|
|
mask[start:blend_end, :, :] *= ramp
|
|
if blend_end < height:
|
|
mask[blend_end:, :, :] = 0.0
|
|
|
|
return mask
|
|
|
|
|
|
def _ttp_mask_tensor_to_pil_list(masks, image_width, image_height):
|
|
if masks is None:
|
|
return []
|
|
if isinstance(masks, Image.Image):
|
|
return [masks.convert("L").resize((image_width, image_height), Image.Resampling.BILINEAR)]
|
|
if isinstance(masks, (list, tuple)):
|
|
result = []
|
|
for mask in masks:
|
|
result.extend(_ttp_mask_tensor_to_pil_list(mask, image_width, image_height))
|
|
return result
|
|
|
|
array = None
|
|
try:
|
|
tensor = masks.detach() if hasattr(masks, "detach") else masks
|
|
tensor = tensor.cpu() if hasattr(tensor, "cpu") else tensor
|
|
array = tensor.numpy() if hasattr(tensor, "numpy") else np.array(tensor)
|
|
except Exception:
|
|
return []
|
|
|
|
array = np.asarray(array)
|
|
if array.ndim == 4:
|
|
if array.shape[1] == 1:
|
|
array = array[:, 0, :, :]
|
|
elif array.shape[-1] == 1:
|
|
array = array[:, :, :, 0]
|
|
if array.ndim == 2:
|
|
array = array[None, :, :]
|
|
if array.ndim != 3:
|
|
return []
|
|
|
|
result = []
|
|
for item in array:
|
|
item = np.nan_to_num(item.astype(np.float32), nan=0.0, posinf=1.0, neginf=0.0)
|
|
if item.max() > 1.0 or item.min() < 0.0:
|
|
item = np.clip(item, 0.0, 255.0) / 255.0
|
|
item = np.clip(item, 0.0, 1.0)
|
|
pil = Image.fromarray((item * 255.0).astype(np.uint8), mode="L")
|
|
if pil.size != (image_width, image_height):
|
|
pil = pil.resize((image_width, image_height), Image.Resampling.BILINEAR)
|
|
result.append(pil)
|
|
return result
|
|
|
|
|
|
def _ttp_encode_object_mask_data(mask_images, mask_indices, box):
|
|
if not mask_images or not mask_indices:
|
|
return None
|
|
x0, y0, x1, y1 = [int(round(value)) for value in box]
|
|
width = max(1, x1 - x0)
|
|
height = max(1, y1 - y0)
|
|
combined = Image.new("L", (width, height), 0)
|
|
for mask_index in mask_indices:
|
|
if mask_index < 0 or mask_index >= len(mask_images):
|
|
continue
|
|
cropped = mask_images[mask_index].crop((x0, y0, x1, y1)).convert("L")
|
|
if cropped.getbbox() is None:
|
|
continue
|
|
combined = ImageChops.lighter(combined, cropped)
|
|
if combined.getbbox() is None:
|
|
return None
|
|
buffer = BytesIO()
|
|
combined.save(buffer, format="PNG", optimize=True)
|
|
return {
|
|
"format": "png_base64",
|
|
"box": [x0, y0, width, height],
|
|
"width": width,
|
|
"height": height,
|
|
"data": base64.b64encode(buffer.getvalue()).decode("ascii"),
|
|
}
|
|
|
|
|
|
def _ttp_decode_object_mask_data(mask_data):
|
|
if not isinstance(mask_data, dict) or mask_data.get("format") != "png_base64":
|
|
return None
|
|
try:
|
|
raw = base64.b64decode(str(mask_data.get("data", "")))
|
|
return Image.open(BytesIO(raw)).convert("L")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _ttp_tile_object_mask_array(tile, out_width, out_height, mask_blend_mode="mask_feather", feather=0):
|
|
if str(mask_blend_mode) == "off":
|
|
return None
|
|
mask_data = tile.get("object_mask")
|
|
mask_pil = _ttp_decode_object_mask_data(mask_data)
|
|
if mask_pil is None:
|
|
return None
|
|
|
|
sx, sy, sw, sh = tile["sample_box"]
|
|
mx, my, mw, mh = [int(round(value)) for value in mask_data.get("box", [sx, sy, sw, sh])]
|
|
source_mask = Image.new("L", (max(1, int(sw)), max(1, int(sh))), 0)
|
|
if mask_pil.size != (max(1, int(mw)), max(1, int(mh))):
|
|
mask_pil = mask_pil.resize((max(1, int(mw)), max(1, int(mh))), Image.Resampling.BILINEAR)
|
|
|
|
paste_x = mx - int(sx)
|
|
paste_y = my - int(sy)
|
|
clip_left = max(0, -paste_x)
|
|
clip_top = max(0, -paste_y)
|
|
clip_right = min(mask_pil.width, source_mask.width - paste_x)
|
|
clip_bottom = min(mask_pil.height, source_mask.height - paste_y)
|
|
if clip_right <= clip_left or clip_bottom <= clip_top:
|
|
return None
|
|
source_mask.paste(
|
|
mask_pil.crop((clip_left, clip_top, clip_right, clip_bottom)),
|
|
(paste_x + clip_left, paste_y + clip_top),
|
|
)
|
|
source_mask = source_mask.resize((out_width, out_height), Image.Resampling.BILINEAR)
|
|
if str(mask_blend_mode) in ("auto", "mask_feather"):
|
|
radius = max(0.0, min(64.0, float(feather) * 0.35))
|
|
if radius > 0:
|
|
source_mask = source_mask.filter(ImageFilter.GaussianBlur(radius=radius))
|
|
array = np.array(source_mask).astype(np.float32) / 255.0
|
|
return array[:, :, None]
|
|
|
|
|
|
def _ttp_should_rect_feather_tile(tile, is_large_tile, mask_blend_mode):
|
|
if str(mask_blend_mode) not in ("auto", "mask_feather"):
|
|
return False
|
|
if is_large_tile or tile.get("object_mask"):
|
|
return False
|
|
label = str(tile.get("label", "") or "").lower()
|
|
name = str(tile.get("name", "") or "").lower()
|
|
source = str(tile.get("source", "") or "").lower()
|
|
text = " ".join((label, name, source))
|
|
if any(word in text for word in ("background", "context", "full image", "full_image", "large context")):
|
|
return False
|
|
focus_keywords = (
|
|
"face", "head", "eye", "eyes", "eyelash", "eyebrow", "glasses",
|
|
"hand", "hands", "finger", "fingers", "mouth", "lip", "lips",
|
|
"teeth", "nose", "ear", "ears", "text", "letter", "logo",
|
|
"detail", "focus", "foreground", "object",
|
|
)
|
|
if any(keyword in text for keyword in focus_keywords):
|
|
return True
|
|
if source in ("auto", "paint", "sam", "sam3", "sam3.1", "qwen", "qwenvl"):
|
|
return float(tile.get("occlusion_priority", 0.0)) >= 100.0 or float(tile.get("layer", 0.0)) >= 2.0
|
|
edges = tile.get("overlap_edges_px_source", {})
|
|
has_surrounding_overlap = all(float(edges.get(edge, 0.0)) > 0.0 for edge in ("left", "right", "top", "bottom"))
|
|
return has_surrounding_overlap and (
|
|
float(tile.get("occlusion_priority", 0.0)) > 0.0 or float(tile.get("layer", 0.0)) > 0.0
|
|
)
|
|
|
|
|
|
def _ttp_rect_feather_mask(width, height, feather):
|
|
feather = int(round(float(feather)))
|
|
if feather <= 0:
|
|
return np.ones((height, width, 1), dtype=np.float32)
|
|
edge_limit = max(1, min(int(width), int(height)) // 3)
|
|
feather = max(1, min(feather, edge_limit))
|
|
return _ttp_create_feather_mask(int(width), int(height), feather)
|
|
|
|
|
|
def _ttp_decode_interactive_paint_mask(mask_payload, image_width, image_height):
|
|
try:
|
|
payload = json.loads(str(mask_payload or "").strip() or "{}")
|
|
except Exception:
|
|
return None
|
|
if not isinstance(payload, dict) or not payload.get("data"):
|
|
return None
|
|
try:
|
|
data = str(payload.get("data", ""))
|
|
if "," in data and data.lower().startswith("data:"):
|
|
data = data.split(",", 1)[1]
|
|
raw = base64.b64decode(data)
|
|
mask = Image.open(BytesIO(raw)).convert("L")
|
|
except Exception:
|
|
return None
|
|
if mask.getbbox() is None:
|
|
return None
|
|
if mask.size != (image_width, image_height):
|
|
mask = mask.resize((image_width, image_height), Image.Resampling.BILINEAR)
|
|
return mask
|
|
|
|
|
|
def _ttp_paint_mask_to_items(mask, image_width, image_height, min_area=24):
|
|
if mask is None:
|
|
return [], []
|
|
array = np.array(mask.convert("L"), dtype=np.uint8)
|
|
binary = np.where(array > 8, 255, 0).astype(np.uint8)
|
|
if int(binary.max()) <= 0:
|
|
return [], []
|
|
if hasattr(cv2, "morphologyEx") and hasattr(cv2, "connectedComponentsWithStats"):
|
|
kernel = np.ones((3, 3), dtype=np.uint8)
|
|
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=1)
|
|
count, labels, stats, _centroids = cv2.connectedComponentsWithStats(binary, 8)
|
|
else:
|
|
height, width = binary.shape
|
|
labels = np.zeros((height, width), dtype=np.int32)
|
|
stats_rows = [[0, 0, 0, 0, 0]]
|
|
component = 0
|
|
for start_y in range(height):
|
|
for start_x in range(width):
|
|
if binary[start_y, start_x] == 0 or labels[start_y, start_x] != 0:
|
|
continue
|
|
component += 1
|
|
stack = [(start_x, start_y)]
|
|
labels[start_y, start_x] = component
|
|
xs = []
|
|
ys = []
|
|
while stack:
|
|
x, y = stack.pop()
|
|
xs.append(x)
|
|
ys.append(y)
|
|
for next_y in range(max(0, y - 1), min(height, y + 2)):
|
|
for next_x in range(max(0, x - 1), min(width, x + 2)):
|
|
if binary[next_y, next_x] and labels[next_y, next_x] == 0:
|
|
labels[next_y, next_x] = component
|
|
stack.append((next_x, next_y))
|
|
x0, x1 = min(xs), max(xs) + 1
|
|
y0, y1 = min(ys), max(ys) + 1
|
|
stats_rows.append([x0, y0, x1 - x0, y1 - y0, len(xs)])
|
|
count = len(stats_rows)
|
|
stats = np.asarray(stats_rows, dtype=np.int32)
|
|
items = []
|
|
masks = []
|
|
min_area = max(1, int(min_area))
|
|
for component in range(1, count):
|
|
x, y, width, height, area = [int(value) for value in stats[component]]
|
|
if area < min_area or width <= 0 or height <= 0:
|
|
continue
|
|
component_mask = np.where(labels == component, array, 0).astype(np.uint8)
|
|
masks.append(Image.fromarray(component_mask, mode="L"))
|
|
items.append({
|
|
"x": x,
|
|
"y": y,
|
|
"width": width,
|
|
"height": height,
|
|
"label": f"paint mask {len(items) + 1}",
|
|
"score": 1.0,
|
|
})
|
|
return items, masks
|
|
|
|
|
|
def _ttp_alignment_weight_mask(mask, mode="mask_edge_match"):
|
|
mask_2d = np.clip(np.asarray(mask[:, :, 0], dtype=np.float32), 0.0, 1.0)
|
|
if str(mode) == "edge_match":
|
|
height, width = mask_2d.shape
|
|
yy, xx = np.mgrid[0:height, 0:width]
|
|
distance = np.minimum(np.minimum(xx, width - 1 - xx), np.minimum(yy, height - 1 - yy))
|
|
edge = np.clip(1.0 - distance.astype(np.float32) / max(1.0, min(width, height) * 0.08), 0.0, 1.0)
|
|
return (edge * mask_2d).astype(np.float32)
|
|
band = 1.0 - np.abs(mask_2d * 2.0 - 1.0)
|
|
band = np.where((mask_2d > 0.02) & (mask_2d < 0.98), band, 0.0)
|
|
if float(band.sum()) < 16.0:
|
|
band = np.where(mask_2d > 0.02, np.minimum(mask_2d, 1.0 - mask_2d * 0.5), 0.0)
|
|
return band.astype(np.float32)
|
|
|
|
|
|
def _ttp_find_pixel_alignment_offset(region, reference, weights, out_x, out_y, mask, radius=0, mode="off"):
|
|
radius = max(0, int(radius))
|
|
if radius <= 0 or str(mode) == "off":
|
|
return 0, 0
|
|
if region.size == 0 or reference.size == 0:
|
|
return 0, 0
|
|
|
|
align_weights = _ttp_alignment_weight_mask(mask, mode)
|
|
if float(align_weights.sum()) < 16.0:
|
|
return 0, 0
|
|
|
|
height, width = region.shape[:2]
|
|
best_score = None
|
|
best_offset = (0, 0)
|
|
for dy in range(-radius, radius + 1):
|
|
for dx in range(-radius, radius + 1):
|
|
rx0 = max(0, out_x + dx)
|
|
ry0 = max(0, out_y + dy)
|
|
rx1 = min(reference.shape[1], out_x + dx + width)
|
|
ry1 = min(reference.shape[0], out_y + dy + height)
|
|
if rx1 <= rx0 or ry1 <= ry0:
|
|
continue
|
|
|
|
tx0 = rx0 - (out_x + dx)
|
|
ty0 = ry0 - (out_y + dy)
|
|
tx1 = tx0 + (rx1 - rx0)
|
|
ty1 = ty0 + (ry1 - ry0)
|
|
local_weight = align_weights[ty0:ty1, tx0:tx1]
|
|
if weights is not None:
|
|
local_weight = local_weight * (weights[ry0:ry1, rx0:rx1, 0] > 1e-6).astype(np.float32)
|
|
weight_sum = float(local_weight.sum())
|
|
if weight_sum < 16.0:
|
|
continue
|
|
|
|
diff = region[ty0:ty1, tx0:tx1] - reference[ry0:ry1, rx0:rx1]
|
|
score = float((np.mean(diff * diff, axis=2) * local_weight).sum() / weight_sum)
|
|
score += (abs(dx) + abs(dy)) * 1e-5
|
|
if best_score is None or score < best_score:
|
|
best_score = score
|
|
best_offset = (dx, dy)
|
|
return best_offset
|
|
|
|
|
|
def _ttp_alignment_torch_device(device_mode="auto"):
|
|
device_mode = str(device_mode or "auto").lower()
|
|
if device_mode == "cpu":
|
|
return None
|
|
if not all(hasattr(torch, name) for name in ("as_tensor", "zeros", "full")):
|
|
return None
|
|
try:
|
|
device = comfy.model_management.get_torch_device()
|
|
except Exception:
|
|
try:
|
|
if hasattr(torch, "cuda") and torch.cuda.is_available():
|
|
device = torch.device("cuda")
|
|
elif hasattr(torch, "backends") and getattr(getattr(torch.backends, "mps", None), "is_available", lambda: False)():
|
|
device = torch.device("mps")
|
|
else:
|
|
return None
|
|
except Exception:
|
|
return None
|
|
if getattr(device, "type", "cpu") == "cpu":
|
|
return None
|
|
return device
|
|
|
|
|
|
def _ttp_find_pixel_alignment_offset_gpu(
|
|
region,
|
|
reference,
|
|
weights,
|
|
out_x,
|
|
out_y,
|
|
mask,
|
|
radius=0,
|
|
mode="off",
|
|
device_mode="auto",
|
|
max_samples=32768,
|
|
):
|
|
radius = max(0, int(radius))
|
|
if radius <= 0 or str(mode) == "off":
|
|
return (0, 0), {"device": "off", "samples": 0, "fallback": False}
|
|
device = _ttp_alignment_torch_device(device_mode)
|
|
if device is None:
|
|
return None
|
|
if region.size == 0 or reference.size == 0:
|
|
return (0, 0), {"device": str(device), "samples": 0, "fallback": False}
|
|
|
|
align_weights = _ttp_alignment_weight_mask(mask, mode)
|
|
if float(align_weights.sum()) < 16.0:
|
|
return (0, 0), {"device": str(device), "samples": 0, "fallback": False}
|
|
|
|
height, width = region.shape[:2]
|
|
ys, xs = np.nonzero(align_weights > 1e-6)
|
|
if len(xs) < 16:
|
|
return (0, 0), {"device": str(device), "samples": int(len(xs)), "fallback": False}
|
|
max_samples = max(256, int(max_samples or 32768))
|
|
original_samples = int(len(xs))
|
|
if len(xs) > max_samples:
|
|
indices = np.linspace(0, len(xs) - 1, max_samples).astype(np.int64)
|
|
xs = xs[indices]
|
|
ys = ys[indices]
|
|
|
|
offsets = [(dx, dy) for dy in range(-radius, radius + 1) for dx in range(-radius, radius + 1)]
|
|
if not offsets:
|
|
return (0, 0), {"device": str(device), "samples": int(len(xs)), "fallback": False}
|
|
|
|
try:
|
|
with torch.inference_mode():
|
|
xs_t = torch.as_tensor(xs.astype(np.int64), device=device)
|
|
ys_t = torch.as_tensor(ys.astype(np.int64), device=device)
|
|
sample_region = torch.as_tensor(region[ys, xs].astype(np.float32), device=device)
|
|
sample_weight = torch.as_tensor(align_weights[ys, xs].astype(np.float32), device=device)
|
|
reference_t = torch.as_tensor(reference.astype(np.float32), device=device)
|
|
ref_flat = reference_t.reshape(-1, reference_t.shape[-1])
|
|
if weights is not None:
|
|
weight_t = torch.as_tensor(weights[:, :, 0].astype(np.float32), device=device)
|
|
weight_flat = weight_t.reshape(-1)
|
|
else:
|
|
weight_flat = None
|
|
|
|
offset_t = torch.as_tensor(offsets, dtype=torch.int64, device=device)
|
|
ref_h, ref_w = int(reference.shape[0]), int(reference.shape[1])
|
|
best_score = None
|
|
best_offset = (0, 0)
|
|
chunk_size = max(8, min(512, int(4000000 // max(1, len(xs)))))
|
|
for start in range(0, int(offset_t.shape[0]), chunk_size):
|
|
chunk = offset_t[start:start + chunk_size]
|
|
dx = chunk[:, 0:1]
|
|
dy = chunk[:, 1:2]
|
|
rx = int(out_x) + dx + xs_t[None, :]
|
|
ry = int(out_y) + dy + ys_t[None, :]
|
|
valid = (rx >= 0) & (rx < ref_w) & (ry >= 0) & (ry < ref_h)
|
|
flat_index = torch.clamp(ry, 0, ref_h - 1) * ref_w + torch.clamp(rx, 0, ref_w - 1)
|
|
local_weight = sample_weight[None, :] * valid.to(torch.float32)
|
|
if weight_flat is not None:
|
|
local_weight = local_weight * (weight_flat[flat_index] > 1e-6).to(torch.float32)
|
|
weight_sum = local_weight.sum(dim=1)
|
|
usable = weight_sum >= 16.0
|
|
if not bool(torch.any(usable).item()):
|
|
continue
|
|
ref_samples = ref_flat[flat_index]
|
|
diff = sample_region[None, :, :] - ref_samples
|
|
mse = torch.mean(diff * diff, dim=2)
|
|
scores = (mse * local_weight).sum(dim=1) / torch.clamp(weight_sum, min=1e-6)
|
|
penalty = (torch.abs(chunk[:, 0]).to(torch.float32) + torch.abs(chunk[:, 1]).to(torch.float32)) * 1e-5
|
|
scores = scores + penalty
|
|
scores = torch.where(usable, scores, torch.full_like(scores, float("inf")))
|
|
score, index = torch.min(scores, dim=0)
|
|
score_value = float(score.item())
|
|
if np.isfinite(score_value) and (best_score is None or score_value < best_score):
|
|
best_score = score_value
|
|
best = chunk[int(index.item())]
|
|
best_offset = (int(best[0].item()), int(best[1].item()))
|
|
return best_offset, {
|
|
"device": str(device),
|
|
"samples": int(len(xs)),
|
|
"source_samples": int(original_samples),
|
|
"candidates": int(len(offsets)),
|
|
"fallback": False,
|
|
}
|
|
except Exception as error:
|
|
if str(device_mode or "auto").lower() == "gpu":
|
|
print(f"[TTP Smart Tile] pixel alignment GPU failed, falling back to CPU: {type(error).__name__}: {error}")
|
|
return None
|
|
|
|
|
|
def _ttp_find_pixel_alignment_offset_torch_canvas(
|
|
region,
|
|
reference_t,
|
|
weights_t,
|
|
out_x,
|
|
out_y,
|
|
mask,
|
|
radius=0,
|
|
mode="off",
|
|
max_samples=32768,
|
|
):
|
|
radius = max(0, int(radius))
|
|
if radius <= 0 or str(mode) == "off":
|
|
return (0, 0), {"device": "off", "samples": 0, "fallback": False}
|
|
if region.size == 0 or reference_t is None or int(reference_t.numel()) <= 0:
|
|
device_name = str(getattr(reference_t, "device", "gpu"))
|
|
return (0, 0), {"device": device_name, "samples": 0, "fallback": False}
|
|
|
|
align_weights = _ttp_alignment_weight_mask(mask, mode)
|
|
if float(align_weights.sum()) < 16.0:
|
|
return (0, 0), {"device": str(reference_t.device), "samples": 0, "fallback": False}
|
|
|
|
height, width = region.shape[:2]
|
|
ys, xs = np.nonzero(align_weights > 1e-6)
|
|
if len(xs) < 16:
|
|
return (0, 0), {"device": str(reference_t.device), "samples": int(len(xs)), "fallback": False}
|
|
max_samples = max(256, int(max_samples or 32768))
|
|
original_samples = int(len(xs))
|
|
if len(xs) > max_samples:
|
|
indices = np.linspace(0, len(xs) - 1, max_samples).astype(np.int64)
|
|
xs = xs[indices]
|
|
ys = ys[indices]
|
|
|
|
offsets = [(dx, dy) for dy in range(-radius, radius + 1) for dx in range(-radius, radius + 1)]
|
|
if not offsets:
|
|
return (0, 0), {"device": str(reference_t.device), "samples": int(len(xs)), "fallback": False}
|
|
|
|
try:
|
|
with torch.inference_mode():
|
|
device = reference_t.device
|
|
reference_t = reference_t.to(device=device, dtype=torch.float32)
|
|
xs_t = torch.as_tensor(xs.astype(np.int64), device=device)
|
|
ys_t = torch.as_tensor(ys.astype(np.int64), device=device)
|
|
sample_region = torch.as_tensor(region[ys, xs].astype(np.float32), device=device)
|
|
sample_weight = torch.as_tensor(align_weights[ys, xs].astype(np.float32), device=device)
|
|
ref_flat = reference_t.reshape(-1, reference_t.shape[-1])
|
|
if weights_t is not None:
|
|
weight_flat = weights_t[:, :, 0].to(device=device, dtype=torch.float32).reshape(-1)
|
|
else:
|
|
weight_flat = None
|
|
|
|
offset_t = torch.as_tensor(offsets, dtype=torch.int64, device=device)
|
|
ref_h, ref_w = int(reference_t.shape[0]), int(reference_t.shape[1])
|
|
best_score = None
|
|
best_offset = (0, 0)
|
|
chunk_size = max(8, min(512, int(4000000 // max(1, len(xs)))))
|
|
for start in range(0, int(offset_t.shape[0]), chunk_size):
|
|
chunk = offset_t[start:start + chunk_size]
|
|
dx = chunk[:, 0:1]
|
|
dy = chunk[:, 1:2]
|
|
rx = int(out_x) + dx + xs_t[None, :]
|
|
ry = int(out_y) + dy + ys_t[None, :]
|
|
valid = (rx >= 0) & (rx < ref_w) & (ry >= 0) & (ry < ref_h)
|
|
flat_index = torch.clamp(ry, 0, ref_h - 1) * ref_w + torch.clamp(rx, 0, ref_w - 1)
|
|
local_weight = sample_weight[None, :] * valid.to(torch.float32)
|
|
if weight_flat is not None:
|
|
local_weight = local_weight * (weight_flat[flat_index] > 1e-6).to(torch.float32)
|
|
weight_sum = local_weight.sum(dim=1)
|
|
usable = weight_sum >= 16.0
|
|
if not bool(torch.any(usable).item()):
|
|
continue
|
|
ref_samples = ref_flat[flat_index]
|
|
diff = sample_region[None, :, :] - ref_samples
|
|
mse = torch.mean(diff * diff, dim=2)
|
|
scores = (mse * local_weight).sum(dim=1) / torch.clamp(weight_sum, min=1e-6)
|
|
penalty = (torch.abs(chunk[:, 0]).to(torch.float32) + torch.abs(chunk[:, 1]).to(torch.float32)) * 1e-5
|
|
scores = scores + penalty
|
|
scores = torch.where(usable, scores, torch.full_like(scores, float("inf")))
|
|
score, index = torch.min(scores, dim=0)
|
|
score_value = float(score.item())
|
|
if np.isfinite(score_value) and (best_score is None or score_value < best_score):
|
|
best_score = score_value
|
|
best = chunk[int(index.item())]
|
|
best_offset = (int(best[0].item()), int(best[1].item()))
|
|
return best_offset, {
|
|
"device": str(device),
|
|
"samples": int(len(xs)),
|
|
"source_samples": int(original_samples),
|
|
"candidates": int(len(offsets)),
|
|
"fallback": False,
|
|
}
|
|
except Exception as error:
|
|
print(f"[TTP Smart Tile] pixel alignment torch canvas failed: {type(error).__name__}: {error}")
|
|
return None
|
|
|
|
|
|
def _ttp_find_pixel_alignment_offset_auto(
|
|
region,
|
|
reference,
|
|
weights,
|
|
out_x,
|
|
out_y,
|
|
mask,
|
|
radius=0,
|
|
mode="off",
|
|
device_mode="auto",
|
|
):
|
|
if str(device_mode or "auto").lower() != "cpu":
|
|
gpu_result = _ttp_find_pixel_alignment_offset_gpu(
|
|
region,
|
|
reference,
|
|
weights,
|
|
out_x,
|
|
out_y,
|
|
mask,
|
|
radius,
|
|
mode,
|
|
device_mode,
|
|
)
|
|
if gpu_result is not None:
|
|
offset, info = gpu_result
|
|
return offset[0], offset[1], info
|
|
offset = _ttp_find_pixel_alignment_offset(region, reference, weights, out_x, out_y, mask, radius, mode)
|
|
return offset[0], offset[1], {
|
|
"device": "cpu",
|
|
"samples": int(np.count_nonzero(_ttp_alignment_weight_mask(mask, mode) > 1e-6)) if str(mode) != "off" else 0,
|
|
"candidates": int((max(0, int(radius)) * 2 + 1) ** 2),
|
|
"fallback": str(device_mode or "auto").lower() != "cpu",
|
|
}
|
|
|
|
|
|
def _ttp_bbox_to_xyxy(box):
|
|
if isinstance(box, dict):
|
|
if all(key in box for key in ("x", "y", "width", "height")):
|
|
return [
|
|
float(box.get("x", 0)),
|
|
float(box.get("y", 0)),
|
|
float(box.get("x", 0)) + float(box.get("width", 0)),
|
|
float(box.get("y", 0)) + float(box.get("height", 0)),
|
|
]
|
|
raw = box.get("bbox_2d", box.get("bbox", box.get("box")))
|
|
if raw is not None:
|
|
return _ttp_bbox_to_xyxy(raw)
|
|
if isinstance(box, (list, tuple)) and len(box) >= 4:
|
|
return [float(box[0]), float(box[1]), float(box[2]), float(box[3])]
|
|
return None
|
|
|
|
|
|
def _ttp_flatten_bboxes(bboxes):
|
|
if bboxes is None:
|
|
return []
|
|
if isinstance(bboxes, dict):
|
|
return [bboxes]
|
|
if not isinstance(bboxes, (list, tuple)):
|
|
return []
|
|
result = []
|
|
for item in bboxes:
|
|
if isinstance(item, (list, tuple)) and item and not isinstance(item[0], (int, float, dict)):
|
|
result.extend(_ttp_flatten_bboxes(item))
|
|
elif isinstance(item, (list, tuple)) and item and isinstance(item[0], dict):
|
|
result.extend(item)
|
|
else:
|
|
result.append(item)
|
|
return result
|
|
|
|
|
|
def _ttp_clamp_box_xyxy(box, image_width, image_height, min_size=8):
|
|
x0, y0, x1, y1 = box
|
|
x0, x1 = sorted((x0, x1))
|
|
y0, y1 = sorted((y0, y1))
|
|
x0 = _ttp_clamp(int(round(x0)), 0, max(0, image_width - 1))
|
|
y0 = _ttp_clamp(int(round(y0)), 0, max(0, image_height - 1))
|
|
x1 = _ttp_clamp(int(round(x1)), x0 + 1, image_width)
|
|
y1 = _ttp_clamp(int(round(y1)), y0 + 1, image_height)
|
|
if x1 - x0 < min_size:
|
|
grow = int(np.ceil((min_size - (x1 - x0)) / 2))
|
|
x0 = max(0, x0 - grow)
|
|
x1 = min(image_width, x1 + grow)
|
|
if y1 - y0 < min_size:
|
|
grow = int(np.ceil((min_size - (y1 - y0)) / 2))
|
|
y0 = max(0, y0 - grow)
|
|
y1 = min(image_height, y1 + grow)
|
|
return [x0, y0, x1, y1]
|
|
|
|
|
|
def _ttp_expand_box_xyxy(box, image_width, image_height, padding):
|
|
x0, y0, x1, y1 = box
|
|
return _ttp_clamp_box_xyxy(
|
|
[x0 - padding, y0 - padding, x1 + padding, y1 + padding],
|
|
image_width,
|
|
image_height,
|
|
)
|
|
|
|
|
|
def _ttp_box_iou(a, b):
|
|
ax0, ay0, ax1, ay1 = a
|
|
bx0, by0, bx1, by1 = b
|
|
ix0, iy0 = max(ax0, bx0), max(ay0, by0)
|
|
ix1, iy1 = min(ax1, bx1), min(ay1, by1)
|
|
inter = max(0, ix1 - ix0) * max(0, iy1 - iy0)
|
|
if inter <= 0:
|
|
return 0.0
|
|
area_a = max(1, (ax1 - ax0) * (ay1 - ay0))
|
|
area_b = max(1, (bx1 - bx0) * (by1 - by0))
|
|
return inter / max(1, area_a + area_b - inter)
|
|
|
|
|
|
def _ttp_merge_overlapping_boxes(boxes, threshold=0.18):
|
|
merged = []
|
|
for item in boxes:
|
|
box = item["box"]
|
|
target = None
|
|
for existing in merged:
|
|
if _ttp_box_iou(box, existing["box"]) >= threshold:
|
|
target = existing
|
|
break
|
|
if target is None:
|
|
merged.append({**item, "box": list(box)})
|
|
continue
|
|
ex = target["box"]
|
|
target["box"] = [min(ex[0], box[0]), min(ex[1], box[1]), max(ex[2], box[2]), max(ex[3], box[3])]
|
|
target["score"] = max(float(target.get("score", 1.0)), float(item.get("score", 1.0)))
|
|
target["mask_indices"] = sorted(set(target.get("mask_indices", []) + item.get("mask_indices", [])))
|
|
if not target.get("label") and item.get("label"):
|
|
target["label"] = item["label"]
|
|
return merged
|
|
|
|
|
|
def _ttp_auto_tile_rank(label, object_area, image_area, object_index):
|
|
label_text = str(label or "").lower()
|
|
detail_keywords = (
|
|
"face", "head", "eye", "eyes", "eyelash", "eyebrow", "glasses",
|
|
"hand", "hands", "finger", "fingers", "mouth", "lip", "lips",
|
|
"teeth", "nose", "ear", "ears", "text", "letter", "logo",
|
|
"脸", "头", "眼", "眼睛", "眉", "睫毛", "眼镜", "手", "手指",
|
|
"嘴", "嘴唇", "牙", "鼻", "耳", "文字", "字体", "标志",
|
|
)
|
|
area_ratio = max(0.0, min(1.0, float(object_area) / max(1.0, float(image_area))))
|
|
small_bonus = min(450, int(round((1.0 - area_ratio) * 450.0)))
|
|
if any(keyword in label_text for keyword in detail_keywords):
|
|
return 4, 2000 + small_bonus + int(object_index)
|
|
return 2, 300 + small_bonus + int(object_index)
|
|
|
|
|
|
def _ttp_boxes_to_auto_layout(
|
|
bboxes,
|
|
image_width,
|
|
image_height,
|
|
default_pad=128,
|
|
default_blend=64,
|
|
object_padding=96,
|
|
max_tiles=16,
|
|
include_background=True,
|
|
allow_object_overlap=True,
|
|
masks=None,
|
|
):
|
|
mask_images = _ttp_mask_tensor_to_pil_list(masks, image_width, image_height)
|
|
raw_boxes = []
|
|
for index, item in enumerate(_ttp_flatten_bboxes(bboxes)):
|
|
box = _ttp_bbox_to_xyxy(item)
|
|
if box is None:
|
|
continue
|
|
score = float(item.get("score", 1.0)) if isinstance(item, dict) else 1.0
|
|
label = str(item.get("label", item.get("name", f"object_{index + 1}"))) if isinstance(item, dict) else f"object_{index + 1}"
|
|
clamped = _ttp_clamp_box_xyxy(box, image_width, image_height)
|
|
area = (clamped[2] - clamped[0]) * (clamped[3] - clamped[1])
|
|
if area <= 0:
|
|
continue
|
|
raw_boxes.append({"box": clamped, "score": score, "label": label, "area": area, "mask_indices": [index]})
|
|
|
|
raw_boxes.sort(key=lambda value: (float(value.get("score", 1.0)), value["area"]), reverse=True)
|
|
merged_boxes = _ttp_merge_overlapping_boxes(raw_boxes[: max(1, int(max_tiles))])
|
|
|
|
tiles = []
|
|
if include_background:
|
|
tiles.append({
|
|
"name": "auto_background",
|
|
"x0": 0.0,
|
|
"y0": 0.0,
|
|
"x1": 1.0,
|
|
"y1": 1.0,
|
|
"pad": 0,
|
|
"blend": max(0, int(default_blend)),
|
|
"priority": 5,
|
|
"importance": 0.35,
|
|
"source": "auto",
|
|
"label": "background",
|
|
"layer": 0,
|
|
"object_id": 0,
|
|
"occlusion_priority": 0,
|
|
})
|
|
|
|
for object_index, item in enumerate(merged_boxes[: max(1, int(max_tiles))], start=1):
|
|
box = _ttp_expand_box_xyxy(item["box"], image_width, image_height, int(max(0, object_padding)))
|
|
x0, y0, x1, y1 = box
|
|
object_area = (x1 - x0) * (y1 - y0)
|
|
image_area = max(1, image_width * image_height)
|
|
layer, occlusion_priority = _ttp_auto_tile_rank(item.get("label", f"object_{object_index}"), object_area, image_area, object_index)
|
|
priority = 70 + min(80, int(100 * object_area / image_area)) + int(float(item.get("score", 1.0)) * 20)
|
|
tile = {
|
|
"name": f"auto_{item.get('label', 'object')}_{object_index}",
|
|
"x0": x0 / image_width,
|
|
"y0": y0 / image_height,
|
|
"x1": x1 / image_width,
|
|
"y1": y1 / image_height,
|
|
"pad": int(default_pad) if allow_object_overlap else max(0, int(default_pad) // 2),
|
|
"blend": int(default_blend),
|
|
"priority": priority,
|
|
"importance": 1.0,
|
|
"source": "auto",
|
|
"label": item.get("label", f"object_{object_index}"),
|
|
"score": float(item.get("score", 1.0)),
|
|
"layer": layer,
|
|
"object_id": object_index,
|
|
"occlusion_priority": occlusion_priority,
|
|
}
|
|
object_mask = _ttp_encode_object_mask_data(mask_images, item.get("mask_indices", []), box)
|
|
if object_mask is not None:
|
|
tile["object_mask"] = object_mask
|
|
tiles.append(tile)
|
|
|
|
if len(tiles) == (1 if include_background else 0):
|
|
tiles.extend([
|
|
{"name": "auto_tile_1", "x0": 0.0, "y0": 0.0, "x1": 0.5, "y1": 0.5, "source": "auto", "layer": 1, "occlusion_priority": 10},
|
|
{"name": "auto_tile_2", "x0": 0.5, "y0": 0.0, "x1": 1.0, "y1": 0.5, "source": "auto", "layer": 1, "occlusion_priority": 10},
|
|
{"name": "auto_tile_3", "x0": 0.0, "y0": 0.5, "x1": 0.5, "y1": 1.0, "source": "auto", "layer": 1, "occlusion_priority": 10},
|
|
{"name": "auto_tile_4", "x0": 0.5, "y0": 0.5, "x1": 1.0, "y1": 1.0, "source": "auto", "layer": 1, "occlusion_priority": 10},
|
|
])
|
|
|
|
return json.dumps({
|
|
"version": 1,
|
|
"type": "ttp_smart_tile_interactive_layout",
|
|
"source_size": [image_width, image_height],
|
|
"defaults": {
|
|
"pad": int(default_pad),
|
|
"blend": int(default_blend),
|
|
"priority": 50,
|
|
"importance": 1.0,
|
|
"source": "auto",
|
|
},
|
|
"tiles": tiles[: max(1, int(max_tiles)) + (1 if include_background else 0)],
|
|
}, separators=(",", ":"))
|
|
|
|
|
|
def _ttp_draw_auto_layout_preview(pil_image, layout_json):
|
|
preview = pil_image.copy().convert("RGB")
|
|
draw = ImageDraw.Draw(preview)
|
|
width, height = preview.size
|
|
colors = ["#38bdf8", "#f97316", "#22c55e", "#e879f9", "#facc15", "#fb7185", "#a3e635", "#60a5fa"]
|
|
try:
|
|
layout = json.loads(layout_json)
|
|
except Exception:
|
|
return pil2tensor(preview)
|
|
for index, tile in enumerate(layout.get("tiles", [])):
|
|
box = _ttp_normalize_tile_box(tile, width, height, {})
|
|
x, y, w, h = box["core_box"]
|
|
color = colors[index % len(colors)]
|
|
draw.rectangle((x, y, x + w, y + h), outline=color, width=3)
|
|
draw.text((x + 5, y + 5), str(tile.get("label", tile.get("name", index + 1))), fill=color)
|
|
return pil2tensor(preview)
|
|
|
|
|
|
def _ttp_tensor_from_pil_batch(pil_image):
|
|
return pil2tensor(pil_image.convert("RGB"))
|
|
|
|
|
|
def _ttp_image_tensor_size(image_tensor):
|
|
shape = image_tensor.shape
|
|
if len(shape) == 4:
|
|
return int(shape[2]), int(shape[1])
|
|
if len(shape) == 3:
|
|
return int(shape[1]), int(shape[0])
|
|
raise ValueError("Expected an IMAGE tensor with shape [H,W,C] or [1,H,W,C].")
|
|
|
|
|
|
def _ttp_image_tensor_to_pil(image_tensor):
|
|
if len(image_tensor.shape) == 3:
|
|
return tensor2pil(image_tensor.unsqueeze(0)).convert("RGB")
|
|
return tensor2pil(image_tensor).convert("RGB")
|
|
|
|
|
|
def _ttp_first_image_tensor(image_tensor):
|
|
if len(image_tensor.shape) == 4:
|
|
return image_tensor[0].unsqueeze(0)
|
|
if len(image_tensor.shape) == 3:
|
|
return image_tensor.unsqueeze(0)
|
|
raise ValueError("Expected an IMAGE tensor with shape [B,H,W,C] or [H,W,C].")
|
|
|
|
|
|
def _ttp_resize_pil_to_round(image, scale=2.0, round_to=8, resampling="lanczos"):
|
|
filters = {
|
|
"nearest": Image.Resampling.NEAREST,
|
|
"nearest-exact": Image.Resampling.NEAREST,
|
|
"bilinear": Image.Resampling.BILINEAR,
|
|
"bicubic": Image.Resampling.BICUBIC,
|
|
"area": Image.Resampling.BOX,
|
|
"lanczos": Image.Resampling.LANCZOS,
|
|
}
|
|
target_width = max(1, int(round(image.width * float(scale))))
|
|
target_height = max(1, int(round(image.height * float(scale))))
|
|
round_to = max(1, int(round_to))
|
|
target_width = max(round_to, _ttp_round_to(target_width, round_to))
|
|
target_height = max(round_to, _ttp_round_to(target_height, round_to))
|
|
return image.resize((target_width, target_height), filters.get(str(resampling), Image.Resampling.LANCZOS))
|
|
|
|
|
|
def _ttp_resize_pil_to_size(image, width, height, resampling="lanczos"):
|
|
filters = {
|
|
"nearest": Image.Resampling.NEAREST,
|
|
"nearest-exact": Image.Resampling.NEAREST,
|
|
"bilinear": Image.Resampling.BILINEAR,
|
|
"bicubic": Image.Resampling.BICUBIC,
|
|
"area": Image.Resampling.BOX,
|
|
"lanczos": Image.Resampling.LANCZOS,
|
|
}
|
|
return image.resize((max(1, int(width)), max(1, int(height))), filters.get(str(resampling), Image.Resampling.LANCZOS))
|
|
|
|
|
|
def _ttp_round_down_to(value, multiple):
|
|
multiple = max(1, int(multiple))
|
|
value = max(1, int(math.floor(float(value))))
|
|
if multiple <= 1:
|
|
return value
|
|
rounded = int(math.floor(value / multiple) * multiple)
|
|
return max(multiple, rounded)
|
|
|
|
|
|
def _ttp_smart_upscale_target_size(width, height, scale=2.0, round_to=8, max_megapixels=0.0):
|
|
width = max(1, int(width))
|
|
height = max(1, int(height))
|
|
scale = max(0.01, float(scale))
|
|
round_to = max(1, int(round_to))
|
|
max_pixels = int(float(max_megapixels) * 1000000.0) if float(max_megapixels or 0.0) > 0 else 0
|
|
desired_width = max(1, int(round(width * scale)))
|
|
desired_height = max(1, int(round(height * scale)))
|
|
capped = False
|
|
if max_pixels > 0 and desired_width * desired_height > max_pixels:
|
|
capped = True
|
|
scale = math.sqrt(max_pixels / max(1.0, float(width) * float(height)))
|
|
target_width = _ttp_round_down_to(width * scale, round_to)
|
|
target_height = _ttp_round_down_to(height * scale, round_to)
|
|
while target_width * target_height > max_pixels and (target_width > 1 or target_height > 1):
|
|
if target_width / max(1, width) >= target_height / max(1, height) and target_width > 1:
|
|
target_width = max(1, target_width - round_to)
|
|
elif target_height > 1:
|
|
target_height = max(1, target_height - round_to)
|
|
else:
|
|
target_width = max(1, target_width - round_to)
|
|
return max(1, target_width), max(1, target_height), capped
|
|
return max(round_to, _ttp_round_to(desired_width, round_to)), max(round_to, _ttp_round_to(desired_height, round_to)), capped
|
|
|
|
|
|
def _ttp_resize_image_tensor(image, width, height, method="lanczos"):
|
|
if not hasattr(image, "movedim") or not hasattr(comfy.utils, "common_upscale"):
|
|
pil = _ttp_image_tensor_to_pil(_ttp_first_image_tensor(image))
|
|
return pil2tensor(_ttp_resize_pil_to_size(pil, int(width), int(height), method))
|
|
method = str(method or "lanczos")
|
|
method_map = {
|
|
"nearest": "nearest-exact",
|
|
"nearest-exact": "nearest-exact",
|
|
"bilinear": "bilinear",
|
|
"bicubic": "bicubic",
|
|
"area": "area",
|
|
"lanczos": "lanczos",
|
|
}
|
|
image = _ttp_first_image_tensor(image)
|
|
samples = image.movedim(-1, 1)
|
|
resized = comfy.utils.common_upscale(samples, int(width), int(height), method_map.get(method, "lanczos"), "disabled")
|
|
return torch.clamp(resized.movedim(1, -1), min=0.0, max=1.0).to(dtype=image.dtype)
|
|
|
|
|
|
def _ttp_upscale_image_with_model(upscale_model, image):
|
|
if upscale_model is None:
|
|
return image
|
|
device = comfy.model_management.get_torch_device()
|
|
upscale_amount = max(float(getattr(upscale_model, "scale", 1.0) or 1.0), 1.0)
|
|
memory_required = comfy.model_management.module_size(upscale_model.model)
|
|
memory_required += (512 * 512 * 3) * image.element_size() * upscale_amount * 384.0
|
|
memory_required += image.nelement() * image.element_size()
|
|
comfy.model_management.free_memory(memory_required, device)
|
|
|
|
upscale_model.to(device)
|
|
in_img = image.movedim(-1, -3).to(device)
|
|
tile = 512
|
|
overlap = 32
|
|
output_device = comfy.model_management.intermediate_device()
|
|
try:
|
|
while True:
|
|
try:
|
|
steps = in_img.shape[0] * comfy.utils.get_tiled_scale_steps(
|
|
in_img.shape[3],
|
|
in_img.shape[2],
|
|
tile_x=tile,
|
|
tile_y=tile,
|
|
overlap=overlap,
|
|
)
|
|
pbar = comfy.utils.ProgressBar(steps)
|
|
scaled = comfy.utils.tiled_scale(
|
|
in_img,
|
|
lambda a: upscale_model(a.float()),
|
|
tile_x=tile,
|
|
tile_y=tile,
|
|
overlap=overlap,
|
|
upscale_amount=upscale_amount,
|
|
pbar=pbar,
|
|
output_device=output_device,
|
|
)
|
|
break
|
|
except Exception as exc:
|
|
comfy.model_management.raise_non_oom(exc)
|
|
tile //= 2
|
|
if tile < 128:
|
|
raise exc
|
|
finally:
|
|
upscale_model.to("cpu")
|
|
|
|
scaled = torch.clamp(scaled.movedim(-3, -1), min=0.0, max=1.0)
|
|
return scaled.to(device=comfy.model_management.intermediate_device(), dtype=comfy.model_management.intermediate_dtype())
|
|
|
|
|
|
def _ttp_align_pil_to_aspect(image, expected_width, expected_height, mode="center_crop"):
|
|
expected_width = max(1, int(expected_width))
|
|
expected_height = max(1, int(expected_height))
|
|
if mode == "off" or image.width <= 0 or image.height <= 0:
|
|
return image
|
|
if mode == "resize":
|
|
return image.resize((expected_width, expected_height), Image.Resampling.LANCZOS)
|
|
|
|
target_aspect = expected_width / max(1, expected_height)
|
|
source_aspect = image.width / max(1, image.height)
|
|
if mode in ("center_crop", "crop_or_pad") and abs(source_aspect - target_aspect) > 1e-4:
|
|
if source_aspect > target_aspect:
|
|
crop_width = max(1, int(round(image.height * target_aspect)))
|
|
left = max(0, (image.width - crop_width) // 2)
|
|
image = image.crop((left, 0, left + crop_width, image.height))
|
|
else:
|
|
crop_height = max(1, int(round(image.width / target_aspect)))
|
|
top = max(0, (image.height - crop_height) // 2)
|
|
image = image.crop((0, top, image.width, top + crop_height))
|
|
|
|
if mode == "crop_or_pad":
|
|
pad_width = max(image.width, int(round(image.height * target_aspect)))
|
|
pad_height = max(image.height, int(round(image.width / target_aspect)))
|
|
if pad_width != image.width or pad_height != image.height:
|
|
canvas = Image.new("RGB", (pad_width, pad_height))
|
|
canvas.paste(image, ((pad_width - image.width) // 2, (pad_height - image.height) // 2))
|
|
image = canvas
|
|
|
|
return image.resize((expected_width, expected_height), Image.Resampling.LANCZOS)
|
|
|
|
|
|
def _ttp_clone_tile_set(tile_set):
|
|
tile_meta = tile_set.get("tile_meta", {})
|
|
return {
|
|
"version": int(tile_set.get("version", 1)),
|
|
"type": "ttp_smart_tile_set",
|
|
"original_size": list(tile_set.get("original_size", tile_meta.get("original_size", [0, 0]))),
|
|
"tile_meta": {
|
|
"version": int(tile_meta.get("version", 3)),
|
|
"type": "ttp_smart_tile",
|
|
"storage": "tile_set",
|
|
"original_size": list(tile_meta.get("original_size", tile_set.get("original_size", [0, 0]))),
|
|
"tiles": [dict(tile) for tile in tile_meta.get("tiles", [])],
|
|
},
|
|
"tile_images": list(tile_set.get("tile_images", [])),
|
|
"positions": list(tile_set.get("positions", [])),
|
|
}
|
|
|
|
|
|
def _ttp_tile_set_fingerprint(tile_set):
|
|
tile_meta = tile_set.get("tile_meta", {})
|
|
tiles = tile_meta.get("tiles", [])
|
|
return json.dumps({
|
|
"size": tile_set.get("original_size", tile_meta.get("original_size")),
|
|
"count": len(tile_set.get("tile_images", [])),
|
|
"boxes": [tile.get("sample_box") for tile in tiles],
|
|
"names": [tile.get("name") for tile in tiles],
|
|
"masks": [bool(tile.get("object_mask")) for tile in tiles],
|
|
"prompts": [
|
|
{
|
|
"prompt": tile.get("prompt", ""),
|
|
"negative": tile.get("negative", ""),
|
|
"caption": tile.get("caption", ""),
|
|
"label": tile.get("label", ""),
|
|
"prompt_tag": tile.get("prompt_tag", ""),
|
|
"prompt_source": tile.get("prompt_source", ""),
|
|
"tile_hash": tile.get("tile_hash", ""),
|
|
}
|
|
for tile in tiles
|
|
],
|
|
}, sort_keys=True)
|
|
|
|
|
|
def _ttp_validate_tile_set(tile_set):
|
|
if not isinstance(tile_set, dict) or tile_set.get("type") != "ttp_smart_tile_set":
|
|
raise ValueError("tile_set must come from TTP Smart Tile Interactive Crop")
|
|
tile_images = tile_set.get("tile_images", [])
|
|
tile_meta = tile_set.get("tile_meta", {})
|
|
tiles_info = tile_meta.get("tiles", [])
|
|
if not tile_images:
|
|
raise ValueError("tile_set contains no tile images.")
|
|
if len(tile_images) != len(tiles_info):
|
|
raise ValueError(f"tile_set images ({len(tile_images)}) do not match tile metadata ({len(tiles_info)}).")
|
|
return tile_images, tile_meta, tiles_info
|
|
|
|
|
|
def _ttp_assemble_placeholder_output(tile_images=None, base_image=None, source_image=None):
|
|
preview = None
|
|
for candidate in (base_image, source_image):
|
|
if candidate is not None:
|
|
preview = _ttp_first_image_tensor(candidate)
|
|
break
|
|
if preview is None and tile_images:
|
|
preview = _ttp_first_image_tensor(tile_images[0])
|
|
if preview is None:
|
|
preview = pil2tensor(Image.new("RGB", (1, 1), "black"))
|
|
width, height = _ttp_image_tensor_size(preview)
|
|
return preview, pil2tensor(Image.new("RGB", (width, height), "black"))
|
|
|
|
|
|
def _ttp_send_smart_tile_loop_event(task, done=False, message=""):
|
|
if PromptServer is None or not isinstance(task, dict):
|
|
return
|
|
payload = {
|
|
"source_node_id": str(task.get("source_node_id", "")),
|
|
"session_id": str(task.get("session_id", "")),
|
|
"index": int(task.get("index", 0)),
|
|
"count": int(task.get("count", 0)),
|
|
"done": bool(done),
|
|
"message": str(message or ""),
|
|
}
|
|
try:
|
|
PromptServer.instance.send_sync("ttp-smart-tile-loop", payload, PromptServer.instance.client_id)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _ttp_pil_to_data_url(image):
|
|
buffer = BytesIO()
|
|
image.save(buffer, format="PNG")
|
|
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
|
|
return f"data:image/png;base64,{encoded}"
|
|
|
|
|
|
def _ttp_model_base_dirs(model_type="text_encoders"):
|
|
dirs = []
|
|
try:
|
|
names_and_paths = getattr(folder_paths, "folder_names_and_paths", {})
|
|
for path in names_and_paths.get(model_type, [[], set()])[0]:
|
|
dirs.append(path)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
models_dir = getattr(folder_paths, "models_dir", None)
|
|
if models_dir:
|
|
dirs.append(os.path.join(models_dir, model_type))
|
|
except Exception:
|
|
pass
|
|
return [path for index, path in enumerate(dirs) if path and path not in dirs[:index] and os.path.isdir(path)]
|
|
|
|
|
|
def _ttp_qwenvl_safetensor_files():
|
|
files = []
|
|
for base_dir in _ttp_model_base_dirs("text_encoders"):
|
|
for root, _dirs, names in os.walk(base_dir):
|
|
for name in names:
|
|
if not name.lower().endswith((".safetensors", ".sft")):
|
|
continue
|
|
rel = os.path.relpath(os.path.join(root, name), base_dir).replace("\\", "/")
|
|
files.append(rel)
|
|
return sorted(set(files)) or ["put_qwenvl3_model_in_models_text_encoders.safetensors"]
|
|
|
|
|
|
def _ttp_resolve_model_file(model_file, model_type="text_encoders"):
|
|
model_file = str(model_file or "").replace("\\", "/")
|
|
for base_dir in _ttp_model_base_dirs(model_type):
|
|
candidate = os.path.abspath(os.path.join(base_dir, model_file))
|
|
try:
|
|
if os.path.commonpath([os.path.abspath(base_dir), candidate]) != os.path.abspath(base_dir):
|
|
continue
|
|
except ValueError:
|
|
continue
|
|
if os.path.isfile(candidate):
|
|
return candidate
|
|
raise FileNotFoundError(f"Model file not found in ComfyUI models/{model_type}: {model_file}")
|
|
|
|
|
|
def _ttp_load_qwenvl3_local_model(model_file, model_family="auto", device_mode="default"):
|
|
model_path = _ttp_resolve_model_file(model_file, "text_encoders")
|
|
model_family = str(model_family or "auto")
|
|
# For visual tagging we should not expose downstream image-model choices.
|
|
# QWEN_IMAGE is ComfyUI's native single-file Qwen-VL text encoder entry;
|
|
# Qwen3.5-VL variants are still auto-detected from the safetensors keys.
|
|
clip_type_name = "QWEN_IMAGE"
|
|
cache_key = json.dumps({
|
|
"path": model_path,
|
|
"device_mode": device_mode,
|
|
"model_family": model_family,
|
|
}, sort_keys=True)
|
|
if cache_key in _TTP_QWENVL3_MODEL_CACHE:
|
|
return _TTP_QWENVL3_MODEL_CACHE[cache_key]
|
|
|
|
model_options = {}
|
|
if str(device_mode) == "cpu":
|
|
model_options["load_device"] = model_options["offload_device"] = torch.device("cpu")
|
|
clip = comfy.sd.load_clip(
|
|
ckpt_paths=[model_path],
|
|
embedding_directory=folder_paths.get_folder_paths("embeddings"),
|
|
clip_type=comfy.sd.CLIPType.QWEN_IMAGE,
|
|
model_options=model_options,
|
|
)
|
|
|
|
loaded = {
|
|
"type": "ttp_qwenvl3_model",
|
|
"model_file": model_file,
|
|
"model_path": model_path,
|
|
"clip_type": clip_type_name,
|
|
"model_family": model_family,
|
|
"clip": clip,
|
|
}
|
|
_TTP_QWENVL3_MODEL_CACHE[cache_key] = loaded
|
|
return loaded
|
|
|
|
|
|
def _ttp_qwen_vl_local_chat(local_model, messages, max_new_tokens=256, temperature=0.2, seed=0):
|
|
if not isinstance(local_model, dict) or local_model.get("type") != "ttp_qwenvl3_model":
|
|
raise ValueError("qwen_vl_local mode requires TTP QwenVL3 Local Loader output.")
|
|
clip = local_model["clip"]
|
|
|
|
system_parts = []
|
|
user_parts = []
|
|
images = []
|
|
for message in messages:
|
|
role = str(message.get("role", "user"))
|
|
content = message.get("content", [])
|
|
if isinstance(content, str):
|
|
if role == "system":
|
|
system_parts.append(content)
|
|
else:
|
|
user_parts.append(content)
|
|
if isinstance(content, list):
|
|
for item in content:
|
|
if isinstance(item, dict) and item.get("type") == "text":
|
|
if role == "system":
|
|
system_parts.append(str(item.get("text", "")))
|
|
else:
|
|
user_parts.append(str(item.get("text", "")))
|
|
if isinstance(item, dict) and item.get("type") == "image":
|
|
images.append(item.get("image"))
|
|
elif isinstance(item, dict) and item.get("type") == "image_url":
|
|
url = item.get("image_url", {}).get("url", "")
|
|
if str(url).startswith("data:image"):
|
|
raw = base64.b64decode(str(url).split(",", 1)[1])
|
|
image = Image.open(BytesIO(raw)).convert("RGB")
|
|
images.append(image)
|
|
system_prompt = "\n".join(part for part in system_parts if part).strip()
|
|
user_prompt = "\n".join(part for part in user_parts if part).strip()
|
|
if system_prompt:
|
|
user_prompt = f"System instruction:\n{system_prompt}\n\nUser request:\n{user_prompt}".strip()
|
|
image_tensors = [pil2tensor(image.convert("RGB")) if isinstance(image, Image.Image) else image for image in images]
|
|
tokenize_kwargs = {}
|
|
if image_tensors:
|
|
tokenize_kwargs["images"] = image_tensors
|
|
prompt = user_prompt.strip()
|
|
tokens = clip.tokenize(prompt, **tokenize_kwargs)
|
|
do_sample = float(temperature) > 0
|
|
output_ids = clip.generate(
|
|
tokens,
|
|
do_sample=do_sample,
|
|
max_length=int(max_new_tokens),
|
|
temperature=float(temperature),
|
|
seed=int(seed if seed is not None else 0),
|
|
)
|
|
text = clip.decode(output_ids, skip_special_tokens=True)
|
|
return _ttp_clean_qwen_vl_response(str(text), prompt)
|
|
|
|
|
|
def _ttp_encode_text_conditioning(clip, text):
|
|
if clip is None:
|
|
return []
|
|
tokens = clip.tokenize(str(text or ""))
|
|
return clip.encode_from_tokens_scheduled(tokens)
|
|
|
|
|
|
def _ttp_tile_hash(image_tensor):
|
|
pil = _ttp_image_tensor_to_pil(image_tensor)
|
|
arr = np.array(pil.resize((min(128, pil.width), min(128, pil.height)), Image.Resampling.BILINEAR))
|
|
return hashlib.sha256(arr.tobytes()).hexdigest()[:16]
|
|
|
|
|
|
def _ttp_resize_pil_for_qwen(image, max_side=0, max_pixels=0):
|
|
if not isinstance(image, Image.Image):
|
|
return image
|
|
max_side = int(max_side or 0)
|
|
max_pixels = int(max_pixels or 0)
|
|
width, height = image.size
|
|
scale = 1.0
|
|
if max_side > 0:
|
|
scale = min(scale, max_side / max(1, width, height))
|
|
if max_pixels > 0 and width * height > max_pixels:
|
|
scale = min(scale, (max_pixels / max(1, width * height)) ** 0.5)
|
|
if scale >= 0.999:
|
|
return image
|
|
next_size = (max(1, int(round(width * scale))), max(1, int(round(height * scale))))
|
|
return image.resize(next_size, Image.Resampling.LANCZOS)
|
|
|
|
|
|
def _ttp_make_contact_sheet(images, labels=None, thumb_size=192, columns=4):
|
|
items = [image.convert("RGB") for image in images if isinstance(image, Image.Image)]
|
|
if not items:
|
|
return None
|
|
labels = labels or [str(index) for index in range(len(items))]
|
|
columns = max(1, int(columns or 1))
|
|
rows = int(np.ceil(len(items) / columns))
|
|
label_height = 24
|
|
gap = 8
|
|
sheet = Image.new("RGB", (
|
|
columns * thumb_size + (columns + 1) * gap,
|
|
rows * (thumb_size + label_height) + (rows + 1) * gap,
|
|
), (28, 31, 36))
|
|
draw = ImageDraw.Draw(sheet)
|
|
for index, image in enumerate(items):
|
|
col = index % columns
|
|
row = index // columns
|
|
x = gap + col * (thumb_size + gap)
|
|
y = gap + row * (thumb_size + label_height + gap)
|
|
preview = image.copy()
|
|
preview.thumbnail((thumb_size, thumb_size), Image.Resampling.LANCZOS)
|
|
offset = (x + (thumb_size - preview.width) // 2, y + (thumb_size - preview.height) // 2)
|
|
sheet.paste(preview, offset)
|
|
draw.rectangle((x, y, x + thumb_size - 1, y + thumb_size - 1), outline=(148, 163, 184), width=1)
|
|
draw.text((x + 5, y + thumb_size + 6), str(labels[index])[:40], fill=(226, 232, 240))
|
|
return sheet
|
|
|
|
|
|
def _ttp_extract_json_object(text):
|
|
text = str(text or "").strip()
|
|
text = _ttp_clean_qwen_vl_response(text)
|
|
if text.startswith("```"):
|
|
text = text.strip("`")
|
|
if text.lower().startswith("json"):
|
|
text = text[4:].strip()
|
|
try:
|
|
return json.loads(text)
|
|
except Exception:
|
|
pass
|
|
start = text.find("{")
|
|
end = text.rfind("}")
|
|
if start >= 0 and end > start:
|
|
try:
|
|
return json.loads(text[start:end + 1])
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def _ttp_extract_json_array(text):
|
|
text = _ttp_clean_qwen_vl_response(text)
|
|
start = text.find("[")
|
|
end = text.rfind("]")
|
|
if start >= 0 and end > start:
|
|
try:
|
|
parsed = json.loads(text[start:end + 1])
|
|
return parsed if isinstance(parsed, list) else []
|
|
except Exception:
|
|
pass
|
|
parsed = _ttp_extract_json_object(text)
|
|
if isinstance(parsed, dict):
|
|
for key in ("objects", "boxes", "bboxes", "tiles", "detections"):
|
|
value = parsed.get(key)
|
|
if isinstance(value, list):
|
|
return value
|
|
return []
|
|
|
|
|
|
def _ttp_clean_qwen_vl_response(text, prompt=""):
|
|
text = str(text or "").strip()
|
|
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL | re.IGNORECASE).strip()
|
|
for marker in ("<|im_start|>assistant", "assistant\n", "assistant:"):
|
|
if marker in text:
|
|
text = text.split(marker)[-1].strip()
|
|
if prompt and text.startswith(prompt):
|
|
text = text[len(prompt):].strip()
|
|
if "<|im_end|>" in text:
|
|
text = text.split("<|im_end|>", 1)[0].strip()
|
|
if text.startswith("```"):
|
|
text = re.sub(r"^```(?:json)?", "", text.strip(), flags=re.IGNORECASE).strip()
|
|
if text.endswith("```"):
|
|
text = text[:-3].strip()
|
|
for prefix in ("Here is the JSON:", "Here is the result:", "Result:", "JSON:"):
|
|
if text.lower().startswith(prefix.lower()):
|
|
text = text[len(prefix):].strip()
|
|
return text.strip()
|
|
|
|
|
|
def _ttp_compact_text(text, max_chars=1200):
|
|
text = str(text or "").strip()
|
|
max_chars = max(0, int(max_chars or 0))
|
|
if max_chars <= 0 or len(text) <= max_chars:
|
|
return text
|
|
return text[:max_chars].rstrip() + "..."
|
|
|
|
|
|
def _ttp_qwen_response_looks_like_instruction(text):
|
|
lowered = str(text or "").strip().lower()
|
|
if not lowered:
|
|
return True
|
|
instruction_fragments = [
|
|
"analyze this tile",
|
|
"return json",
|
|
"return strict json",
|
|
"refine this tile",
|
|
"preserve all visible visual facts",
|
|
"do not invent",
|
|
"write prompt fields",
|
|
]
|
|
return any(fragment in lowered for fragment in instruction_fragments)
|
|
|
|
|
|
def _ttp_jsonish_field(raw, field):
|
|
text = str(raw or "")
|
|
pattern = r'"' + re.escape(str(field)) + r'"\s*:\s*"((?:\\.|[^"\\])*)'
|
|
match = re.search(pattern, text, flags=re.DOTALL)
|
|
if not match:
|
|
return ""
|
|
value = match.group(1)
|
|
try:
|
|
return json.loads(f'"{value}"').strip()
|
|
except Exception:
|
|
return value.replace('\\"', '"').replace("\\n", "\n").strip()
|
|
|
|
|
|
def _ttp_extract_jsonish_qwen_fields(raw):
|
|
if "{" not in str(raw or ""):
|
|
return {}
|
|
fields = {
|
|
"label": _ttp_jsonish_field(raw, "label"),
|
|
"caption": _ttp_jsonish_field(raw, "caption"),
|
|
"prompt": _ttp_jsonish_field(raw, "prompt"),
|
|
"negative": _ttp_jsonish_field(raw, "negative"),
|
|
}
|
|
if any(fields.values()):
|
|
return fields
|
|
return {}
|
|
|
|
|
|
def _ttp_parse_qwen_tile_record(raw, index):
|
|
parsed = _ttp_extract_json_object(raw)
|
|
if not isinstance(parsed, dict) or not parsed:
|
|
parsed = _ttp_extract_jsonish_qwen_fields(raw)
|
|
if not isinstance(parsed, dict) or not parsed:
|
|
snippet = str(raw or "").strip().replace("\n", " ")[:240]
|
|
raise RuntimeError(f"QwenVL did not return JSON fields for tile {index}. Raw: {snippet}")
|
|
|
|
label = str(parsed.get("label", "")).strip()
|
|
caption = str(parsed.get("caption", "")).strip()
|
|
prompt = str(parsed.get("prompt", "")).strip()
|
|
negative = str(parsed.get("negative", "")).strip()
|
|
if not caption and not prompt:
|
|
snippet = str(raw or "").strip().replace("\n", " ")[:240]
|
|
raise RuntimeError(f"QwenVL JSON for tile {index} is missing caption/prompt. Raw: {snippet}")
|
|
if _ttp_qwen_response_looks_like_instruction(caption) or _ttp_qwen_response_looks_like_instruction(prompt):
|
|
snippet = str(raw or "").strip().replace("\n", " ")[:240]
|
|
raise RuntimeError(f"QwenVL appears to have echoed instructions for tile {index}. Raw: {snippet}")
|
|
return {
|
|
"label": label,
|
|
"caption": caption,
|
|
"prompt": prompt,
|
|
"negative": negative,
|
|
}
|
|
|
|
|
|
def _ttp_qwen_tile_retry_messages(
|
|
system_prompt,
|
|
tile_pil,
|
|
index,
|
|
tile,
|
|
output_language,
|
|
global_prompt="",
|
|
global_negative="",
|
|
reference_context="",
|
|
):
|
|
language_hint = {
|
|
"english": "Use English.",
|
|
"chinese": "Use Chinese.",
|
|
"bilingual": "Use concise English and Chinese.",
|
|
}.get(str(output_language), "Use English.")
|
|
user_text = "\n".join([
|
|
"Return compact JSON only. No markdown. No explanation. Stop immediately after the closing brace.",
|
|
'Schema: {"label":"short label","caption":"visible facts","prompt":"img2img positive prompt","negative":"negative prompt"}',
|
|
"Keep caption under 35 words, prompt under 55 words, negative under 25 words.",
|
|
language_hint,
|
|
f"Tile index: {index}. Existing label: {tile.get('label', tile.get('name', 'tile'))}.",
|
|
f"Global prompt keywords: {_ttp_compact_text(global_prompt, 360)}",
|
|
f"Global negative keywords: {_ttp_compact_text(global_negative, 240)}",
|
|
f"Reference context: {_ttp_compact_text(reference_context, 360)}",
|
|
])
|
|
return [
|
|
{"role": "system", "content": _ttp_compact_text(system_prompt, 900) or "You output only compact JSON for tile image tagging."},
|
|
{"role": "user", "content": [
|
|
{"type": "text", "text": user_text},
|
|
{"type": "image_url", "image_url": {"url": _ttp_pil_to_data_url(tile_pil)}},
|
|
]},
|
|
]
|
|
|
|
|
|
def _ttp_compose_tile_prompt(tile, caption, global_prompt, global_negative, merge_mode):
|
|
label = str(tile.get("label", tile.get("name", "tile")) or "tile")
|
|
caption = str(caption or "").strip()
|
|
pieces = []
|
|
if merge_mode in ("global_plus_caption", "global_plus_label_plus_caption") and str(global_prompt or "").strip():
|
|
pieces.append(str(global_prompt).strip())
|
|
if merge_mode == "global_plus_label_plus_caption" and label:
|
|
pieces.append(label)
|
|
if caption:
|
|
pieces.append(caption)
|
|
if not pieces:
|
|
pieces.append(label)
|
|
negative = str(global_negative or "").strip()
|
|
return ", ".join(piece for piece in pieces if piece), negative
|
|
|
|
|
|
def _ttp_template_prompt_for_tile(tile, global_prompt, global_negative, merge_mode):
|
|
label = str(tile.get("label", tile.get("name", "tile")) or "tile").lower()
|
|
source = str(tile.get("source", "manual") or "manual")
|
|
x, y, w, h = tile.get("sample_box", tile.get("core_box", [0, 0, 1, 1]))
|
|
area_hint = "small detail tile" if int(w) * int(h) < 160000 else "large context tile"
|
|
if "face" in label or "head" in label:
|
|
caption = "detailed face region, sharp eyes, clean skin texture, consistent identity"
|
|
prompt_label = "face"
|
|
elif "hand" in label:
|
|
caption = "detailed hands, natural fingers, clean anatomy, consistent lighting"
|
|
prompt_label = "hands"
|
|
elif "text" in label:
|
|
caption = "sharp readable text, clean edges, preserved typography"
|
|
prompt_label = "text"
|
|
elif "background" in label:
|
|
caption = "clean background, consistent lighting, coherent texture"
|
|
prompt_label = "background"
|
|
else:
|
|
caption = f"{area_hint}, local visual detail, consistent style, coherent texture"
|
|
prompt_label = label or source
|
|
prompt, negative = _ttp_compose_tile_prompt({**tile, "label": prompt_label}, caption, global_prompt, global_negative, merge_mode)
|
|
return {
|
|
"label": prompt_label,
|
|
"caption": caption,
|
|
"prompt": prompt,
|
|
"negative": negative,
|
|
}
|
|
|
|
|
|
def _ttp_apply_color_transfer(image_tensor, ref_tensor, method, strength):
|
|
if method == "off" or strength <= 0:
|
|
return image_tensor
|
|
try:
|
|
from comfy_extras.nodes_post_processing import ColorTransfer
|
|
except Exception as exc:
|
|
raise RuntimeError("ComfyUI Transfer Color is unavailable; set color_correction to off.") from exc
|
|
|
|
result = ColorTransfer.execute(
|
|
image_tensor,
|
|
ref_tensor,
|
|
method,
|
|
{"source_stats": "per_frame", "target_index": 0},
|
|
float(strength),
|
|
)
|
|
if isinstance(result, (tuple, list)):
|
|
return result[0]
|
|
if hasattr(result, "result"):
|
|
return result.result[0]
|
|
try:
|
|
return result[0]
|
|
except Exception as exc:
|
|
raise RuntimeError("Unexpected Transfer Color result from ComfyUI.") from exc
|
|
|
|
|
|
def _ttp_apply_local_mean_std_color(region, reference, mask=None, strength=1.0):
|
|
strength = float(np.clip(float(strength), 0.0, 1.0))
|
|
if strength <= 0:
|
|
return region
|
|
|
|
source = np.asarray(region, dtype=np.float32)
|
|
target = np.asarray(reference, dtype=np.float32)
|
|
if source.shape != target.shape or source.size == 0:
|
|
return source
|
|
|
|
if mask is None:
|
|
weights = np.ones(source.shape[:2], dtype=np.float32)
|
|
else:
|
|
weights = np.asarray(mask[:, :, 0] if getattr(mask, "ndim", 0) == 3 else mask, dtype=np.float32)
|
|
weights = np.clip(weights, 0.0, 1.0)
|
|
if float(weights.sum()) < 16.0:
|
|
weights = np.ones(source.shape[:2], dtype=np.float32)
|
|
|
|
weights_3 = weights[:, :, None]
|
|
weight_sum = max(1e-6, float(weights.sum()))
|
|
source_mean = (source * weights_3).sum(axis=(0, 1)) / weight_sum
|
|
target_mean = (target * weights_3).sum(axis=(0, 1)) / weight_sum
|
|
source_var = (((source - source_mean) ** 2) * weights_3).sum(axis=(0, 1)) / weight_sum
|
|
target_var = (((target - target_mean) ** 2) * weights_3).sum(axis=(0, 1)) / weight_sum
|
|
source_std = np.sqrt(np.maximum(source_var, 1e-6))
|
|
target_std = np.sqrt(np.maximum(target_var, 1e-6))
|
|
corrected = (source - source_mean) * (target_std / source_std) + target_mean
|
|
corrected = np.clip(corrected, 0.0, 1.0)
|
|
return np.clip(source * (1.0 - strength) + corrected * strength, 0.0, 1.0)
|
|
|
|
|
|
def _ttp_apply_local_mean_std_color_torch(region, reference, mask=None, strength=1.0):
|
|
strength = float(np.clip(float(strength), 0.0, 1.0))
|
|
if strength <= 0:
|
|
return region
|
|
if region.shape != reference.shape or region.numel() == 0:
|
|
return region
|
|
|
|
if mask is None:
|
|
weights = torch.ones(region.shape[:2], dtype=region.dtype, device=region.device)
|
|
else:
|
|
weights = mask[:, :, 0] if getattr(mask, "ndim", 0) == 3 else mask
|
|
weights = torch.clamp(weights.to(dtype=region.dtype, device=region.device), 0.0, 1.0)
|
|
if float(weights.sum().detach().cpu().item()) < 16.0:
|
|
weights = torch.ones(region.shape[:2], dtype=region.dtype, device=region.device)
|
|
|
|
weights_3 = weights[:, :, None]
|
|
weight_sum = torch.clamp(weights.sum(), min=1e-6)
|
|
source_mean = (region * weights_3).sum(dim=(0, 1)) / weight_sum
|
|
target_mean = (reference * weights_3).sum(dim=(0, 1)) / weight_sum
|
|
source_var = (((region - source_mean) ** 2) * weights_3).sum(dim=(0, 1)) / weight_sum
|
|
target_var = (((reference - target_mean) ** 2) * weights_3).sum(dim=(0, 1)) / weight_sum
|
|
source_std = torch.sqrt(torch.clamp(source_var, min=1e-6))
|
|
target_std = torch.sqrt(torch.clamp(target_var, min=1e-6))
|
|
corrected = torch.clamp((region - source_mean) * (target_std / source_std) + target_mean, 0.0, 1.0)
|
|
return torch.clamp(region * (1.0 - strength) + corrected * strength, 0.0, 1.0)
|
|
|
|
|
|
def _ttp_pad_image_to_size(image, target_width, target_height):
|
|
if image.width == target_width and image.height == target_height:
|
|
return image
|
|
|
|
padded = Image.new("RGB", (target_width, target_height))
|
|
padded.paste(image, (0, 0))
|
|
|
|
if image.width < target_width:
|
|
right_edge = image.crop((image.width - 1, 0, image.width, image.height))
|
|
right_fill = right_edge.resize((target_width - image.width, image.height), Image.Resampling.NEAREST)
|
|
padded.paste(right_fill, (image.width, 0))
|
|
|
|
if image.height < target_height:
|
|
bottom_edge = padded.crop((0, image.height - 1, target_width, image.height))
|
|
bottom_fill = bottom_edge.resize((target_width, target_height - image.height), Image.Resampling.NEAREST)
|
|
padded.paste(bottom_fill, (0, image.height))
|
|
|
|
return padded
|
|
|
|
|
|
def _ttp_expand_axis_to_size(start, length, target_length, limit, allow_before, allow_after):
|
|
start = int(start)
|
|
length = int(length)
|
|
target_length = min(int(target_length), int(limit))
|
|
if target_length <= length:
|
|
return start, length
|
|
|
|
deficit = target_length - length
|
|
before = 0
|
|
after = 0
|
|
if allow_before:
|
|
before = min(deficit, start)
|
|
deficit -= before
|
|
if allow_after:
|
|
after_space = max(0, int(limit) - (start + length))
|
|
after = min(deficit, after_space)
|
|
deficit -= after
|
|
if allow_before and deficit > 0:
|
|
before_space = max(0, start - before)
|
|
extra_before = min(deficit, before_space)
|
|
before += extra_before
|
|
deficit -= extra_before
|
|
|
|
return start - before, length + before + after
|
|
|
|
|
|
def _ttp_actual_overlap_edges_from_sample(tile):
|
|
sx, sy, sw, sh = tile["sample_box"]
|
|
x, y, w, h = tile["core_box"]
|
|
return {
|
|
"left": max(0, x - sx),
|
|
"right": max(0, sx + sw - (x + w)),
|
|
"top": max(0, y - sy),
|
|
"bottom": max(0, sy + sh - (y + h)),
|
|
}
|
|
|
|
|
|
def _ttp_expand_smart_tile_samples_to_batch(tiles_meta, image_width, image_height, target_width, target_height):
|
|
for tile in tiles_meta:
|
|
sx, sy, sw, sh = tile["sample_box"]
|
|
edges = tile.get("overlap_edges_px_source", {})
|
|
sx, sw = _ttp_expand_axis_to_size(
|
|
sx,
|
|
sw,
|
|
target_width,
|
|
image_width,
|
|
int(edges.get("left", 0)) > 0,
|
|
int(edges.get("right", 0)) > 0,
|
|
)
|
|
sy, sh = _ttp_expand_axis_to_size(
|
|
sy,
|
|
sh,
|
|
target_height,
|
|
image_height,
|
|
int(edges.get("top", 0)) > 0,
|
|
int(edges.get("bottom", 0)) > 0,
|
|
)
|
|
tile["sample_box"] = [sx, sy, sw, sh]
|
|
tile["overlap_edges_px_source"] = _ttp_actual_overlap_edges_from_sample(tile)
|
|
return tiles_meta
|
|
|
|
|
|
def _ttp_crop_smart_tiles_from_meta(pil_image, tiles_meta, round_to):
|
|
image_width, image_height = pil_image.size
|
|
positions = []
|
|
for tile in tiles_meta:
|
|
sx, sy, sw, sh = tile["sample_box"]
|
|
sw = max(1, _ttp_round_to(sw, round_to))
|
|
sh = max(1, _ttp_round_to(sh, round_to))
|
|
if sx + sw > image_width:
|
|
sx = max(0, image_width - sw)
|
|
if sy + sh > image_height:
|
|
sy = max(0, image_height - sh)
|
|
sw = min(sw, image_width - sx)
|
|
sh = min(sh, image_height - sy)
|
|
tile["sample_box"] = [sx, sy, sw, sh]
|
|
x, y, w, h = tile["paste_box"]
|
|
positions.append((x, y, x + w, y + h))
|
|
|
|
max_tile_width = max(tile["sample_box"][2] for tile in tiles_meta)
|
|
max_tile_height = max(tile["sample_box"][3] for tile in tiles_meta)
|
|
max_tile_width = max(1, _ttp_round_to(max_tile_width, round_to))
|
|
max_tile_height = max(1, _ttp_round_to(max_tile_height, round_to))
|
|
_ttp_expand_smart_tile_samples_to_batch(tiles_meta, image_width, image_height, max_tile_width, max_tile_height)
|
|
|
|
tile_images = []
|
|
for tile in tiles_meta:
|
|
sx, sy, sw, sh = tile["sample_box"]
|
|
crop = pil_image.crop((sx, sy, sx + sw, sy + sh))
|
|
tile_images.append(crop)
|
|
tile["tile_canvas_box"] = [0, 0, sw, sh]
|
|
|
|
tile_tensors = []
|
|
for tile, crop in zip(tiles_meta, tile_images):
|
|
tile["tile_canvas_size"] = [max_tile_width, max_tile_height]
|
|
padded_crop = _ttp_pad_image_to_size(crop, max_tile_width, max_tile_height)
|
|
tile_tensors.append(pil2tensor(padded_crop))
|
|
|
|
tiles = torch.cat(tile_tensors, dim=0)
|
|
preview = pil_image.copy()
|
|
draw = ImageDraw.Draw(preview)
|
|
colors = ["red", "lime", "cyan", "yellow", "magenta", "orange", "blue", "white"]
|
|
for tile in tiles_meta:
|
|
color = colors[tile["id"] % len(colors)]
|
|
sx, sy, sw, sh = tile["sample_box"]
|
|
x, y, w, h = tile["paste_box"]
|
|
draw.rectangle((sx, sy, sx + sw, sy + sh), outline=color, width=2)
|
|
draw.rectangle((x, y, x + w, y + h), outline="white", width=2)
|
|
draw.text((x + 4, y + 4), f'{tile["id"]}:{tile["name"]}', fill=color)
|
|
|
|
tile_meta = {
|
|
"version": 2,
|
|
"type": "ttp_smart_tile",
|
|
"original_size": [image_width, image_height],
|
|
"tiles": tiles_meta,
|
|
}
|
|
return tiles, tile_meta, positions, pil2tensor(preview)
|
|
|
|
|
|
def _ttp_crop_smart_tile_set_from_meta(pil_image, tiles_meta, round_to):
|
|
image_width, image_height = pil_image.size
|
|
positions = []
|
|
tile_tensors = []
|
|
set_tiles_meta = []
|
|
for tile in tiles_meta:
|
|
item = dict(tile)
|
|
sx, sy, sw, sh = item["sample_box"]
|
|
sw = max(1, _ttp_round_to(sw, round_to))
|
|
sh = max(1, _ttp_round_to(sh, round_to))
|
|
if sx + sw > image_width:
|
|
sx = max(0, image_width - sw)
|
|
if sy + sh > image_height:
|
|
sy = max(0, image_height - sh)
|
|
sw = min(sw, image_width - sx)
|
|
sh = min(sh, image_height - sy)
|
|
item["sample_box"] = [sx, sy, sw, sh]
|
|
item["overlap_edges_px_source"] = _ttp_actual_overlap_edges_from_sample(item)
|
|
item["tile_canvas_box"] = [0, 0, sw, sh]
|
|
item["tile_canvas_size"] = [sw, sh]
|
|
x, y, w, h = item["paste_box"]
|
|
positions.append((x, y, x + w, y + h))
|
|
crop = pil_image.crop((sx, sy, sx + sw, sy + sh))
|
|
tile_tensors.append(pil2tensor(crop))
|
|
set_tiles_meta.append(item)
|
|
|
|
tile_meta = {
|
|
"version": 3,
|
|
"type": "ttp_smart_tile",
|
|
"storage": "tile_set",
|
|
"original_size": [image_width, image_height],
|
|
"tiles": set_tiles_meta,
|
|
}
|
|
return {
|
|
"version": 1,
|
|
"type": "ttp_smart_tile_set",
|
|
"original_size": [image_width, image_height],
|
|
"tile_meta": tile_meta,
|
|
"tile_images": tile_tensors,
|
|
"positions": positions,
|
|
}
|
|
|
|
|
|
def _ttp_input_image_files():
|
|
input_dir = folder_paths.get_input_directory()
|
|
files = [file for file in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, file))]
|
|
try:
|
|
files = folder_paths.filter_files_content_types(files, ["image"])
|
|
except Exception:
|
|
pass
|
|
return sorted(files)
|
|
|
|
|
|
def _ttp_load_input_image(image):
|
|
image_name = str(image or "").strip()
|
|
if not image_name:
|
|
raise ValueError("No image was selected. Use the official image upload/dropdown on this node, or connect source_image.")
|
|
image_path = folder_paths.get_annotated_filepath(image_name)
|
|
pil_image = Image.open(image_path)
|
|
pil_image = ImageOps.exif_transpose(pil_image)
|
|
return pil_image.convert("RGB")
|
|
|
|
|
|
def _ttp_get_interactive_source_image(source_image=None, image=""):
|
|
if source_image is not None:
|
|
if not isinstance(source_image, torch.Tensor) or source_image.ndim != 4 or int(source_image.shape[0]) <= 0:
|
|
raise ValueError("source_image input must be a non-empty ComfyUI IMAGE tensor.")
|
|
return tensor2pil(source_image[0].unsqueeze(0)).convert("RGB")
|
|
return _ttp_load_input_image(image)
|
|
|
|
|
|
def _ttp_send_smart_tile_layout_event(node_id, layout_json=None, message="", ok=True):
|
|
if PromptServer is None or not node_id:
|
|
return
|
|
payload = {
|
|
"node_id": str(node_id),
|
|
"ok": bool(ok),
|
|
"message": str(message or ""),
|
|
}
|
|
if layout_json:
|
|
payload["layout_json"] = layout_json
|
|
try:
|
|
PromptServer.instance.send_sync("ttp-smart-tile-layout", payload, PromptServer.instance.client_id)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _ttp_unpack_node_output(result, index):
|
|
if isinstance(result, (tuple, list)):
|
|
return result[index]
|
|
if hasattr(result, "result"):
|
|
return result.result[index]
|
|
return result[index]
|
|
|
|
|
|
def _ttp_split_prompt_terms(prompt):
|
|
terms = []
|
|
for part in str(prompt or "").replace("\n", ",").split(","):
|
|
term = part.strip()
|
|
if term:
|
|
terms.append(term)
|
|
return terms
|
|
|
|
|
|
def _ttp_encode_sam3_prompt_conditioning(clip, prompt, max_detections=1):
|
|
if clip is None:
|
|
return None
|
|
terms = _ttp_split_prompt_terms(prompt)
|
|
if not terms:
|
|
terms = [str(prompt or "").strip() or "foreground object"]
|
|
|
|
entries = []
|
|
base_meta = {}
|
|
for term in terms:
|
|
tokens = clip.tokenize(term)
|
|
cond = clip.encode_from_tokens_scheduled(tokens)
|
|
if not cond:
|
|
continue
|
|
meta = dict(cond[0][1]) if len(cond[0]) > 1 and isinstance(cond[0][1], dict) else {}
|
|
if not base_meta:
|
|
base_meta = dict(meta)
|
|
entries.append({
|
|
"cond": cond[0][0],
|
|
"attention_mask": meta.get("attention_mask"),
|
|
"max_detections": max(1, int(max_detections)),
|
|
"prompt": term,
|
|
})
|
|
|
|
if not entries:
|
|
return None
|
|
if len(entries) == 1:
|
|
meta = dict(base_meta)
|
|
meta["sam3_prompt"] = entries[0]["prompt"]
|
|
return [[entries[0]["cond"], meta]]
|
|
|
|
meta = dict(base_meta)
|
|
meta["sam3_multi_cond"] = entries
|
|
meta["sam3_prompts"] = [entry["prompt"] for entry in entries]
|
|
return [[entries[0]["cond"], meta]]
|
|
|
|
|
|
def _ttp_run_sam3_auto_layout(
|
|
pil_image,
|
|
vision_model,
|
|
vision_conditioning,
|
|
clip,
|
|
auto_prompt,
|
|
default_pad,
|
|
default_blend,
|
|
object_padding,
|
|
max_tiles,
|
|
allow_object_overlap,
|
|
paint_mask_payload=None,
|
|
):
|
|
if vision_model is None:
|
|
raise ValueError("Connect an official SAM3/SAM3.1 model to vision_model before inference.")
|
|
if vision_conditioning is None:
|
|
per_prompt_max = max(1, int(max_tiles) // max(1, len(_ttp_split_prompt_terms(auto_prompt))))
|
|
vision_conditioning = _ttp_encode_sam3_prompt_conditioning(clip, auto_prompt, per_prompt_max)
|
|
if vision_conditioning is None:
|
|
raise ValueError("Connect CLIP to this node, or connect SAM3 text conditioning to vision_conditioning, before SAM3 text-prompt inference.")
|
|
|
|
try:
|
|
from comfy_extras.nodes_sam3 import SAM3_Detect
|
|
except Exception as exc:
|
|
raise RuntimeError("Official ComfyUI SAM3 Detect is unavailable in this ComfyUI build.") from exc
|
|
|
|
image_tensor = pil2tensor(pil_image)
|
|
result = SAM3_Detect.execute(
|
|
vision_model,
|
|
image_tensor,
|
|
conditioning=vision_conditioning,
|
|
threshold=0.5,
|
|
refine_iterations=2,
|
|
individual_masks=True,
|
|
)
|
|
masks = _ttp_unpack_node_output(result, 0)
|
|
bboxes = _ttp_unpack_node_output(result, 1)
|
|
image_width, image_height = pil_image.size
|
|
paint_mask = _ttp_decode_interactive_paint_mask(paint_mask_payload, image_width, image_height)
|
|
paint_items, paint_masks = _ttp_paint_mask_to_items(paint_mask, image_width, image_height)
|
|
detected_items = _ttp_flatten_bboxes(bboxes)
|
|
detected_masks = _ttp_mask_tensor_to_pil_list(masks, image_width, image_height)
|
|
while len(detected_masks) < len(detected_items):
|
|
detected_masks.append(Image.new("L", (image_width, image_height), 0))
|
|
layout_json = _ttp_boxes_to_auto_layout(
|
|
detected_items + paint_items,
|
|
image_width,
|
|
image_height,
|
|
default_pad=int(default_pad),
|
|
default_blend=int(default_blend),
|
|
object_padding=int(object_padding),
|
|
max_tiles=int(max_tiles),
|
|
include_background=False,
|
|
allow_object_overlap=bool(allow_object_overlap),
|
|
masks=detected_masks + paint_masks,
|
|
)
|
|
count = len(detected_items)
|
|
prompt_note = "using internal auto_prompt CLIP encode" if clip is not None else "using external conditioning"
|
|
paint_note = f", plus {len(paint_items)} paint mask region(s)" if paint_items else ""
|
|
return layout_json, f"SAM3 inference created an auto tile layout from {count} detected object(s){paint_note}, {prompt_note}."
|
|
|
|
|
|
def _ttp_qwen_bbox_items(raw, image_width, image_height):
|
|
items = []
|
|
for index, item in enumerate(_ttp_extract_json_array(raw)):
|
|
if not isinstance(item, dict):
|
|
continue
|
|
bbox = item.get("bbox", item.get("box", item.get("bbox_2d")))
|
|
if not isinstance(bbox, (list, tuple)) or len(bbox) < 4:
|
|
continue
|
|
values = [float(value) for value in bbox[:4]]
|
|
if max(values) <= 1.5:
|
|
x0, y0, x1, y1 = values[0] * image_width, values[1] * image_height, values[2] * image_width, values[3] * image_height
|
|
elif max(values) <= 1000.0:
|
|
x0, y0, x1, y1 = values[0] * image_width / 1000.0, values[1] * image_height / 1000.0, values[2] * image_width / 1000.0, values[3] * image_height / 1000.0
|
|
else:
|
|
x0, y0, x1, y1 = values
|
|
x0, y0, x1, y1 = _ttp_clamp_box_xyxy([x0, y0, x1, y1], image_width, image_height)
|
|
items.append({
|
|
"x": x0,
|
|
"y": y0,
|
|
"width": x1 - x0,
|
|
"height": y1 - y0,
|
|
"label": str(item.get("label", item.get("name", f"object_{index + 1}"))),
|
|
"score": float(item.get("score", 1.0) or 1.0),
|
|
"prompt_hint": str(item.get("prompt_hint", "")),
|
|
})
|
|
return items
|
|
|
|
|
|
def _ttp_run_qwenvl3_auto_layout(
|
|
pil_image,
|
|
qwen_vl_model,
|
|
auto_prompt,
|
|
default_pad,
|
|
default_blend,
|
|
object_padding,
|
|
max_tiles,
|
|
allow_object_overlap,
|
|
paint_mask_payload=None,
|
|
):
|
|
if not isinstance(qwen_vl_model, dict) or qwen_vl_model.get("type") != "ttp_qwenvl3_model":
|
|
raise ValueError("Connect TTP QwenVL3 Local Loader to qwen_vl_model before QwenVL3 Auto Tile.")
|
|
image_width, image_height = pil_image.size
|
|
prompt = "\n".join([
|
|
_TTP_QWENVL_PRESETS["bbox_detect"]["instruction"],
|
|
f"User focus prompt: {auto_prompt}",
|
|
"Prioritize face, eyes, hands, text, glasses, foreground subject, and important small details.",
|
|
"Return only the JSON list. No markdown. No explanation.",
|
|
])
|
|
messages = [
|
|
{"role": "system", "content": _TTP_QWENVL_PRESETS["bbox_detect"]["system"]},
|
|
{"role": "user", "content": [
|
|
{"type": "text", "text": prompt},
|
|
{"type": "image_url", "image_url": {"url": _ttp_pil_to_data_url(_ttp_resize_pil_for_qwen(pil_image, 1024, 1048576))}},
|
|
]},
|
|
]
|
|
raw = _ttp_qwen_vl_local_chat(qwen_vl_model, messages, max_new_tokens=1024, temperature=0.0, seed=0)
|
|
bboxes = _ttp_qwen_bbox_items(raw, image_width, image_height)
|
|
paint_mask = _ttp_decode_interactive_paint_mask(paint_mask_payload, image_width, image_height)
|
|
paint_items, paint_masks = _ttp_paint_mask_to_items(paint_mask, image_width, image_height)
|
|
if not bboxes and not paint_items:
|
|
snippet = _ttp_compact_text(str(raw or "").replace("\n", " "), 300)
|
|
raise RuntimeError(f"QwenVL3 Auto Tile did not return bbox JSON. Raw: {snippet}")
|
|
layout_json = _ttp_boxes_to_auto_layout(
|
|
bboxes + paint_items,
|
|
image_width,
|
|
image_height,
|
|
default_pad=int(default_pad),
|
|
default_blend=int(default_blend),
|
|
object_padding=int(object_padding),
|
|
max_tiles=int(max_tiles),
|
|
include_background=False,
|
|
allow_object_overlap=bool(allow_object_overlap),
|
|
masks=[Image.new("L", (image_width, image_height), 0) for _item in bboxes] + paint_masks,
|
|
)
|
|
paint_note = f", plus {len(paint_items)} paint mask region(s)" if paint_items else ""
|
|
return layout_json, f"QwenVL3 inference created an auto tile layout from {len(bboxes)} detected object(s){paint_note}."
|
|
|
|
|
|
def _ttp_run_paint_mask_auto_layout(
|
|
pil_image,
|
|
paint_mask_payload,
|
|
default_pad,
|
|
default_blend,
|
|
object_padding,
|
|
max_tiles,
|
|
allow_object_overlap,
|
|
):
|
|
image_width, image_height = pil_image.size
|
|
paint_mask = _ttp_decode_interactive_paint_mask(paint_mask_payload, image_width, image_height)
|
|
paint_items, paint_masks = _ttp_paint_mask_to_items(paint_mask, image_width, image_height)
|
|
if not paint_items:
|
|
raise ValueError("Paint mask is empty. Brush on the image first, then click Auto Tile.")
|
|
layout_json = _ttp_boxes_to_auto_layout(
|
|
paint_items,
|
|
image_width,
|
|
image_height,
|
|
default_pad=int(default_pad),
|
|
default_blend=int(default_blend),
|
|
object_padding=int(object_padding),
|
|
max_tiles=int(max_tiles),
|
|
include_background=False,
|
|
allow_object_overlap=bool(allow_object_overlap),
|
|
masks=paint_masks,
|
|
)
|
|
return layout_json, f"Paint mask created an auto tile layout from {len(paint_items)} painted region(s)."
|
|
|
|
|
|
def _ttp_interactive_layout_with_defaults(layout_json, default_pad, default_blend, include_full_image):
|
|
try:
|
|
layout = json.loads(str(layout_json or "").strip() or "{}")
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"Invalid interactive Smart Tile layout JSON: {exc}") from exc
|
|
|
|
if not isinstance(layout, dict):
|
|
raise ValueError("interactive Smart Tile layout must be a JSON object.")
|
|
|
|
defaults = {
|
|
"pad": int(max(0, default_pad)),
|
|
"blend": int(max(0, default_blend)),
|
|
"priority": 50,
|
|
"importance": 1.0,
|
|
}
|
|
defaults.update(layout.get("defaults", {}) if isinstance(layout.get("defaults"), dict) else {})
|
|
|
|
raw_tiles = layout.get("tiles", [])
|
|
if not isinstance(raw_tiles, list):
|
|
raw_tiles = []
|
|
|
|
tiles = []
|
|
if include_full_image:
|
|
tiles.append({
|
|
"name": "full_image",
|
|
"x0": 0.0,
|
|
"y0": 0.0,
|
|
"x1": 1.0,
|
|
"y1": 1.0,
|
|
"pad": 0,
|
|
"blend": max(int(default_blend), int(defaults.get("blend", 0))),
|
|
"priority": 10,
|
|
"importance": 0.5,
|
|
})
|
|
|
|
for index, tile in enumerate(raw_tiles):
|
|
if isinstance(tile, dict):
|
|
tiles.append({
|
|
"name": tile.get("name", f"tile_{index + 1}"),
|
|
**tile,
|
|
})
|
|
|
|
if not tiles:
|
|
tiles = [
|
|
{"name": "tile_1", "x0": 0.0, "y0": 0.0, "x1": 0.5, "y1": 0.5},
|
|
{"name": "tile_2", "x0": 0.5, "y0": 0.0, "x1": 1.0, "y1": 0.5},
|
|
{"name": "tile_3", "x0": 0.0, "y0": 0.5, "x1": 0.5, "y1": 1.0},
|
|
{"name": "tile_4", "x0": 0.5, "y0": 0.5, "x1": 1.0, "y1": 1.0},
|
|
]
|
|
|
|
return json.dumps({
|
|
"type": "ttp_smart_tile_interactive_layout",
|
|
"defaults": defaults,
|
|
"tiles": tiles,
|
|
})
|
|
|
|
|
|
class TTP_Smart_Tile_Layout_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
default_layout = json.dumps({
|
|
"defaults": {"pad": 128, "blend": 48, "priority": 50, "importance": 1.0},
|
|
"tiles": [
|
|
{"name": "full_image", "x": 0, "y": 0, "w": 1.0, "h": 1.0, "pad": 0, "blend": 96, "priority": 10, "importance": 0.5}
|
|
]
|
|
}, indent=2)
|
|
return {
|
|
"required": {
|
|
"layout_json": ("STRING", {"default": default_layout, "multiline": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING",)
|
|
RETURN_NAMES = ("layout_json",)
|
|
FUNCTION = "pass_layout"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def pass_layout(self, layout_json):
|
|
return (layout_json,)
|
|
|
|
|
|
class TTP_Smart_Tile_Crop_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
default_layout = json.dumps({
|
|
"defaults": {"pad": 128, "blend": 48, "priority": 50, "importance": 1.0},
|
|
"tiles": [
|
|
{"name": "full_image", "x": 0, "y": 0, "w": 1.0, "h": 1.0, "pad": 0, "blend": 96, "priority": 10, "importance": 0.5},
|
|
{"name": "center_detail", "x": 0.25, "y": 0.25, "w": 0.5, "h": 0.5, "pad": 128, "blend": 48, "priority": 100, "importance": 1.0}
|
|
]
|
|
}, indent=2)
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"layout_json": ("STRING", {"default": default_layout, "multiline": True}),
|
|
"round_to": ("INT", {"default": 8, "min": 1, "max": 128, "step": 1}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "TTP_SMART_TILE_META", "LIST", "IMAGE")
|
|
RETURN_NAMES = ("tiles", "tile_meta", "positions", "preview")
|
|
FUNCTION = "crop_tiles"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def crop_tiles(self, image, layout_json, round_to=8):
|
|
pil_image = tensor2pil(image[0].unsqueeze(0)).convert("RGB")
|
|
image_width, image_height = pil_image.size
|
|
tiles_meta = _ttp_parse_smart_tile_layout(layout_json, image_width, image_height)
|
|
return _ttp_crop_smart_tiles_from_meta(pil_image, tiles_meta, round_to)
|
|
|
|
|
|
class TTP_Smart_Tile_Visual_Crop_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"grid_columns": ("INT", {"default": 2, "min": 1, "max": 8, "step": 1}),
|
|
"grid_rows": ("INT", {"default": 2, "min": 1, "max": 8, "step": 1}),
|
|
"include_full_image": ("BOOLEAN", {"default": True}),
|
|
"grid_pad": ("INT", {"default": 128, "min": 0, "max": 1024, "step": 8}),
|
|
"grid_blend": ("INT", {"default": 64, "min": 0, "max": 512, "step": 8}),
|
|
"grid_priority": ("FLOAT", {"default": 40.0, "min": 0.0, "max": 200.0, "step": 1.0}),
|
|
"grid_importance": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 4.0, "step": 0.05}),
|
|
"round_to": ("INT", {"default": 8, "min": 1, "max": 128, "step": 1}),
|
|
"focus_1_enabled": ("BOOLEAN", {"default": False}),
|
|
"focus_1_x": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"focus_1_y": ("FLOAT", {"default": 0.08, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"focus_1_w": ("FLOAT", {"default": 0.30, "min": 0.01, "max": 1.0, "step": 0.01}),
|
|
"focus_1_h": ("FLOAT", {"default": 0.28, "min": 0.01, "max": 1.0, "step": 0.01}),
|
|
"focus_2_enabled": ("BOOLEAN", {"default": False}),
|
|
"focus_2_x": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"focus_2_y": ("FLOAT", {"default": 0.55, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"focus_2_w": ("FLOAT", {"default": 0.50, "min": 0.01, "max": 1.0, "step": 0.01}),
|
|
"focus_2_h": ("FLOAT", {"default": 0.20, "min": 0.01, "max": 1.0, "step": 0.01}),
|
|
"focus_pad": ("INT", {"default": 192, "min": 0, "max": 1024, "step": 8}),
|
|
"focus_blend": ("INT", {"default": 64, "min": 0, "max": 512, "step": 8}),
|
|
"focus_priority": ("FLOAT", {"default": 110.0, "min": 0.0, "max": 300.0, "step": 1.0}),
|
|
"focus_importance": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.05}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "TTP_SMART_TILE_META", "LIST", "IMAGE")
|
|
RETURN_NAMES = ("tiles", "tile_meta", "positions", "preview")
|
|
FUNCTION = "visual_crop_tiles"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def _add_tile_meta(self, raw_tiles, image_width, image_height):
|
|
tiles_meta = []
|
|
for index, tile in enumerate(raw_tiles):
|
|
normalized = _ttp_normalize_tile_box({**tile, "id": index}, image_width, image_height, {})
|
|
normalized["id"] = index
|
|
tiles_meta.append(normalized)
|
|
return _ttp_add_smart_tile_sample_boxes(tiles_meta, image_width, image_height)
|
|
|
|
def visual_crop_tiles(
|
|
self,
|
|
image,
|
|
grid_columns,
|
|
grid_rows,
|
|
include_full_image,
|
|
grid_pad,
|
|
grid_blend,
|
|
grid_priority,
|
|
grid_importance,
|
|
round_to,
|
|
focus_1_enabled,
|
|
focus_1_x,
|
|
focus_1_y,
|
|
focus_1_w,
|
|
focus_1_h,
|
|
focus_2_enabled,
|
|
focus_2_x,
|
|
focus_2_y,
|
|
focus_2_w,
|
|
focus_2_h,
|
|
focus_pad,
|
|
focus_blend,
|
|
focus_priority,
|
|
focus_importance,
|
|
):
|
|
pil_image = tensor2pil(image[0].unsqueeze(0)).convert("RGB")
|
|
image_width, image_height = pil_image.size
|
|
raw_tiles = []
|
|
|
|
if include_full_image:
|
|
raw_tiles.append({
|
|
"name": "full_image",
|
|
"x": 0.0,
|
|
"y": 0.0,
|
|
"w": 1.0,
|
|
"h": 1.0,
|
|
"pad": 0,
|
|
"blend": max(grid_blend, focus_blend),
|
|
"priority": max(0.0, grid_priority * 0.25),
|
|
"importance": max(0.0, grid_importance * 0.5),
|
|
"layer": 0,
|
|
"occlusion_priority": 0,
|
|
})
|
|
|
|
for row in range(grid_rows):
|
|
for col in range(grid_columns):
|
|
raw_tiles.append({
|
|
"name": f"grid_{row}_{col}",
|
|
"x": col / grid_columns,
|
|
"y": row / grid_rows,
|
|
"w": 1.0 / grid_columns,
|
|
"h": 1.0 / grid_rows,
|
|
"pad": grid_pad,
|
|
"blend": grid_blend,
|
|
"priority": grid_priority,
|
|
"importance": grid_importance,
|
|
"layer": 1,
|
|
"occlusion_priority": 100,
|
|
})
|
|
|
|
focus_tiles = [
|
|
(focus_1_enabled, "focus_1", focus_1_x, focus_1_y, focus_1_w, focus_1_h),
|
|
(focus_2_enabled, "focus_2", focus_2_x, focus_2_y, focus_2_w, focus_2_h),
|
|
]
|
|
for enabled, name, x, y, w, h in focus_tiles:
|
|
if enabled:
|
|
raw_tiles.append({
|
|
"name": name,
|
|
"x": x,
|
|
"y": y,
|
|
"w": w,
|
|
"h": h,
|
|
"pad": focus_pad,
|
|
"blend": focus_blend,
|
|
"priority": focus_priority,
|
|
"importance": focus_importance,
|
|
"layer": 5,
|
|
"occlusion_priority": 3000,
|
|
})
|
|
|
|
tiles_meta = self._add_tile_meta(raw_tiles, image_width, image_height)
|
|
return _ttp_crop_smart_tiles_from_meta(pil_image, tiles_meta, round_to)
|
|
|
|
|
|
class TTP_Smart_Tile_Interactive_Crop_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
default_layout = json.dumps({
|
|
"tiles": [
|
|
{"name": "tile_1", "x0": 0.0, "y0": 0.0, "x1": 0.5, "y1": 0.5},
|
|
{"name": "tile_2", "x0": 0.5, "y0": 0.0, "x1": 1.0, "y1": 0.5},
|
|
{"name": "tile_3", "x0": 0.0, "y0": 0.5, "x1": 0.5, "y1": 1.0},
|
|
{"name": "tile_4", "x0": 0.5, "y0": 0.5, "x1": 1.0, "y1": 1.0},
|
|
]
|
|
}, separators=(",", ":"))
|
|
return {
|
|
"required": {
|
|
"image": (_ttp_input_image_files(), {"image_upload": True}),
|
|
"layout_json": ("STRING", {"default": default_layout, "multiline": False}),
|
|
"default_pad": ("INT", {"default": 128, "min": 0, "max": 2048, "step": 8}),
|
|
"default_blend": ("INT", {"default": 64, "min": 0, "max": 1024, "step": 8}),
|
|
"include_full_image": ("BOOLEAN", {"default": False}),
|
|
"round_to": ("INT", {"default": 8, "min": 1, "max": 128, "step": 1}),
|
|
"auto_detect_mode": (["none", "sam3.1", "qwenvl3"], {"default": "none"}),
|
|
"auto_detect_request": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
|
|
"auto_prompt": ("STRING", {
|
|
"default": "person, face, hands, eyes, text, foreground object, important object",
|
|
"multiline": False,
|
|
}),
|
|
"allow_object_overlap": ("BOOLEAN", {"default": True}),
|
|
"auto_object_padding": ("INT", {"default": 96, "min": 0, "max": 2048, "step": 8}),
|
|
"auto_max_tiles": ("INT", {"default": 16, "min": 1, "max": 64, "step": 1}),
|
|
"auto_paint_mask": ("STRING", {"default": "", "multiline": False}),
|
|
},
|
|
"optional": {
|
|
"source_image": ("IMAGE",),
|
|
"vision_model": ("MODEL",),
|
|
"vision_conditioning": ("CONDITIONING",),
|
|
"clip": ("CLIP",),
|
|
"qwen_vl_model": ("TTP_QWENVL3_MODEL",),
|
|
},
|
|
"hidden": {
|
|
"unique_id": "UNIQUE_ID",
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "IMAGE", "TTP_SMART_TILE_SET", "TTP_SMART_TILE_META", "LIST", "IMAGE", "STRING")
|
|
RETURN_NAMES = ("source_image", "tiles", "tile_set", "tile_meta", "positions", "preview", "layout_json")
|
|
FUNCTION = "interactive_crop_tiles"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
image = str(kwargs.get("image", ""))
|
|
fingerprint = {
|
|
"image": image,
|
|
"layout_json": kwargs.get("layout_json", ""),
|
|
"default_pad": kwargs.get("default_pad"),
|
|
"default_blend": kwargs.get("default_blend"),
|
|
"include_full_image": kwargs.get("include_full_image"),
|
|
"round_to": kwargs.get("round_to"),
|
|
"auto_detect_mode": kwargs.get("auto_detect_mode", "none"),
|
|
"auto_detect_request": kwargs.get("auto_detect_request", 0),
|
|
"auto_prompt": kwargs.get("auto_prompt", ""),
|
|
"allow_object_overlap": kwargs.get("allow_object_overlap", True),
|
|
"auto_object_padding": kwargs.get("auto_object_padding", 96),
|
|
"auto_max_tiles": kwargs.get("auto_max_tiles", 16),
|
|
"auto_paint_mask": hashlib.sha256(str(kwargs.get("auto_paint_mask", "") or "").encode("utf-8")).hexdigest()[:16],
|
|
"source_image": type(kwargs.get("source_image")).__name__ if kwargs.get("source_image") is not None else None,
|
|
"vision_model": type(kwargs.get("vision_model")).__name__ if kwargs.get("vision_model") is not None else None,
|
|
"vision_conditioning": type(kwargs.get("vision_conditioning")).__name__ if kwargs.get("vision_conditioning") is not None else None,
|
|
"clip": type(kwargs.get("clip")).__name__ if kwargs.get("clip") is not None else None,
|
|
"qwen_vl_model": type(kwargs.get("qwen_vl_model")).__name__ if kwargs.get("qwen_vl_model") is not None else None,
|
|
}
|
|
if image and folder_paths.exists_annotated_filepath(image):
|
|
try:
|
|
fingerprint["image_mtime"] = os.path.getmtime(folder_paths.get_annotated_filepath(image))
|
|
except OSError:
|
|
pass
|
|
return json.dumps(fingerprint, sort_keys=True)
|
|
|
|
@classmethod
|
|
def VALIDATE_INPUTS(cls, image="", **kwargs):
|
|
if image and not folder_paths.exists_annotated_filepath(image):
|
|
return f"Invalid image file: {image}"
|
|
return True
|
|
|
|
def interactive_crop_tiles(
|
|
self,
|
|
image,
|
|
layout_json,
|
|
default_pad=128,
|
|
default_blend=64,
|
|
include_full_image=False,
|
|
round_to=8,
|
|
auto_detect_mode="none",
|
|
auto_detect_request=0,
|
|
auto_prompt="person, face, hands, eyes, text, foreground object, important object",
|
|
allow_object_overlap=True,
|
|
auto_object_padding=96,
|
|
auto_max_tiles=16,
|
|
auto_paint_mask="",
|
|
source_image=None,
|
|
vision_model=None,
|
|
vision_conditioning=None,
|
|
clip=None,
|
|
qwen_vl_model=None,
|
|
unique_id=None,
|
|
):
|
|
pil_image = _ttp_get_interactive_source_image(source_image=source_image, image=image)
|
|
image_width, image_height = pil_image.size
|
|
mode = str(auto_detect_mode or "none").strip().lower()
|
|
has_paint_mask = bool(str(auto_paint_mask or "").strip())
|
|
if int(auto_detect_request or 0) > 0 and (mode != "none" or has_paint_mask):
|
|
try:
|
|
if mode == "sam3.1":
|
|
layout_json, message = _ttp_run_sam3_auto_layout(
|
|
pil_image,
|
|
vision_model,
|
|
vision_conditioning,
|
|
clip,
|
|
str(auto_prompt or ""),
|
|
int(default_pad),
|
|
int(default_blend),
|
|
int(auto_object_padding),
|
|
int(auto_max_tiles),
|
|
bool(allow_object_overlap),
|
|
auto_paint_mask,
|
|
)
|
|
_ttp_send_smart_tile_layout_event(unique_id, layout_json, message, ok=True)
|
|
elif mode == "qwenvl3":
|
|
layout_json, message = _ttp_run_qwenvl3_auto_layout(
|
|
pil_image,
|
|
qwen_vl_model,
|
|
str(auto_prompt or ""),
|
|
int(default_pad),
|
|
int(default_blend),
|
|
int(auto_object_padding),
|
|
int(auto_max_tiles),
|
|
bool(allow_object_overlap),
|
|
auto_paint_mask,
|
|
)
|
|
_ttp_send_smart_tile_layout_event(unique_id, layout_json, message, ok=True)
|
|
elif has_paint_mask:
|
|
layout_json, message = _ttp_run_paint_mask_auto_layout(
|
|
pil_image,
|
|
auto_paint_mask,
|
|
int(default_pad),
|
|
int(default_blend),
|
|
int(auto_object_padding),
|
|
int(auto_max_tiles),
|
|
bool(allow_object_overlap),
|
|
)
|
|
_ttp_send_smart_tile_layout_event(unique_id, layout_json, message, ok=True)
|
|
else:
|
|
_ttp_send_smart_tile_layout_event(unique_id, None, f"Unsupported auto detect mode: {mode}", ok=False)
|
|
except Exception as exc:
|
|
_ttp_send_smart_tile_layout_event(unique_id, None, str(exc), ok=False)
|
|
|
|
normalized_layout_json = _ttp_interactive_layout_with_defaults(
|
|
layout_json,
|
|
int(default_pad),
|
|
int(default_blend),
|
|
bool(include_full_image),
|
|
)
|
|
tiles_meta = _ttp_parse_smart_tile_layout(normalized_layout_json, image_width, image_height)
|
|
tile_set = _ttp_crop_smart_tile_set_from_meta(pil_image, [dict(tile) for tile in tiles_meta], int(round_to))
|
|
tiles, tile_meta, positions, preview = _ttp_crop_smart_tiles_from_meta(pil_image, tiles_meta, int(round_to))
|
|
return (
|
|
pil2tensor(pil_image),
|
|
tiles,
|
|
tile_set,
|
|
tile_meta,
|
|
positions,
|
|
preview,
|
|
normalized_layout_json,
|
|
)
|
|
|
|
|
|
class TTP_Smart_Tile_Set_Preview_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"tile_set": ("TTP_SMART_TILE_SET", {"forceInput": True}),
|
|
"mode": (["contact_sheet", "selected_tile"], {"default": "contact_sheet"}),
|
|
"selected_index": ("INT", {"default": 0, "min": 0, "max": 63, "step": 1}),
|
|
"thumbnail_size": ("INT", {"default": 256, "min": 64, "max": 1024, "step": 16}),
|
|
"columns": ("INT", {"default": 4, "min": 1, "max": 8, "step": 1}),
|
|
"show_labels": ("BOOLEAN", {"default": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("image", "info")
|
|
FUNCTION = "preview_tile_set"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def preview_tile_set(
|
|
self,
|
|
tile_set,
|
|
mode="contact_sheet",
|
|
selected_index=0,
|
|
thumbnail_size=256,
|
|
columns=4,
|
|
show_labels=True,
|
|
):
|
|
if not isinstance(tile_set, dict) or tile_set.get("type") != "ttp_smart_tile_set":
|
|
raise ValueError("tile_set must come from TTP Smart Tile Interactive Crop")
|
|
tile_images = tile_set.get("tile_images", [])
|
|
tile_meta = tile_set.get("tile_meta", {})
|
|
tiles_info = tile_meta.get("tiles", [])
|
|
if not tile_images:
|
|
raise ValueError("tile_set contains no tile images.")
|
|
if len(tile_images) != len(tiles_info):
|
|
raise ValueError(f"tile_set images ({len(tile_images)}) do not match tile metadata ({len(tiles_info)}).")
|
|
|
|
index = _ttp_clamp(int(selected_index), 0, len(tile_images) - 1)
|
|
thumbs = []
|
|
info_lines = []
|
|
for tile_index, image_tensor in enumerate(tile_images):
|
|
tile = tiles_info[tile_index]
|
|
pil = _ttp_image_tensor_to_pil(image_tensor)
|
|
label = str(tile.get("label", tile.get("name", f"tile_{tile_index + 1}")))
|
|
layer = int(tile.get("layer", 0))
|
|
priority = float(tile.get("occlusion_priority", tile.get("priority", 0)))
|
|
info_lines.append(f"{tile_index}: {label} {pil.width}x{pil.height} layer={layer} priority={priority:g}")
|
|
thumbs.append((pil, label, layer, priority))
|
|
|
|
if mode == "selected_tile":
|
|
pil, label, layer, priority = thumbs[index]
|
|
preview = pil.copy().convert("RGB")
|
|
if show_labels:
|
|
draw = ImageDraw.Draw(preview)
|
|
text = f"{index}: {label} {preview.width}x{preview.height} L{layer} P{priority:g}"
|
|
draw.rectangle((0, 0, min(preview.width, 12 + len(text) * 7), 24), fill=(0, 0, 0))
|
|
draw.text((6, 5), text, fill=(255, 255, 255))
|
|
return (pil2tensor(preview), "\n".join(info_lines))
|
|
|
|
thumb_size = int(thumbnail_size)
|
|
columns = max(1, int(columns))
|
|
rows = int(np.ceil(len(thumbs) / columns))
|
|
label_height = 30 if show_labels else 0
|
|
gap = 8
|
|
sheet_width = columns * thumb_size + (columns + 1) * gap
|
|
sheet_height = rows * (thumb_size + label_height) + (rows + 1) * gap
|
|
sheet = Image.new("RGB", (sheet_width, sheet_height), (24, 24, 27))
|
|
draw = ImageDraw.Draw(sheet)
|
|
|
|
for tile_index, (pil, label, layer, priority) in enumerate(thumbs):
|
|
col = tile_index % columns
|
|
row = tile_index // columns
|
|
x = gap + col * (thumb_size + gap)
|
|
y = gap + row * (thumb_size + label_height + gap)
|
|
tile_preview = pil.copy().convert("RGB")
|
|
tile_preview.thumbnail((thumb_size, thumb_size), Image.Resampling.LANCZOS)
|
|
box = Image.new("RGB", (thumb_size, thumb_size), (39, 39, 42))
|
|
offset = ((thumb_size - tile_preview.width) // 2, (thumb_size - tile_preview.height) // 2)
|
|
box.paste(tile_preview, offset)
|
|
sheet.paste(box, (x, y))
|
|
draw.rectangle((x, y, x + thumb_size - 1, y + thumb_size - 1), outline=(148, 163, 184), width=1)
|
|
if show_labels:
|
|
text = f"{tile_index}: {pil.width}x{pil.height} L{layer} P{priority:g}"
|
|
draw.text((x + 4, y + thumb_size + 7), text, fill=(226, 232, 240))
|
|
|
|
return (pil2tensor(sheet), "\n".join(info_lines))
|
|
|
|
|
|
class TTP_QwenVL3_Local_Loader_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model_file": (_ttp_qwenvl_safetensor_files(),),
|
|
"model_family": (["auto", "qwen_vl"], {"default": "auto"}),
|
|
"device": (["default", "cpu"], {"default": "default", "advanced": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("TTP_QWENVL3_MODEL", "STRING")
|
|
RETURN_NAMES = ("qwen_vl_model", "info")
|
|
FUNCTION = "load_model"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def load_model(self, model_file, model_family="auto", device="default"):
|
|
loaded = _ttp_load_qwenvl3_local_model(
|
|
model_file,
|
|
model_family=str(model_family),
|
|
device_mode=str(device),
|
|
)
|
|
info = f"Loaded QwenVL tagging model from {loaded['model_file']} using ComfyUI native Qwen-VL loader"
|
|
return (loaded, info)
|
|
|
|
|
|
class TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"reference_image_mode": (["none", "first_message", "every_tile", "contact_sheet"], {"default": "contact_sheet"}),
|
|
"system_prompt": ("STRING", {
|
|
"default": "",
|
|
"multiline": True,
|
|
"dynamicPrompts": False,
|
|
}),
|
|
"tile_instruction": ("STRING", {
|
|
"default": "",
|
|
"multiline": True,
|
|
"dynamicPrompts": False,
|
|
}),
|
|
"global_prompt": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False}),
|
|
"prompt_merge_mode": (["caption_only", "global_plus_caption", "global_plus_label_plus_caption"], {"default": "global_plus_label_plus_caption"}),
|
|
"output_language": (["english", "chinese", "bilingual"], {"default": "english"}),
|
|
"max_new_tokens": ("INT", {"default": 512, "min": 32, "max": 4096, "step": 16}),
|
|
"temperature": ("FLOAT", {"default": 0.2, "min": 0.0, "max": 2.0, "step": 0.05}),
|
|
"prompt_preset": (list(_TTP_QWENVL_PRESETS.keys()), {"default": "tile_img2img_prompt"}),
|
|
"qwen_max_side": ("INT", {"default": 768, "min": 0, "max": 4096, "step": 32}),
|
|
"qwen_max_pixels": ("INT", {"default": 786432, "min": 0, "max": 16777216, "step": 65536}),
|
|
"use_tile_cache": ("BOOLEAN", {"default": True}),
|
|
"global_negative": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False}),
|
|
"qwen_seed": ("INT", {"default": 123, "min": 0, "max": 2147483647, "step": 1}),
|
|
},
|
|
"optional": {
|
|
"tile_set": ("TTP_SMART_TILE_SET", {"forceInput": True}),
|
|
"reference_image": ("IMAGE",),
|
|
"qwen_vl_model": ("TTP_QWENVL3_MODEL",),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("TTP_SMART_TILE_SET", "STRING", "STRING")
|
|
RETURN_NAMES = ("tile_set", "prompt_set_json", "summary")
|
|
FUNCTION = "build_prompt_set"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def build_prompt_set(
|
|
self,
|
|
tile_set=None,
|
|
reference_image_mode="first_message",
|
|
system_prompt="",
|
|
tile_instruction="",
|
|
global_prompt="",
|
|
prompt_merge_mode="global_plus_label_plus_caption",
|
|
output_language="english",
|
|
max_new_tokens=512,
|
|
temperature=0.2,
|
|
prompt_preset="tile_img2img_prompt",
|
|
qwen_max_side=768,
|
|
qwen_max_pixels=786432,
|
|
use_tile_cache=True,
|
|
global_negative="",
|
|
qwen_seed=123,
|
|
reference_image=None,
|
|
qwen_vl_model=None,
|
|
**kwargs,
|
|
):
|
|
requested_mode = str(kwargs.get("mode", "") or "").strip() if kwargs else ""
|
|
if kwargs:
|
|
reference_image_mode = kwargs.get("reference_image_mode", reference_image_mode)
|
|
prompt_preset = kwargs.get("prompt_preset", prompt_preset)
|
|
system_prompt = kwargs.get("system_prompt", system_prompt)
|
|
tile_instruction = kwargs.get("tile_instruction", tile_instruction)
|
|
global_prompt = kwargs.get("global_prompt", global_prompt)
|
|
global_negative = kwargs.get("global_negative", global_negative)
|
|
prompt_merge_mode = kwargs.get("prompt_merge_mode", prompt_merge_mode)
|
|
output_language = kwargs.get("output_language", output_language)
|
|
max_new_tokens = kwargs.get("max_new_tokens", max_new_tokens)
|
|
temperature = kwargs.get("temperature", temperature)
|
|
qwen_max_side = kwargs.get("qwen_max_side", qwen_max_side)
|
|
qwen_max_pixels = kwargs.get("qwen_max_pixels", qwen_max_pixels)
|
|
use_tile_cache = kwargs.get("use_tile_cache", use_tile_cache)
|
|
reference_image = kwargs.get("reference_image", reference_image)
|
|
qwen_vl_model = kwargs.get("qwen_vl_model", qwen_vl_model)
|
|
qwen_seed = kwargs.get("qwen_seed", kwargs.get("seed", qwen_seed))
|
|
|
|
preset_names = set(_TTP_QWENVL_PRESETS.keys())
|
|
merge_modes = {"caption_only", "global_plus_caption", "global_plus_label_plus_caption"}
|
|
languages = {"english", "chinese", "bilingual"}
|
|
if str(system_prompt or "") in preset_names and str(prompt_preset or "") not in preset_names:
|
|
shifted_prompt_preset = system_prompt
|
|
shifted_system_prompt = tile_instruction
|
|
shifted_tile_instruction = global_prompt
|
|
shifted_global_prompt = global_negative
|
|
shifted_global_negative = prompt_merge_mode
|
|
shifted_prompt_merge_mode = output_language
|
|
shifted_output_language = max_new_tokens
|
|
shifted_max_new_tokens = temperature
|
|
shifted_temperature = prompt_preset
|
|
prompt_preset = shifted_prompt_preset
|
|
system_prompt = shifted_system_prompt
|
|
tile_instruction = shifted_tile_instruction
|
|
global_prompt = shifted_global_prompt
|
|
global_negative = shifted_global_negative
|
|
prompt_merge_mode = shifted_prompt_merge_mode
|
|
output_language = shifted_output_language
|
|
max_new_tokens = shifted_max_new_tokens
|
|
temperature = shifted_temperature
|
|
|
|
if str(reference_image_mode or "") not in ("none", "first_message", "every_tile", "contact_sheet"):
|
|
reference_image_mode = "contact_sheet"
|
|
if str(prompt_preset or "") not in preset_names:
|
|
prompt_preset = "tile_img2img_prompt"
|
|
if str(prompt_merge_mode or "") not in merge_modes:
|
|
prompt_merge_mode = "global_plus_label_plus_caption"
|
|
if str(output_language or "") not in languages:
|
|
output_language = "english"
|
|
max_new_tokens = _ttp_safe_int(max_new_tokens, 512, 32, 4096)
|
|
temperature = _ttp_safe_float(temperature, 0.2, 0.0, 2.0)
|
|
qwen_max_side = _ttp_safe_int(qwen_max_side, 768, 0, 4096)
|
|
qwen_max_pixels = _ttp_safe_int(qwen_max_pixels, 786432, 0, 16777216)
|
|
use_tile_cache = _ttp_safe_bool(use_tile_cache, True)
|
|
qwen_seed = _ttp_safe_int(qwen_seed, 123, 0, 2147483647)
|
|
|
|
if isinstance(reference_image, dict) and reference_image.get("type") == "ttp_qwenvl3_model" and qwen_vl_model is None:
|
|
qwen_vl_model = reference_image
|
|
reference_image = None
|
|
if isinstance(qwen_seed, dict) and qwen_seed.get("type") == "ttp_qwenvl3_model" and qwen_vl_model is None:
|
|
qwen_vl_model = qwen_seed
|
|
qwen_seed = 0
|
|
tile_images, _tile_meta, tiles_info = _ttp_validate_tile_set(tile_set)
|
|
next_tile_set = _ttp_clone_tile_set(tile_set)
|
|
next_tiles = next_tile_set["tile_meta"]["tiles"]
|
|
preset = _TTP_QWENVL_PRESETS.get(str(prompt_preset or ""), _TTP_QWENVL_PRESETS["tile_img2img_prompt"])
|
|
effective_system_prompt = str(system_prompt or "").strip() or preset["system"]
|
|
effective_tile_instruction = str(tile_instruction or "").strip() or preset["instruction"]
|
|
reference_context = ""
|
|
reference_pil = None
|
|
if reference_image is not None:
|
|
reference_pil = _ttp_image_tensor_to_pil(_ttp_first_image_tensor(reference_image))
|
|
reference_pil = _ttp_resize_pil_for_qwen(reference_pil, qwen_max_side, qwen_max_pixels)
|
|
|
|
effective_mode = "qwen_vl_local" if qwen_vl_model is not None else "template"
|
|
|
|
if effective_mode == "qwen_vl_local" and qwen_vl_model is None:
|
|
raise ValueError("qwen_vl_local mode requires TTP QwenVL3 Local Loader output.")
|
|
|
|
def chat_fn(messages, retry=False):
|
|
chat_temperature = 0.0 if retry else float(temperature)
|
|
if effective_mode == "qwen_vl_local":
|
|
return _ttp_qwen_vl_local_chat(
|
|
qwen_vl_model,
|
|
messages,
|
|
max_new_tokens=max_new_tokens,
|
|
temperature=chat_temperature,
|
|
seed=qwen_seed,
|
|
)
|
|
return ""
|
|
|
|
contact_sheet_pil = None
|
|
if effective_mode == "qwen_vl_local" and str(reference_image_mode) == "contact_sheet":
|
|
contact_images = []
|
|
contact_labels = []
|
|
for index, (image_tensor, tile) in enumerate(zip(tile_images, next_tiles)):
|
|
contact_images.append(_ttp_resize_pil_for_qwen(_ttp_image_tensor_to_pil(image_tensor), 256, 0))
|
|
contact_labels.append(f'{index}: {tile.get("label", tile.get("name", "tile"))}')
|
|
contact_sheet_pil = _ttp_resize_pil_for_qwen(_ttp_make_contact_sheet(contact_images, contact_labels), qwen_max_side, qwen_max_pixels)
|
|
|
|
if effective_mode == "qwen_vl_local" and reference_pil is not None and reference_image_mode == "first_message":
|
|
messages = [
|
|
{"role": "system", "content": _ttp_compact_text(effective_system_prompt, 900)},
|
|
{"role": "user", "content": [
|
|
{"type": "text", "text": "Analyze this reference image for identity, style, lighting, material, camera perspective, and global visual consistency. Return one concise paragraph under 80 words. Do not output JSON."},
|
|
{"type": "image_url", "image_url": {"url": _ttp_pil_to_data_url(reference_pil)}},
|
|
]},
|
|
]
|
|
reference_context = _ttp_compact_text(chat_fn(messages), 900)
|
|
elif effective_mode == "qwen_vl_local" and contact_sheet_pil is not None:
|
|
messages = [
|
|
{"role": "system", "content": _ttp_compact_text(effective_system_prompt, 900)},
|
|
{"role": "user", "content": [
|
|
{"type": "text", "text": "This is a contact sheet of all Smart Tile crops. Each crop is labeled by tile index. Summarize the global subject, style, lighting, identity, and how tile indices relate to the whole image. Return one concise paragraph under 100 words. Do not output JSON."},
|
|
{"type": "image_url", "image_url": {"url": _ttp_pil_to_data_url(contact_sheet_pil)}},
|
|
]},
|
|
]
|
|
reference_context = _ttp_compact_text(chat_fn(messages), 1000)
|
|
|
|
prompt_records = []
|
|
for index, (image_tensor, tile) in enumerate(zip(tile_images, next_tiles)):
|
|
raw = ""
|
|
retry_raw = ""
|
|
if effective_mode == "template":
|
|
record = _ttp_template_prompt_for_tile(tile, global_prompt, global_negative, prompt_merge_mode)
|
|
elif effective_mode == "qwen_vl_local":
|
|
source_tile_hash = _ttp_tile_hash(image_tensor)
|
|
tile_pil = _ttp_resize_pil_for_qwen(_ttp_image_tensor_to_pil(image_tensor), qwen_max_side, qwen_max_pixels)
|
|
language_hint = {
|
|
"english": "Write prompt fields in English.",
|
|
"chinese": "Write prompt fields in Chinese.",
|
|
"bilingual": "Write prompt fields in English and Chinese.",
|
|
}.get(str(output_language), "Write prompt fields in English.")
|
|
user_text = "\n".join([
|
|
str(effective_tile_instruction or ""),
|
|
language_hint,
|
|
"Return one compact JSON object only. No markdown, no explanation, no repeated instruction. Stop immediately after the closing brace.",
|
|
"Keep caption under 45 words, prompt under 75 words, negative under 30 words.",
|
|
f"Tile index: {index}. Existing label: {tile.get('label', tile.get('name', 'tile'))}.",
|
|
f"Global prompt: {_ttp_compact_text(global_prompt, 900)}",
|
|
f"Global negative: {_ttp_compact_text(global_negative, 600)}",
|
|
f"Reference context: {_ttp_compact_text(reference_context, 700)}",
|
|
])
|
|
content = [{"type": "text", "text": user_text}]
|
|
if reference_pil is not None and reference_image_mode == "every_tile":
|
|
content.append({"type": "image_url", "image_url": {"url": _ttp_pil_to_data_url(reference_pil)}})
|
|
elif contact_sheet_pil is not None:
|
|
content.append({"type": "image_url", "image_url": {"url": _ttp_pil_to_data_url(contact_sheet_pil)}})
|
|
content.append({"type": "image_url", "image_url": {"url": _ttp_pil_to_data_url(tile_pil)}})
|
|
messages = [
|
|
{"role": "system", "content": _ttp_compact_text(effective_system_prompt, 1200)},
|
|
{"role": "user", "content": content},
|
|
]
|
|
cache_key = json.dumps({
|
|
"model_file": qwen_vl_model.get("model_file", "") if isinstance(qwen_vl_model, dict) else "",
|
|
"tile_index": int(index),
|
|
"tile_name": tile.get("name", ""),
|
|
"sample_box": tile.get("sample_box", []),
|
|
"core_box": tile.get("core_box", []),
|
|
"tile_hash": source_tile_hash,
|
|
"system_prompt": effective_system_prompt,
|
|
"tile_instruction": effective_tile_instruction,
|
|
"global_prompt": global_prompt,
|
|
"global_negative": global_negative,
|
|
"reference_context": reference_context,
|
|
"reference_mode": reference_image_mode,
|
|
"prompt_merge_mode": prompt_merge_mode,
|
|
"output_language": output_language,
|
|
"qwen_seed": int(qwen_seed if qwen_seed is not None else 0),
|
|
"max_side": int(qwen_max_side or 0),
|
|
"max_pixels": int(qwen_max_pixels or 0),
|
|
}, sort_keys=True, ensure_ascii=False)
|
|
cached = _TTP_QWENVL_PROMPT_CACHE.get(cache_key) if bool(use_tile_cache) else None
|
|
if cached:
|
|
raw = cached.get("raw", "")
|
|
retry_raw = cached.get("retry_raw", "")
|
|
parsed = dict(cached.get("parsed", {}))
|
|
else:
|
|
raw = chat_fn(messages)
|
|
parsed = None
|
|
if not str(raw or "").strip():
|
|
raise RuntimeError(f"QwenVL returned an empty response for tile {index}.")
|
|
if parsed is None:
|
|
try:
|
|
parsed = _ttp_parse_qwen_tile_record(raw, index)
|
|
except RuntimeError as first_exc:
|
|
retry_messages = _ttp_qwen_tile_retry_messages(
|
|
effective_system_prompt,
|
|
tile_pil,
|
|
index,
|
|
tile,
|
|
output_language,
|
|
global_prompt=global_prompt,
|
|
global_negative=global_negative,
|
|
reference_context=reference_context,
|
|
)
|
|
retry_raw = chat_fn(retry_messages, retry=True)
|
|
if not str(retry_raw or "").strip():
|
|
raise RuntimeError(f"QwenVL retry returned an empty response for tile {index}. First error: {first_exc}") from first_exc
|
|
try:
|
|
parsed = _ttp_parse_qwen_tile_record(retry_raw, index)
|
|
except RuntimeError as retry_exc:
|
|
first_snippet = _ttp_compact_text(str(raw or "").replace("\n", " "), 240)
|
|
retry_snippet = _ttp_compact_text(str(retry_raw or "").replace("\n", " "), 240)
|
|
raise RuntimeError(
|
|
f"QwenVL failed to produce usable JSON for tile {index} after retry. "
|
|
f"First error: {first_exc} First raw: {first_snippet} Retry raw: {retry_snippet}"
|
|
) from retry_exc
|
|
if bool(use_tile_cache):
|
|
_TTP_QWENVL_PROMPT_CACHE[cache_key] = {"raw": raw, "retry_raw": retry_raw, "parsed": dict(parsed)}
|
|
caption = parsed.get("caption", "")
|
|
prompt = parsed.get("prompt", "")
|
|
negative = parsed.get("negative", global_negative) or global_negative
|
|
label = parsed.get("label", tile.get("label", tile.get("name", "tile"))) or tile.get("label", tile.get("name", "tile"))
|
|
if not prompt:
|
|
prompt, negative = _ttp_compose_tile_prompt({**tile, "label": label}, caption, global_prompt, negative, prompt_merge_mode)
|
|
record = {
|
|
"label": str(label),
|
|
"caption": str(caption),
|
|
"prompt": str(prompt),
|
|
"negative": str(negative),
|
|
}
|
|
else:
|
|
raise ValueError(f"Unsupported prompt set builder mode: {effective_mode}")
|
|
|
|
tile.update({
|
|
"label": record["label"],
|
|
"caption": record["caption"],
|
|
"prompt": record["prompt"],
|
|
"negative": record["negative"],
|
|
"prompt_tag": tile.get("prompt_tag", f"tile_{index}_{record['label']}"),
|
|
"prompt_source": effective_mode,
|
|
"prompt_preset": str(prompt_preset or ""),
|
|
"qwen_model": str(qwen_vl_model.get("model_file", "")) if effective_mode == "qwen_vl_local" and isinstance(qwen_vl_model, dict) else "",
|
|
"tile_hash": _ttp_tile_hash(image_tensor),
|
|
"qwen_input_size": [tile_pil.width, tile_pil.height] if effective_mode == "qwen_vl_local" else [],
|
|
"qwen_cache": "hit" if effective_mode == "qwen_vl_local" and cached else "miss" if effective_mode == "qwen_vl_local" else "",
|
|
})
|
|
if raw:
|
|
tile["qwen_raw"] = raw
|
|
if retry_raw:
|
|
tile["qwen_raw_retry"] = retry_raw
|
|
prompt_records.append({
|
|
"index": index,
|
|
"name": tile.get("name", f"tile_{index}"),
|
|
"label": tile["label"],
|
|
"caption": tile["caption"],
|
|
"prompt": tile["prompt"],
|
|
"negative": tile["negative"],
|
|
"prompt_tag": tile["prompt_tag"],
|
|
})
|
|
|
|
prompt_set_json = json.dumps({
|
|
"type": "ttp_smart_tile_prompt_set",
|
|
"mode": effective_mode,
|
|
"requested_mode": requested_mode or effective_mode,
|
|
"reference_image_mode": reference_image_mode,
|
|
"prompt_preset": str(prompt_preset or ""),
|
|
"qwen_seed": int(qwen_seed if qwen_seed is not None else 0),
|
|
"model_file": qwen_vl_model.get("model_file", "") if effective_mode == "qwen_vl_local" and isinstance(qwen_vl_model, dict) else "",
|
|
"qwen_max_side": int(qwen_max_side or 0),
|
|
"qwen_max_pixels": int(qwen_max_pixels or 0),
|
|
"tiles": prompt_records,
|
|
}, ensure_ascii=False, indent=2)
|
|
source_name = qwen_vl_model.get("model_file", "") if effective_mode == "qwen_vl_local" and isinstance(qwen_vl_model, dict) else ""
|
|
summary_lines = [f"mode={effective_mode} model={source_name or 'none'} tiles={len(prompt_records)}"]
|
|
summary_lines.extend(f'{item["index"]}: {item["label"]} -> {item["prompt"]}' for item in prompt_records)
|
|
summary = "\n".join(summary_lines)
|
|
return (next_tile_set, prompt_set_json, summary)
|
|
|
|
|
|
class TTP_Smart_Tile_Loop_Source_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"tile_set": ("TTP_SMART_TILE_SET", {"forceInput": True}),
|
|
"session_id": ("STRING", {"default": "default", "multiline": False}),
|
|
"restart_request": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
|
|
"loop_request": ("INT", {"default": 0, "min": 0, "max": 2147483647, "step": 1}),
|
|
},
|
|
"optional": {
|
|
"clip": ("CLIP",),
|
|
},
|
|
"hidden": {
|
|
"unique_id": "UNIQUE_ID",
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "TTP_SMART_TILE_TASK", "INT", "INT", "BOOLEAN", "STRING", "STRING", "STRING", "STRING", "STRING", "STRING", "CONDITIONING", "CONDITIONING")
|
|
RETURN_NAMES = ("image", "tile_task", "index", "count", "done", "status", "prompt", "negative", "caption", "label", "prompt_tag", "positive_conditioning", "negative_conditioning")
|
|
FUNCTION = "loop_source"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
session_id = str(kwargs.get("session_id", "default") or "default")
|
|
session = _TTP_SMART_TILE_LOOP_SESSIONS.get(session_id, {})
|
|
tile_set = kwargs.get("tile_set")
|
|
fingerprint = _ttp_tile_set_fingerprint(tile_set) if isinstance(tile_set, dict) else None
|
|
return json.dumps({
|
|
"session_id": session_id,
|
|
"restart_request": kwargs.get("restart_request", 0),
|
|
"loop_request": kwargs.get("loop_request", 0),
|
|
"fingerprint": fingerprint,
|
|
"index": session.get("index", 0),
|
|
"done": session.get("done", False),
|
|
"clip": type(kwargs.get("clip")).__name__ if kwargs.get("clip") is not None else None,
|
|
}, sort_keys=True)
|
|
|
|
def loop_source(self, tile_set, session_id="default", restart_request=0, loop_request=0, clip=None, unique_id=None):
|
|
tile_images, tile_meta, tiles_info = _ttp_validate_tile_set(tile_set)
|
|
session_key = str(session_id or "default")
|
|
fingerprint = _ttp_tile_set_fingerprint(tile_set)
|
|
session = _TTP_SMART_TILE_LOOP_SESSIONS.get(session_key)
|
|
should_restart = (
|
|
session is None
|
|
or session.get("fingerprint") != fingerprint
|
|
or int(session.get("restart_request", -1)) != int(restart_request)
|
|
)
|
|
if should_restart:
|
|
session = {
|
|
"fingerprint": fingerprint,
|
|
"restart_request": int(restart_request),
|
|
"source_node_id": str(unique_id or ""),
|
|
"source_tile_set": _ttp_clone_tile_set(tile_set),
|
|
"processed_tile_set": _ttp_clone_tile_set(tile_set),
|
|
"index": 0,
|
|
"done": False,
|
|
}
|
|
_TTP_SMART_TILE_LOOP_SESSIONS[session_key] = session
|
|
|
|
count = len(tile_images)
|
|
index = _ttp_clamp(int(session.get("index", 0)), 0, max(0, count - 1))
|
|
done = bool(session.get("done", False)) or count <= 0
|
|
if done:
|
|
index = max(0, count - 1)
|
|
|
|
task = {
|
|
"type": "ttp_smart_tile_task",
|
|
"session_id": session_key,
|
|
"source_node_id": str(unique_id or session.get("source_node_id", "")),
|
|
"index": int(index),
|
|
"count": int(count),
|
|
"done": bool(done),
|
|
"tile_meta": dict(tiles_info[index]) if count else {},
|
|
}
|
|
image = tile_images[index] if count else pil2tensor(Image.new("RGB", (1, 1), "black"))
|
|
status = "done" if done else f"tile {index + 1}/{count}"
|
|
current_tile = tiles_info[index] if count else {}
|
|
prompt = str(current_tile.get("prompt", ""))
|
|
negative = str(current_tile.get("negative", ""))
|
|
return (
|
|
image,
|
|
task,
|
|
int(index),
|
|
int(count),
|
|
bool(done),
|
|
status,
|
|
prompt,
|
|
negative,
|
|
str(current_tile.get("caption", "")),
|
|
str(current_tile.get("label", current_tile.get("name", ""))),
|
|
str(current_tile.get("prompt_tag", "")),
|
|
_ttp_encode_text_conditioning(clip, prompt),
|
|
_ttp_encode_text_conditioning(clip, negative),
|
|
)
|
|
|
|
|
|
class TTP_Smart_Tile_Loop_Collect_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"tile_task": ("TTP_SMART_TILE_TASK", {"forceInput": True}),
|
|
"processed_image": ("IMAGE",),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("TTP_SMART_TILE_SET", "BOOLEAN", "INT", "STRING")
|
|
RETURN_NAMES = ("tile_set", "done", "next_index", "status")
|
|
FUNCTION = "loop_collect"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
@classmethod
|
|
def IS_CHANGED(cls, **kwargs):
|
|
task = kwargs.get("tile_task", {})
|
|
session_id = task.get("session_id", "") if isinstance(task, dict) else ""
|
|
session = _TTP_SMART_TILE_LOOP_SESSIONS.get(session_id, {})
|
|
return json.dumps({
|
|
"session_id": session_id,
|
|
"task_index": task.get("index") if isinstance(task, dict) else None,
|
|
"session_index": session.get("index", 0),
|
|
"done": session.get("done", False),
|
|
}, sort_keys=True)
|
|
|
|
def loop_collect(self, tile_task, processed_image):
|
|
if not isinstance(tile_task, dict) or tile_task.get("type") != "ttp_smart_tile_task":
|
|
raise ValueError("tile_task must come from TTP Smart Tile Loop Source")
|
|
session_id = str(tile_task.get("session_id", "default"))
|
|
session = _TTP_SMART_TILE_LOOP_SESSIONS.get(session_id)
|
|
if session is None:
|
|
raise ValueError(f"Smart Tile loop session not found: {session_id}")
|
|
|
|
processed_tile_set = session["processed_tile_set"]
|
|
tile_images, _tile_meta, _tiles_info = _ttp_validate_tile_set(processed_tile_set)
|
|
count = len(tile_images)
|
|
index = _ttp_clamp(int(tile_task.get("index", 0)), 0, max(0, count - 1))
|
|
if not bool(tile_task.get("done", False)) and count > 0:
|
|
tile_images[index] = _ttp_first_image_tensor(processed_image)
|
|
|
|
next_index = index + 1
|
|
done = next_index >= count
|
|
session["index"] = min(next_index, max(0, count - 1))
|
|
session["done"] = bool(done)
|
|
status = "done" if done else f"next tile {next_index + 1}/{count}"
|
|
event_task = dict(tile_task)
|
|
event_task["index"] = min(next_index, max(0, count - 1))
|
|
_ttp_send_smart_tile_loop_event(event_task, done=done, message=status)
|
|
return (_ttp_clone_tile_set(processed_tile_set), bool(done), int(next_index), status)
|
|
|
|
|
|
class TTP_Smart_Tile_Image_Upscale_Prep_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"scale": ("FLOAT", {"default": 2.0, "min": 0.1, "max": 16.0, "step": 0.05}),
|
|
"round_to": ("INT", {"default": 8, "min": 1, "max": 256, "step": 1}),
|
|
"resampling": (["lanczos", "bicubic", "bilinear", "area", "nearest-exact", "nearest"], {"default": "lanczos"}),
|
|
"max_megapixels": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 64.0, "step": 0.05}),
|
|
"use_upscale_model": ("BOOLEAN", {"default": True}),
|
|
},
|
|
"optional": {
|
|
"upscale_model": ("UPSCALE_MODEL",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "STRING")
|
|
RETURN_NAMES = ("image", "info")
|
|
FUNCTION = "upscale_tile"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def upscale_tile(
|
|
self,
|
|
image,
|
|
scale=2.0,
|
|
round_to=8,
|
|
resampling="lanczos",
|
|
max_megapixels=0.0,
|
|
use_upscale_model=True,
|
|
upscale_model=None,
|
|
):
|
|
tile = _ttp_first_image_tensor(image)
|
|
width, height = _ttp_image_tensor_size(tile[0])
|
|
target_width, target_height, capped = _ttp_smart_upscale_target_size(
|
|
width,
|
|
height,
|
|
float(scale),
|
|
int(round_to),
|
|
float(max_megapixels),
|
|
)
|
|
model_used = bool(use_upscale_model and upscale_model is not None)
|
|
if model_used:
|
|
upscaled = _ttp_upscale_image_with_model(upscale_model, tile)
|
|
if _ttp_image_tensor_size(upscaled[0]) != (target_width, target_height):
|
|
upscaled = _ttp_resize_image_tensor(upscaled, target_width, target_height, str(resampling))
|
|
else:
|
|
upscaled = _ttp_resize_image_tensor(tile, target_width, target_height, str(resampling))
|
|
effective_scale_x = target_width / max(1, width)
|
|
effective_scale_y = target_height / max(1, height)
|
|
cap_text = " capped" if capped else ""
|
|
model_text = "model" if model_used else str(resampling)
|
|
info = (
|
|
f"{width}x{height} -> {target_width}x{target_height} "
|
|
f"requested_scale={float(scale):g} effective_scale={effective_scale_x:.4g}x{effective_scale_y:.4g} "
|
|
f"round_to={int(round_to)} max_megapixels={float(max_megapixels):g}{cap_text} method={model_text}"
|
|
)
|
|
return (upscaled, info)
|
|
|
|
|
|
class TTP_Smart_Tile_Assemble_Experimental:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"blend_multiplier": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 4.0, "step": 0.05}),
|
|
"output_scale": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 16.0, "step": 0.05}),
|
|
"use_priority": ("BOOLEAN", {"default": True}),
|
|
"tile_alignment": (["center_crop", "crop_or_pad", "resize", "off"], {"default": "center_crop"}),
|
|
"edge_crop_px": ("INT", {"default": 0, "min": 0, "max": 512, "step": 1}),
|
|
"color_correction": (["off", "local_mean_std", "reinhard_lab", "mkl_lab", "histogram"], {"default": "off"}),
|
|
"color_strength": ("FLOAT", {"default": 0.35, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"mask_blend_mode": (["off", "auto", "mask_only", "mask_feather"], {"default": "mask_feather"}),
|
|
"pixel_alignment": (["off", "mask_edge_match", "edge_match"], {"default": "off"}),
|
|
"pixel_alignment_radius": ("INT", {"default": 8, "min": 0, "max": 64, "step": 1}),
|
|
"pixel_alignment_device": (["auto", "cpu", "gpu"], {"default": "auto"}),
|
|
"large_tile_policy": (["use_if_higher_resolution", "context_only", "always_use"], {"default": "use_if_higher_resolution"}),
|
|
"large_tile_area_threshold": ("FLOAT", {"default": 0.55, "min": 0.05, "max": 1.0, "step": 0.01}),
|
|
"min_tile_scale_ratio": ("FLOAT", {"default": 0.95, "min": 0.25, "max": 2.0, "step": 0.05}),
|
|
"context_tile_weight": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.05}),
|
|
"assemble_device": (["auto", "cpu", "gpu"], {"default": "auto"}),
|
|
"assemble_mode": (["final_only", "always"], {"default": "final_only"}),
|
|
"base_canvas_mode": (["auto", "black", "base_image", "source_image"], {"default": "auto"}),
|
|
"small_tile_on_top": ("BOOLEAN", {"default": False}),
|
|
},
|
|
"optional": {
|
|
"sampled_tiles": ("IMAGE",),
|
|
"tile_meta": ("TTP_SMART_TILE_META",),
|
|
"tile_set": ("TTP_SMART_TILE_SET",),
|
|
"base_image": ("IMAGE",),
|
|
"source_image": ("IMAGE",),
|
|
"color_reference_image": ("IMAGE",),
|
|
"done": ("BOOLEAN", {"forceInput": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "IMAGE")
|
|
RETURN_NAMES = ("image", "weight_preview")
|
|
FUNCTION = "assemble_tiles"
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def assemble_tiles(
|
|
self,
|
|
blend_multiplier=1.0,
|
|
output_scale=0.0,
|
|
use_priority=True,
|
|
tile_alignment="center_crop",
|
|
edge_crop_px=0,
|
|
color_correction="off",
|
|
color_strength=0.35,
|
|
mask_blend_mode="mask_feather",
|
|
pixel_alignment="off",
|
|
pixel_alignment_radius=8,
|
|
pixel_alignment_device="auto",
|
|
large_tile_policy="use_if_higher_resolution",
|
|
large_tile_area_threshold=0.55,
|
|
min_tile_scale_ratio=0.95,
|
|
context_tile_weight=0.25,
|
|
assemble_device="auto",
|
|
assemble_mode="final_only",
|
|
base_canvas_mode="auto",
|
|
small_tile_on_top=False,
|
|
sampled_tiles=None,
|
|
tile_meta=None,
|
|
tile_set=None,
|
|
base_image=None,
|
|
source_image=None,
|
|
color_reference_image=None,
|
|
done=None,
|
|
):
|
|
tile_images = None
|
|
if tile_set is not None:
|
|
if not isinstance(tile_set, dict) or tile_set.get("type") != "ttp_smart_tile_set":
|
|
raise ValueError("tile_set must come from TTP Smart Tile Interactive Crop")
|
|
tile_meta = tile_set.get("tile_meta")
|
|
tile_images = tile_set.get("tile_images", [])
|
|
|
|
if not isinstance(tile_meta, dict) or tile_meta.get("type") != "ttp_smart_tile":
|
|
raise ValueError("tile_meta must come from TTP Smart Tile Crop")
|
|
|
|
tiles_info = tile_meta.get("tiles", [])
|
|
if tile_images is None:
|
|
if sampled_tiles is None:
|
|
raise ValueError("Connect either tile_set, or sampled_tiles plus tile_meta.")
|
|
if len(tiles_info) != sampled_tiles.shape[0]:
|
|
raise ValueError(f"sampled_tiles batch ({sampled_tiles.shape[0]}) does not match tile_meta tiles ({len(tiles_info)})")
|
|
tile_images = [sampled_tiles[index] for index in range(int(sampled_tiles.shape[0]))]
|
|
elif len(tile_images) != len(tiles_info):
|
|
raise ValueError(f"tile_set images ({len(tile_images)}) do not match tile_meta tiles ({len(tiles_info)})")
|
|
|
|
requested_assemble_mode = str(assemble_mode or "final_only")
|
|
effective_assemble_mode = requested_assemble_mode
|
|
if requested_assemble_mode == "always" and str(pixel_alignment) != "off" and done is not None:
|
|
effective_assemble_mode = "final_only"
|
|
|
|
canvas_mode = str(base_canvas_mode or "auto")
|
|
if canvas_mode == "base_image":
|
|
base_source_image = base_image
|
|
elif canvas_mode == "source_image":
|
|
base_source_image = source_image
|
|
elif canvas_mode == "black":
|
|
base_source_image = None
|
|
else:
|
|
canvas_mode = "auto"
|
|
base_source_image = base_image if base_image is not None else source_image
|
|
|
|
if effective_assemble_mode == "final_only" and done is not None and not bool(done):
|
|
return _ttp_assemble_placeholder_output(tile_images, base_image=base_source_image)
|
|
|
|
original_width, original_height = tile_meta["original_size"]
|
|
if output_scale <= 0:
|
|
inferred_scales = []
|
|
scale_source_image = None
|
|
if canvas_mode == "base_image":
|
|
scale_source_image = base_image
|
|
elif canvas_mode == "source_image":
|
|
scale_source_image = source_image
|
|
elif canvas_mode == "auto" and base_image is not None:
|
|
scale_source_image = base_image
|
|
if scale_source_image is not None:
|
|
base_width, base_height = _ttp_image_tensor_size(scale_source_image[0])
|
|
inferred_scales.append(float(base_width) / max(1, original_width))
|
|
inferred_scales.append(float(base_height) / max(1, original_height))
|
|
else:
|
|
for index, tile in enumerate(tiles_info):
|
|
_, _, sw, sh = tile["sample_box"]
|
|
canvas_width, canvas_height = tile.get("tile_canvas_size", [sw, sh])
|
|
canvas_box = tile.get("tile_canvas_box", [0, 0, sw, sh])
|
|
canvas_content_w = max(1, float(canvas_box[2]) - float(canvas_box[0]))
|
|
canvas_content_h = max(1, float(canvas_box[3]) - float(canvas_box[1]))
|
|
tile_width, tile_height = _ttp_image_tensor_size(tile_images[index])
|
|
content_width = float(tile_width) * (canvas_content_w / max(1, canvas_width))
|
|
content_height = float(tile_height) * (canvas_content_h / max(1, canvas_height))
|
|
inferred_scales.append(content_width / max(1, sw))
|
|
inferred_scales.append(content_height / max(1, sh))
|
|
output_scale = float(np.median(inferred_scales)) if inferred_scales else 1.0
|
|
output_scale = max(0.01, output_scale)
|
|
|
|
output_width = max(1, int(round(original_width * output_scale)))
|
|
output_height = max(1, int(round(original_height * output_scale)))
|
|
|
|
assemble_device_mode = str(assemble_device or "auto").lower()
|
|
assemble_torch_device = _ttp_alignment_torch_device(assemble_device_mode)
|
|
assemble_fallback_reason = ""
|
|
if assemble_torch_device is None and assemble_device_mode == "gpu":
|
|
assemble_fallback_reason = "gpu_unavailable"
|
|
use_gpu_assemble = assemble_torch_device is not None
|
|
|
|
if base_source_image is not None:
|
|
base_pil = tensor2pil(base_source_image[0].unsqueeze(0)).convert("RGB")
|
|
if base_pil.size != (output_width, output_height):
|
|
base_pil = base_pil.resize((output_width, output_height), Image.Resampling.LANCZOS)
|
|
base_array = np.array(base_pil).astype(np.float32) / 255.0
|
|
if use_gpu_assemble:
|
|
canvas = torch.as_tensor(base_array, dtype=torch.float32, device=assemble_torch_device)
|
|
weights = torch.ones((output_height, output_width, 1), dtype=torch.float32, device=assemble_torch_device)
|
|
else:
|
|
canvas = base_array
|
|
weights = np.ones((output_height, output_width, 1), dtype=np.float32)
|
|
else:
|
|
if use_gpu_assemble:
|
|
canvas = torch.zeros((output_height, output_width, 3), dtype=torch.float32, device=assemble_torch_device)
|
|
weights = torch.zeros((output_height, output_width, 1), dtype=torch.float32, device=assemble_torch_device)
|
|
else:
|
|
canvas = np.zeros((output_height, output_width, 3), dtype=np.float32)
|
|
weights = np.zeros((output_height, output_width, 1), dtype=np.float32)
|
|
|
|
small_tile_on_top = bool(small_tile_on_top)
|
|
use_occlusion = small_tile_on_top or any(
|
|
float(tile.get("occlusion_priority", 0.0)) != 0.0 or int(tile.get("layer", 0)) != 0
|
|
for tile in tiles_info
|
|
)
|
|
if use_gpu_assemble:
|
|
priority_ranks = torch.full((output_height, output_width, 1), -float("inf"), dtype=torch.float32, device=assemble_torch_device)
|
|
if base_source_image is not None and use_occlusion:
|
|
priority_ranks.fill_(0.0)
|
|
else:
|
|
priority_ranks = np.full((output_height, output_width, 1), -np.inf, dtype=np.float32)
|
|
if base_source_image is not None and use_occlusion:
|
|
priority_ranks[:, :, :] = 0.0
|
|
|
|
color_reference_pil = None
|
|
if color_reference_image is not None:
|
|
color_reference_pil = tensor2pil(color_reference_image[0].unsqueeze(0)).convert("RGB")
|
|
elif source_image is not None:
|
|
color_reference_pil = tensor2pil(source_image[0].unsqueeze(0)).convert("RGB")
|
|
elif base_image is not None:
|
|
color_reference_pil = tensor2pil(base_image[0].unsqueeze(0)).convert("RGB")
|
|
if color_correction != "off" and color_reference_pil is None:
|
|
raise ValueError("color_correction requires source_image, color_reference_image, or base_image as a reference.")
|
|
|
|
target_scale = max(0.01, float(output_scale))
|
|
large_tile_policy = str(large_tile_policy or "use_if_higher_resolution")
|
|
large_tile_area_threshold = float(large_tile_area_threshold)
|
|
min_tile_scale_ratio = max(0.01, float(min_tile_scale_ratio))
|
|
context_tile_weight = _ttp_clamp(float(context_tile_weight), 0.0, 1.0)
|
|
original_area = max(1.0, float(original_width) * float(original_height))
|
|
has_base_canvas = base_source_image is not None
|
|
focus_label_keywords = (
|
|
"face", "head", "eye", "eyes", "eyelash", "eyebrow", "glasses",
|
|
"hand", "hands", "finger", "fingers", "mouth", "lip", "lips",
|
|
"teeth", "nose", "ear", "ears", "text", "letter", "logo",
|
|
"detail", "focus",
|
|
)
|
|
alignment_stats = {
|
|
"gpu": 0,
|
|
"cpu": 0,
|
|
"fallback": 0,
|
|
"aligned": 0,
|
|
"samples": 0,
|
|
"candidates": 0,
|
|
"devices": set(),
|
|
}
|
|
|
|
for index, tile in enumerate(tiles_info):
|
|
tile_pil = _ttp_image_tensor_to_pil(tile_images[index])
|
|
sx, sy, sw, sh = tile["sample_box"]
|
|
|
|
canvas_width, canvas_height = tile.get("tile_canvas_size", [sw, sh])
|
|
canvas_box = tile.get("tile_canvas_box", [0, 0, sw, sh])
|
|
content_left = int(round(tile_pil.width * canvas_box[0] / max(1, canvas_width)))
|
|
content_top = int(round(tile_pil.height * canvas_box[1] / max(1, canvas_height)))
|
|
canvas_content_w = max(1, float(canvas_box[2]) - float(canvas_box[0]))
|
|
canvas_content_h = max(1, float(canvas_box[3]) - float(canvas_box[1]))
|
|
content_width = max(1, int(round(tile_pil.width * canvas_content_w / max(1, canvas_width))))
|
|
content_height = max(1, int(round(tile_pil.height * canvas_content_h / max(1, canvas_height))))
|
|
|
|
tile_scale_x = content_width / max(1, sw)
|
|
tile_scale_y = content_height / max(1, sh)
|
|
tile_effective_scale = min(tile_scale_x, tile_scale_y)
|
|
tile_area_ratio = (float(sw) * float(sh)) / original_area
|
|
tile_label = str(tile.get("label", tile.get("source", ""))).lower()
|
|
is_context_label = any(word in tile_label for word in ("background", "context", "full image", "full_image", "large context"))
|
|
is_large_tile = tile_area_ratio >= large_tile_area_threshold or is_context_label
|
|
low_resolution_large_tile = (
|
|
has_base_canvas
|
|
and is_large_tile
|
|
and tile_effective_scale < target_scale * min_tile_scale_ratio
|
|
)
|
|
local_left = _ttp_clamp(content_left, 0, tile_pil.width - 1)
|
|
local_top = _ttp_clamp(content_top, 0, tile_pil.height - 1)
|
|
local_right = _ttp_clamp(content_left + content_width, local_left + 1, tile_pil.width)
|
|
local_bottom = _ttp_clamp(content_top + content_height, local_top + 1, tile_pil.height)
|
|
|
|
paste_region = tile_pil.crop((local_left, local_top, local_right, local_bottom))
|
|
out_x = int(round(sx * output_scale))
|
|
out_y = int(round(sy * output_scale))
|
|
out_w = max(1, int(round(sw * output_scale)))
|
|
out_h = max(1, int(round(sh * output_scale)))
|
|
crop_px = max(0, int(edge_crop_px))
|
|
if crop_px > 0 and paste_region.width > crop_px * 2 and paste_region.height > crop_px * 2:
|
|
paste_region = paste_region.crop((
|
|
crop_px,
|
|
crop_px,
|
|
paste_region.width - crop_px,
|
|
paste_region.height - crop_px,
|
|
))
|
|
paste_region = _ttp_align_pil_to_aspect(paste_region, out_w, out_h, str(tile_alignment))
|
|
|
|
if color_correction not in ("off", "local_mean_std"):
|
|
ref = color_reference_pil
|
|
if ref.size != (original_width, original_height):
|
|
ref = ref.resize((original_width, original_height), Image.Resampling.LANCZOS)
|
|
ref_region = ref.crop((sx, sy, sx + sw, sy + sh))
|
|
if ref_region.size != (out_w, out_h):
|
|
ref_region = ref_region.resize((out_w, out_h), Image.Resampling.LANCZOS)
|
|
corrected = _ttp_apply_color_transfer(
|
|
pil2tensor(paste_region),
|
|
pil2tensor(ref_region),
|
|
color_correction,
|
|
float(color_strength),
|
|
)
|
|
paste_region = tensor2pil(corrected[0].unsqueeze(0)).convert("RGB")
|
|
|
|
blend = int(round(tile.get("blend", 0) * output_scale * blend_multiplier))
|
|
mask = _ttp_create_sample_blend_mask(
|
|
out_w,
|
|
out_h,
|
|
tile.get("overlap_edges_px_source", {}),
|
|
blend,
|
|
output_scale,
|
|
output_scale,
|
|
)
|
|
object_mask = _ttp_tile_object_mask_array(tile, out_w, out_h, str(mask_blend_mode), blend)
|
|
if object_mask is not None:
|
|
if str(mask_blend_mode) == "mask_only":
|
|
mask = object_mask
|
|
else:
|
|
mask = mask * object_mask
|
|
elif _ttp_should_rect_feather_tile(tile, is_large_tile, str(mask_blend_mode)):
|
|
mask = mask * _ttp_rect_feather_mask(out_w, out_h, blend)
|
|
coverage_mask = np.clip(mask.copy(), 0.0, 1.0)
|
|
importance = max(0.0, float(tile.get("importance", 1.0)))
|
|
priority = max(0.0, float(tile.get("priority", 50.0)))
|
|
priority_scale = (1.0 + priority / 100.0) if use_priority else 1.0
|
|
mask = mask * importance * priority_scale
|
|
rank_occlusion = float(tile.get("occlusion_priority", 0.0))
|
|
rank_layer = float(tile.get("layer", 0.0))
|
|
rank_priority = priority
|
|
if small_tile_on_top and not is_large_tile:
|
|
small_tile_rank = 10000.0 + (1.0 - _ttp_clamp(tile_area_ratio, 0.0, 1.0)) * 9000.0
|
|
if any(keyword in tile_label for keyword in focus_label_keywords):
|
|
small_tile_rank += 1000.0
|
|
rank_occlusion = max(rank_occlusion, small_tile_rank)
|
|
rank_layer = max(rank_layer, 1.0)
|
|
if is_large_tile and large_tile_policy == "context_only" and has_base_canvas:
|
|
mask = mask * context_tile_weight
|
|
rank_occlusion = min(rank_occlusion, 0.0)
|
|
rank_layer = min(rank_layer, 0.0)
|
|
rank_priority = 0.0
|
|
elif low_resolution_large_tile and large_tile_policy == "use_if_higher_resolution":
|
|
mask = mask * context_tile_weight
|
|
rank_occlusion = min(rank_occlusion, 0.0)
|
|
rank_layer = min(rank_layer, 0.0)
|
|
rank_priority = 0.0
|
|
|
|
region = np.array(paste_region).astype(np.float32) / 255.0
|
|
if str(pixel_alignment) != "off":
|
|
align_info = None
|
|
if use_gpu_assemble and str(pixel_alignment_device or "auto").lower() != "cpu":
|
|
reference_t = canvas / torch.clamp(weights, min=1e-6)
|
|
gpu_align = _ttp_find_pixel_alignment_offset_torch_canvas(
|
|
region,
|
|
reference_t,
|
|
weights,
|
|
out_x,
|
|
out_y,
|
|
coverage_mask,
|
|
int(pixel_alignment_radius),
|
|
str(pixel_alignment),
|
|
)
|
|
if gpu_align is not None:
|
|
offset, align_info = gpu_align
|
|
dx, dy = int(offset[0]), int(offset[1])
|
|
else:
|
|
reference = reference_t.detach().cpu().numpy()
|
|
weights_np = weights.detach().cpu().numpy()
|
|
dx, dy, align_info = _ttp_find_pixel_alignment_offset_auto(
|
|
region,
|
|
reference,
|
|
weights_np,
|
|
out_x,
|
|
out_y,
|
|
coverage_mask,
|
|
int(pixel_alignment_radius),
|
|
str(pixel_alignment),
|
|
"cpu",
|
|
)
|
|
align_info = {**align_info, "fallback": True, "fallback_reason": "torch_canvas_failed"}
|
|
elif use_gpu_assemble:
|
|
reference = (canvas / torch.clamp(weights, min=1e-6)).detach().cpu().numpy()
|
|
weights_np = weights.detach().cpu().numpy()
|
|
dx, dy, align_info = _ttp_find_pixel_alignment_offset_auto(
|
|
region,
|
|
reference,
|
|
weights_np,
|
|
out_x,
|
|
out_y,
|
|
coverage_mask,
|
|
int(pixel_alignment_radius),
|
|
str(pixel_alignment),
|
|
"cpu",
|
|
)
|
|
else:
|
|
reference = canvas / np.maximum(weights, 1e-6)
|
|
dx, dy, align_info = _ttp_find_pixel_alignment_offset_auto(
|
|
region,
|
|
reference,
|
|
weights,
|
|
out_x,
|
|
out_y,
|
|
coverage_mask,
|
|
int(pixel_alignment_radius),
|
|
str(pixel_alignment),
|
|
str(pixel_alignment_device),
|
|
)
|
|
align_device = str(align_info.get("device", "cpu"))
|
|
if align_device.startswith("cuda") or align_device.startswith("mps") or align_device not in ("cpu", "off"):
|
|
alignment_stats["gpu"] += 1
|
|
alignment_stats["devices"].add(align_device)
|
|
elif align_device == "cpu":
|
|
alignment_stats["cpu"] += 1
|
|
if bool(align_info.get("fallback", False)):
|
|
alignment_stats["fallback"] += 1
|
|
alignment_stats["samples"] = max(alignment_stats["samples"], int(align_info.get("samples", 0) or 0))
|
|
alignment_stats["candidates"] = max(alignment_stats["candidates"], int(align_info.get("candidates", 0) or 0))
|
|
if dx or dy:
|
|
alignment_stats["aligned"] += 1
|
|
out_x += dx
|
|
out_y += dy
|
|
paste_x = max(0, out_x)
|
|
paste_y = max(0, out_y)
|
|
x_end = min(output_width, out_x + out_w)
|
|
y_end = min(output_height, out_y + out_h)
|
|
region_left = paste_x - out_x
|
|
region_top = paste_y - out_y
|
|
region_w = x_end - paste_x
|
|
region_h = y_end - paste_y
|
|
if region_w <= 0 or region_h <= 0:
|
|
continue
|
|
region = region[region_top:region_top + region_h, region_left:region_left + region_w]
|
|
mask = mask[region_top:region_top + region_h, region_left:region_left + region_w]
|
|
coverage_mask = coverage_mask[region_top:region_top + region_h, region_left:region_left + region_w]
|
|
if str(color_correction) == "local_mean_std":
|
|
ref = color_reference_pil
|
|
if ref.size != (output_width, output_height):
|
|
ref = ref.resize((output_width, output_height), Image.Resampling.LANCZOS)
|
|
ref_region = np.array(ref.crop((paste_x, paste_y, x_end, y_end))).astype(np.float32) / 255.0
|
|
if use_gpu_assemble:
|
|
region_t = torch.as_tensor(region, dtype=torch.float32, device=assemble_torch_device)
|
|
ref_region_t = torch.as_tensor(ref_region, dtype=torch.float32, device=assemble_torch_device)
|
|
coverage_t = torch.as_tensor(coverage_mask, dtype=torch.float32, device=assemble_torch_device)
|
|
region_t = _ttp_apply_local_mean_std_color_torch(region_t, ref_region_t, coverage_t, float(color_strength))
|
|
region = None
|
|
else:
|
|
region = _ttp_apply_local_mean_std_color(region, ref_region, coverage_mask, float(color_strength))
|
|
if use_gpu_assemble:
|
|
if str(color_correction) != "local_mean_std":
|
|
region_t = torch.as_tensor(region, dtype=torch.float32, device=assemble_torch_device)
|
|
coverage_t = torch.as_tensor(coverage_mask, dtype=torch.float32, device=assemble_torch_device)
|
|
mask_t = torch.as_tensor(mask, dtype=torch.float32, device=assemble_torch_device)
|
|
if use_occlusion:
|
|
rank = rank_occlusion * 1000000.0 + rank_layer * 10000.0 + (rank_priority if use_priority else 0.0)
|
|
rank_view = priority_ranks[paste_y:y_end, paste_x:x_end]
|
|
mask_active = coverage_t > 1e-6
|
|
higher = (rank > rank_view + 1e-6) & mask_active
|
|
eligible = (rank >= rank_view - 1e-6) & mask_active
|
|
canvas_view = canvas[paste_y:y_end, paste_x:x_end]
|
|
weights_view = weights[paste_y:y_end, paste_x:x_end]
|
|
higher_alpha = coverage_t * higher.to(dtype=torch.float32)
|
|
keep = 1.0 - higher_alpha
|
|
canvas_view.mul_(keep)
|
|
weights_view.mul_(keep)
|
|
rank_view[higher] = rank
|
|
eligible_f = eligible.to(dtype=torch.float32)
|
|
canvas_view.add_(region_t * mask_t * eligible_f)
|
|
weights_view.add_(mask_t * eligible_f)
|
|
else:
|
|
canvas[paste_y:y_end, paste_x:x_end].add_(region_t * mask_t)
|
|
weights[paste_y:y_end, paste_x:x_end].add_(mask_t)
|
|
elif use_occlusion:
|
|
rank = rank_occlusion * 1000000.0 + rank_layer * 10000.0 + (rank_priority if use_priority else 0.0)
|
|
rank_view = priority_ranks[paste_y:y_end, paste_x:x_end]
|
|
mask_active = coverage_mask > 1e-6
|
|
higher = (rank > rank_view + 1e-6) & mask_active
|
|
eligible = (rank >= rank_view - 1e-6) & mask_active
|
|
canvas_view = canvas[paste_y:y_end, paste_x:x_end]
|
|
weights_view = weights[paste_y:y_end, paste_x:x_end]
|
|
if np.any(higher):
|
|
higher_alpha = coverage_mask * higher.astype(np.float32)
|
|
keep = 1.0 - higher_alpha
|
|
canvas_view *= keep
|
|
weights_view *= keep
|
|
rank_view[higher[:, :, 0], :] = rank
|
|
canvas_view += region * mask * eligible
|
|
weights_view += mask * eligible
|
|
else:
|
|
canvas[paste_y:y_end, paste_x:x_end] += region * mask
|
|
weights[paste_y:y_end, paste_x:x_end] += mask
|
|
|
|
if str(pixel_alignment) != "off":
|
|
devices = ",".join(sorted(alignment_stats["devices"])) or ("cpu" if alignment_stats["cpu"] else "none")
|
|
print(
|
|
"[TTP Smart Tile] pixel alignment "
|
|
f"mode={pixel_alignment} requested_device={pixel_alignment_device} "
|
|
f"actual_gpu_tiles={alignment_stats['gpu']} actual_cpu_tiles={alignment_stats['cpu']} "
|
|
f"fallback_tiles={alignment_stats['fallback']} moved_tiles={alignment_stats['aligned']} "
|
|
f"radius={int(pixel_alignment_radius)} max_samples={alignment_stats['samples']} "
|
|
f"candidates={alignment_stats['candidates']} devices={devices}"
|
|
)
|
|
|
|
actual_assemble_device = str(assemble_torch_device) if use_gpu_assemble else "cpu"
|
|
if assemble_device_mode != "cpu" or assemble_fallback_reason:
|
|
print(
|
|
"[TTP Smart Tile] assemble paste "
|
|
f"requested_device={assemble_device_mode} actual_device={actual_assemble_device} "
|
|
f"mode={effective_assemble_mode} requested_mode={requested_assemble_mode} tiles={len(tiles_info)} "
|
|
f"fallback={assemble_fallback_reason or 'none'}"
|
|
)
|
|
|
|
if use_gpu_assemble:
|
|
safe_weights = torch.clamp(weights, min=1e-6)
|
|
output = torch.clamp(canvas / safe_weights, 0.0, 1.0).detach().cpu()
|
|
max_weight = torch.clamp(weights.max(), min=1e-6)
|
|
weight_preview = torch.clamp(weights / max_weight, 0.0, 1.0).repeat(1, 1, 3).detach().cpu()
|
|
return (output.unsqueeze(0), weight_preview.unsqueeze(0))
|
|
|
|
safe_weights = np.maximum(weights, 1e-6)
|
|
output = canvas / safe_weights
|
|
output = np.clip(output, 0.0, 1.0)
|
|
weight_preview = np.clip(weights / max(1e-6, weights.max()), 0.0, 1.0)
|
|
weight_preview = np.repeat(weight_preview, 3, axis=2)
|
|
|
|
return (
|
|
torch.from_numpy(output.astype(np.float32)).unsqueeze(0),
|
|
torch.from_numpy(weight_preview.astype(np.float32)).unsqueeze(0),
|
|
)
|
|
|
|
|
|
class TTP_Smart_Tile_Save_Final_Image_Experimental:
|
|
def __init__(self):
|
|
self.output_dir = folder_paths.get_output_directory()
|
|
self.type = "output"
|
|
self.compress_level = 4
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"images": ("IMAGE",),
|
|
"done": ("BOOLEAN", {"default": True}),
|
|
"filename_prefix": ("STRING", {"default": "TTP_Smart_Tile"}),
|
|
},
|
|
"hidden": {
|
|
"prompt": "PROMPT",
|
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
FUNCTION = "save_final_image"
|
|
OUTPUT_NODE = True
|
|
CATEGORY = "TTP/Smart Tile"
|
|
|
|
def save_final_image(self, images, done=True, filename_prefix="TTP_Smart_Tile", prompt=None, extra_pnginfo=None):
|
|
if not bool(done):
|
|
return {"ui": {"images": []}}
|
|
|
|
if images is None or int(images.shape[0]) <= 0:
|
|
raise ValueError("images must contain at least one image.")
|
|
|
|
image = images[-1]
|
|
full_output_folder, filename, counter, subfolder, _filename_prefix = folder_paths.get_save_image_path(
|
|
str(filename_prefix or "TTP_Smart_Tile"),
|
|
self.output_dir,
|
|
image.shape[1],
|
|
image.shape[0],
|
|
)
|
|
i = 255.0 * image.cpu().numpy()
|
|
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
|
|
|
metadata = None
|
|
if not args.disable_metadata:
|
|
metadata = PngInfo()
|
|
if prompt is not None:
|
|
metadata.add_text("prompt", json.dumps(prompt))
|
|
if extra_pnginfo is not None:
|
|
for key in extra_pnginfo:
|
|
metadata.add_text(key, json.dumps(extra_pnginfo[key]))
|
|
|
|
file = f"{filename}_{counter:05}_.png"
|
|
img.save(os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=self.compress_level)
|
|
return {
|
|
"ui": {
|
|
"images": [{
|
|
"filename": file,
|
|
"subfolder": subfolder,
|
|
"type": self.type,
|
|
}]
|
|
}
|
|
}
|
|
|
|
|
|
class TTP_CoordinateSplitter:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"Positions": ("LIST", {"forceInput": True}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("LIST",)
|
|
RETURN_NAMES = ("Coordinates",)
|
|
FUNCTION = "split_coordinates"
|
|
|
|
CATEGORY = "TTP/Conditioning"
|
|
|
|
def split_coordinates(self, Positions):
|
|
coordinates = []
|
|
for i, coords in enumerate(Positions):
|
|
if len(coords) != 4:
|
|
raise ValueError(f"Coordinate group {i+1} must contain exactly 4 values, but got {len(coords)}")
|
|
|
|
x, y, x2, y2 = coords
|
|
width = x2 - x
|
|
height = y2 - y
|
|
coordinates.append((x, y, width, height)) # Create a tuple for each coordinate group
|
|
|
|
return (coordinates,) # Return as a tuple containing a list of tuples
|
|
|
|
|
|
|
|
class TTP_condtobatch:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"conditionings": ("CONDITIONING", {"forceInput": True}),
|
|
}
|
|
}
|
|
|
|
INPUT_IS_LIST = True
|
|
|
|
RETURN_TYPES = ("CONDITIONING",)
|
|
FUNCTION = "combine_to_batch"
|
|
|
|
CATEGORY = "TTP/Conditioning"
|
|
|
|
def combine_to_batch(self, conditionings):
|
|
# 直接将所有conditioning组合在一起并返回
|
|
combined_conditioning = sum(conditionings, [])
|
|
return (combined_conditioning,)
|
|
|
|
|
|
class TTP_condsetarea_merge:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"conditioning_batch": ("CONDITIONING", {"forceInput": True}),
|
|
"coordinates": ("LIST", {"forceInput": True}),
|
|
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("CONDITIONING",)
|
|
FUNCTION = "apply_coordinates_to_batch"
|
|
|
|
CATEGORY = "TTP/Conditioning"
|
|
|
|
def apply_coordinates_to_batch(self, conditioning_batch, coordinates, strength):
|
|
# 确保coordinates和conditioning_batch的数量一致
|
|
if len(coordinates) != len(conditioning_batch):
|
|
raise ValueError(f"The number of coordinates ({len(coordinates)}) does not match the number of conditionings ({len(conditioning_batch)})")
|
|
|
|
updated_conditionings = []
|
|
|
|
# 遍历每个conditioning和相应的coordinate
|
|
for conditioning, coord in zip(conditioning_batch, coordinates):
|
|
if len(coord) != 4:
|
|
raise ValueError(f"Each coordinate should have exactly 4 values, but got {len(coord)}")
|
|
|
|
x, y, width, height = coord
|
|
|
|
# Print x, y, width, height for debugging
|
|
print(f"Processing coordinate - x: {x}, y: {y}, width: {width}, height: {height}")
|
|
|
|
# 将每个 conditioning 处理为列表格式
|
|
single_conditioning = [conditioning]
|
|
|
|
# 使用标准的 node_helpers.conditioning_set_values 方法进行区域设置
|
|
updated_conditioning = node_helpers.conditioning_set_values(
|
|
single_conditioning,
|
|
{
|
|
"area": (height // 8, width // 8, y // 8, x // 8),
|
|
"strength": strength,
|
|
"set_area_to_bounds": False,
|
|
}
|
|
)
|
|
|
|
updated_conditionings.append(updated_conditioning)
|
|
|
|
# 将所有更新后的conditioning重新组合为一个batch
|
|
combined_conditioning = sum(updated_conditionings, [])
|
|
return (combined_conditioning,)
|
|
|
|
class TTP_condsetarea_merge_test:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"conditioning_batch": ("CONDITIONING", {"forceInput": True}),
|
|
"coordinates": ("LIST", {"forceInput": True}),
|
|
"group_size": ("INT", {"default": 1, "min": 1, "step": 1}),
|
|
"strength": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.01}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("CONDITIONING",)
|
|
FUNCTION = "apply_coordinates_to_batch"
|
|
|
|
CATEGORY = "TTP/Conditioning"
|
|
|
|
def apply_coordinates_to_batch(self, conditioning_batch, coordinates, group_size, strength):
|
|
import math
|
|
|
|
# 计算 conditioning 中的组数
|
|
num_conditionings = len(conditioning_batch)
|
|
num_groups = math.ceil(num_conditionings / group_size)
|
|
|
|
# 如果坐标数量大于组数,需要复制 conditioning
|
|
if len(coordinates) > num_groups:
|
|
# 计算需要的倍数
|
|
multiplier = math.ceil(len(coordinates) * group_size / num_conditionings)
|
|
# 复制 conditioning_batch
|
|
conditioning_batch = conditioning_batch * multiplier
|
|
num_conditionings = len(conditioning_batch)
|
|
num_groups = math.ceil(num_conditionings / group_size)
|
|
|
|
# 重新计算需要的坐标数量
|
|
required_coords = num_groups
|
|
|
|
# 检查坐标数量是否足够
|
|
if len(coordinates) != required_coords:
|
|
raise ValueError(f"The number of coordinates ({len(coordinates)}) does not match the required number ({required_coords}) based on group size ({group_size}) and conditioning length ({num_conditionings})")
|
|
|
|
updated_conditionings = []
|
|
conditioning_index = 0
|
|
|
|
# 遍历坐标和分组
|
|
for coord in coordinates:
|
|
if len(coord) != 4:
|
|
raise ValueError(f"Each coordinate should have exactly 4 values, but got {len(coord)}")
|
|
|
|
x, y, width, height = coord
|
|
|
|
# 打印调试信息
|
|
print(f"Processing coordinate - x: {x}, y: {y}, width: {width}, height: {height}")
|
|
|
|
# 获取当前组的 conditioning
|
|
group_conditionings = conditioning_batch[conditioning_index:conditioning_index + group_size]
|
|
|
|
for conditioning in group_conditionings:
|
|
# 使用标准的 node_helpers.conditioning_set_values 方法进行区域设置
|
|
updated_conditioning = node_helpers.conditioning_set_values(
|
|
[conditioning],
|
|
{
|
|
"area": (height // 8, width // 8, y // 8, x // 8),
|
|
"strength": strength,
|
|
"set_area_to_bounds": False,
|
|
}
|
|
)
|
|
|
|
updated_conditionings.append(updated_conditioning)
|
|
|
|
conditioning_index += group_size
|
|
|
|
# 将所有更新后的 conditioning 重新组合为一个批次
|
|
combined_conditioning = sum(updated_conditionings, [])
|
|
return (combined_conditioning,)
|
|
|
|
|
|
class Tile_imageSize:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"width_factor": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}),
|
|
"height_factor": ("INT", {"default": 3, "min": 1, "max": 10, "step": 1}),
|
|
"overlap_rate": ("FLOAT", {"default": 0.1, "min": 0.00, "max": 0.95, "step": 0.05}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("INT", "INT")
|
|
RETURN_NAMES = ("tile_width", "tile_height")
|
|
CATEGORY = "TTP/Image"
|
|
FUNCTION = "image_width_height"
|
|
|
|
def image_width_height(self, image, width_factor, height_factor, overlap_rate):
|
|
_, raw_H, raw_W, _ = image.shape
|
|
if overlap_rate == 0:
|
|
# 水平方向
|
|
if width_factor == 1:
|
|
tile_width = raw_W
|
|
else:
|
|
tile_width = int(raw_W / width_factor)
|
|
if tile_width % 8 != 0:
|
|
tile_width = ((tile_width + 7) // 8) * 8
|
|
# 垂直方向
|
|
if height_factor == 1:
|
|
tile_height = raw_H
|
|
else:
|
|
tile_height = int(raw_H / height_factor)
|
|
if tile_height % 8 != 0:
|
|
tile_height = ((tile_height + 7) // 8) * 8
|
|
|
|
else:
|
|
# 水平方向
|
|
if width_factor == 1:
|
|
tile_width = raw_W
|
|
else:
|
|
tile_width = int(raw_W / (1 + (width_factor - 1) * (1 - overlap_rate)))
|
|
if tile_width % 8 != 0:
|
|
tile_width = (tile_width // 8) * 8
|
|
# 垂直方向
|
|
if height_factor == 1:
|
|
tile_height = raw_H
|
|
else:
|
|
tile_height = int(raw_H / (1 + (height_factor - 1) * (1 - overlap_rate)))
|
|
if tile_height % 8 != 0:
|
|
tile_height = (tile_height // 8) * 8
|
|
|
|
return (tile_width, tile_height)
|
|
|
|
class TTP_Expand_And_Mask:
|
|
"""
|
|
这是一个节点类,用于将输入图片在指定方向扩展一定数量的块并创建相应蒙版。
|
|
|
|
功能:
|
|
1. 支持同时在多个方向上扩展图像。
|
|
2. 分别控制每个方向的扩展块数量。
|
|
3. 将输入图像的透明通道(Alpha 通道)信息转换为蒙版,并与新创建的蒙版合并。
|
|
4. 添加一个布尔参数 fill_alpha_decision 来决定是否将输出图片中的透明区域填充为指定颜色,并输出 RGB 图像。
|
|
"""
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
directions = ["left", "right", "top", "bottom"]
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",), # 输入一张图片
|
|
"fill_mode": (["duplicate", "white"], {"default": "duplicate", "label": "Fill Mode"}),
|
|
"fill_alpha_decision": ("BOOLEAN", {"default": False, "label": "Fill Alpha with Color"}),
|
|
"fill_color": ("STRING", {"default": "#7F7F7F", "label": "Fill Color"}),
|
|
},
|
|
"optional": {
|
|
**{f"expand_{dir}": ("BOOLEAN", {"default": False, "label": f"Expand {dir.capitalize()}"}) for dir in directions},
|
|
**{f"num_blocks_{dir}": ("INT", {"default": 1, "min": 0, "max": 3, "step": 1, "label": f"Blocks {dir.capitalize()}"}) for dir in directions},
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK")
|
|
RETURN_NAMES = ("EXPANDED_IMAGE", "MASK")
|
|
FUNCTION = "expand_and_mask"
|
|
CATEGORY = "TTP/Image"
|
|
|
|
def hex_to_rgba(self, hex_color):
|
|
# 去除可能存在的 '#' 字符
|
|
hex_color = hex_color.lstrip('#')
|
|
# 如果为6位十六进制字符串,默认为不透明
|
|
if len(hex_color) == 6:
|
|
r = int(hex_color[0:2], 16)
|
|
g = int(hex_color[2:4], 16)
|
|
b = int(hex_color[4:6], 16)
|
|
return (r, g, b, 255)
|
|
# 如果为8位,则最后两位为透明度
|
|
elif len(hex_color) == 8:
|
|
r = int(hex_color[0:2], 16)
|
|
g = int(hex_color[2:4], 16)
|
|
b = int(hex_color[4:6], 16)
|
|
a = int(hex_color[6:8], 16)
|
|
return (r, g, b, a)
|
|
else:
|
|
raise ValueError("Invalid hex color format")
|
|
|
|
def expand_and_mask(self, image, fill_mode="duplicate", fill_alpha_decision=False, fill_color="#7F7F7F", **kwargs):
|
|
pil_image = tensor2pil(image)
|
|
orig_width, orig_height = pil_image.size
|
|
has_alpha = (pil_image.mode == 'RGBA')
|
|
|
|
# 解析方向和块数
|
|
directions = ["left", "right", "top", "bottom"]
|
|
expand_directions = {dir: kwargs.get(f"expand_{dir}", False) for dir in directions}
|
|
num_blocks = {dir: kwargs.get(f"num_blocks_{dir}", 0) if expand_directions[dir] else 0 for dir in directions}
|
|
|
|
# 计算扩展后的尺寸
|
|
total_width = orig_width + orig_width * (num_blocks["left"] + num_blocks["right"])
|
|
total_height = orig_height + orig_height * (num_blocks["top"] + num_blocks["bottom"])
|
|
|
|
# 创建扩展后的图像
|
|
expanded_image_mode = pil_image.mode
|
|
expanded_image = Image.new(expanded_image_mode, (total_width, total_height))
|
|
|
|
# 根据 fill_mode 创建填充图像
|
|
def create_fill_image():
|
|
if pil_image.mode == 'RGBA':
|
|
return Image.new("RGBA", (orig_width, orig_height), color=(255, 255, 255, 255))
|
|
elif pil_image.mode == 'RGB':
|
|
return Image.new("RGB", (orig_width, orig_height), color=(255, 255, 255))
|
|
elif pil_image.mode == 'L':
|
|
return Image.new("L", (orig_width, orig_height), color=255)
|
|
else:
|
|
raise ValueError(f"Unsupported image mode for fill: {pil_image.mode}")
|
|
|
|
if fill_mode == "duplicate":
|
|
fill_image = pil_image.copy()
|
|
elif fill_mode == "white":
|
|
fill_image = create_fill_image()
|
|
else:
|
|
fill_image = pil_image.copy()
|
|
|
|
# 计算原图在扩展图像中的位置
|
|
left_offset = orig_width * num_blocks["left"]
|
|
top_offset = orig_height * num_blocks["top"]
|
|
|
|
# 粘贴原始图像
|
|
expanded_image.paste(pil_image, (left_offset, top_offset))
|
|
|
|
# 粘贴填充区域
|
|
for dir in directions:
|
|
blocks = num_blocks[dir]
|
|
for i in range(blocks):
|
|
if dir == "left":
|
|
x = left_offset - orig_width * (i + 1)
|
|
y = top_offset
|
|
elif dir == "right":
|
|
x = left_offset + orig_width * (i + 1)
|
|
y = top_offset
|
|
elif dir == "top":
|
|
x = left_offset
|
|
y = top_offset - orig_height * (i + 1)
|
|
elif dir == "bottom":
|
|
x = left_offset
|
|
y = top_offset + orig_height * (i + 1)
|
|
else:
|
|
continue
|
|
expanded_image.paste(fill_image, (x, y))
|
|
|
|
# 粘贴角落填充区域(处理同时选择多个方向的情况)
|
|
corner_positions = []
|
|
if expand_directions["left"] and expand_directions["top"]:
|
|
for i in range(num_blocks["left"]):
|
|
for j in range(num_blocks["top"]):
|
|
x = left_offset - orig_width * (i + 1)
|
|
y = top_offset - orig_height * (j + 1)
|
|
corner_positions.append((x, y))
|
|
if expand_directions["left"] and expand_directions["bottom"]:
|
|
for i in range(num_blocks["left"]):
|
|
for j in range(num_blocks["bottom"]):
|
|
x = left_offset - orig_width * (i + 1)
|
|
y = top_offset + orig_height * (j + 1)
|
|
corner_positions.append((x, y))
|
|
if expand_directions["right"] and expand_directions["top"]:
|
|
for i in range(num_blocks["right"]):
|
|
for j in range(num_blocks["top"]):
|
|
x = left_offset + orig_width * (i + 1)
|
|
y = top_offset - orig_height * (j + 1)
|
|
corner_positions.append((x, y))
|
|
if expand_directions["right"] and expand_directions["bottom"]:
|
|
for i in range(num_blocks["right"]):
|
|
for j in range(num_blocks["bottom"]):
|
|
x = left_offset + orig_width * (i + 1)
|
|
y = top_offset + orig_height * (j + 1)
|
|
corner_positions.append((x, y))
|
|
|
|
for pos in corner_positions:
|
|
expanded_image.paste(fill_image, pos)
|
|
|
|
# 创建蒙版
|
|
mask_array = np.zeros((total_height, total_width), dtype=np.float32)
|
|
|
|
# 原始图像区域蒙版处理
|
|
if has_alpha:
|
|
alpha_array = np.array(pil_image.getchannel("A"), dtype=np.float32) / 255.0
|
|
alpha_mask_array = 1.0 - alpha_array
|
|
mask_array[top_offset:top_offset + orig_height, left_offset:left_offset + orig_width] = alpha_mask_array
|
|
|
|
# 填充区域蒙版设置为1.0
|
|
# 左右扩展区域
|
|
for dir in ["left", "right"]:
|
|
blocks = num_blocks[dir]
|
|
for i in range(blocks):
|
|
if dir == "left":
|
|
x_start = left_offset - orig_width * (i + 1)
|
|
x_end = left_offset - orig_width * i
|
|
elif dir == "right":
|
|
x_start = left_offset + orig_width * (i + 1)
|
|
x_end = left_offset + orig_width * (i + 2)
|
|
else:
|
|
continue
|
|
mask_array[top_offset:top_offset + orig_height, x_start:x_end] = 1.0
|
|
|
|
# 上下扩展区域
|
|
for dir in ["top", "bottom"]:
|
|
blocks = num_blocks[dir]
|
|
for i in range(blocks):
|
|
if dir == "top":
|
|
y_start = top_offset - orig_height * (i + 1)
|
|
y_end = top_offset - orig_height * i
|
|
elif dir == "bottom":
|
|
y_start = top_offset + orig_height * (i + 1)
|
|
y_end = top_offset + orig_height * (i + 2)
|
|
else:
|
|
continue
|
|
mask_array[y_start:y_end, left_offset:left_offset + orig_width] = 1.0
|
|
|
|
# 角落区域蒙版设置为1.0
|
|
for pos in corner_positions:
|
|
x, y = pos
|
|
mask_array[y:y + orig_height, x:x + orig_width] = 1.0
|
|
|
|
# 创建蒙版张量 (1, 1, height, width)
|
|
mask_tensor = torch.from_numpy(mask_array).unsqueeze(0).unsqueeze(0)
|
|
|
|
# 根据 fill_alpha_decision 参数决定是否将输出图像中的透明区域填充为指定颜色
|
|
if fill_alpha_decision and has_alpha:
|
|
expanded_image = expanded_image.convert('RGBA') # 确保图像是RGBA模式
|
|
# 使用自定义填充颜色代替纯白色
|
|
fill_rgba = self.hex_to_rgba(fill_color)
|
|
background = Image.new('RGBA', expanded_image.size, fill_rgba)
|
|
expanded_image = Image.alpha_composite(background, expanded_image)
|
|
expanded_image = expanded_image.convert('RGB') # 转换为RGB模式
|
|
expanded_image_mode = 'RGB'
|
|
|
|
expanded_image_tensor = pil2tensor(expanded_image)
|
|
|
|
return (expanded_image_tensor, mask_tensor)
|
|
|
|
class TTP_text_mix:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"text1": ("STRING", {"default": "", "multiline": True, "label": "Text Box 1"}),
|
|
"text2": ("STRING", {"default": "", "multiline": True, "label": "Text Box 2"}),
|
|
"text3": ("STRING", {"default": "", "multiline": True, "label": "Text Box 3"}),
|
|
"template": ("STRING", {"default": "", "multiline": True, "label": "Template Text Box"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("STRING", "STRING", "STRING", "STRING")
|
|
RETURN_NAMES = ("text1", "text2", "text3", "final_text")
|
|
FUNCTION = "mix_texts"
|
|
CATEGORY = "TTP/text"
|
|
|
|
def mix_texts(self, text1, text2, text3, template):
|
|
# 使用replace方法替换模板中的占位符{text1}和{text2}
|
|
final_text = template.replace("{text1}", text1).replace("{text2}", text2).replace("{text3}", text3)
|
|
|
|
return (text1, text2, text3, final_text)
|
|
|
|
def horner_poly(x: torch.Tensor, coefficients: torch.Tensor) -> torch.Tensor:
|
|
"""
|
|
使用 Horner's scheme 计算多项式:
|
|
c[0]*x^(n-1) + c[1]*x^(n-2) + ... + c[n-2]*x + c[n-1]
|
|
其中 coefficients = [c[0], c[1], ..., c[n-1]].
|
|
"""
|
|
out = torch.zeros_like(x)
|
|
for c in coefficients:
|
|
out = out * x + c
|
|
return out
|
|
|
|
def modulate(x, shift, scale):
|
|
"""Modulate layer implementation for HunyuanVideo"""
|
|
try:
|
|
# Ensure consistent data types
|
|
shift = shift.to(dtype=x.dtype, device=x.device)
|
|
scale = scale.to(dtype=x.dtype, device=x.device)
|
|
|
|
# Reshape shift and scale to match x dimensions
|
|
B = x.shape[0] # batch size
|
|
|
|
if len(x.shape) == 3: # [B, L, D]
|
|
shift = shift.view(B, 1, -1) # [B, 1, D]
|
|
scale = scale.view(B, 1, -1) # [B, 1, D]
|
|
shift = shift.expand(-1, x.shape[1], -1) # [B, L, D]
|
|
scale = scale.expand(-1, x.shape[1], -1) # [B, L, D]
|
|
elif len(x.shape) == 5: # [B, C, T, H, W]
|
|
shift = shift.view(B, -1, 1, 1, 1) # [B, C, 1, 1, 1]
|
|
scale = scale.view(B, -1, 1, 1, 1) # [B, C, 1, 1, 1]
|
|
shift = shift.expand(-1, -1, x.shape[2], x.shape[3], x.shape[4]) # [B, C, T, H, W]
|
|
scale = scale.expand(-1, -1, x.shape[2], x.shape[3], x.shape[4]) # [B, C, T, H, W]
|
|
else:
|
|
raise ValueError(f"Unsupported input shape: {x.shape}")
|
|
|
|
# Step-by-step calculation to reduce memory usage
|
|
result = x.mul_(1 + scale) # in-place operation
|
|
result.add_(shift) # in-place operation
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f"Modulation failed: {str(e)}")
|
|
|
|
def modulate(x, shift, scale):
|
|
"""Modulate layer implementation for HunyuanVideo"""
|
|
try:
|
|
# Ensure consistent data types
|
|
shift = shift.to(dtype=x.dtype, device=x.device)
|
|
scale = scale.to(dtype=x.dtype, device=x.device)
|
|
|
|
# Reshape shift and scale to match x dimensions
|
|
B = x.shape[0] # batch size
|
|
|
|
if len(x.shape) == 3: # [B, L, D]
|
|
shift = shift.view(B, 1, -1) # [B, 1, D]
|
|
scale = scale.view(B, 1, -1) # [B, 1, D]
|
|
shift = shift.expand(-1, x.shape[1], -1) # [B, L, D]
|
|
scale = scale.expand(-1, x.shape[1], -1) # [B, L, D]
|
|
elif len(x.shape) == 5: # [B, C, T, H, W]
|
|
shift = shift.view(B, -1, 1, 1, 1) # [B, C, 1, 1, 1]
|
|
scale = scale.view(B, -1, 1, 1, 1) # [B, C, 1, 1, 1]
|
|
shift = shift.expand(-1, -1, x.shape[2], x.shape[3], x.shape[4]) # [B, C, T, H, W]
|
|
scale = scale.expand(-1, -1, x.shape[2], x.shape[3], x.shape[4]) # [B, C, T, H, W]
|
|
else:
|
|
raise ValueError(f"Unsupported input shape: {x.shape}")
|
|
|
|
# Step-by-step calculation to reduce memory usage
|
|
result = x.mul_(1 + scale) # in-place operation
|
|
result.add_(shift) # in-place operation
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f"Modulation failed: {str(e)}")
|
|
|
|
class TeaCacheHunyuanVideoSampler:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"noise": ("NOISE",),
|
|
"guider": ("GUIDER",),
|
|
"sampler": ("SAMPLER",),
|
|
"sigmas": ("SIGMAS",),
|
|
"latent_image": ("LATENT",),
|
|
"speedup": ([
|
|
"Original (1x)",
|
|
"Fast (1.6x)",
|
|
"Faster (2.1x)",
|
|
"Ultra Fast (3.2x)",
|
|
"Shapeless Fast (4.4x)"
|
|
], {
|
|
"default": "Fast (1.6x)",
|
|
"tooltip": (
|
|
"Control TeaCache speed/quality trade-off:\n"
|
|
"Original: Base quality\n"
|
|
"Fast: 1.6x speedup\n"
|
|
"Faster: 2.1x speedup\n"
|
|
"Ultra Fast: 3.2x speedup\n"
|
|
"Shapeless Fast: 4.4x speedup"
|
|
)
|
|
}),
|
|
"enable_custom_speed": ("BOOLEAN", {
|
|
"default": False,
|
|
"label": "Enable Custom Speed"
|
|
}),
|
|
"custom_speed": ("FLOAT", {
|
|
"default": 1.0,
|
|
"min": 1.0,
|
|
"max": 4.4,
|
|
"step": 0.1,
|
|
"label": "Custom Speed Multiplier"
|
|
})
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("LATENT", "LATENT")
|
|
RETURN_NAMES = ("output", "denoised_output")
|
|
FUNCTION = "sample"
|
|
CATEGORY = "sampling/custom_sampling"
|
|
|
|
def calculate_threshold(self, speed_multiplier: float) -> float:
|
|
"""根据预设速度点进行线性插值,计算自定义速度对应的阈值"""
|
|
# 预设的速度倍数和对应阈值
|
|
predefined_speeds = [1.0, 1.6, 2.1, 3.2, 4.4]
|
|
predefined_thresholds = [0.0, 0.1, 0.15, 0.25, 0.35]
|
|
|
|
# 使用 numpy 的线性插值函数
|
|
threshold = np.interp(speed_multiplier, predefined_speeds, predefined_thresholds)
|
|
|
|
# 确保阈值不超过最大值
|
|
threshold = min(threshold, 0.35)
|
|
|
|
return threshold
|
|
|
|
def teacache_forward(
|
|
self,
|
|
transformer,
|
|
x: torch.Tensor,
|
|
timestep: torch.Tensor, # Should be in range(0, 1000).
|
|
context: Optional[torch.Tensor] = None,
|
|
y: Optional[torch.Tensor] = None, # Text embedding for modulation.
|
|
guidance: Optional[torch.Tensor] = None, # Guidance for modulation, should be cfg_scale x 1000.
|
|
attention_mask: Optional[torch.Tensor] = None,
|
|
control: Any = None,
|
|
transformer_options: Dict = {},
|
|
**kwargs
|
|
) -> Union[torch.Tensor, Dict[str, torch.Tensor]]:
|
|
"""TeaCache forward implementation"""
|
|
should_calc = True
|
|
|
|
if transformer.enable_teacache:
|
|
try:
|
|
# 获取输入维度
|
|
B, C, T, H, W = x.shape
|
|
|
|
# 准备调制向量
|
|
try:
|
|
# HunyuanVideo 使用 timestep_embedding 进行时间步编码
|
|
time_emb = comfy.ldm.flux.layers.timestep_embedding(timestep, 256, time_factor=1.0).to(x.dtype)
|
|
vec = transformer.time_in(time_emb) # [B, hidden_size]
|
|
|
|
# 文本调制 - HunyuanVideo 使用 vector_in 处理 y 而不是 context
|
|
if y is not None:
|
|
if not hasattr(transformer, 'params') or not hasattr(transformer.params, 'vec_in_dim'):
|
|
raise AttributeError("Transformer missing required attributes: params.vec_in_dim")
|
|
vec = vec + transformer.vector_in(y[:, :transformer.params.vec_in_dim])
|
|
|
|
# 指导调制
|
|
if guidance is not None and getattr(transformer, 'params', None) and transformer.params.guidance_embed:
|
|
guidance_emb = comfy.ldm.flux.layers.timestep_embedding(guidance, 256).to(x.dtype)
|
|
guidance_vec = transformer.guidance_in(guidance_emb)
|
|
vec = vec + guidance_vec
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to prepare modulation vector: {str(e)}")
|
|
|
|
# 嵌入图像
|
|
try:
|
|
img = transformer.img_in(x)
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to embed image: {str(e)}")
|
|
|
|
if transformer.enable_teacache:
|
|
try:
|
|
# 使用原地操作减少内存使用
|
|
inp = img.clone()
|
|
vec_ = vec.clone()
|
|
|
|
# 获取调制参数
|
|
modulation_output = transformer.double_blocks[0].img_mod(vec_)
|
|
|
|
# 处理调制输出
|
|
if isinstance(modulation_output, tuple):
|
|
if len(modulation_output) >= 2:
|
|
mod_shift = modulation_output[0]
|
|
mod_scale = modulation_output[1]
|
|
if hasattr(mod_shift, 'shift') and hasattr(mod_scale, 'scale'):
|
|
img_mod1_shift = mod_shift.shift
|
|
img_mod1_scale = mod_scale.scale
|
|
else:
|
|
img_mod1_shift = mod_shift
|
|
img_mod1_scale = mod_scale
|
|
else:
|
|
raise ValueError(f"Tuple too short, expected at least 2 elements, got {len(modulation_output)}")
|
|
elif hasattr(modulation_output, 'shift') and hasattr(modulation_output, 'scale'):
|
|
img_mod1_shift = modulation_output.shift
|
|
img_mod1_scale = modulation_output.scale
|
|
elif hasattr(modulation_output, 'chunk'):
|
|
chunks = modulation_output.chunk(6, dim=-1)
|
|
img_mod1_shift = chunks[0]
|
|
img_mod1_scale = chunks[1]
|
|
else:
|
|
raise ValueError(f"Unsupported modulation output format: {type(modulation_output)}")
|
|
|
|
# 确保获取到的是张量
|
|
if not isinstance(img_mod1_shift, torch.Tensor) or not isinstance(img_mod1_scale, torch.Tensor):
|
|
raise ValueError(f"Failed to get tensor values for shift and scale")
|
|
|
|
# 应用归一化和调制
|
|
normed_inp = transformer.double_blocks[0].img_norm1(inp)
|
|
del inp # 释放内存
|
|
|
|
modulated_inp = modulate(normed_inp, shift=img_mod1_shift, scale=img_mod1_scale)
|
|
del normed_inp # 释放内存
|
|
|
|
# 计算相对 L1 距离并决定是否需要计算
|
|
if transformer.cnt == 0 or transformer.cnt == transformer.num_steps - 1:
|
|
should_calc = True
|
|
transformer.accumulated_rel_l1_distance = 0
|
|
else:
|
|
try:
|
|
coefficients = [7.33226126e+02, -4.01131952e+02, 6.75869174e+01, -3.14987800e+00, 9.61237896e-02]
|
|
rescale_func = np.poly1d(coefficients)
|
|
rel_l1 = ((modulated_inp - transformer.previous_modulated_input).abs().mean() /
|
|
(transformer.previous_modulated_input.abs().mean() + 1e-6)).cpu().item()
|
|
transformer.accumulated_rel_l1_distance += rescale_func(rel_l1)
|
|
|
|
if transformer.accumulated_rel_l1_distance < transformer.rel_l1_thresh:
|
|
should_calc = False
|
|
else:
|
|
should_calc = True
|
|
transformer.accumulated_rel_l1_distance = 0
|
|
except Exception as e:
|
|
should_calc = True
|
|
|
|
transformer.previous_modulated_input = modulated_inp
|
|
transformer.cnt += 1
|
|
|
|
except Exception as e:
|
|
should_calc = True
|
|
|
|
except Exception as e:
|
|
should_calc = True
|
|
|
|
# 如果需要计算,调用原始的 forward 方法
|
|
if should_calc:
|
|
try:
|
|
out = transformer.original_forward(x, timestep, context, y, guidance,
|
|
attention_mask=attention_mask,
|
|
control=control,
|
|
transformer_options=transformer_options,
|
|
**kwargs)
|
|
transformer.previous_residual = out
|
|
return out
|
|
except Exception as e:
|
|
raise
|
|
else:
|
|
# 如果不需要计算,返回之前的结果
|
|
return transformer.previous_residual
|
|
|
|
def sample(self, noise, guider, sampler, sigmas, latent_image, speedup, enable_custom_speed=False, custom_speed=1.0):
|
|
"""Sampling implementation"""
|
|
device = comfy.model_management.get_torch_device()
|
|
|
|
# 定义预设速度的阈值映射
|
|
predefined_speeds = [1.0, 1.6, 2.1, 3.2, 4.4]
|
|
predefined_thresholds = [0.0, 0.1, 0.15, 0.25, 0.35]
|
|
|
|
# 根据是否启用自定义速度来决定使用哪个阈值
|
|
if enable_custom_speed:
|
|
if not (1.0 <= custom_speed <= 4.4):
|
|
raise ValueError("Custom speed must be between 1.0 and 4.4")
|
|
threshold = self.calculate_threshold(custom_speed)
|
|
else:
|
|
# 定义预设的速度选项
|
|
thresh_map = {
|
|
"Original (1x)": 0.0,
|
|
"Fast (1.6x)": 0.1,
|
|
"Faster (2.1x)": 0.15,
|
|
"Ultra Fast (3.2x)": 0.25,
|
|
"Shapeless Fast (4.4x)": 0.35
|
|
}
|
|
if speedup not in thresh_map:
|
|
raise ValueError(f"Unsupported speedup option: {speedup}")
|
|
threshold = thresh_map[speedup]
|
|
|
|
try:
|
|
# 获取 transformer
|
|
transformer = guider.model_patcher.model.diffusion_model
|
|
|
|
# 初始化 TeaCache 状态
|
|
transformer.enable_teacache = True
|
|
transformer.cnt = 0
|
|
transformer.num_steps = len(sigmas) - 1
|
|
transformer.rel_l1_thresh = threshold
|
|
transformer.accumulated_rel_l1_distance = 0
|
|
transformer.previous_modulated_input = None
|
|
transformer.previous_residual = None
|
|
|
|
latent = latent_image
|
|
latent_image = latent["samples"].clone()
|
|
latent = latent.copy()
|
|
|
|
noise_mask = None
|
|
if "noise_mask" in latent:
|
|
noise_mask = latent["noise_mask"].clone()
|
|
|
|
# 保存原始 forward 方法
|
|
transformer.original_forward = transformer.forward
|
|
|
|
# 使用 lambda 替换 forward 方法,确保正确绑定 self
|
|
transformer.forward = lambda x, t, context=None, y=None, guidance=None, attention_mask=None, control=None, transformer_options={}, **kwargs: self.teacache_forward(
|
|
transformer, x, t, context, y, guidance, attention_mask, control, transformer_options, **kwargs
|
|
)
|
|
|
|
try:
|
|
x0_output = {}
|
|
callback = latent_preview.prepare_callback(guider.model_patcher, sigmas.shape[-1] - 1, x0_output)
|
|
|
|
disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED
|
|
samples = guider.sample(
|
|
noise.generate_noise(latent),
|
|
latent_image,
|
|
sampler,
|
|
sigmas,
|
|
denoise_mask=noise_mask,
|
|
callback=callback,
|
|
disable_pbar=disable_pbar,
|
|
seed=noise.seed
|
|
)
|
|
samples = samples.to(comfy.model_management.intermediate_device())
|
|
|
|
finally:
|
|
# 恢复原始的 forward 方法
|
|
transformer.forward = transformer.original_forward
|
|
delattr(transformer, 'original_forward')
|
|
transformer.enable_teacache = False
|
|
|
|
out = latent.copy()
|
|
out["samples"] = samples
|
|
if "x0" in x0_output:
|
|
out_denoised = latent.copy()
|
|
out_denoised["samples"] = guider.model_patcher.model.process_latent_out(x0_output["x0"].cpu())
|
|
else:
|
|
out_denoised = out
|
|
|
|
return (out, out_denoised)
|
|
|
|
except Exception as e:
|
|
raise RuntimeError(f"Sampling failed: {str(e)}")
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"TTPlanet_Tile_Preprocessor_Simple": TTPlanet_Tile_Preprocessor_Simple,
|
|
"TTP_Image_Tile_Batch": TTP_Image_Tile_Batch,
|
|
"TTP_Image_Assy": TTP_Image_Assy,
|
|
"TTP_CoordinateSplitter": TTP_CoordinateSplitter,
|
|
"TTP_condtobatch": TTP_condtobatch,
|
|
"TTP_condsetarea_merge": TTP_condsetarea_merge,
|
|
"TTP_Tile_image_size": Tile_imageSize,
|
|
"TTP_condsetarea_merge_test": TTP_condsetarea_merge_test,
|
|
"TTP_Expand_And_Mask": TTP_Expand_And_Mask,
|
|
"TTP_text_mix": TTP_text_mix,
|
|
"TeaCacheHunyuanVideoSampler": TeaCacheHunyuanVideoSampler,
|
|
"TTP_Smart_Tile_Interactive_Crop_Experimental": TTP_Smart_Tile_Interactive_Crop_Experimental,
|
|
"TTP_Smart_Tile_Set_Preview_Experimental": TTP_Smart_Tile_Set_Preview_Experimental,
|
|
"TTP_QwenVL3_Local_Loader_Experimental": TTP_QwenVL3_Local_Loader_Experimental,
|
|
"TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental": TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental,
|
|
"TTP_Smart_Tile_Loop_Source_Experimental": TTP_Smart_Tile_Loop_Source_Experimental,
|
|
"TTP_Smart_Tile_Loop_Collect_Experimental": TTP_Smart_Tile_Loop_Collect_Experimental,
|
|
"TTP_Smart_Tile_Image_Upscale_Prep_Experimental": TTP_Smart_Tile_Image_Upscale_Prep_Experimental,
|
|
"TTP_Smart_Tile_Assemble_Experimental": TTP_Smart_Tile_Assemble_Experimental,
|
|
"TTP_Smart_Tile_Save_Final_Image_Experimental": TTP_Smart_Tile_Save_Final_Image_Experimental,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"TTPlanet_Tile_Preprocessor_Simple": "TTP Tile Preprocessor Simple",
|
|
"TTP_Image_Tile_Batch": "TTP_Image_Tile_Batch",
|
|
"TTP_Image_Assy": "TTP_Image_Assy",
|
|
"TTP_CoordinateSplitter": "TTP_CoordinateSplitter",
|
|
"TTP_condtobatch": "TTP_cond to batch",
|
|
"TTP_condsetarea_merge": "TTP_condsetarea_merge",
|
|
"TTP_Tile_image_size": "TTP_Tile_image_size",
|
|
"TTP_condsetarea_merge_test": "TTP_condsetarea_merge_test",
|
|
"TTP_Expand_And_Mask": "TTP_Expand_And_Mask",
|
|
"TTP_text_mix": "TTP_text_mix",
|
|
"TeaCacheHunyuanVideoSampler": "TTP_TeaCache HunyuanVideo Sampler",
|
|
"TTP_Smart_Tile_Interactive_Crop_Experimental": "TTP Smart Tile Interactive Crop",
|
|
"TTP_Smart_Tile_Set_Preview_Experimental": "TTP Smart Tile Set Preview",
|
|
"TTP_QwenVL3_Local_Loader_Experimental": "TTP QwenVL3 Local Loader",
|
|
"TTP_Smart_Tile_QwenVL_Prompt_Set_Builder_Experimental": "TTP Smart Tile QwenVL Prompt Set Builder",
|
|
"TTP_Smart_Tile_Loop_Source_Experimental": "TTP Smart Tile Loop Source",
|
|
"TTP_Smart_Tile_Loop_Collect_Experimental": "TTP Smart Tile Loop Collect",
|
|
"TTP_Smart_Tile_Image_Upscale_Prep_Experimental": "TTP Smart Tile Image Upscale Prep",
|
|
"TTP_Smart_Tile_Assemble_Experimental": "TTP Smart Tile Assemble",
|
|
"TTP_Smart_Tile_Save_Final_Image_Experimental": "TTP Smart Tile Save Final Image",
|
|
}
|