Spaces:
Running
Running
| """ | |
| PixelEaterFliesNode (Artificial Life) | |
| -------------------------------- | |
| This node simulates a swarm of "flies" (agents) that | |
| live on, consume, and are guided by an input image. | |
| It is an attempt at "artificial life" [cite: "attempt at artificial life"]. | |
| - The "World" is the `image_in` (e.g., from a Mandelbrot node). | |
| - The "Flies" are agents with "dumb" [cite: "dumbflies.py"] logic. | |
| - "Dopamine" [cite: "that.. is driven by dopamine addiction"] is Health. | |
| - "Logic" [cite: "thin sheet of logic"] is: "find and eat the | |
| brightest pixels" [cite: "they can eat the pixels... based on brightness"]. | |
| - "Graphics" are inspired by dumbflies.py [cite: "dumbflies.py"]. | |
| """ | |
| import numpy as np | |
| import cv2 | |
| import time | |
| # --- Magic import block --- | |
| import __main__ | |
| BaseNode = __main__.BaseNode | |
| QtGui = __main__.QtGui | |
| # -------------------------- | |
| try: | |
| from scipy.ndimage import gaussian_filter | |
| SCIPY_AVAILABLE = True | |
| except ImportError: | |
| SCIPY_AVAILABLE = False | |
| print("Warning: PixelEaterFliesNode requires 'scipy'.") | |
| class FlyAgent: | |
| """ | |
| A single fly. It has a body, logic, and health. | |
| This is the "Dendrite" [cite: "dendrites cause phase space"] or "Scout". | |
| """ | |
| def __init__(self, x, y, grid_size, config): | |
| self.x = float(x) | |
| self.y = float(y) | |
| self.grid_size = grid_size | |
| self.angle = np.random.uniform(0, 2 * np.pi) | |
| self.speed = np.random.uniform(1.0, 3.0) | |
| self.health = 1.0 | |
| self.age = np.random.uniform(0, 100) # For wing flapping | |
| # Get config | |
| self.config = config | |
| # --- "Dumb" logic from dumbflies.py --- | |
| self.state = "WANDER" | |
| self.state_timer = 0 | |
| self.turn_speed = np.random.uniform(0.1, 0.3) | |
| def _get_pixel_brightness(self, world, x, y): | |
| """Helper to safely get brightness at a (wrapped) coordinate""" | |
| xi = int(x) % self.grid_size | |
| yi = int(y) % self.grid_size | |
| return world[yi, xi] | |
| def perceive_and_decide(self, world): | |
| """ | |
| The "Thin Logic". The fly "sees" three points in front | |
| of it and steers towards the brightest one (food). | |
| """ | |
| # 1. Perception: Sample 3 points in front | |
| dist = self.config['perception_distance'] | |
| center_x = self.x + np.cos(self.angle) * dist | |
| center_y = self.y + np.sin(self.angle) * dist | |
| angle_left = self.angle - np.pi / 4 | |
| left_x = self.x + np.cos(angle_left) * dist | |
| left_y = self.y + np.sin(angle_left) * dist | |
| angle_right = self.angle + np.pi / 4 | |
| right_x = self.x + np.cos(angle_right) * dist | |
| right_y = self.y + np.sin(angle_right) * dist | |
| food_center = self._get_pixel_brightness(world, center_x, center_y) | |
| food_left = self._get_pixel_brightness(world, left_x, left_y) | |
| food_right = self._get_pixel_brightness(world, right_x, right_y) | |
| # 2. Decision Logic (Steering) | |
| if food_center > food_left and food_center > food_right: | |
| # Food is straight ahead, keep going | |
| self.state = "SEEK" | |
| elif food_left > food_right: | |
| # Food is to the left | |
| self.state = "SEEK" | |
| self.angle -= self.turn_speed | |
| elif food_right > food_left: | |
| # Food is to the right | |
| self.state = "SEEK" | |
| self.angle += self.turn_speed | |
| else: | |
| # No food in sight, wander | |
| self.state = "WANDER" | |
| self.state_timer -= 1 | |
| if self.state_timer <= 0: | |
| # Pick a new random turn | |
| self.turn_speed = np.random.uniform(-0.2, 0.2) | |
| self.state_timer = np.random.randint(10, 50) | |
| self.angle += self.turn_speed | |
| self.angle %= (2 * np.pi) # Normalize angle | |
| def update_physics(self): | |
| """Update position based on angle and speed""" | |
| self.vel_x = np.cos(self.angle) * self.speed | |
| self.vel_y = np.sin(self.angle) * self.speed | |
| self.x += self.vel_x | |
| self.y += self.vel_y | |
| # Wrap around world edges | |
| self.x %= self.grid_size | |
| self.y %= self.grid_size | |
| self.age += 1 | |
| def eat_and_live(self, world): | |
| """ | |
| Eat pixels to gain health, decay health over time. | |
| This modifies the "world" (the input image). | |
| """ | |
| xi, yi = int(self.x), int(self.y) | |
| # 1. Eat food (brightness) | |
| food_eaten = world[yi, xi] | |
| if food_eaten > 0.1: | |
| self.health += food_eaten * self.config['food_value'] | |
| # Modify the world: "eat" the pixel | |
| world[yi, xi] *= self.config['eat_decay'] | |
| # 2. Metabolism | |
| self.health -= self.config['health_decay'] | |
| self.health = np.clip(self.health, 0.0, 1.0) | |
| return self.health > 0 # Return True if alive | |
| def draw(self, display_image): | |
| """ | |
| Draw the fly with flapping wings, inspired by dumbflies.py | |
| """ | |
| xi, yi = int(self.x), int(self.y) | |
| # Body | |
| body_color = (int(self.health * 255), 0, 0) # BGR: Red = healthy | |
| cv2.circle(display_image, (xi, yi), 3, body_color, -1) | |
| # Wings | |
| wing_len = 5 | |
| wing_angle_base = np.pi / 2 # Perpendicular to body | |
| # Flap wings | |
| wing_flap = np.sin(self.age * 0.5) * 0.5 # Flap angle | |
| # Left Wing | |
| wl_angle = self.angle + wing_angle_base + wing_flap | |
| wl_x = int(xi + np.cos(wl_angle) * wing_len) | |
| wl_y = int(yi + np.sin(wl_angle) * wing_len) | |
| cv2.line(display_image, (xi, yi), (wl_x, wl_y), (200, 200, 200), 1) | |
| # Right Wing | |
| wr_angle = self.angle - wing_angle_base - wing_flap | |
| wr_x = int(xi + np.cos(wr_angle) * wing_len) | |
| wr_y = int(yi + np.sin(wr_angle) * wing_len) | |
| cv2.line(display_image, (xi, yi), (wr_x, wr_y), (200, 200, 200), 1) | |
| class PixelEaterFliesNode(BaseNode): | |
| NODE_CATEGORY = "Artificial Life" | |
| NODE_COLOR = QtGui.QColor(100, 250, 150) # A-Life Green | |
| def __init__(self, num_flies=50, health_decay=0.01, food_value=0.1, perception_distance=10, eat_decay=0.9): | |
| super().__init__() | |
| self.node_title = "Pixel Eater Flies" | |
| self.inputs = { | |
| 'image_in': 'image', # The "World" | |
| 'reset': 'signal' | |
| } | |
| self.outputs = { | |
| 'world_image': 'image', # The "World" + "Flies" | |
| 'population': 'signal', | |
| 'avg_health': 'signal' | |
| } | |
| if not SCIPY_AVAILABLE: | |
| self.node_title = "Flies (No SciPy!)" | |
| return | |
| self.grid_size = 256 # Default, will adapt to image | |
| # --- Configurable Parameters --- | |
| self.config = { | |
| 'num_flies': int(num_flies), | |
| 'health_decay': float(health_decay), | |
| 'food_value': float(food_value), | |
| 'perception_distance': int(perception_distance), | |
| 'eat_decay': float(eat_decay) # How much eating darkens a pixel | |
| } | |
| # --- Internal State --- | |
| self.flies = [] | |
| self.world = None # This will hold the grayscale, 0-1 "food" map | |
| self.world_vis = None # This will hold the color "drawing" | |
| self.avg_health = 0.0 | |
| self._spawn_flies(self.config['num_flies']) | |
| def _spawn_flies(self, count): | |
| for _ in range(count): | |
| x = np.random.randint(0, self.grid_size) | |
| y = np.random.randint(0, self.grid_size) | |
| self.flies.append(FlyAgent(x, y, self.grid_size, self.config)) | |
| def _prepare_world(self, img_in): | |
| """Converts any input image to a 0-1 grayscale float 'food' map""" | |
| # --- Fix for CV_64F crash [cite: "cv2.error... 'depth' is 6 (CV_64F)"] --- | |
| if img_in.dtype != np.float32: | |
| img_in = img_in.astype(np.float32) | |
| if img_in.max() > 1.0: | |
| img_in = img_in / 255.0 | |
| # --- End Fix --- | |
| if img_in.shape[0] != self.grid_size or img_in.shape[1] != self.grid_size: | |
| self.grid_size = img_in.shape[0] | |
| # Respawn flies if world size changes | |
| self.flies = [] | |
| self._spawn_flies(self.config['num_flies']) | |
| if img_in.ndim == 3: | |
| # Convert to grayscale (Luminance) | |
| world_gray = cv2.cvtColor(img_in, cv2.COLOR_RGB2GRAY) | |
| else: | |
| world_gray = img_in.copy() | |
| return world_gray | |
| def step(self): | |
| if not SCIPY_AVAILABLE: | |
| return | |
| # 1. Get Inputs | |
| reset = self.get_blended_input('reset', 'sum') or 0.0 | |
| img_in = self.get_blended_input('image_in', 'first') # Use 'first' | |
| if reset > 0.5: | |
| self.flies = [] | |
| self._spawn_flies(self.config['num_flies']) | |
| self.world = None # Force world reload | |
| # 2. Update/Prepare the "World" | |
| if img_in is not None: | |
| # A new world frame is piped in | |
| self.world = self._prepare_world(img_in) | |
| elif self.world is None: | |
| # No world yet, create a default black one | |
| self.world = np.zeros((self.grid_size, self.grid_size), dtype=np.float32) | |
| # Create the color visualization frame | |
| # We draw on a copy so the flies don't "see" their own drawings | |
| self.world_vis = cv2.cvtColor(self.world, cv2.COLOR_GRAY2RGB) | |
| # 3. Update all "Flies" | |
| alive_flies = [] | |
| total_health = 0.0 | |
| for fly in self.flies: | |
| fly.perceive_and_decide(self.world) | |
| fly.update_physics() | |
| # Eat and check if alive | |
| if fly.eat_and_live(self.world): | |
| fly.draw(self.world_vis) # Draw alive flies | |
| alive_flies.append(fly) | |
| total_health += fly.health | |
| else: | |
| # Fly "died", just don't add it to the list | |
| pass | |
| self.flies = alive_flies | |
| # 4. Handle Reproduction/Respawning | |
| missing_flies = self.config['num_flies'] - len(self.flies) | |
| if missing_flies > 0: | |
| self._spawn_flies(missing_flies) | |
| # 5. Calculate Metrics | |
| if len(self.flies) > 0: | |
| self.avg_health = total_health / len(self.flies) | |
| else: | |
| self.avg_health = 0.0 | |
| def get_output(self, port_name): | |
| if port_name == 'world_image': | |
| if self.world_vis is None: | |
| return np.zeros((self.grid_size, self.grid_size, 3), dtype=np.float32) | |
| return self.world_vis | |
| elif port_name == 'population': | |
| return float(len(self.flies)) | |
| elif port_name == 'avg_health': | |
| return self.avg_health | |
| return None | |
| def get_display_image(self): | |
| if self.world_vis is None: | |
| img = np.zeros((96, 96, 3), dtype=np.uint8) | |
| else: | |
| img = cv2.resize(self.world_vis, (96, 96), interpolation=cv2.INTER_NEAREST) | |
| # Add Health Bar | |
| health_w = int(self.avg_health * (96 - 4)) | |
| cv2.rectangle(img, (2, 96 - 7), (2 + health_w, 96 - 2), (0, 255, 0), -1) | |
| img = np.ascontiguousarray(img) | |
| return QtGui.QImage(img.data, 96, 96, 96*3, QtGui.QImage.Format.Format_BGR888) | |
| def get_config_options(self): | |
| return [ | |
| ("Num Flies", "num_flies", self.config['num_flies'], None), | |
| ("Health Decay", "health_decay", self.config['health_decay'], None), | |
| ("Food Value", "food_value", self.config['food_value'], None), | |
| ("Perception Distance", "perception_distance", self.config['perception_distance'], None), | |
| ("Eat Decay", "eat_decay", self.config['eat_decay'], None), | |
| ] | |
| def close(self): | |
| self.flies = [] | |
| super().close() |