Files
Geekatplay Dev 281401e3da feat: ultra-strong SDXL QR variant, workflow blueprints, router updates
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.
2026-08-07 13:22:08 -06:00

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",
}