Files
IsulionandClaude Opus 5.5 96553dee1c feat: v6.1.0 — Censored Part & Prompt Vault nodes, selector RNG, hardening
New nodes:
- Isulion Censored Part: blur/pixelate/black bar/noise over a mask and/or
  a percent rectangle; accepts 2D/3D/4D masks and fails closed (blacks the
  item out) instead of passing an uncensored image through
- Prompt Vault Save/Load: named prompt library stored in ComfyUI's user
  directory (outside the checkout, so updates never conflict), seeded from
  prompt_vault.example.json; Load re-runs when the vault changes

Refactor:
- SimpleSelectorNode base: ~20 selector nodes use an instance RNG instead
  of reseeding the global random module (same seed => same output)
- Theme handlers declare DISPLAY_NAME; theme package auto-discovers modules
- __init__ imports node classes directly

Fixes:
- net_utils: re-validate every redirect hop, unwrap IPv4-mapped IPv6,
  block NAT64 and all non-global addresses (SSRF)
- Multiple Prompt Generator: no duplicate prompts for themes listed in two
  categories; 6 themes missing from every category are now reachable
- Shutdown node: parameter renamed to match its "any" input (TypeError)
- ConfigManager: deterministic (sorted) merge order, warns on key
  collisions; drop stale space_colony copy from tropical_paradise_config
- Mega Prompt V3: seed honored again with randomize=disable

Breaking: Civitai api_key widget removed (use CIVITAI_API_TOKEN), absolute
paths refused by Load Images, Negative Prompt pools de-duplicated (shorter
"strict" output), Dynamic Random theme order is now alphabetical.

Tooling: pytest suite (51 tests), GitHub CI (compileall, pytest, ruff),
ruff config, uv.lock.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 18:35:56 +02:00

290 lines
15 KiB
Python

