add
This commit is contained in:
+126
@@ -0,0 +1,126 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# ComfyUI specific
|
||||
models/
|
||||
input/
|
||||
output/
|
||||
temp/
|
||||
@@ -0,0 +1,64 @@
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
class CheckerboardNode:
|
||||
CATEGORY = "illusion"
|
||||
FUNCTION = "generate_checkerboard"
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"img1": ("IMAGE",), # Première image ou couleur
|
||||
"img2": ("IMAGE",), # Deuxième image ou couleur
|
||||
"tiles_x": ("INT", {"default": 8, "min": 1, "max": 128}), # Cases sur X
|
||||
"tiles_y": ("INT", {"default": 8, "min": 1, "max": 128}), # Cases sur Y
|
||||
"tile_width": ("INT", {"default": 128, "min": 8, "max": 1024}), # Largeur carreau
|
||||
"tile_height": ("INT", {"default": 128, "min": 8, "max": 1024}), # Hauteur carreau
|
||||
"tile_mode": (["crop", "resize"], {"default": "resize"}),
|
||||
}
|
||||
}
|
||||
|
||||
def generate_checkerboard(self, img1, img2, tiles_x, tiles_y, tile_width, tile_height, tile_mode):
|
||||
# Conversion de torch.Tensor vers numpy si besoin
|
||||
if hasattr(img1, "cpu"):
|
||||
img1 = img1.cpu().numpy()
|
||||
if hasattr(img2, "cpu"):
|
||||
img2 = img2.cpu().numpy()
|
||||
if isinstance(img1, list) or img1.ndim == 4:
|
||||
img1 = img1[0]
|
||||
if isinstance(img2, list) or img2.ndim == 4:
|
||||
img2 = img2[0]
|
||||
im1 = Image.fromarray(np.clip((img1 * 255), 0, 255).astype(np.uint8))
|
||||
im2 = Image.fromarray(np.clip((img2 * 255), 0, 255).astype(np.uint8))
|
||||
|
||||
# Nouvelle taille finale
|
||||
final_width = tiles_x * tile_width
|
||||
final_height = tiles_y * tile_height
|
||||
|
||||
# Préparer les dalles
|
||||
if tile_mode == "resize":
|
||||
tile1 = im1.resize((tile_width, tile_height))
|
||||
tile2 = im2.resize((tile_width, tile_height))
|
||||
else: # "crop"
|
||||
tile1 = im1.crop((0, 0, tile_width, tile_height))
|
||||
tile2 = im2.crop((0, 0, tile_width, tile_height))
|
||||
|
||||
result = Image.new("RGB", (final_width, final_height))
|
||||
for y in range(tiles_y):
|
||||
for x in range(tiles_x):
|
||||
tile = tile1 if (x + y) % 2 == 0 else tile2
|
||||
px, py = x * tile_width, y * tile_height
|
||||
result.paste(tile, (px, py))
|
||||
arr = np.array(result).astype(np.float32) / 255.0
|
||||
tensor = torch.from_numpy(arr).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"CheckerboardNode": CheckerboardNode,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"CheckerboardNode": "Checkerboard Composer",
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
def parse_color(color):
|
||||
# Gère hex, noms, tuple/list
|
||||
if isinstance(color, str):
|
||||
color = color.lstrip("#")
|
||||
if len(color) == 6:
|
||||
return tuple(int(color[i:i+2], 16) for i in (0, 2, 4))
|
||||
elif len(color) == 3:
|
||||
return tuple(int(color[i]*2, 16) for i in range(3))
|
||||
else:
|
||||
try:
|
||||
img = Image.new("RGB", (1, 1), color)
|
||||
return img.getpixel((0, 0))
|
||||
except:
|
||||
return (0, 0, 0)
|
||||
elif isinstance(color, (tuple, list)) and len(color) == 3:
|
||||
return tuple(int(c) for c in color)
|
||||
else:
|
||||
return (0, 0, 0)
|
||||
|
||||
class ColorImageNode:
|
||||
CATEGORY = "illusion"
|
||||
FUNCTION = "generate_color"
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 512, "min": 16, "max": 4096}),
|
||||
"height": ("INT", {"default": 512, "min": 16, "max": 4096}),
|
||||
"mode": (["solid", "linear", "radial", "angular", "mirror", "diamond"], {"default": "solid"}),
|
||||
"color1": ("STRING", {"default": "#ffffff"}),
|
||||
"color2": ("STRING", {"default": "#000000"}),
|
||||
"angle": ("FLOAT", {"default": 0.0, "min": 0, "max": 360, "step": 0.1}),
|
||||
}
|
||||
}
|
||||
|
||||
def generate_color(self, width, height, mode, color1, color2, angle):
|
||||
rgb1 = parse_color(color1)
|
||||
rgb2 = parse_color(color2)
|
||||
arr = np.zeros((height, width, 3), dtype=np.uint8)
|
||||
cx, cy = width // 2, height // 2
|
||||
Y, X = np.ogrid[:height, :width]
|
||||
|
||||
if mode == "solid":
|
||||
arr[:, :] = rgb1
|
||||
|
||||
elif mode == "linear":
|
||||
x = np.linspace(0, 1, width)
|
||||
y = np.linspace(0, 1, height)
|
||||
Xg, Yg = np.meshgrid(x, y)
|
||||
theta = np.deg2rad(angle)
|
||||
t = Xg * np.cos(theta) + Yg * np.sin(theta)
|
||||
t = (t - t.min()) / (t.max() - t.min())
|
||||
for i in range(3):
|
||||
arr[..., i] = (rgb1[i] * (1 - t) + rgb2[i] * t).astype(np.uint8)
|
||||
|
||||
elif mode == "radial":
|
||||
dist = np.sqrt((X - cx) ** 2 + (Y - cy) ** 2)
|
||||
dist = dist / dist.max()
|
||||
for i in range(3):
|
||||
arr[..., i] = (rgb1[i] * (1 - dist) + rgb2[i] * dist).astype(np.uint8)
|
||||
|
||||
elif mode == "angular": # Sweep/angle Photoshop
|
||||
Xg = X - cx
|
||||
Yg = Y - cy
|
||||
theta = np.arctan2(Yg, Xg) # -π à π
|
||||
offset = np.deg2rad(angle)
|
||||
t = ((theta + np.pi + offset) % (2 * np.pi)) / (2 * np.pi)
|
||||
for i in range(3):
|
||||
arr[..., i] = (rgb1[i] * (1 - t) + rgb2[i] * t).astype(np.uint8)
|
||||
|
||||
elif mode == "mirror": # Réfléchi
|
||||
x = np.linspace(0, 1, width)
|
||||
y = np.linspace(0, 1, height)
|
||||
Xg, Yg = np.meshgrid(x, y)
|
||||
theta = np.deg2rad(angle)
|
||||
t = Xg * np.cos(theta) + Yg * np.sin(theta)
|
||||
t = np.abs((t - 0.5) * 2) # miroir autour du centre
|
||||
t = (t - t.min()) / (t.max() - t.min())
|
||||
for i in range(3):
|
||||
arr[..., i] = (rgb1[i] * (1 - t) + rgb2[i] * t).astype(np.uint8)
|
||||
|
||||
elif mode == "diamond":
|
||||
dx = np.abs((X - cx) / (width / 2))
|
||||
dy = np.abs((Y - cy) / (height / 2))
|
||||
t = (dx + dy) / 2
|
||||
t = np.clip(t, 0, 1)
|
||||
for i in range(3):
|
||||
arr[..., i] = (rgb1[i] * (1 - t) + rgb2[i] * t).astype(np.uint8)
|
||||
|
||||
img = torch.from_numpy(arr.astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (img,)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ColorImageNode": ColorImageNode,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ColorImageNode": "Color/Gradient Image",
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
import torch
|
||||
import math
|
||||
|
||||
class OpticalGeometricNode:
|
||||
CATEGORY = "illusion"
|
||||
FUNCTION = "generate_geometric"
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"pattern_type": (
|
||||
["concentric_squares", "concentric_triangles", "wavy_grid", "starburst", "hexagons", "waves"],
|
||||
{"default": "concentric_squares"}
|
||||
),
|
||||
"size": ("INT", {"default": 512, "min": 128, "max": 2048}),
|
||||
"frequency": ("INT", {"default": 10, "min": 2, "max": 100}),
|
||||
"line_width": ("INT", {"default": 3, "min": 1, "max": 50}),
|
||||
"color1": ("STRING", {"default": "#FFFFFF"}),
|
||||
"color2": ("STRING", {"default": "#000000"})
|
||||
}
|
||||
}
|
||||
|
||||
def generate_geometric(self, pattern_type, size, frequency, line_width, color1, color2):
|
||||
img = Image.new('RGB', (size, size), color1)
|
||||
draw = ImageDraw.Draw(img)
|
||||
cx, cy = size // 2, size // 2
|
||||
|
||||
if pattern_type == "concentric_squares":
|
||||
step = size // (2 * frequency)
|
||||
for i in range(frequency):
|
||||
offset = step * i
|
||||
draw.rectangle(
|
||||
[offset, offset, size - offset, size - offset],
|
||||
outline=color2 if i % 2 == 0 else color1, width=line_width
|
||||
)
|
||||
|
||||
elif pattern_type == "concentric_triangles":
|
||||
for i in range(frequency):
|
||||
r = (size // 2) * (i + 1) / frequency
|
||||
points = [
|
||||
(cx, cy - r),
|
||||
(cx - r * math.sin(math.pi / 3), cy + r * 0.5),
|
||||
(cx + r * math.sin(math.pi / 3), cy + r * 0.5)
|
||||
]
|
||||
draw.polygon(points, outline=color2 if i % 2 == 0 else color1, width=line_width)
|
||||
|
||||
elif pattern_type == "wavy_grid":
|
||||
waves = frequency
|
||||
amp = size / 30
|
||||
for y in range(0, size, size // waves):
|
||||
points = [
|
||||
(x, int(y + amp * math.sin(2 * math.pi * x / size * waves)))
|
||||
for x in range(size)
|
||||
]
|
||||
draw.line(points, fill=color2, width=line_width)
|
||||
for x in range(0, size, size // waves):
|
||||
points = [
|
||||
(int(x + amp * math.sin(2 * math.pi * y / size * waves)), y)
|
||||
for y in range(size)
|
||||
]
|
||||
draw.line(points, fill=color2, width=line_width)
|
||||
|
||||
elif pattern_type == "starburst":
|
||||
rays = frequency * 2
|
||||
for i in range(rays):
|
||||
angle = 2 * math.pi * i / rays
|
||||
x = cx + (size // 2) * math.cos(angle)
|
||||
y = cy + (size // 2) * math.sin(angle)
|
||||
draw.line([(cx, cy), (x, y)], fill=color2 if i % 2 == 0 else color1, width=line_width)
|
||||
|
||||
elif pattern_type == "hexagons":
|
||||
# motif nid d’abeille
|
||||
hex_r = size // (2 * frequency)
|
||||
for y in range(-hex_r, size + hex_r, int(hex_r * 1.5)):
|
||||
for x in range(-hex_r, size + hex_r, int(hex_r * math.sqrt(3))):
|
||||
x_shift = x + (hex_r * math.sqrt(3)/2 if (y // (hex_r * 1.5)) % 2 else 0)
|
||||
points = [
|
||||
(x_shift + hex_r * math.cos(a), y + hex_r * math.sin(a))
|
||||
for a in [math.radians(60 * k) for k in range(6)]
|
||||
]
|
||||
draw.polygon(points, outline=color2, width=line_width)
|
||||
|
||||
elif pattern_type == "waves":
|
||||
# Superposition de vagues sinusoïdales (motif Op Art simple)
|
||||
for i in range(frequency):
|
||||
amp = size / (30 + i * 5)
|
||||
y_offset = i * size // (frequency + 1)
|
||||
points = [
|
||||
(x, int(y_offset + amp * math.sin(2 * math.pi * x / size * (i+1))))
|
||||
for x in range(size)
|
||||
]
|
||||
draw.line(points, fill=color2 if i % 2 == 0 else color1, width=line_width)
|
||||
|
||||
img_array = np.array(img).astype(np.float32) / 255.0
|
||||
tensor = torch.from_numpy(img_array).unsqueeze(0)
|
||||
return (tensor,)
|
||||
@@ -0,0 +1,66 @@
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
import torch
|
||||
import math
|
||||
|
||||
class OpticalIllusionNode:
|
||||
CATEGORY = "illusion"
|
||||
FUNCTION = "generate_illusion"
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"illusion_type": (["checkerboard", "circles", "lines", "spiral"], {"default": "checkerboard"}),
|
||||
"size": ("INT", {"default": 512, "min": 128, "max": 2048}),
|
||||
"frequency": ("INT", {"default": 10, "min": 2, "max": 100}),
|
||||
"line_width": ("INT", {"default": 3, "min": 1, "max": 100}),
|
||||
"color1": ("STRING", {"default": "#FFFFFF"}),
|
||||
"color2": ("STRING", {"default": "#000000"})
|
||||
}
|
||||
}
|
||||
|
||||
def generate_illusion(self, illusion_type, size, frequency, line_width, color1, color2):
|
||||
img = Image.new('RGB', (size, size), color1)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if illusion_type == "checkerboard":
|
||||
tile = size // frequency
|
||||
for y in range(frequency):
|
||||
for x in range(frequency):
|
||||
if (x + y) % 2 == 0:
|
||||
draw.rectangle([x*tile, y*tile, (x+1)*tile, (y+1)*tile], fill=color2)
|
||||
|
||||
elif illusion_type == "circles":
|
||||
step = size / (frequency * 2)
|
||||
for i in range(frequency):
|
||||
radius = step * (i + 1)
|
||||
bbox = [size//2 - radius, size//2 - radius, size//2 + radius, size//2 + radius]
|
||||
draw.ellipse(bbox, outline=color2 if i % 2 == 0 else color1, width=line_width)
|
||||
|
||||
elif illusion_type == "lines":
|
||||
spacing = size / frequency
|
||||
for i in range(frequency):
|
||||
offset = i * spacing
|
||||
draw.line([(offset, 0), (offset, size)], fill=color2 if i % 2 == 0 else color1, width=line_width)
|
||||
|
||||
elif illusion_type == "spiral":
|
||||
cx, cy = size // 2, size // 2
|
||||
max_radius = size * 0.48
|
||||
num_turns = frequency
|
||||
step_theta = math.pi / 720 # très fin = très lisse
|
||||
a = 0
|
||||
b = max_radius / (2 * math.pi * num_turns)
|
||||
theta = 0
|
||||
while theta < 2 * math.pi * num_turns:
|
||||
r = a + b * theta
|
||||
bbox = [cx - r, cy - r, cx + r, cy + r]
|
||||
start = math.degrees(theta)
|
||||
end = math.degrees(theta + step_theta)
|
||||
draw.arc(bbox, start, end, fill=color2, width=line_width)
|
||||
theta += step_theta
|
||||
|
||||
img_array = np.array(img).astype(np.float32) / 255.0
|
||||
tensor = torch.from_numpy(img_array).unsqueeze(0)
|
||||
return (tensor,)
|
||||
@@ -0,0 +1,149 @@
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageDraw, ImageColor
|
||||
import random
|
||||
|
||||
class PatternGeneratorNode:
|
||||
PATTERN_TYPES = ["Stripes", "Checkerboard", "Random Dots", "Solid Color", "Gradient", "Noise"]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 128, "min": 16, "max": 4096, "step": 8}),
|
||||
"height": ("INT", {"default": 128, "min": 16, "max": 4096, "step": 8}),
|
||||
"pattern_type": (cls.PATTERN_TYPES, {"default": "Noise"}),
|
||||
"color1_hex": ("STRING", {"default": "#000000", "multiline": False}),
|
||||
"color2_hex": ("STRING", {"default": "#FFFFFF", "multiline": False}),
|
||||
"parameter1": ("INT", {"default": 1, "min": 0, "max": 256, "step": 1, "tooltip":"Stripes:width; Dots:density%; Gradient:direction; Noise:0=Color/1=Grayscale"}),
|
||||
"parameter2": ("INT", {"default": 1, "min": 1, "max": 64, "step": 1, "tooltip":"Dots:max_radius; Noise:block_scale"}),
|
||||
"seed": ("INT", {"default": 0, "min": 0, "max": 0xFFFFFFFF}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate_pattern"
|
||||
CATEGORY = "illusion"
|
||||
|
||||
def _hex_to_rgb(self, hex_color_string):
|
||||
try:
|
||||
return ImageColor.getrgb(hex_color_string)
|
||||
except ValueError:
|
||||
print(f"PatternGeneratorNode Warning: Invalid color string '{hex_color_string}'. Defaulting to black.")
|
||||
return (0, 0, 0)
|
||||
|
||||
def generate_pattern(self, width, height, pattern_type, color1_hex, color2_hex, parameter1, parameter2, seed):
|
||||
np.random.seed(seed)
|
||||
random.seed(seed)
|
||||
|
||||
c1 = self._hex_to_rgb(color1_hex)
|
||||
c2 = self._hex_to_rgb(color2_hex)
|
||||
|
||||
image_np = np.zeros((height, width, 3), dtype=np.uint8)
|
||||
|
||||
if pattern_type == "Stripes":
|
||||
stripe_width = max(1, parameter1) # Stripe width
|
||||
orientation = "Vertical" # Could be an input later
|
||||
for y_coord in range(height):
|
||||
for x_coord in range(width):
|
||||
if orientation == "Vertical":
|
||||
if (x_coord // stripe_width) % 2 == 0:
|
||||
image_np[y_coord, x_coord] = c1
|
||||
else:
|
||||
image_np[y_coord, x_coord] = c2
|
||||
else: # Horizontal
|
||||
if (y_coord // stripe_width) % 2 == 0:
|
||||
image_np[y_coord, x_coord] = c1
|
||||
else:
|
||||
image_np[y_coord, x_coord] = c2
|
||||
|
||||
elif pattern_type == "Checkerboard":
|
||||
square_size = max(1, parameter1) # Square size
|
||||
for y_coord in range(height):
|
||||
for x_coord in range(width):
|
||||
if ((x_coord // square_size) % 2 == (y_coord // square_size) % 2):
|
||||
image_np[y_coord, x_coord] = c1
|
||||
else:
|
||||
image_np[y_coord, x_coord] = c2
|
||||
|
||||
elif pattern_type == "Random Dots":
|
||||
density_percent = np.clip(parameter1, 1, 100) # Density percentage
|
||||
dot_radius_min = 1
|
||||
dot_radius_max = np.clip(parameter2, 1, min(width, height)//4) # Max dot radius
|
||||
|
||||
avg_radius = (dot_radius_min + dot_radius_max) / 2.0
|
||||
if avg_radius < 1: avg_radius = 1
|
||||
num_dots = int((width * height * (density_percent / 100.0)) / (np.pi * avg_radius**2))
|
||||
num_dots = max(10, num_dots)
|
||||
num_dots = min(num_dots, width * height // 2) # Prevent extreme overdraw
|
||||
|
||||
pil_image = Image.fromarray(image_np)
|
||||
pil_image = pil_image.convert("RGB")
|
||||
pil_image.paste(c1, (0,0,width,height))
|
||||
draw = ImageDraw.Draw(pil_image)
|
||||
|
||||
for _ in range(num_dots):
|
||||
dot_x = random.randint(0, width - 1)
|
||||
dot_y = random.randint(0, height - 1)
|
||||
dot_radius = random.randint(dot_radius_min, dot_radius_max)
|
||||
color_choice = c2
|
||||
|
||||
bbox = (dot_x - dot_radius, dot_y - dot_radius,
|
||||
dot_x + dot_radius, dot_y + dot_radius)
|
||||
try: # Add try-except for rare issues with ellipse on tiny images/radii
|
||||
draw.ellipse(bbox, fill=color_choice)
|
||||
except ValueError:
|
||||
pass # Skip dot if it causes an issue
|
||||
image_np = np.array(pil_image)
|
||||
|
||||
elif pattern_type == "Solid Color":
|
||||
image_np[:, :] = c1
|
||||
|
||||
elif pattern_type == "Gradient":
|
||||
direction = parameter1 % 4 # Gradient direction
|
||||
for y_coord in range(height):
|
||||
for x_coord in range(width):
|
||||
if direction == 0: # Left to Right
|
||||
ratio = x_coord / (width -1) if width > 1 else 0
|
||||
elif direction == 1: # Top to Bottom
|
||||
ratio = y_coord / (height -1) if height > 1 else 0
|
||||
elif direction == 2: # Right to Left
|
||||
ratio = (width - 1 - x_coord) / (width - 1) if width > 1 else 0
|
||||
else: # Bottom to Top (direction == 3)
|
||||
ratio = (height - 1 - y_coord) / (height - 1) if height > 1 else 0
|
||||
|
||||
r_val = int(c1[0] * (1 - ratio) + c2[0] * ratio)
|
||||
g_val = int(c1[1] * (1 - ratio) + c2[1] * ratio)
|
||||
b_val = int(c1[2] * (1 - ratio) + c2[2] * ratio)
|
||||
image_np[y_coord, x_coord] = (r_val, g_val, b_val)
|
||||
|
||||
elif pattern_type == "Noise":
|
||||
is_grayscale_noise = parameter1 == 1 # 0 for color, 1 for grayscale
|
||||
block_scale = max(1, parameter2) # Scale of noise blocks, 1 for pixel noise
|
||||
|
||||
for y_base in range(0, height, block_scale):
|
||||
for x_base in range(0, width, block_scale):
|
||||
if is_grayscale_noise:
|
||||
val = random.randint(0, 255)
|
||||
chosen_color = (val, val, val)
|
||||
else:
|
||||
chosen_color = (random.randint(0, 255),
|
||||
random.randint(0, 255),
|
||||
random.randint(0, 255))
|
||||
|
||||
for y_offset in range(block_scale):
|
||||
for x_offset in range(block_scale):
|
||||
y = y_base + y_offset
|
||||
x = x_base + x_offset
|
||||
if y < height and x < width:
|
||||
image_np[y, x] = chosen_color
|
||||
|
||||
image_tensor = torch.from_numpy(image_np.astype(np.float32) / 255.0).unsqueeze(0)
|
||||
return (image_tensor,)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"PatternGeneratorNode": PatternGeneratorNode,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"PatternGeneratorNode": "Pattern Generator"
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
# ComfyUI Illusion & Pattern Nodes
|
||||
|
||||
This repository contains a collection of custom nodes for ComfyUI, designed for generating various patterns, optical illusions, and performing related image manipulations. All nodes are categorized under "illusion" in the ComfyUI menu.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Navigate to your ComfyUI `custom_nodes` directory:
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes/
|
||||
```
|
||||
2. Clone this repository:
|
||||
```bash
|
||||
git clone <your_repository_url_here> ComfyUI-IllusionNodes
|
||||
```
|
||||
(Replace `<your_repository_url_here>` with the actual URL of your Git repository).
|
||||
3. Restart ComfyUI.
|
||||
|
||||
The nodes should now be available in the "illusion" category when you right-click or use the "Add Node" menu.
|
||||
|
||||
## Nodes Overview
|
||||
|
||||
Below is a summary of each node provided in this pack:
|
||||
|
||||
---
|
||||
|
||||
### 1. Pattern Generator (`PatternGenerator_node.py`)
|
||||
|
||||
* **Display Name:** `Pattern Generator`
|
||||
* **Function:** Generates various 2D procedural patterns.
|
||||
* **Key Features:**
|
||||
* **Pattern Types:**
|
||||
* `Stripes`: Creates vertical or horizontal stripes using two colors.
|
||||
* `parameter1`: Stripe width.
|
||||
* `Checkerboard`: Creates a checkerboard pattern.
|
||||
* `parameter1`: Square size.
|
||||
* `Random Dots`: Scatters dots of `color2` over a `color1` background.
|
||||
* `parameter1`: Density percentage (1-100).
|
||||
* `parameter2`: Maximum dot radius.
|
||||
* `Solid Color`: Fills the image with `color1`.
|
||||
* `Gradient`: Creates a linear gradient between `color1` and `color2`.
|
||||
* `parameter1`: Direction (0: L-R, 1: T-B, 2: R-L, 3: B-T).
|
||||
* `Noise`: Generates blocky random noise.
|
||||
* `parameter1`: 0 for Color Noise, 1 for Grayscale Noise.
|
||||
* `parameter2`: Block scale (1 for pixel-level noise).
|
||||
* Customizable `width`, `height`, `color1_hex`, `color2_hex`, and `seed`.
|
||||
|
||||
---
|
||||
|
||||
### 2. Tessellation Composer (Advanced) (`TessellationNode.py`)
|
||||
|
||||
* **Display Name:** `Tessellation Composer (Advanced)`
|
||||
* **Function:** Creates complex tiled patterns from an input image, with various transformations per tile.
|
||||
* **Key Features:**
|
||||
* Uses an `input_image` as the base tile.
|
||||
* `tile_width`, `tile_height`: Dimensions of the base tile (input image will be resized).
|
||||
* `tiles_x`, `tiles_y`: Number of tiles in horizontal and vertical directions.
|
||||
* `mode`: Tiling strategy (`repeat`, `mirror`, `diamond`).
|
||||
* `mirror_axis`: Optional mirroring of tiles (`none`, `x`, `y`, `xy`, `random`).
|
||||
* `offset_x`, `offset_y`: Offsets applied to alternating rows/columns.
|
||||
* `rotation_mode`: How rotation is applied (`none`, `by_tile`, `random`).
|
||||
* `rotation_angle`: Base rotation angle.
|
||||
* `scale_mode`: How scaling is applied (`none`, `by_tile`, `random`).
|
||||
* `scale_factor`: Base scale factor.
|
||||
* `opacity`: Opacity of the composited tiles.
|
||||
* `random_seed`: For random operations.
|
||||
|
||||
---
|
||||
|
||||
### 3. Optical Illusion Generator (`OpticalIllusionNode.py`)
|
||||
|
||||
* **Display Name:** `OpticalIllusionNode` (or similar, based on class name if not explicitly mapped)
|
||||
* **Function:** Generates classic optical illusion patterns.
|
||||
* **Key Features:**
|
||||
* `illusion_type`:
|
||||
* `checkerboard`: Standard checkerboard.
|
||||
* `circles`: Concentric circles.
|
||||
* `lines`: Parallel lines.
|
||||
* `spiral`: Archimedean spiral.
|
||||
* Customizable `size`, `frequency` (density/count of elements), `line_width`, `color1` (background), and `color2` (foreground/lines).
|
||||
|
||||
---
|
||||
|
||||
### 4. Optical Geometric Pattern Generator (`OpticalGeometricNode.py`)
|
||||
|
||||
* **Display Name:** `OpticalGeometricNode` (or similar, based on class name if not explicitly mapped)
|
||||
* **Function:** Generates various geometric optical art patterns.
|
||||
* **Key Features:**
|
||||
* `pattern_type`:
|
||||
* `concentric_squares`
|
||||
* `concentric_triangles`
|
||||
* `wavy_grid`: Grid lines distorted by sine waves.
|
||||
* `starburst`: Lines radiating from the center.
|
||||
* `hexagons`: Honeycomb pattern.
|
||||
* `waves`: Superimposed sinusoidal waves.
|
||||
* Customizable `size`, `frequency`, `line_width`, `color1`, and `color2`.
|
||||
|
||||
---
|
||||
|
||||
### 5. Color/Gradient Image (`ColorImageNode.py`)
|
||||
|
||||
* **Display Name:** `Color/Gradient Image`
|
||||
* **Function:** Creates images with solid colors or various types of gradients.
|
||||
* **Key Features:**
|
||||
* `mode`:
|
||||
* `solid`: Fills with `color1`.
|
||||
* `linear`: Linear gradient between `color1` and `color2`, controlled by `angle`.
|
||||
* `radial`: Radial gradient from center (`color1`) to edges (`color2`).
|
||||
* `angular`: Angular (sweep/cone) gradient, rotation controlled by `angle`.
|
||||
* `mirror`: Reflected linear gradient.
|
||||
* `diamond`: Diamond-shaped gradient.
|
||||
* Customizable `width`, `height`, `color1`, `color2`, and `angle`.
|
||||
|
||||
---
|
||||
|
||||
### 6. Autostereogram Creator (Advanced) (`autostereogram_node.py`)
|
||||
|
||||
* **Display Name:** `Autostereogram Creator (Advanced)`
|
||||
* **Function:** Generates Single Image Random Dot Stereograms (SIRDS), also known as "Magic Eye" images.
|
||||
* **Key Features:**
|
||||
* Takes a `depth_map` (grayscale image where brightness indicates depth) and a `pattern` image.
|
||||
* `eye_separation_pixels`: Simulates the distance between eyes projected onto the image plane, influencing the pattern period.
|
||||
* `depth_scale_factor`: Controls the intensity of the 3D effect (how much objects "pop out" or recede).
|
||||
|
||||
---
|
||||
|
||||
### 7. Checkerboard Composer (`CheckerboardNode.py`)
|
||||
|
||||
* **Display Name:** `Checkerboard Composer`
|
||||
* **Function:** Creates a checkerboard pattern using two input images as alternating tiles.
|
||||
* **Key Features:**
|
||||
* Takes `img1` and `img2` as inputs for the two alternating tiles.
|
||||
* `tiles_x`, `tiles_y`: Number of tiles in the checkerboard.
|
||||
* `tile_width`, `tile_height`: Desired dimensions for each tile.
|
||||
* `tile_mode`:
|
||||
* `resize`: Input images are resized to `tile_width` x `tile_height`.
|
||||
* `crop`: Input images are cropped from the top-left to `tile_width` x `tile_height`.
|
||||
|
||||
---
|
||||
|
||||
### 8. Tile Image Repeater (Smart Resize) (`TileImageRepeaterNode.py`)
|
||||
|
||||
* **Display Name:** `Tile Image Repeater (Smart Resize)`
|
||||
* **Function:** Repeats an input image to create a larger tiled image, with intelligent resizing options for the base tile.
|
||||
* **Key Features:**
|
||||
* Takes an `image` as the base tile.
|
||||
* `horizontal_repeats`, `vertical_repeats`: Number of times to repeat the tile.
|
||||
* `resize_mode`: How the base tile is resized before tiling:
|
||||
* `None`: No resizing.
|
||||
* `Width`: Tile is resized to `tile_target_size` width, height adjusted by aspect ratio.
|
||||
* `Height`: Tile is resized to `tile_target_size` height, width adjusted by aspect ratio.
|
||||
* `Shortest Side`: The shorter side of the tile is resized to `tile_target_size`.
|
||||
* `Longest Side`: The longer side of the tile is resized to `tile_target_size`.
|
||||
* `tile_target_size`: The target dimension for resizing (if `resize_mode` is not `None`).
|
||||
* `resampling_filter`: Filter used for resizing (`lanczos`, `bicubic`, `bilinear`, `nearest`).
|
||||
|
||||
---
|
||||
|
||||
Enjoy creating illusions and patterns!
|
||||
@@ -0,0 +1,141 @@
|
||||
from PIL import Image, ImageEnhance
|
||||
import numpy as np
|
||||
import torch
|
||||
import random
|
||||
|
||||
class TessellationNode:
|
||||
CATEGORY = "illusion"
|
||||
FUNCTION = "tessellate"
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input_image": ("IMAGE",),
|
||||
"tile_width": ("INT", {"default": 128, "min": 8, "max": 2048}),
|
||||
"tile_height": ("INT", {"default": 128, "min": 8, "max": 2048}),
|
||||
"tiles_x": ("INT", {"default": 4, "min": 1, "max": 32}),
|
||||
"tiles_y": ("INT", {"default": 4, "min": 1, "max": 32}),
|
||||
"mode": (["repeat", "mirror", "diamond"], {"default": "repeat"}),
|
||||
"mirror_axis": (["none", "x", "y", "xy", "random"], {"default": "none"}),
|
||||
"offset_x": ("INT", {"default": 0, "min": -2048, "max": 2048}),
|
||||
"offset_y": ("INT", {"default": 0, "min": -2048, "max": 2048}),
|
||||
"rotation_mode": (["none", "by_tile", "random"], {"default": "none"}),
|
||||
"rotation_angle": ("FLOAT", {"default": 0, "min": 0, "max": 360}),
|
||||
"scale_mode": (["none", "by_tile", "random"], {"default": "none"}),
|
||||
"scale_factor": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 4.0}),
|
||||
"opacity": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 1.0}),
|
||||
"random_seed": ("INT", {"default": 0, "min": 0, "max": 999999}),
|
||||
}
|
||||
}
|
||||
|
||||
def tensor_to_pil(self, img_tensor):
|
||||
arr = img_tensor[0] if isinstance(img_tensor, list) or len(img_tensor.shape) == 4 else img_tensor
|
||||
arr = arr.cpu().numpy() if hasattr(arr, 'cpu') else arr
|
||||
arr = np.clip(arr, 0, 1)
|
||||
arr = (arr * 255).astype(np.uint8)
|
||||
if arr.shape[-1] == 1:
|
||||
arr = np.repeat(arr, 3, axis=-1)
|
||||
return Image.fromarray(arr)
|
||||
|
||||
def tessellate(
|
||||
self,
|
||||
input_image,
|
||||
tile_width,
|
||||
tile_height,
|
||||
tiles_x,
|
||||
tiles_y,
|
||||
mode,
|
||||
mirror_axis,
|
||||
offset_x,
|
||||
offset_y,
|
||||
rotation_mode,
|
||||
rotation_angle,
|
||||
scale_mode,
|
||||
scale_factor,
|
||||
opacity,
|
||||
random_seed
|
||||
):
|
||||
random.seed(random_seed)
|
||||
base_tile = self.tensor_to_pil(input_image).convert("RGBA")
|
||||
if base_tile.size != (tile_width, tile_height):
|
||||
base_tile = base_tile.resize((tile_width, tile_height), resample=Image.LANCZOS)
|
||||
|
||||
# Canvas size for diamond mode
|
||||
if mode == "diamond":
|
||||
result_w = int(tile_width * (tiles_x + tiles_y/2))
|
||||
result_h = int(tile_height * (tiles_y/2 + 0.5))
|
||||
else:
|
||||
result_w = tile_width * tiles_x
|
||||
result_h = tile_height * tiles_y
|
||||
|
||||
result = Image.new("RGBA", (result_w, result_h), (0, 0, 0, 0))
|
||||
|
||||
for iy in range(tiles_y):
|
||||
for ix in range(tiles_x):
|
||||
tile = base_tile.copy()
|
||||
|
||||
# SCALE
|
||||
if scale_mode == "by_tile":
|
||||
fac = scale_factor * (1 + 0.05 * ((ix + iy) % 3))
|
||||
tw, th = max(8, int(tile_width * fac)), max(8, int(tile_height * fac))
|
||||
tile = tile.resize((tw, th), resample=Image.LANCZOS)
|
||||
elif scale_mode == "random":
|
||||
fac = scale_factor * random.uniform(0.85, 1.15)
|
||||
tw, th = max(8, int(tile_width * fac)), max(8, int(tile_height * fac))
|
||||
tile = tile.resize((tw, th), resample=Image.LANCZOS)
|
||||
else:
|
||||
tw, th = tile_width, tile_height
|
||||
|
||||
# ROTATION
|
||||
angle = 0
|
||||
if rotation_mode == "by_tile":
|
||||
angle = rotation_angle * ((ix + iy) % 4)
|
||||
elif rotation_mode == "random":
|
||||
angle = random.uniform(0, rotation_angle)
|
||||
if angle != 0:
|
||||
tile = tile.rotate(angle, expand=True, fillcolor=(0,0,0,0))
|
||||
|
||||
# MIRROR
|
||||
if mirror_axis == "x" and (ix % 2 == 1):
|
||||
tile = tile.transpose(Image.FLIP_LEFT_RIGHT)
|
||||
if mirror_axis == "y" and (iy % 2 == 1):
|
||||
tile = tile.transpose(Image.FLIP_TOP_BOTTOM)
|
||||
if mirror_axis == "xy" and ((ix + iy) % 2 == 1):
|
||||
tile = tile.transpose(Image.ROTATE_180)
|
||||
if mirror_axis == "random" and random.random() < 0.5:
|
||||
tile = tile.transpose(random.choice([
|
||||
Image.FLIP_LEFT_RIGHT,
|
||||
Image.FLIP_TOP_BOTTOM,
|
||||
Image.ROTATE_180
|
||||
]))
|
||||
|
||||
# OPACITY
|
||||
if opacity < 1.0:
|
||||
if tile.mode != "RGBA":
|
||||
tile = tile.convert("RGBA")
|
||||
alpha = tile.split()[-1]
|
||||
alpha = ImageEnhance.Brightness(alpha).enhance(opacity)
|
||||
tile.putalpha(alpha)
|
||||
|
||||
# OFFSETS (classique ou diamant)
|
||||
if mode == "diamond":
|
||||
px = int(ix * tile_width + iy * tile_width / 2 + (offset_x if (iy % 2 == 1) else 0))
|
||||
py = int(iy * tile_height / 2 + (offset_y if (ix % 2 == 1) else 0))
|
||||
else:
|
||||
px = ix * tile_width + (offset_x if (iy % 2 == 1) else 0)
|
||||
py = iy * tile_height + (offset_y if (ix % 2 == 1) else 0)
|
||||
|
||||
result.alpha_composite(tile, (int(px), int(py)))
|
||||
|
||||
arr_out = np.array(result.convert("RGB")).astype(np.float32) / 255.0
|
||||
tensor = torch.from_numpy(arr_out).unsqueeze(0)
|
||||
return (tensor,)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"TessellationNode": TessellationNode,
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"TessellationNode": "Tessellation Composer (Advanced)",
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
class TileImageRepeaterNode:
|
||||
RESIZE_MODES = ["None", "Width", "Height", "Shortest Side", "Longest Side"] # Ajout de None, et de Shortest/Longest Side
|
||||
RESAMPLING_FILTERS = ["lanczos", "bicubic", "bilinear", "nearest"]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"horizontal_repeats": ("INT", {"default": 3, "min": 1, "max": 32, "step": 1}),
|
||||
"vertical_repeats": ("INT", {"default": 3, "min": 1, "max": 32, "step": 1}),
|
||||
"resize_mode": (cls.RESIZE_MODES, {"default": "None"}),
|
||||
"tile_target_size": ("INT", {"default": 256, "min": 0, "max": 8192, "step": 8, "tooltip": "Target size for the chosen dimension (Width, Height, Shortest/Longest Side). 0 or 'None' mode to disable resize."}),
|
||||
"resampling_filter": (cls.RESAMPLING_FILTERS, {"default": "lanczos"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "repeat_image_as_tiles"
|
||||
CATEGORY = "illusion"
|
||||
|
||||
def repeat_image_as_tiles(self, image, horizontal_repeats, vertical_repeats, resize_mode, tile_target_size, resampling_filter):
|
||||
if not isinstance(image, torch.Tensor):
|
||||
if isinstance(image, list) and len(image) > 0 and isinstance(image[0], torch.Tensor):
|
||||
image_tensor = image[0]
|
||||
else:
|
||||
raise TypeError(f"Input image must be a torch.Tensor or a list containing one, got {type(image)}")
|
||||
else:
|
||||
image_tensor = image
|
||||
|
||||
if image_tensor.ndim == 3:
|
||||
image_bchw_float = image_tensor.unsqueeze(0)
|
||||
elif image_tensor.ndim == 4:
|
||||
image_bchw_float = image_tensor
|
||||
else:
|
||||
raise ValueError(f"Input image tensor must be 3D (H,W,C) or 4D (B,H,W,C), got {image_tensor.ndim}D shape: {image_tensor.shape}")
|
||||
|
||||
single_image_hwc_float = image_bchw_float[0].cpu().numpy()
|
||||
|
||||
pil_image_mode = 'RGB'
|
||||
if single_image_hwc_float.ndim == 3 and single_image_hwc_float.shape[2] == 1:
|
||||
pil_image_mode = 'L'
|
||||
elif single_image_hwc_float.ndim == 2: # Si c'est déjà 2D (grayscale)
|
||||
pil_image_mode = 'L'
|
||||
|
||||
pil_image = Image.fromarray((single_image_hwc_float * 255).squeeze().astype(np.uint8), mode=pil_image_mode)
|
||||
|
||||
original_width, original_height = pil_image.size
|
||||
resized_image_hwc_float = single_image_hwc_float # Par défaut, pas de redimensionnement
|
||||
|
||||
if resize_mode != "None" and tile_target_size > 0:
|
||||
target_w = original_width
|
||||
target_h = original_height
|
||||
aspect_ratio = original_width / original_height if original_height != 0 else 1
|
||||
|
||||
if resize_mode == "Width":
|
||||
target_w = tile_target_size
|
||||
target_h = int(target_w / aspect_ratio) if aspect_ratio != 0 else original_height
|
||||
elif resize_mode == "Height":
|
||||
target_h = tile_target_size
|
||||
target_w = int(target_h * aspect_ratio)
|
||||
elif resize_mode == "Shortest Side":
|
||||
if original_width < original_height: # Width is shortest
|
||||
target_w = tile_target_size
|
||||
target_h = int(target_w / aspect_ratio) if aspect_ratio != 0 else original_height
|
||||
else: # Height is shortest (or square)
|
||||
target_h = tile_target_size
|
||||
target_w = int(target_h * aspect_ratio)
|
||||
elif resize_mode == "Longest Side":
|
||||
if original_width > original_height: # Width is longest
|
||||
target_w = tile_target_size
|
||||
target_h = int(target_w / aspect_ratio) if aspect_ratio != 0 else original_height
|
||||
else: # Height is longest (or square)
|
||||
target_h = tile_target_size
|
||||
target_w = int(target_h * aspect_ratio)
|
||||
|
||||
# S'assurer que les dimensions cibles ne sont pas nulles
|
||||
target_w = max(1, target_w)
|
||||
target_h = max(1, target_h)
|
||||
|
||||
if (target_w != original_width or target_h != original_height):
|
||||
resampling_map = {
|
||||
"lanczos": Image.Resampling.LANCZOS, "bicubic": Image.Resampling.BICUBIC,
|
||||
"bilinear": Image.Resampling.BILINEAR, "nearest": Image.Resampling.NEAREST
|
||||
}
|
||||
resample_pil = resampling_map.get(resampling_filter, Image.Resampling.LANCZOS)
|
||||
|
||||
print(f"TileImageRepeaterNode: Resizing tile from {original_width}x{original_height} to {target_w}x{target_h} using {resampling_filter}")
|
||||
pil_image_resized = pil_image.resize((target_w, target_h), resample=resample_pil)
|
||||
|
||||
# Reconvertir en NumPy array et s'assurer qu'il a 3 canaux si l'original en avait 3
|
||||
resized_np = np.array(pil_image_resized).astype(np.float32) / 255.0
|
||||
if resized_np.ndim == 2: # Si PIL retourne une image en niveaux de gris (L mode)
|
||||
resized_image_hwc_float = np.stack((resized_np,) * 3, axis=-1) if single_image_hwc_float.shape[2] == 3 else resized_np[:,:,np.newaxis]
|
||||
else: # Déjà RGB
|
||||
resized_image_hwc_float = resized_np
|
||||
else:
|
||||
resized_image_hwc_float = single_image_hwc_float # Aucune redimension effective
|
||||
else: # resize_mode == "None" or tile_target_size == 0
|
||||
resized_image_hwc_float = single_image_hwc_float
|
||||
|
||||
|
||||
# S'assurer que le nombre de canaux est correct après toutes les opérations
|
||||
# Surtout si l'entrée était grayscale
|
||||
if single_image_hwc_float.shape[2] == 1 and resized_image_hwc_float.ndim == 3 and resized_image_hwc_float.shape[2] == 3:
|
||||
# Si l'entrée était grayscale mais que le redimensionnement a produit RGB (ex: mode 'L' vers 'RGB'), prendre la moyenne
|
||||
resized_image_hwc_float = np.mean(resized_image_hwc_float, axis=2, keepdims=True)
|
||||
elif single_image_hwc_float.shape[2] == 3 and resized_image_hwc_float.ndim == 2:
|
||||
# Si l'entrée était RGB mais que le redimensionnement a produit Grayscale, répéter le canal
|
||||
resized_image_hwc_float = np.stack((resized_image_hwc_float,) * 3, axis=-1)
|
||||
elif single_image_hwc_float.shape[2] == 3 and resized_image_hwc_float.ndim == 3 and resized_image_hwc_float.shape[2] == 1:
|
||||
resized_image_hwc_float = np.repeat(resized_image_hwc_float, 3, axis=2)
|
||||
|
||||
|
||||
tiled_image_np_float = np.tile(resized_image_hwc_float,
|
||||
(vertical_repeats, horizontal_repeats, 1))
|
||||
|
||||
output_tensor_bhwc = torch.from_numpy(tiled_image_np_float).unsqueeze(0)
|
||||
|
||||
return (output_tensor_bhwc,)
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"TileImageRepeaterNode": TileImageRepeaterNode
|
||||
}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"TileImageRepeaterNode": "Tile Image Repeater (Smart Resize)"
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
# AdvancedAutostereogramNode
|
||||
try:
|
||||
from .autostereogram_node import NODE_CLASS_MAPPINGS as ADV_AS_CLASS_MAPPINGS
|
||||
NODE_CLASS_MAPPINGS.update(ADV_AS_CLASS_MAPPINGS)
|
||||
from .autostereogram_node import NODE_DISPLAY_NAME_MAPPINGS as ADV_AS_DISPLAY_MAPPINGS
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(ADV_AS_DISPLAY_MAPPINGS)
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[illusion_node] Avertissement (AdvancedAutostereogramNode): {e}")
|
||||
|
||||
# PatternGeneratorNode
|
||||
try:
|
||||
from .PatternGenerator_node import NODE_CLASS_MAPPINGS as PG_CLASS_MAPPINGS
|
||||
NODE_CLASS_MAPPINGS.update(PG_CLASS_MAPPINGS)
|
||||
from .PatternGenerator_node import NODE_DISPLAY_NAME_MAPPINGS as PG_DISPLAY_MAPPINGS
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(PG_DISPLAY_MAPPINGS)
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[illusion_node] Avertissement (PatternGeneratorNode): {e}")
|
||||
|
||||
# TileImageRepeaterNode
|
||||
try:
|
||||
from .TileImageRepeaterNode import NODE_CLASS_MAPPINGS as TIR_CLASS_MAPPINGS
|
||||
NODE_CLASS_MAPPINGS.update(TIR_CLASS_MAPPINGS)
|
||||
from .TileImageRepeaterNode import NODE_DISPLAY_NAME_MAPPINGS as TIR_DISPLAY_MAPPINGS
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(TIR_DISPLAY_MAPPINGS)
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[illusion_node] Avertissement (TileImageRepeaterNode): {e}")
|
||||
|
||||
# OpticalIllusionNode
|
||||
try:
|
||||
from .OpticalIllusionNode import OpticalIllusionNode
|
||||
NODE_CLASS_MAPPINGS["OpticalIllusionNode"] = OpticalIllusionNode
|
||||
NODE_DISPLAY_NAME_MAPPINGS["OpticalIllusionNode"] = "Optical Illusion Generator"
|
||||
except (ImportError, AttributeError) as e:
|
||||
print(f"[illusion_node] Avertissement (OpticalIllusionNode): {e}")
|
||||
|
||||
# OpticalGeometricNodee
|
||||
try:
|
||||
from .OpticalGeometricNode import OpticalGeometricNode
|
||||
NODE_CLASS_MAPPINGS["OpticalGeometricNode"] = OpticalGeometricNode
|
||||
NODE_DISPLAY_NAME_MAPPINGS["OpticalGeometricNode"] = "Optical Geometric Generator"
|
||||
except Exception as e:
|
||||
print(f"[illusion_node] OpticalGeometricNode import error: {e}")
|
||||
|
||||
# CheckerboardNode
|
||||
try:
|
||||
from .CheckerboardNode import CheckerboardNode
|
||||
NODE_CLASS_MAPPINGS["CheckerboardNode"] = CheckerboardNode
|
||||
NODE_DISPLAY_NAME_MAPPINGS["CheckerboardNode"] = "Checkerboard Composer"
|
||||
except Exception as e:
|
||||
print(f"[illusion_node] CheckerboardNode import error: {e}")
|
||||
|
||||
# ColorImageNode
|
||||
try:
|
||||
from .ColorImageNode import ColorImageNode
|
||||
NODE_CLASS_MAPPINGS["ColorImageNode"] = ColorImageNode
|
||||
NODE_DISPLAY_NAME_MAPPINGS["ColorImageNode"] = "Color/Gradient Image"
|
||||
except Exception as e:
|
||||
print(f"[illusion_node] ColorImageNode import error: {e}")
|
||||
|
||||
# TessellationNode
|
||||
try:
|
||||
from .TessellationNode import NODE_CLASS_MAPPINGS as TESS_CLASS_MAPPINGS
|
||||
NODE_CLASS_MAPPINGS.update(TESS_CLASS_MAPPINGS)
|
||||
from .TessellationNode import NODE_DISPLAY_NAME_MAPPINGS as TESS_DISPLAY_MAPPINGS
|
||||
NODE_DISPLAY_NAME_MAPPINGS.update(TESS_DISPLAY_MAPPINGS)
|
||||
except Exception as e:
|
||||
print(f"[illusion_node] TessellationNode import error: {e}")
|
||||
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
print("--- Chargement du pack de nœuds 'illusion_node' ---")
|
||||
print(f" {len(NODE_CLASS_MAPPINGS)} classe(s) de nœud(s) trouvée(s) : {list(NODE_CLASS_MAPPINGS.keys())}")
|
||||
print("--- Fin du chargement de 'illusion_node' ---")
|
||||
@@ -0,0 +1,165 @@
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
class AdvancedAutostereogramNode: # Le nom de la classe est AdvancedAutostereogramNode
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"depth_map": ("IMAGE",),
|
||||
"pattern": ("IMAGE",),
|
||||
"eye_separation_pixels": ("INT", {"default": 100, "min": 30, "max": 400, "step": 1, "tooltip": "Typical eye separation projected onto the image plane in pixels. Influences pattern period and perceived depth."}),
|
||||
"depth_scale_factor": ("FLOAT", {"default": 0.5, "min": 0.01, "max": 2.0, "step": 0.01, "tooltip": "Scales the depth effect. Values around 0.3-0.7 are common. Higher values = more 'pop-out'."}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "create_advanced_autostereogram" # La fonction à appeler
|
||||
CATEGORY = "illusion"
|
||||
|
||||
def preprocess_image_to_numpy(self, image_tensor_or_pil, target_channels=None, is_depth_map=False):
|
||||
# Fonction utilitaire pour convertir l'entrée IMAGE en array NumPy HWC, float32 [0,1]
|
||||
if isinstance(image_tensor_or_pil, torch.Tensor):
|
||||
img_tensor = image_tensor_or_pil.clone() # Cloner pour éviter de modifier l'original
|
||||
if img_tensor.ndim == 4: # B,H,W,C
|
||||
img_np = img_tensor[0].cpu().numpy()
|
||||
elif img_tensor.ndim == 3: # H,W,C
|
||||
img_np = img_tensor.cpu().numpy()
|
||||
elif img_tensor.ndim == 2: # H,W (grayscale)
|
||||
img_np = img_tensor.cpu().numpy()
|
||||
else:
|
||||
raise ValueError(f"Unsupported tensor dimensions: {img_tensor.shape}")
|
||||
elif isinstance(image_tensor_or_pil, Image.Image):
|
||||
# Convertir PIL en NumPy HWC, float32 [0,1]
|
||||
if is_depth_map or (target_channels == 1 and image_tensor_or_pil.mode != 'RGB' and image_tensor_or_pil.mode != 'RGBA'):
|
||||
img_pil = image_tensor_or_pil.convert("L")
|
||||
img_np = np.array(img_pil).astype(np.float32) / 255.0
|
||||
else:
|
||||
img_pil = image_tensor_or_pil.convert("RGB")
|
||||
img_np = np.array(img_pil).astype(np.float32) / 255.0
|
||||
else:
|
||||
raise TypeError(f"Input must be a torch.Tensor or PIL.Image. Got {type(image_tensor_or_pil)}")
|
||||
|
||||
# S'assurer que les valeurs sont bien entre 0 et 1 si elles ne le sont pas déjà
|
||||
if img_np.max() > 1.1 and not (img_np.min() >=0 and img_np.max() <=1.01): # Vérifier si déjà normalisé avant de re-normaliser
|
||||
img_np = np.clip(img_np.astype(np.float32) / 255.0, 0.0, 1.0)
|
||||
else:
|
||||
img_np = np.clip(img_np.astype(np.float32), 0.0, 1.0)
|
||||
|
||||
|
||||
# Gestion des canaux et de la forme finale
|
||||
if is_depth_map: # Pour les cartes de profondeur, on veut (H,W) puis on ajoutera le canal
|
||||
if img_np.ndim == 3 and img_np.shape[2] > 1: # Si c'est HWC (ex: RGB), prendre la moyenne pour grayscale
|
||||
img_np = np.mean(img_np, axis=2)
|
||||
img_np = img_np[..., np.newaxis] # Assurer H, W, 1
|
||||
elif target_channels:
|
||||
current_channels = img_np.shape[2] if img_np.ndim == 3 else 1 if img_np.ndim == 2 else 0
|
||||
|
||||
if img_np.ndim == 2: # H,W -> H,W,C
|
||||
if target_channels == 1:
|
||||
img_np = img_np[..., np.newaxis]
|
||||
elif target_channels == 3:
|
||||
img_np = np.stack([img_np]*target_channels, axis=-1)
|
||||
elif img_np.ndim == 3: # H,W,Cin -> H,W,Cout
|
||||
if current_channels == 1 and target_channels == 3:
|
||||
img_np = np.repeat(img_np, 3, axis=2)
|
||||
elif current_channels == 3 and target_channels == 1:
|
||||
img_np = np.mean(img_np, axis=2, keepdims=True)
|
||||
elif current_channels == 4 and target_channels == 3: # RGBA -> RGB
|
||||
img_np = img_np[..., :3]
|
||||
elif current_channels != target_channels:
|
||||
# Tentative de gestion simple si pas match parfait
|
||||
print(f"Warning: Channel mismatch for pattern. Input {current_channels}, target {target_channels}. Attempting basic conversion.")
|
||||
if target_channels == 3:
|
||||
img_np = np.mean(img_np, axis=2, keepdims=True) # D'abord grayscale
|
||||
img_np = np.repeat(img_np, 3, axis=2) # Puis RGB
|
||||
elif target_channels == 1:
|
||||
img_np = np.mean(img_np, axis=2, keepdims=True) # Grayscale
|
||||
|
||||
return img_np
|
||||
|
||||
|
||||
def create_advanced_autostereogram(self, depth_map, pattern, eye_separation_pixels, depth_scale_factor):
|
||||
depth_map_np = self.preprocess_image_to_numpy(depth_map, is_depth_map=True) # H, W, 1, float [0,1]
|
||||
pattern_np = self.preprocess_image_to_numpy(pattern, target_channels=3) # PatH, PatW, 3, float [0,1]
|
||||
|
||||
h, w, _ = depth_map_np.shape
|
||||
pat_h, pat_w, pat_c = pattern_np.shape
|
||||
|
||||
if pat_w == 0:
|
||||
raise ValueError("Pattern width cannot be zero.")
|
||||
if eye_separation_pixels <=0:
|
||||
raise ValueError("Eye separation in pixels must be positive.")
|
||||
|
||||
|
||||
stereogram = np.zeros((h, w, pat_c), dtype=np.float32)
|
||||
links = np.full(w, -1, dtype=int) # Stores the source pattern column index for each stereogram column
|
||||
|
||||
# Période du motif à utiliser pour les liens. Devrait être eye_separation_pixels.
|
||||
# Mais le motif fourni (pattern_np) a sa propre largeur pat_w.
|
||||
# On va utiliser eye_separation_pixels comme la "largeur virtuelle" du motif de base pour les calculs de liens,
|
||||
# et ensuite on mapperax % eye_separation_pixels à une colonne dans le motif réel (pat_w).
|
||||
effective_pattern_period = eye_separation_pixels
|
||||
|
||||
for y in range(h):
|
||||
links.fill(-1)
|
||||
pattern_row_tile = pattern_np[y % pat_h, :, :] # (pat_w, C)
|
||||
|
||||
for x in range(w):
|
||||
# Depth_value: 0.0 (loin, sur le plan de l'écran), 1.0 (proche, sort le plus)
|
||||
depth_value = depth_map_np[y, x, 0]
|
||||
|
||||
# Separation: combien de pixels le point correspondant à l'oeil droit est décalé par rapport à l'oeil gauche
|
||||
# Si depth_value = 0, separation = 0 (points sur l'écran)
|
||||
# Si depth_value = 1, separation = max_separation (points les plus proches)
|
||||
# max_separation est une fraction (depth_scale_factor) de eye_separation_pixels.
|
||||
# Par exemple, si eye_separation_pixels = 100 et depth_scale_factor = 0.5, max_separation = 50.
|
||||
# Cela signifie que pour les objets les plus proches, l'oeil gauche voit le pixel x,
|
||||
# et l'oeil droit voit le pixel x + 50. Les deux doivent avoir la même couleur.
|
||||
|
||||
separation = int(round(depth_value * depth_scale_factor * effective_pattern_period))
|
||||
|
||||
# Le pixel x de l'autostéréogramme est vu par l'un des yeux (disons l'oeil gauche).
|
||||
# Le pixel "frère" correspondant (qui devrait avoir la même couleur de motif) est à:
|
||||
# x_linked = x - effective_pattern_period + separation
|
||||
# (Formule classique: S(i) = S(i - P + s(D(i))), où P=period, s(D)=separation)
|
||||
|
||||
x_linked = x - effective_pattern_period + separation
|
||||
|
||||
if 0 <= x_linked < w and links[x_linked] != -1:
|
||||
# Si x_linked est valide et a déjà une source de motif assignée (via links[x_linked]),
|
||||
# alors x doit utiliser la même source de motif.
|
||||
source_col_in_virtual_pattern = links[x_linked]
|
||||
# On mappe cette colonne du "motif virtuel" (de période effective_pattern_period)
|
||||
# à une colonne du motif réel (de largeur pat_w).
|
||||
actual_col_in_real_pattern = source_col_in_virtual_pattern % pat_w
|
||||
|
||||
stereogram[y, x, :] = pattern_row_tile[actual_col_in_real_pattern, :]
|
||||
links[x] = source_col_in_virtual_pattern # On propage le lien au motif virtuel
|
||||
else:
|
||||
# Pas de lien à gauche, ou le pixel lié n'a pas encore de source de motif.
|
||||
# x devient un point d'ancrage. Sa source de motif est déterminée par sa position
|
||||
# dans le "motif virtuel" de période effective_pattern_period.
|
||||
source_col_in_virtual_pattern = x % effective_pattern_period
|
||||
actual_col_in_real_pattern = source_col_in_virtual_pattern % pat_w
|
||||
|
||||
stereogram[y, x, :] = pattern_row_tile[actual_col_in_real_pattern, :]
|
||||
links[x] = source_col_in_virtual_pattern
|
||||
|
||||
output_tensor = torch.from_numpy(stereogram.astype(np.float32)).unsqueeze(0)
|
||||
return (output_tensor,)
|
||||
|
||||
# --- Mappings pour ComfyUI ---
|
||||
# Assurez-vous que le nom de la classe ici correspond à celui défini ci-dessus.
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"AdvancedAutostereogramNode": AdvancedAutostereogramNode
|
||||
# Si vous voulez que l'ancien workflow fonctionne sans changer le nom du noeud dans le JSON:
|
||||
# "AutostereogramNode": AdvancedAutostereogramNode
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"AdvancedAutostereogramNode": "Autostereogram Creator (Advanced)"
|
||||
# Ou pour correspondre à la clé ci-dessus si vous l'avez changée :
|
||||
# "AutostereogramNode": "Autostereogram Creator (Adv.)"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
# Core dependencies (essentiels)
|
||||
numpy>=1.21.0
|
||||
scipy>=1.7.0
|
||||
opencv-python>=4.5.0
|
||||
pillow>=8.0.0
|
||||
|
||||
# Image processing
|
||||
scikit-image>=0.18.0
|
||||
imageio>=2.9.0
|
||||
|
||||
# Performance
|
||||
numba>=0.56.0
|
||||
Reference in New Issue
Block a user