PerceptionLabPortable / app /nodes /PixelEaterFliesNode.py
Aluode's picture
Upload folder using huggingface_hub
3bb804c verified
"""
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()