Adds a stronger SDXL QR hidden-image workflow and separates reusable blueprints from the runnable workflows, alongside node and installer updates. - workflows/sdxl_qr_hidden_image_ultra_strong.json: new higher-strength variant of the SDXL QR workflow. - workflow_blueprints/: blueprint copies of the flux ghost, sd15 QR, sdxl QR, and unified router graphs, kept separate from the executable workflows. - illusion_nodes.py, install.bat, docs/WORKFLOWS.md, README.md: node and install/documentation updates to match.
271 lines
9.4 KiB
Python
271 lines
9.4 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Iterable, List, Tuple
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont, ImageOps
|
|
|
|
|
|
def _batched_image(image: torch.Tensor) -> torch.Tensor:
|
|
if image.ndim == 3:
|
|
return image.unsqueeze(0)
|
|
return image
|
|
|
|
|
|
def _tensor_to_pil_batch(image: torch.Tensor) -> List[Image.Image]:
|
|
image = _batched_image(image).detach().cpu().clamp(0.0, 1.0)
|
|
array = (image.numpy() * 255.0).astype(np.uint8)
|
|
return [Image.fromarray(frame, mode="RGB") for frame in array]
|
|
|
|
|
|
def _pil_batch_to_image(images: Iterable[Image.Image]) -> torch.Tensor:
|
|
frames = []
|
|
for image in images:
|
|
rgb = image.convert("RGB")
|
|
frames.append(np.asarray(rgb).astype(np.float32) / 255.0)
|
|
return torch.from_numpy(np.stack(frames, axis=0))
|
|
|
|
|
|
def _pil_batch_to_mask(images: Iterable[Image.Image]) -> torch.Tensor:
|
|
frames = []
|
|
for image in images:
|
|
grayscale = image.convert("L")
|
|
frames.append(np.asarray(grayscale).astype(np.float32) / 255.0)
|
|
return torch.from_numpy(np.stack(frames, axis=0))
|
|
|
|
|
|
def _load_font(font_size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
font_candidates = [
|
|
"DejaVuSans-Bold.ttf",
|
|
"Arial Bold.ttf",
|
|
"Arial.ttf",
|
|
]
|
|
for candidate in font_candidates:
|
|
try:
|
|
return ImageFont.truetype(candidate, font_size)
|
|
except OSError:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
|
|
def _remap_levels(values: np.ndarray, midpoint: float, contrast: float) -> np.ndarray:
|
|
normalized = values / 255.0
|
|
shifted = (normalized - midpoint) * contrast + 0.5
|
|
return np.clip(shifted, 0.0, 1.0)
|
|
|
|
|
|
class IllusionPatternPreprocessor:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"blur_radius": ("FLOAT", {"default": 6.0, "min": 0.0, "max": 64.0, "step": 0.5}),
|
|
"contrast": ("FLOAT", {"default": 1.6, "min": 0.1, "max": 4.0, "step": 0.05}),
|
|
"brightness": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 3.0, "step": 0.05}),
|
|
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"invert": ("BOOLEAN", {"default": False}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK")
|
|
RETURN_NAMES = ("image", "mask")
|
|
FUNCTION = "process"
|
|
CATEGORY = "NanoBanana/Illusion"
|
|
|
|
def process(
|
|
self,
|
|
image: torch.Tensor,
|
|
blur_radius: float,
|
|
contrast: float,
|
|
brightness: float,
|
|
threshold: float,
|
|
invert: bool,
|
|
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
processed_images = []
|
|
processed_masks = []
|
|
|
|
for frame in _tensor_to_pil_batch(image):
|
|
grayscale = ImageOps.grayscale(frame)
|
|
if blur_radius > 0:
|
|
grayscale = grayscale.filter(ImageFilter.GaussianBlur(radius=blur_radius))
|
|
if contrast != 1.0:
|
|
grayscale = ImageEnhance.Contrast(grayscale).enhance(contrast)
|
|
if brightness != 1.0:
|
|
grayscale = ImageEnhance.Brightness(grayscale).enhance(brightness)
|
|
if invert:
|
|
grayscale = ImageOps.invert(grayscale)
|
|
|
|
cutoff = int(max(0.0, min(1.0, threshold)) * 255)
|
|
mask = grayscale.point(lambda value: 255 if value >= cutoff else 0)
|
|
processed_images.append(grayscale.convert("RGB"))
|
|
processed_masks.append(mask)
|
|
|
|
return _pil_batch_to_image(processed_images), _pil_batch_to_mask(processed_masks)
|
|
|
|
|
|
class IllusionTextMask:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"text": ("STRING", {"multiline": True, "default": "NANOBANANA"}),
|
|
"width": ("INT", {"default": 1024, "min": 128, "max": 4096, "step": 64}),
|
|
"height": ("INT", {"default": 1024, "min": 128, "max": 4096, "step": 64}),
|
|
"font_size": ("INT", {"default": 180, "min": 12, "max": 1024, "step": 2}),
|
|
"margin": ("INT", {"default": 64, "min": 0, "max": 512, "step": 2}),
|
|
"invert": ("BOOLEAN", {"default": False}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE", "MASK")
|
|
RETURN_NAMES = ("image", "mask")
|
|
FUNCTION = "render"
|
|
CATEGORY = "NanoBanana/Illusion"
|
|
|
|
def render(
|
|
self,
|
|
text: str,
|
|
width: int,
|
|
height: int,
|
|
font_size: int,
|
|
margin: int,
|
|
invert: bool,
|
|
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
background = 255 if invert else 0
|
|
foreground = 0 if invert else 255
|
|
|
|
image = Image.new("L", (width, height), color=background)
|
|
draw = ImageDraw.Draw(image)
|
|
font = _load_font(font_size)
|
|
content = text.strip() or "NANOBANANA"
|
|
bbox = draw.multiline_textbbox((0, 0), content, font=font, align="center", spacing=max(8, font_size // 10))
|
|
text_width = bbox[2] - bbox[0]
|
|
text_height = bbox[3] - bbox[1]
|
|
x = max(margin, (width - text_width) / 2)
|
|
y = max(margin, (height - text_height) / 2)
|
|
draw.multiline_text((x, y), content, fill=foreground, font=font, align="center", spacing=max(8, font_size // 10))
|
|
|
|
return _pil_batch_to_image([image.convert("RGB")]), _pil_batch_to_mask([image])
|
|
|
|
|
|
class IllusionMagicScheduler:
|
|
PRESETS = {
|
|
"sd15": {
|
|
"strength": (0.55, 1.35),
|
|
"end_percent": (0.45, 1.0),
|
|
"cfg": (5.5, 8.5),
|
|
"blur": (10.0, 2.0),
|
|
"latent": (0.15, 0.45),
|
|
},
|
|
"sdxl": {
|
|
"strength": (0.85, 1.6),
|
|
"end_percent": (0.7, 1.0),
|
|
"cfg": (3.2, 5.0),
|
|
"blur": (12.0, 3.0),
|
|
"latent": (0.12, 0.35),
|
|
},
|
|
"flux": {
|
|
"strength": (0.18, 0.55),
|
|
"end_percent": (0.2, 0.65),
|
|
"cfg": (2.5, 4.0),
|
|
"blur": (18.0, 5.0),
|
|
"latent": (0.3, 0.8),
|
|
},
|
|
}
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"model_family": (["sd15", "sdxl", "flux"],),
|
|
"magic_slider": ("FLOAT", {"default": 0.72, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("FLOAT", "FLOAT", "FLOAT", "FLOAT", "FLOAT")
|
|
RETURN_NAMES = (
|
|
"controlnet_strength",
|
|
"start_percent",
|
|
"end_percent",
|
|
"recommended_cfg",
|
|
"latent_mask_strength",
|
|
)
|
|
FUNCTION = "schedule"
|
|
CATEGORY = "NanoBanana/Illusion"
|
|
|
|
def schedule(self, model_family: str, magic_slider: float) -> Tuple[float, float, float, float, float]:
|
|
slider = max(0.0, min(1.0, magic_slider))
|
|
preset = self.PRESETS[model_family]
|
|
strength = preset["strength"][0] + (preset["strength"][1] - preset["strength"][0]) * slider
|
|
end_percent = preset["end_percent"][0] + (preset["end_percent"][1] - preset["end_percent"][0]) * slider
|
|
cfg = preset["cfg"][0] + (preset["cfg"][1] - preset["cfg"][0]) * slider
|
|
latent = preset["latent"][0] + (preset["latent"][1] - preset["latent"][0]) * slider
|
|
return (round(strength, 4), 0.0, round(end_percent, 4), round(cfg, 4), round(latent, 4))
|
|
|
|
|
|
class IllusionLatentMask:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"blur_radius": ("FLOAT", {"default": 12.0, "min": 0.0, "max": 64.0, "step": 0.5}),
|
|
"contrast": ("FLOAT", {"default": 1.8, "min": 0.1, "max": 4.0, "step": 0.05}),
|
|
"midpoint": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"mask_strength": ("FLOAT", {"default": 0.55, "min": 0.0, "max": 1.0, "step": 0.01}),
|
|
"invert": ("BOOLEAN", {"default": False}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("MASK", "IMAGE")
|
|
RETURN_NAMES = ("mask", "preview")
|
|
FUNCTION = "build"
|
|
CATEGORY = "NanoBanana/Illusion"
|
|
|
|
def build(
|
|
self,
|
|
image: torch.Tensor,
|
|
blur_radius: float,
|
|
contrast: float,
|
|
midpoint: float,
|
|
mask_strength: float,
|
|
invert: bool,
|
|
) -> Tuple[torch.Tensor, torch.Tensor]:
|
|
masks = []
|
|
previews = []
|
|
|
|
for frame in _tensor_to_pil_batch(image):
|
|
grayscale = ImageOps.grayscale(frame)
|
|
if invert:
|
|
grayscale = ImageOps.invert(grayscale)
|
|
if blur_radius > 0:
|
|
grayscale = grayscale.filter(ImageFilter.GaussianBlur(radius=blur_radius))
|
|
|
|
remapped = _remap_levels(np.asarray(grayscale).astype(np.float32), midpoint, contrast)
|
|
remapped = np.clip(remapped * mask_strength, 0.0, 1.0)
|
|
mask = Image.fromarray((remapped * 255.0).astype(np.uint8), mode="L")
|
|
preview = mask.convert("RGB")
|
|
|
|
masks.append(mask)
|
|
previews.append(preview)
|
|
|
|
return _pil_batch_to_mask(masks), _pil_batch_to_image(previews)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"IllusionPatternPreprocessor": IllusionPatternPreprocessor,
|
|
"IllusionTextMask": IllusionTextMask,
|
|
"IllusionMagicScheduler": IllusionMagicScheduler,
|
|
"IllusionLatentMask": IllusionLatentMask,
|
|
}
|
|
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"IllusionPatternPreprocessor": "NanoBanana Pattern Preprocessor",
|
|
"IllusionTextMask": "NanoBanana Text Mask",
|
|
"IllusionMagicScheduler": "NanoBanana Magic Scheduler",
|
|
"IllusionLatentMask": "NanoBanana Latent Mask",
|
|
}
|