From 319f2efb3498e9529bb543215f49c6cc2212c563 Mon Sep 17 00:00:00 2001 From: Bouletto <118677264+orion4d@users.noreply.github.com> Date: Sun, 22 Jun 2025 10:41:52 +0200 Subject: [PATCH] add --- .gitignore | 126 ++++++++++++++++++++++++++++++ CheckerboardNode.py | 64 +++++++++++++++ ColorImageNode.py | 104 ++++++++++++++++++++++++ OpticalGeometricNode.py | 100 ++++++++++++++++++++++++ OpticalIllusionNode.py | 66 ++++++++++++++++ PatternGenerator_node.py | 149 +++++++++++++++++++++++++++++++++++ README.md | 158 +++++++++++++++++++++++++++++++++++++ TessellationNode.py | 141 +++++++++++++++++++++++++++++++++ TileImageRepeaterNode.py | 131 +++++++++++++++++++++++++++++++ __init__.py | 76 ++++++++++++++++++ autostereogram_node.py | 165 +++++++++++++++++++++++++++++++++++++++ requirements.txt | 12 +++ 12 files changed, 1292 insertions(+) create mode 100644 .gitignore create mode 100644 CheckerboardNode.py create mode 100644 ColorImageNode.py create mode 100644 OpticalGeometricNode.py create mode 100644 OpticalIllusionNode.py create mode 100644 PatternGenerator_node.py create mode 100644 README.md create mode 100644 TessellationNode.py create mode 100644 TileImageRepeaterNode.py create mode 100644 __init__.py create mode 100644 autostereogram_node.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..978ae41 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/CheckerboardNode.py b/CheckerboardNode.py new file mode 100644 index 0000000..020ceb4 --- /dev/null +++ b/CheckerboardNode.py @@ -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", +} diff --git a/ColorImageNode.py b/ColorImageNode.py new file mode 100644 index 0000000..1966ad7 --- /dev/null +++ b/ColorImageNode.py @@ -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", +} diff --git a/OpticalGeometricNode.py b/OpticalGeometricNode.py new file mode 100644 index 0000000..5c34704 --- /dev/null +++ b/OpticalGeometricNode.py @@ -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,) diff --git a/OpticalIllusionNode.py b/OpticalIllusionNode.py new file mode 100644 index 0000000..cdf4779 --- /dev/null +++ b/OpticalIllusionNode.py @@ -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,) diff --git a/PatternGenerator_node.py b/PatternGenerator_node.py new file mode 100644 index 0000000..8a51b5f --- /dev/null +++ b/PatternGenerator_node.py @@ -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" +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d9e470 --- /dev/null +++ b/README.md @@ -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 ComfyUI-IllusionNodes + ``` + (Replace `` 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! \ No newline at end of file diff --git a/TessellationNode.py b/TessellationNode.py new file mode 100644 index 0000000..c6f8f57 --- /dev/null +++ b/TessellationNode.py @@ -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)", +} diff --git a/TileImageRepeaterNode.py b/TileImageRepeaterNode.py new file mode 100644 index 0000000..af2f1e0 --- /dev/null +++ b/TileImageRepeaterNode.py @@ -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)" +} \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..6543afe --- /dev/null +++ b/__init__.py @@ -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' ---") diff --git a/autostereogram_node.py b/autostereogram_node.py new file mode 100644 index 0000000..43b71f4 --- /dev/null +++ b/autostereogram_node.py @@ -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.)" +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e02721d --- /dev/null +++ b/requirements.txt @@ -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