# Core_Nodes/qrcode_node.py
import torch
import numpy as np
import traceback # For detailed error printing
import logging
logger = logging.getLogger(__name__)
# Try to import the bundled qrcodegen module
try:
from . import qrcodegen
qrcodegen_available = True
except ImportError:
logger.error("QRCodeNode - Could not import the bundled 'qrcodegen.py'.")
logger.error("Please ensure 'qrcodegen.py' is in the same directory as 'qrcode_node.py'.")
logger.error("Download it from: https://github.com/nayuki/QR-Code-generator/blob/master/python/qrcodegen.py")
qrcodegen_available = False
except Exception as e:
logger.error(f"QRCodeNode - An unexpected error occurred importing qrcodegen: {e}")
qrcodegen_available = False
# --- Helper Function for Color Parsing ---
def hex_to_rgb(hex_color):
"""Converts a hex color string (e.g., '#FF0000') to an RGB tuple (0-255). Returns None on error."""
hex_color = hex_color.lstrip('#')
if len(hex_color) != 6:
return None
try:
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
except ValueError:
return None
# --- The Node Class ---
class QRCodeNode:
"""
Generates a QR Code image from input text (e.g., URL).
Allows customization of colors, scale, border, optional logo overlay,
and optional transparent background. Supports RGBA input logos.
Uses the bundled 'qrcodegen.py'. Requires 'qrcodegen.py' in the same directory.
NOTE: ComfyUI preview might not display transparency correctly; verify with SaveImage (PNG).
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": "https://civitai.com/user/Isulion"}),
"error_correction": (["L", "M", "Q", "H"], {"default": "H"}),
"scale": ("INT", {"default": 10, "min": 1, "max": 100, "step": 1, "display": "number"}),
"border": ("INT", {"default": 4, "min": 0, "max": 40, "step": 1, "display": "number"}),
"foreground_color": ("STRING", {"default": "#000000", "multiline": False}),
"background_color": ("STRING", {"default": "#FFFFFF", "multiline": False}),
"transparent_background": ("BOOLEAN", {"default": False}), # Option for transparency
},
"optional": {
"logo": ("IMAGE",), # Optional input image (expects B, H, W, C float tensor)
"logo_scale_percent": ("FLOAT", {"default": 23.0, "min": 5.0, "max": 33.0, "step": 0.5, "display": "number"}),
"clear_behind_logo": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = ("IMAGE",) # Output is IMAGE type, ComfyUI handles RGBA (B, H, W, 4)
FUNCTION = "generate_qr_code"
CATEGORY = "image"
def generate_qr_code(self, text, error_correction, scale, border,
foreground_color, background_color, transparent_background,
logo=None, logo_scale_percent=23.0, clear_behind_logo=True):
# Determine output channels and create a dummy tensor for error returns
output_channels = 4 if transparent_background else 3
# Create dummy tensor matching expected output channels
dummy_tensor = torch.zeros((1, 64, 64, output_channels), dtype=torch.float32)
if not qrcodegen_available:
logger.error("QRCodeNode - Bundled 'qrcodegen' module is not available.")
return (dummy_tensor,)
data_to_encode = text
# --- 1. Parse Colors and Define Final Color Tuples (RGB or RGBA) ---
fg_rgb = hex_to_rgb(foreground_color) or (0, 0, 0)
bg_rgb = hex_to_rgb(background_color) or (255, 255, 255)
if transparent_background:
logger.info("QRCodeNode - Generating RGBA image with transparent background.")
# Foreground is opaque black/color, Background is transparent white/color
final_fg_color = fg_rgb + (255,) # RGBA, Alpha=255 (Opaque)
final_bg_color = bg_rgb + (0,) # RGBA, Alpha=0 (Transparent)
final_channels = 4
else:
# Standard RGB
final_fg_color = fg_rgb # RGB
final_bg_color = bg_rgb # RGB
final_channels = 3
# Handle empty text input gracefully
if not data_to_encode:
logger.warning("QRCodeNode - Input text is empty. Generating blank QR code area.")
img_size = max(64, (border * 2 + 21) * scale) # Estimate minimum size
try:
# Use the determined final background color and channels
img_np = np.full((img_size, img_size, final_channels), final_bg_color, dtype=np.uint8)
img_tensor_out = torch.from_numpy(img_np.astype(np.float32) / 255.0).unsqueeze(0).cpu()
return (img_tensor_out,)
except Exception as e:
logger.error(f"QRCodeNode - Failed to create blank image: {e}")
return (dummy_tensor,)
# --- 2. Generate QR Code Structure ---
ecl_map = { "L": qrcodegen.QrCode.Ecc.LOW, "M": qrcodegen.QrCode.Ecc.MEDIUM,
"Q": qrcodegen.QrCode.Ecc.QUARTILE, "H": qrcodegen.QrCode.Ecc.HIGH }
ecl = ecl_map.get(error_correction, qrcodegen.QrCode.Ecc.HIGH)
try:
qr = qrcodegen.QrCode.encode_text(data_to_encode, ecl)
except qrcodegen.DataTooLongError as e:
logger.error(f"QRCodeNode - Data too long. Reduce text or lower Error Correction ('{error_correction}'). Details: {str(e)}")
return (dummy_tensor,)
except ValueError as e:
logger.error(f"QRCodeNode - Value error during encoding (invalid chars?). Details: {str(e)}")
return (dummy_tensor,)
except Exception as e:
logger.error(f"QRCodeNode - Error generating QR code structure: {str(e)}\n{traceback.format_exc()}")
return (dummy_tensor,)
# --- 3. Draw QR Code Base Image (RGB or RGBA) ---
qr_size = qr.get_size()
if qr_size <= 0:
logger.error("QRCodeNode - QR Code generation resulted in zero size.")
return (dummy_tensor,)
module_size = qr_size + border * 2
image_size = module_size * scale
if image_size <= 0 or image_size > 16384: # Size check
logger.error(f"QRCodeNode - Invalid calculated image size ({image_size}x{image_size}). Check scale/border.")
return (dummy_tensor,)
# Create base image with the correct number of channels and background color
try:
# Use final_channels and final_bg_color determined earlier
img_np = np.full((image_size, image_size, final_channels), final_bg_color, dtype=np.uint8)
except MemoryError:
logger.error(f"QRCodeNode - Not enough memory for {image_size}x{image_size}x{final_channels} image.")
return (dummy_tensor,)
except Exception as e:
logger.error(f"QRCodeNode - Failed to create numpy array for QR code: {e}")
return (dummy_tensor,)
# Draw the QR code modules using the final foreground color (RGB or RGBA).
# Vectorized: build a boolean module matrix, upscale it with np.kron,
# then assign the foreground color through the mask (no per-module loops).
modules = np.fromiter(
(qr.get_module(x, y) for y in range(qr_size) for x in range(qr_size)),
dtype=bool, count=qr_size * qr_size,
).reshape(qr_size, qr_size)
padded_modules = np.pad(modules, pad_width=border, constant_values=False)
fg_mask = np.kron(padded_modules, np.ones((scale, scale), dtype=bool))
img_np[fg_mask] = final_fg_color
# --- 4. Handle Optional Logo ---
if logo is not None and logo.numel() > 0:
# Warnings about ECL vs logo size
if ecl == qrcodegen.QrCode.Ecc.LOW:
logger.warning("QRCodeNode - Using logo with LOW ECL is not recommended.")
elif ecl == qrcodegen.QrCode.Ecc.MEDIUM and logo_scale_percent > 20:
logger.warning("QRCodeNode - Logo > 20% with MEDIUM ECL might impact scannability.")
try:
# --- 4a. Prepare Input Logo ---
logo_input_tensor = logo # B, H, W, C float
if logo_input_tensor.shape[0] > 1:
logger.warning("QRCodeNode - Multiple logos in batch, using only the first one.")
logo_np_float = logo_input_tensor[0].cpu().numpy() # H, W, C float [0, 1]
# Check input logo channels and handle grayscale
input_logo_channels = logo_np_float.shape[2] if logo_np_float.ndim == 3 else 1
if logo_np_float.ndim == 2: # Grayscale (H, W)
logger.info("QRCodeNode - Input logo is grayscale, converting to RGB.")
logo_np_float = np.stack((logo_np_float,) * 3, axis=-1) # -> H, W, 3
input_logo_channels = 3
elif input_logo_channels == 1: # Grayscale (H, W, 1)
logger.info("QRCodeNode - Input logo is grayscale (1 channel), converting to RGB.")
logo_np_float = np.concatenate((logo_np_float,) * 3, axis=-1) # -> H, W, 3
input_logo_channels = 3
elif input_logo_channels == 4: # RGBA
logger.info("QRCodeNode - Input logo has alpha channel.")
elif input_logo_channels != 3: # RGB
logger.warning(f"QRCodeNode - Input logo has unsupported channel count ({input_logo_channels}). Treating as RGB.")
logo_np_float = logo_np_float[:, :, :3] # Take first 3 channels if > 4? Risky. Better to just treat as RGB.
input_logo_channels = 3
# Convert prepared logo (RGB or RGBA float) to uint8
logo_np_uint8 = np.clip(logo_np_float * 255.0, 0, 255).astype(np.uint8)
logo_h, logo_w = logo_np_uint8.shape[:2]
# --- 4b. Calculate Logo Size and Position ---
qr_data_area_size_pixels = qr_size * scale
max_logo_dim = max(1, int(qr_data_area_size_pixels * (logo_scale_percent / 100.0)))
aspect_ratio = logo_w / logo_h if logo_h > 0 else 1
target_w = max_logo_dim if logo_w >= logo_h else max(1, int(max_logo_dim * aspect_ratio))
target_h = max_logo_dim if logo_h > logo_w else max(1, int(max_logo_dim / aspect_ratio))
if target_w <= 0 or target_h <= 0:
logger.warning("QRCodeNode - Calculated logo dimensions are zero/negative. Skipping overlay.")
else:
# --- 4c. Resize Logo ---
# Resize using torch interpolation (requires float NCHW)
logo_tensor_for_resize = torch.from_numpy(logo_np_uint8.astype(np.float32) / 255.0).permute(2, 0, 1).unsqueeze(0).cpu()
resized_logo_tensor = torch.nn.functional.interpolate(
logo_tensor_for_resize, size=(target_h, target_w), mode='bicubic', align_corners=False
)
# Convert back to HWC uint8 NumPy array
resized_logo_np = (resized_logo_tensor.squeeze(0).permute(1, 2, 0).cpu().numpy() * 255).astype(np.uint8)
resized_logo_channels = resized_logo_np.shape[2]
# --- 4d. Calculate Placement ---
offset_x = max(0, (image_size - target_w) // 2)
offset_y = max(0, (image_size - target_h) // 2)
slice_y = slice(offset_y, min(offset_y + target_h, image_size))
slice_x = slice(offset_x, min(offset_x + target_w, image_size))
# Adjust logo size if placement goes out of bounds
bg_slice_shape = img_np[slice_y, slice_x].shape
logo_to_blend = resized_logo_np[:bg_slice_shape[0], :bg_slice_shape[1], :]
if logo_to_blend.size == 0:
logger.warning("QRCodeNode - Logo placement resulted in zero-sized area. Skipping overlay.")
else:
# --- 4e. Clear Background Area ---
if clear_behind_logo:
# Use the final background color (RGB or RGBA)
img_np[slice_y, slice_x] = final_bg_color
# --- 4f. Blend Logo onto QR Image ---
# Perform calculations using float32 for precision
current_bg_area_float = img_np[slice_y, slice_x].astype(np.float32) / 255.0
logo_to_blend_float = logo_to_blend.astype(np.float32) / 255.0
final_blended_area_float = current_bg_area_float # Initialize with background
# Extract logo alpha (assume 1.0 if logo is RGB)
logo_alpha = logo_to_blend_float[:, :, 3:4] if resized_logo_channels == 4 else np.ones_like(logo_to_blend_float[:, :, 0:1])
# Extract background alpha (use 1.0 if background is RGB)
bg_alpha = current_bg_area_float[:, :, 3:4] if final_channels == 4 else np.ones_like(current_bg_area_float[:, :, 0:1])
# Blend RGB channels using "over" compositing: Co = Fa*alpha_f + Ba*(1-alpha_f)
# Ensure logo has 3 channels for RGB blending
logo_rgb = logo_to_blend_float[:, :, :3]
bg_rgb = current_bg_area_float[:, :, :3]
blended_rgb = logo_rgb * logo_alpha + bg_rgb * (1.0 - logo_alpha)
# Calculate output alpha using "over" compositing: Ao = alpha_f + alpha_b*(1-alpha_f)
blended_alpha = logo_alpha + bg_alpha * (1.0 - logo_alpha)
if final_channels == 4: # RGBA Output
final_blended_area_float = np.concatenate((blended_rgb, blended_alpha), axis=2)
else: # RGB Output
final_blended_area_float = blended_rgb # Discard calculated alpha
# Convert back to uint8 and assign to the main image
blended_area_uint8 = np.clip(final_blended_area_float * 255.0, 0, 255).astype(np.uint8)
img_np[slice_y, slice_x] = blended_area_uint8
except Exception as e:
logger.error(f"QRCodeNode - Error processing or overlaying logo: {str(e)}\n{traceback.format_exc()}")
# Continue without logo if processing fails
# --- 5. Convert Final Image to Torch Tensor ---
try:
# Convert the final NumPy array (potentially RGBA) to tensor
img_tensor_out = torch.from_numpy(img_np.astype(np.float32) / 255.0).unsqueeze(0).cpu()
# Final check on tensor shape
if img_tensor_out.shape[3] != final_channels:
logger.error(f"QRCodeNode - Final tensor channel mismatch! Expected {final_channels}, got {img_tensor_out.shape[3]}. Returning dummy.")
return (dummy_tensor,)
# print(f"DEBUG: QRCodeNode - Output tensor shape: {img_tensor_out.shape}") # Optional debug
except Exception as e:
logger.error(f"QRCodeNode - Failed to convert final numpy array to tensor: {e}")
return (dummy_tensor,)
return (img_tensor_out,)